pyshal 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyshal-0.1.0.dist-info/METADATA +385 -0
- pyshal-0.1.0.dist-info/RECORD +42 -0
- pyshal-0.1.0.dist-info/WHEEL +5 -0
- pyshal-0.1.0.dist-info/entry_points.txt +19 -0
- pyshal-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyshal-0.1.0.dist-info/top_level.txt +1 -0
- shal/__init__.py +65 -0
- shal/approval.py +143 -0
- shal/buses/__init__.py +0 -0
- shal/buses/http_bus.py +74 -0
- shal/buses/i2c_cli.py +90 -0
- shal/buses/local.py +51 -0
- shal/buses/mux.py +83 -0
- shal/buses/scpi_raw.py +110 -0
- shal/buses/sim.py +232 -0
- shal/buses/sim_msg.py +101 -0
- shal/buses/sim_scpi.py +138 -0
- shal/buses/spi_cli.py +63 -0
- shal/buses/ssh.py +74 -0
- shal/buses/tcp.py +100 -0
- shal/capabilities.py +67 -0
- shal/conformance.py +246 -0
- shal/driver.py +299 -0
- shal/drivers/__init__.py +0 -0
- shal/drivers/ads1115.py +48 -0
- shal/drivers/ina219.py +57 -0
- shal/drivers/keysight_34461a.py +44 -0
- shal/drivers/mcp23017.py +57 -0
- shal/drivers/mcp9808.py +39 -0
- shal/drivers/rigol_dp832.py +62 -0
- shal/drivers/tmp102.py +30 -0
- shal/errors.py +89 -0
- shal/hal.py +221 -0
- shal/limits.py +192 -0
- shal/loader.py +224 -0
- shal/log.py +76 -0
- shal/logging.py +113 -0
- shal/node.py +71 -0
- shal/py.typed +0 -0
- shal/registry.py +229 -0
- shal/schema/shal-v1.schema.json +142 -0
- shal/transport.py +118 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyshal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: System/Software Hardware Abstraction Layer — device-tree-inspired, dynamic, user-space, network-capable
|
|
5
|
+
Author: SHAL contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Documentation, https://github.com/determlab/shal#readme
|
|
8
|
+
Project-URL: Changelog, https://github.com/determlab/shal/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Source, https://github.com/determlab/shal
|
|
10
|
+
Keywords: hardware,hal,device-tree,i2c,spi,ssh,embedded,lab-automation,robotics,instrumentation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: System :: Hardware
|
|
22
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: pyyaml>=6.0
|
|
28
|
+
Requires-Dist: jsonschema>=4.18
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
<div align="center">
|
|
35
|
+
|
|
36
|
+
# SHAL
|
|
37
|
+
|
|
38
|
+
### Give an AI agent real hardware — gated, so it asks before it moves.
|
|
39
|
+
|
|
40
|
+
Describe your whole lab — sensors, instruments, robots, services — in **one YAML
|
|
41
|
+
file**, and hand it to an LLM as **typed, permission-gated tools**: writes stop
|
|
42
|
+
for approval before they reach the device; reads don't. No transport code, no glue.
|
|
43
|
+
|
|
44
|
+
> In a blind test, an agent wrote working, safety-checked drivers for **4 of 4**
|
|
45
|
+
> devices from documentation alone — then drove a **real robot, gated**.
|
|
46
|
+
|
|
47
|
+
<!-- BADGES -->
|
|
48
|
+
[](https://github.com/determlab/shal)
|
|
49
|
+
[](#install)
|
|
50
|
+
[](#install)
|
|
51
|
+
[](LICENSE)
|
|
52
|
+
[](#roadmap)
|
|
53
|
+
|
|
54
|
+
<picture>
|
|
55
|
+
<source srcset="docs/assets/SHAL_banner.webp" type="image/webp">
|
|
56
|
+
<img alt="SHAL turns your lab and services into one YAML topology — controlled from Python or exposed to an AI agent as typed, gated tools" src="docs/assets/SHAL_banner.png" width="100%">
|
|
57
|
+
</picture>
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
### [→ Try it in 60 seconds — no hardware required](#quick-start)
|
|
61
|
+
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
**Built for:**
|
|
65
|
+
|
|
66
|
+
✓ AI agent builders · ✓ Validation & test engineers ·
|
|
67
|
+
✓ Hardware-in-the-loop automation · ✓ Labs with mixed hardware + software
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## From glue scripts to agent tools — in three steps
|
|
72
|
+
|
|
73
|
+
**Step 1 · Today, without SHAL** — a separate library, address, and retry per device:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
sensor = TMP102(i2c_bus, 0x48)
|
|
77
|
+
supply = SCPIPowerSupply("10.0.0.50:5025")
|
|
78
|
+
results = RESTClient("https://mes.lab.internal")
|
|
79
|
+
# ...and you wire each one's retries, logging, and tool-wrapper by hand
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Step 2 · With SHAL** — describe the rack once, then call devices by name:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
hal = shal.load("lab.yaml")
|
|
86
|
+
|
|
87
|
+
hal.get_device("ambient_temp").read_celsius() # I²C sensor
|
|
88
|
+
hal.get_device("dut_power").set_voltage(3.3) # SCPI supply
|
|
89
|
+
hal.get_device("results_db").record(status="pass") # HTTP service
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> **Validation & test engineers can stop here.** One model for the whole rack —
|
|
93
|
+
> no agent needed, no transport code, no glue.
|
|
94
|
+
|
|
95
|
+
**Step 3 · Hand the same rack to an agent** — the tool catalog is generated for you:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
tools = hal.tool_schemas() # one typed tool per device op
|
|
99
|
+
hal.call_tool("dut_power__set_voltage", {"volts": 3.3})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
> Writes are gated, reads aren't. The agent never sees SCPI, I²C, or an address.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Why existing agent frameworks fall short
|
|
107
|
+
|
|
108
|
+
Most agent tooling assumes **software-only** tools: APIs, databases, functions.
|
|
109
|
+
The moment a tool is a *physical* device — a sensor on I²C, an instrument over a
|
|
110
|
+
raw socket, a robot behind a network hop — you're on your own.
|
|
111
|
+
|
|
112
|
+
SHAL exposes physical devices, remote labs, instruments, **and** software
|
|
113
|
+
services as the *same* kind of tool — with the safety rails physical actions
|
|
114
|
+
need: gated writes, honest failure, a full audit trail.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## One model for hardware *and* software
|
|
119
|
+
|
|
120
|
+
The core idea is small:
|
|
121
|
+
|
|
122
|
+
> **A bus is just a node that provides a transport to its children.**
|
|
123
|
+
|
|
124
|
+
A sensor on I²C and an HTTP service are the same kind of node. Your code — and
|
|
125
|
+
your agent — calls **capabilities** (`read_celsius()`, `set_voltage()`), never
|
|
126
|
+
transports. `[core]` ships with SHAL; `[pkg]` is a driver you install or write.
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
# lab.yaml — hardware and software in ONE graph
|
|
130
|
+
shal_version: 1
|
|
131
|
+
root:
|
|
132
|
+
bench: # one SSH hop to the bench controller [core]
|
|
133
|
+
driver: shal,ssh-host
|
|
134
|
+
address: ${BENCH_SSH} # secrets resolve from the environment, never logged
|
|
135
|
+
children:
|
|
136
|
+
i2c0: # I²C rendered as argv over the SSH hop [core]
|
|
137
|
+
driver: shal,i2c-cli
|
|
138
|
+
address: /dev/i2c-1
|
|
139
|
+
children:
|
|
140
|
+
ambient: { id: ambient_temp, driver: ti,tmp102, address: 0x48 } # [core]
|
|
141
|
+
|
|
142
|
+
instruments: # raw-socket SCPI bus [pkg]
|
|
143
|
+
driver: acme,scpi
|
|
144
|
+
address: 10.0.0.50:5025
|
|
145
|
+
children:
|
|
146
|
+
supply: { id: dut_power, driver: keysight,e36312, address: ch1 } # [pkg]
|
|
147
|
+
|
|
148
|
+
services: # HTTPS to internal services [core]
|
|
149
|
+
driver: shal,http
|
|
150
|
+
address: https://mes.lab.internal
|
|
151
|
+
children:
|
|
152
|
+
results: { id: results_db, driver: acme,mes-results, address: api/v2 } # [pkg]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Every node is reached the same way — `hal.get_device("dut_power").set_voltage(3.3)`
|
|
156
|
+
— sensor or database, local or across the network. Same retries, same logs. Swap
|
|
157
|
+
any node for its sim and **nothing in your code changes**.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Features
|
|
162
|
+
|
|
163
|
+
- **Agent-native** — every device op becomes a gated LLM tool.
|
|
164
|
+
- **Asks before it moves** — actuator & destructive/config ops stop for a
|
|
165
|
+
host-supplied approver (CLI prompt, an agent, or auto in sim/CI); the gate is
|
|
166
|
+
pre-I/O and unbypassable.
|
|
167
|
+
- **Hardware + software, one graph** — a sensor and an HTTP service are the same node.
|
|
168
|
+
- **Capabilities, not wires** — call `read_celsius()`, never I²C.
|
|
169
|
+
- **Retry you can trust** — reads auto-retry; risky writes never silently repeat.
|
|
170
|
+
- **Sim-first** — test the whole rack with zero hardware.
|
|
171
|
+
- **Recursive** — muxes, jumpboxes, nested buses: one primitive, no special cases.
|
|
172
|
+
- **Drivers as plugins** — add a device in one small class.
|
|
173
|
+
- **Secure by default** — no shell strings, TLS on, secrets via `${ENV}`.
|
|
174
|
+
- **Observable** — structured logs, one `txn` id per call.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Install
|
|
179
|
+
|
|
180
|
+
> SHAL is in **alpha** (Phase 1). A PyPI release is coming; for now install from
|
|
181
|
+
> source:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
pip install git+https://github.com/determlab/shal
|
|
185
|
+
|
|
186
|
+
# or, for development
|
|
187
|
+
git clone https://github.com/determlab/shal && cd shal
|
|
188
|
+
pip install -e ".[dev]" # pytest, ruff
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Requires **Python ≥ 3.10**. Dependencies: `pyyaml`, `jsonschema`.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Quick Start
|
|
196
|
+
|
|
197
|
+
Runs with **zero hardware** — the simulated bus ships with SHAL. New to hardware?
|
|
198
|
+
This is the whole setup, and it's just Python and a YAML file.
|
|
199
|
+
|
|
200
|
+
```yaml
|
|
201
|
+
# sim.yaml
|
|
202
|
+
shal_version: 1
|
|
203
|
+
root:
|
|
204
|
+
bus:
|
|
205
|
+
driver: shal,sim-i2c
|
|
206
|
+
address: sim0
|
|
207
|
+
children:
|
|
208
|
+
temp0:
|
|
209
|
+
id: ambient_temp
|
|
210
|
+
driver: ti,tmp102
|
|
211
|
+
address: 0x48
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import shal
|
|
216
|
+
|
|
217
|
+
with shal.load("sim.yaml") as hal:
|
|
218
|
+
print(hal.get_device("ambient_temp").read_celsius()) # 25.0
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
$ python quickstart.py
|
|
223
|
+
25.0
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
When the real board arrives, change `shal,sim-i2c` → `shal,i2c-cli`. **Your
|
|
227
|
+
Python doesn't change.**
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Write a driver in 30 seconds
|
|
232
|
+
|
|
233
|
+
Need a device SHAL doesn't have yet? A driver is one small class. This is the
|
|
234
|
+
*entire* bundled temperature-sensor driver:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from shal import Driver, TemperatureSensor, registry, idempotent, op, ByteTransport, Read, Write
|
|
238
|
+
|
|
239
|
+
@registry.register
|
|
240
|
+
class Tmp102(Driver, TemperatureSensor):
|
|
241
|
+
compatible = "ti,tmp102" # matched against the YAML `driver:` field
|
|
242
|
+
kind = ByteTransport
|
|
243
|
+
llm_ready = True
|
|
244
|
+
|
|
245
|
+
@idempotent # a read: safe to auto-retry across drops
|
|
246
|
+
@op("Read the ambient temperature now.", unit="celsius", side_effect="none")
|
|
247
|
+
def read_celsius(self) -> float:
|
|
248
|
+
raw = self.bus.txn(self.addr, [Write(b"\x00"), Read(2)])
|
|
249
|
+
return ((raw[0] << 4) | (raw[1] >> 4)) * 0.0625
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
That's it — register the `compatible`, implement the capability. The `@op`
|
|
253
|
+
metadata is what makes it show up as a gated agent tool. SHAL discovers your
|
|
254
|
+
driver via the `shal.drivers` entry point.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## How It Works
|
|
259
|
+
|
|
260
|
+
A topology is a tree, and **every edge is a bus** — itself a node that carries
|
|
261
|
+
traffic to its children. You call a capability; SHAL translates it down the stack
|
|
262
|
+
to the wire and hands the result back up. No layer leaks into the one above.
|
|
263
|
+
|
|
264
|
+
```mermaid
|
|
265
|
+
sequenceDiagram
|
|
266
|
+
participant U as Your code
|
|
267
|
+
participant T as tmp102 driver
|
|
268
|
+
participant I as i2c-cli bus
|
|
269
|
+
participant S as ssh-host bus
|
|
270
|
+
U->>T: read_celsius()
|
|
271
|
+
T->>I: read register 0x00
|
|
272
|
+
I->>S: i2ctransfer 0x48 … (argv)
|
|
273
|
+
Note over S: runs on lab_server
|
|
274
|
+
S-->>I: raw bytes
|
|
275
|
+
I-->>T: raw bytes
|
|
276
|
+
T-->>U: 22.5 °C
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Because every hop is the same primitive, an SSH jumpbox, an I²C mux, and an
|
|
280
|
+
in-process sim all compose — no special cases.
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Core Concepts
|
|
285
|
+
|
|
286
|
+
| Concept | What it means |
|
|
287
|
+
|---|---|
|
|
288
|
+
| **Node** | Anything in the tree: a device, a bus, a board. |
|
|
289
|
+
| **Bus** | A node that provides a transport to its children (I²C, SSH, HTTP…). |
|
|
290
|
+
| **Driver** | Bound to a node by its `compatible` string. Implements a capability. |
|
|
291
|
+
| **Capability** | The typed API your code calls (`read_celsius()`), independent of transport. |
|
|
292
|
+
| **id vs path** | `id` is a stable name for lookup; `path` is where it sits. Move a device, keep its `id`. |
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
hal.get_device("ambient_temp").read_celsius() # by semantic id — no wires leak in
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Real-World Use Cases
|
|
301
|
+
|
|
302
|
+
- **AI agents with real-world access** — expose a lab or robot to an LLM as
|
|
303
|
+
gated tools; every actuator call stops for a human (or policy) to approve
|
|
304
|
+
before it fires, and a delivery-unknown write is never silently retried.
|
|
305
|
+
- **Validation & test racks** — one model for eval boards, instruments, and the
|
|
306
|
+
results database; test against sims in CI before hardware.
|
|
307
|
+
- **Manufacturing lines** — same capability calls across stations; one audit
|
|
308
|
+
trail (`shal.audit`) for every actuator command.
|
|
309
|
+
- **Remote & distributed setups** — drive hardware behind an SSH jumpbox with
|
|
310
|
+
nothing on the far side but standard CLI tools.
|
|
311
|
+
- **Robotics bringup** — start against a sim, swap in transports as boards land,
|
|
312
|
+
without rewriting control code.
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## FAQ
|
|
317
|
+
|
|
318
|
+
**Why not just wrap Python libraries as agent tools myself?**
|
|
319
|
+
You can — until there are ten devices on four transports, some behind an SSH hop,
|
|
320
|
+
some not. Then you're hand-maintaining a tool wrapper, address, retry policy, and
|
|
321
|
+
audit log *per device*. SHAL generates all of it from one topology.
|
|
322
|
+
|
|
323
|
+
**Is it production-ready?**
|
|
324
|
+
It's **alpha** (Phase 1). The synchronous core — topology, drivers, buses, retry
|
|
325
|
+
policy, and the agent tool surface — is real and tested. Async/streaming, the
|
|
326
|
+
actuator watchdog, and route failover are Phase 2 ([roadmap](#roadmap)).
|
|
327
|
+
|
|
328
|
+
**Do I need real hardware to try it?**
|
|
329
|
+
No. The bundled simulated bus runs the [Quick Start](#quick-start) with zero
|
|
330
|
+
hardware — swap in a real transport later, and your code doesn't change.
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Roadmap
|
|
335
|
+
|
|
336
|
+
**Shipped — Phase 1 (synchronous core, v0.1.0):**
|
|
337
|
+
|
|
338
|
+
- ✅ Declarative YAML topology: JSON-Schema validation, `id`/`path`/`$ref`,
|
|
339
|
+
`${ENV}` secrets, reusable `template:` includes
|
|
340
|
+
- ✅ Bundled buses: `sim-i2c`, `local`, `ssh-host`, `i2c-cli`, `spi-cli`,
|
|
341
|
+
`tcp` (TLS), `http`, `nxp,pca9548` mux
|
|
342
|
+
- ✅ Capability model, driver plugin registry, trustworthy retry policy
|
|
343
|
+
- ✅ Agent tool surface: `tool_schemas()` / `tool_catalog()` / `call_tool()`
|
|
344
|
+
- ✅ Human-in-the-loop actuation gate: actuator ops stop for an injectable
|
|
345
|
+
`Approver` (pre-I/O, unbypassable, every decision audited)
|
|
346
|
+
- ✅ Structured observability + `capture()` flight recorder
|
|
347
|
+
|
|
348
|
+
**Designed, in progress — Phase 2:**
|
|
349
|
+
|
|
350
|
+
- 🚧 Async / streaming (`subscribe`, held channels) — [spec](docs/DESIGN%20-%20PHASE%202%20ASYNC.md)
|
|
351
|
+
- 🚧 Actuator watchdog & safe-state (timeouts, auto safe-state on disconnect)
|
|
352
|
+
- 🚧 Route failover for multi-path devices
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Documentation
|
|
357
|
+
|
|
358
|
+
- [Architecture & locked decisions](docs/DESIGN%20V2.md)
|
|
359
|
+
- [Phase 1 implementation decisions](docs/DECISIONS%20-%20V2.1.md)
|
|
360
|
+
- [Phase 2 async + watchdog spec](docs/DESIGN%20-%20PHASE%202%20ASYNC.md)
|
|
361
|
+
- Build guides: write a [driver](.claude/skills/shal-build-driver/SKILL.md),
|
|
362
|
+
a [bus](.claude/skills/shal-build-bus/SKILL.md), or a
|
|
363
|
+
[topology](.claude/skills/shal-build-yaml/SKILL.md)
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Contributing
|
|
368
|
+
|
|
369
|
+
Contributions welcome — **new drivers and buses especially**. A driver is one
|
|
370
|
+
small class (see [above](#write-a-driver-in-30-seconds)); SHAL discovers it via
|
|
371
|
+
the `shal.drivers` entry point.
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
pip install -e ".[dev]"
|
|
375
|
+
python -m pytest # test suite
|
|
376
|
+
ruff check src tests # lint
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## License
|
|
384
|
+
|
|
385
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
pyshal-0.1.0.dist-info/licenses/LICENSE,sha256=tdL5yHCYzaznc1F7JtcuLha1ygaVGwEk3XBgL_HOUfs,1074
|
|
2
|
+
shal/__init__.py,sha256=5R7SUSSTGranj_4tGejYo13kU3JiyHviLicpEhwfW88,1711
|
|
3
|
+
shal/approval.py,sha256=5sydRVaTpKqzh-w4e_amPXHZD7nWB7XGEV1Y7tEsxFU,5341
|
|
4
|
+
shal/capabilities.py,sha256=qf4vLhiuCpace1nG7auEW5x-tQW7A3gcls2sihq3JZY,2232
|
|
5
|
+
shal/conformance.py,sha256=JpOcciSo-Os-oyjYEdMIdQVPKiKTU3985SI4JP6NBH0,10514
|
|
6
|
+
shal/driver.py,sha256=AdrQrIfCjUDwHEYA7np-RCPvZh_9gHxmQIfMaNvg_04,15500
|
|
7
|
+
shal/errors.py,sha256=0vreTxdOOq8lHpZ8n5d2S8qsVUdMb2h5W4XtgMrVL6w,2944
|
|
8
|
+
shal/hal.py,sha256=UFLeqgkL9CVCC05d3mpIugJHz9-uS0C5kYlgJPQAVY0,9785
|
|
9
|
+
shal/limits.py,sha256=i4l_7linn0Nptf-JaHoE04Aybp8njgRXOrYkjNHeeug,8806
|
|
10
|
+
shal/loader.py,sha256=RMFuoOlDk3bA1zPrnHI3D06RjWKHZKTZv7YlwdpCWaQ,9890
|
|
11
|
+
shal/log.py,sha256=y1UR2GLnbIQQupHRTrgdyNHys3XPMQ1FqZm6TEhwYAk,2958
|
|
12
|
+
shal/logging.py,sha256=FZsiawuvJJrkABwT1MP5z8mwo4PYckG3BDJsBIRBkm0,4313
|
|
13
|
+
shal/node.py,sha256=UfqFsAkHYrOGCZmrlNCuiOnmwA8WnhKMCyDyt1y0pcc,2503
|
|
14
|
+
shal/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
shal/registry.py,sha256=e10azuG4aqT95AWxpIFqRN7VyU_fScvVRh1B5TUa8oI,8624
|
|
16
|
+
shal/transport.py,sha256=zomnaUkc-2JPhP2k6wxaijQtuXKsoml7E7DeQm5JTDo,3312
|
|
17
|
+
shal/buses/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
shal/buses/http_bus.py,sha256=EkkbxAUrVJh3-G-9hi4lKYnLJe5U725bV4lGRoLc4X4,3390
|
|
19
|
+
shal/buses/i2c_cli.py,sha256=ZoqNj6Jfgq1IlT_1CvOMy5ppNIZt4Evsrinf93md3IM,3612
|
|
20
|
+
shal/buses/local.py,sha256=Tqv6tiKI_B5wJtwk8BJFKCz74c3SE_-6RjBREL9Nc00,2113
|
|
21
|
+
shal/buses/mux.py,sha256=Yw1xQXD5gNBRAw913ALmH4vcm0zQg4CEF1XhOjwGuNg,3031
|
|
22
|
+
shal/buses/scpi_raw.py,sha256=9VwrMq_-8XxEuYBXzcJutCnJXjyZLWcJdjpQFMh1sls,4694
|
|
23
|
+
shal/buses/sim.py,sha256=9n7L6QeVh_ifEUDLBPlVCQ5aiQLn751sa1zwH1_apEA,8555
|
|
24
|
+
shal/buses/sim_msg.py,sha256=SIHUsn9X2Vnl9ETmPLtfz6QvvDRn00isP8HcMyCBGa8,4072
|
|
25
|
+
shal/buses/sim_scpi.py,sha256=t7hGEZu8K-mu4ZsLGB7OoB0Dr941Dc0VSzzRBeEKwN4,5355
|
|
26
|
+
shal/buses/spi_cli.py,sha256=CBvxbeXge0LzbfRASd4MjgZOiwxQIIb7X1uoo9AMZQ4,2554
|
|
27
|
+
shal/buses/ssh.py,sha256=JWBgu24H5ALYzv7AvAfXHHtj-efJRcasM4mQqJ1Dd6o,3254
|
|
28
|
+
shal/buses/tcp.py,sha256=GuwKtIDGrigtaN7_FSHOugdFJsYRGb4yymtiZUGNX4g,4008
|
|
29
|
+
shal/drivers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
30
|
+
shal/drivers/ads1115.py,sha256=9advweCf_rio2WP2tr44gktwA-X4epcHUFzCdKRL7tM,1824
|
|
31
|
+
shal/drivers/ina219.py,sha256=366uXJQlQovQzGhIomByMQ6uSRaKMZfGoxbPPY9wgsM,2109
|
|
32
|
+
shal/drivers/keysight_34461a.py,sha256=4qJmLQfaAbVgW-_OhbhCPrbpfAp-8ITPggUnGbs6m84,1549
|
|
33
|
+
shal/drivers/mcp23017.py,sha256=GnncKWhhK1pv7WjTw14kgMIpWJ79kCcp6tz3DPN-Jww,2064
|
|
34
|
+
shal/drivers/mcp9808.py,sha256=jrQM38T_hm02K7LxujtLxTBlvdUnfOMeScEvqNh0fmE,1423
|
|
35
|
+
shal/drivers/rigol_dp832.py,sha256=0LeJ6l57lAD69YPsKm0TGnykIwdBKaukPbm7mz9YdpM,2327
|
|
36
|
+
shal/drivers/tmp102.py,sha256=7bHigCQeBsf89-vvJzKpDhN7X4CNlCa_1A6c-WBNCOI,1198
|
|
37
|
+
shal/schema/shal-v1.schema.json,sha256=tavFK3gzhvkWBjO-LFC28X6RfzvZqnLE9vjBnj380C0,5083
|
|
38
|
+
pyshal-0.1.0.dist-info/METADATA,sha256=wneLnQ77fVdh9WOZ1whHUiaIiWlWSdkHOU8UFE8gn9s,14056
|
|
39
|
+
pyshal-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
40
|
+
pyshal-0.1.0.dist-info/entry_points.txt,sha256=ZZAbTDE3HcMCZ2IjLpPXM8EEXlAKe9JSGGIOIVNtH3g,801
|
|
41
|
+
pyshal-0.1.0.dist-info/top_level.txt,sha256=229u67H27LrLF5xo7mG607l1XRGqy4gjYx5dHPtRvXk,5
|
|
42
|
+
pyshal-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[shal.drivers]
|
|
2
|
+
keysight,34461a = shal.drivers.keysight_34461a:Keysight34461a
|
|
3
|
+
microchip,mcp23017 = shal.drivers.mcp23017:Mcp23017
|
|
4
|
+
microchip,mcp9808 = shal.drivers.mcp9808:Mcp9808
|
|
5
|
+
nxp,pca9548 = shal.buses.mux:Pca9548
|
|
6
|
+
rigol,dp832 = shal.drivers.rigol_dp832:RigolDp832
|
|
7
|
+
shal,http = shal.buses.http_bus:HttpBus
|
|
8
|
+
shal,i2c-cli = shal.buses.i2c_cli:I2cCliBus
|
|
9
|
+
shal,local = shal.buses.local:LocalBus
|
|
10
|
+
shal,scpi-raw = shal.buses.scpi_raw:ScpiRawBus
|
|
11
|
+
shal,sim-i2c = shal.buses.sim:SimI2cBus
|
|
12
|
+
shal,sim-msg = shal.buses.sim_msg:SimMsgBus
|
|
13
|
+
shal,sim-scpi = shal.buses.sim_scpi:SimScpiBus
|
|
14
|
+
shal,spi-cli = shal.buses.spi_cli:SpiCliBus
|
|
15
|
+
shal,ssh-host = shal.buses.ssh:SshBus
|
|
16
|
+
shal,tcp = shal.buses.tcp:TcpBus
|
|
17
|
+
ti,ads1115 = shal.drivers.ads1115:Ads1115
|
|
18
|
+
ti,ina219 = shal.drivers.ina219:Ina219
|
|
19
|
+
ti,tmp102 = shal.drivers.tmp102:Tmp102
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SHAL contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
shal
|
shal/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""SHAL — System/Software Hardware Abstraction Layer.
|
|
2
|
+
|
|
3
|
+
import shal
|
|
4
|
+
with shal.load("setup.yaml") as hal:
|
|
5
|
+
print(hal.get_device("ambient_temp").read_celsius())
|
|
6
|
+
"""
|
|
7
|
+
from . import logging # opt-in observability: shal.logging.{Console,JSON}Formatter, capture
|
|
8
|
+
from .approval import (
|
|
9
|
+
ApprovalRequest,
|
|
10
|
+
Approver,
|
|
11
|
+
AutoApprove,
|
|
12
|
+
CallableApprover,
|
|
13
|
+
ConsoleApprover,
|
|
14
|
+
DenyAll,
|
|
15
|
+
approver,
|
|
16
|
+
get_approver,
|
|
17
|
+
set_approver,
|
|
18
|
+
)
|
|
19
|
+
from .capabilities import (
|
|
20
|
+
ADC,
|
|
21
|
+
DigitalMultimeter,
|
|
22
|
+
GPIOExpander,
|
|
23
|
+
PowerMonitor,
|
|
24
|
+
PowerSupply,
|
|
25
|
+
TemperatureSensor,
|
|
26
|
+
)
|
|
27
|
+
from .driver import Driver, idempotent, op
|
|
28
|
+
from .errors import (
|
|
29
|
+
ApprovalDenied,
|
|
30
|
+
Busy,
|
|
31
|
+
Error,
|
|
32
|
+
Gap,
|
|
33
|
+
HopError,
|
|
34
|
+
HopTimeout,
|
|
35
|
+
LimitError,
|
|
36
|
+
LoadError,
|
|
37
|
+
)
|
|
38
|
+
from .hal import Hal, load
|
|
39
|
+
from .node import Node
|
|
40
|
+
from .registry import catalog, register
|
|
41
|
+
from .transport import (
|
|
42
|
+
ByteTransport,
|
|
43
|
+
CommandTransport,
|
|
44
|
+
Completed,
|
|
45
|
+
MessageTransport,
|
|
46
|
+
Op,
|
|
47
|
+
Read,
|
|
48
|
+
Stream,
|
|
49
|
+
Transport,
|
|
50
|
+
Write,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"load", "Hal", "Node", "Driver", "idempotent", "op", "register", "catalog", "logging",
|
|
57
|
+
"Approver", "ApprovalRequest", "AutoApprove", "DenyAll", "CallableApprover",
|
|
58
|
+
"ConsoleApprover", "set_approver", "get_approver", "approver",
|
|
59
|
+
"Error", "LoadError", "HopError", "HopTimeout", "LimitError", "ApprovalDenied",
|
|
60
|
+
"Busy", "Gap",
|
|
61
|
+
"Transport", "ByteTransport", "CommandTransport", "MessageTransport",
|
|
62
|
+
"Stream", "Op", "Read", "Write", "Completed",
|
|
63
|
+
"TemperatureSensor", "PowerMonitor", "PowerSupply", "DigitalMultimeter",
|
|
64
|
+
"ADC", "GPIOExpander", "__version__",
|
|
65
|
+
]
|
shal/approval.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Human-in-the-loop actuation gate (issue #14).
|
|
2
|
+
|
|
3
|
+
SHAL's promise — "it asks permission before it moves" — is enforced HERE, at the
|
|
4
|
+
same capability-wrapper chokepoint where limits and audit already fire. For an op
|
|
5
|
+
whose effective ``side_effect`` is ``"actuator"`` (physical motion), the wrapper
|
|
6
|
+
consults the active :class:`Approver` AFTER the limit check and BEFORE any bus
|
|
7
|
+
I/O. Because the gate lives in the wrapper, NO call path can bypass it — the tool
|
|
8
|
+
surface (``hal.call_tool``) and the raw path (``get_device().method()``) go
|
|
9
|
+
through the exact same enforcement.
|
|
10
|
+
|
|
11
|
+
SHAL ships the *mechanism* and a *safe default* (ask on the terminal when
|
|
12
|
+
interactive; deny when there is no one to ask — a pipe, a cron job, CI). The host
|
|
13
|
+
injects the *decision*:
|
|
14
|
+
|
|
15
|
+
import shal
|
|
16
|
+
shal.set_approver(shal.AutoApprove()) # sim / CI / tests
|
|
17
|
+
with shal.approver(MyNanoClawApprover()): # scoped policy
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
Order is always **limits -> approval -> I/O**: an impossible call is rejected by
|
|
21
|
+
limits before anyone is asked to approve it. Every decision (allow or deny) is
|
|
22
|
+
written to ``shal.audit`` so runs stay deterministic and replayable.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import sys
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from contextvars import ContextVar, Token
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Protocol, runtime_checkable
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ApprovalRequest:
|
|
36
|
+
"""What the wrapper hands an Approver. Immutable, fully self-describing so a
|
|
37
|
+
decision can be made (and logged) without reaching back into framework state."""
|
|
38
|
+
op: str
|
|
39
|
+
path: str
|
|
40
|
+
id: str
|
|
41
|
+
side_effect: str
|
|
42
|
+
params: dict
|
|
43
|
+
txn: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class Approver(Protocol):
|
|
48
|
+
"""A policy that decides whether a side-effecting op may proceed.
|
|
49
|
+
|
|
50
|
+
Return ``True`` to allow, ``False`` to deny. Raising is treated by the caller
|
|
51
|
+
as a hard failure (not a denial) — return ``False`` to refuse cleanly."""
|
|
52
|
+
|
|
53
|
+
def approve(self, request: ApprovalRequest) -> bool: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AutoApprove:
|
|
57
|
+
"""Allow every actuation. For sim, CI, and tests — never production."""
|
|
58
|
+
|
|
59
|
+
def approve(self, request: ApprovalRequest) -> bool:
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DenyAll:
|
|
64
|
+
"""Refuse every actuation. The safest possible policy."""
|
|
65
|
+
|
|
66
|
+
def approve(self, request: ApprovalRequest) -> bool:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CallableApprover:
|
|
71
|
+
"""Adapt a plain ``fn(request) -> bool`` into an Approver (e.g. a NanoClaw
|
|
72
|
+
callback, a queue handoff, a custom prompt)."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, fn: Callable[[ApprovalRequest], bool]) -> None:
|
|
75
|
+
self._fn = fn
|
|
76
|
+
|
|
77
|
+
def approve(self, request: ApprovalRequest) -> bool:
|
|
78
|
+
return bool(self._fn(request))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ConsoleApprover:
|
|
82
|
+
"""Ask on the terminal. The shipped default.
|
|
83
|
+
|
|
84
|
+
If the input stream is not a TTY (a pipe, cron, CI), there is no one to
|
|
85
|
+
answer, so it DENIES — never block a headless run on an unanswerable prompt.
|
|
86
|
+
Inject :class:`AutoApprove` for those environments.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, *, stream=None, prompt: Callable[[str], str] = input) -> None:
|
|
90
|
+
self._stream = stream
|
|
91
|
+
self._prompt = prompt
|
|
92
|
+
|
|
93
|
+
def approve(self, request: ApprovalRequest) -> bool:
|
|
94
|
+
stream = self._stream or sys.stdin
|
|
95
|
+
if not getattr(stream, "isatty", lambda: False)():
|
|
96
|
+
return False # no one to ask -> don't move
|
|
97
|
+
args = ", ".join(f"{k}={v!r}" for k, v in request.params.items())
|
|
98
|
+
who = request.id or request.path
|
|
99
|
+
banner = (f"\n SHAL approval required [{request.side_effect}]\n"
|
|
100
|
+
f" {who}.{request.op}({args})\n"
|
|
101
|
+
f" Allow this actuation? [y/N] ")
|
|
102
|
+
try:
|
|
103
|
+
answer = self._prompt(banner)
|
|
104
|
+
except EOFError:
|
|
105
|
+
return False
|
|
106
|
+
return answer.strip().lower() in ("y", "yes")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# The active policy. The default is safe: prompt when interactive, deny otherwise.
|
|
110
|
+
# (ContextVar default must be immutable per flake8-bugbear; resolve the singleton
|
|
111
|
+
# in get_approver() so an unset context falls back to ConsoleApprover.)
|
|
112
|
+
_DEFAULT: Approver = ConsoleApprover()
|
|
113
|
+
_current: ContextVar[Approver | None] = ContextVar("shal_approver", default=None)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_approver() -> Approver:
|
|
117
|
+
"""The Approver the wrapper will consult for the current context."""
|
|
118
|
+
return _current.get() or _DEFAULT
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def set_approver(approver: Approver) -> Token:
|
|
122
|
+
"""Install ``approver`` as the active policy. Returns a token for ``reset``.
|
|
123
|
+
|
|
124
|
+
Note: the policy lives in a :class:`~contextvars.ContextVar`. A newly spawned
|
|
125
|
+
OS thread does NOT inherit the caller's context, so it falls back to the safe
|
|
126
|
+
default (deny-when-headless) until you call ``set_approver`` inside that
|
|
127
|
+
thread. ``asyncio`` tasks created with the running loop DO inherit it."""
|
|
128
|
+
return _current.set(approver)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def reset(token: Token) -> None:
|
|
132
|
+
"""Undo a :func:`set_approver`, restoring the previous policy."""
|
|
133
|
+
_current.reset(token)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@contextmanager
|
|
137
|
+
def approver(a: Approver):
|
|
138
|
+
"""Scope an Approver to a ``with`` block; the previous policy is restored on exit."""
|
|
139
|
+
token = _current.set(a)
|
|
140
|
+
try:
|
|
141
|
+
yield a
|
|
142
|
+
finally:
|
|
143
|
+
_current.reset(token)
|
shal/buses/__init__.py
ADDED
|
File without changes
|