openmhp 0.3.0__tar.gz
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.
- openmhp-0.3.0/PKG-INFO +200 -0
- openmhp-0.3.0/README.md +189 -0
- openmhp-0.3.0/openmhp/__init__.py +9 -0
- openmhp-0.3.0/openmhp/adapters/__init__.py +15 -0
- openmhp-0.3.0/openmhp/adapters/base.py +140 -0
- openmhp-0.3.0/openmhp/adapters/madsci.py +111 -0
- openmhp-0.3.0/openmhp/adapters/opcua.py +72 -0
- openmhp-0.3.0/openmhp/adapters/pylabrobot.py +77 -0
- openmhp-0.3.0/openmhp/adapters/ros2.py +116 -0
- openmhp-0.3.0/openmhp/adapters/sila2.py +93 -0
- openmhp-0.3.0/openmhp/cli.py +128 -0
- openmhp-0.3.0/openmhp/client.py +236 -0
- openmhp-0.3.0/openmhp/devices/__init__.py +1 -0
- openmhp-0.3.0/openmhp/devices/arm-01/DEVICE.md +37 -0
- openmhp-0.3.0/openmhp/devices/arm-01/descriptor.yaml +79 -0
- openmhp-0.3.0/openmhp/devices/arm-01/driver.py +2 -0
- openmhp-0.3.0/openmhp/devices/arm-01/references/locations.md +12 -0
- openmhp-0.3.0/openmhp/devices/arm-01/scripts/plate_to_thermocycler.py +10 -0
- openmhp-0.3.0/openmhp/devices/sim_arm.py +77 -0
- openmhp-0.3.0/openmhp/devices/sim_thermocycler.py +67 -0
- openmhp-0.3.0/openmhp/devices/thermocycler-01/DEVICE.md +39 -0
- openmhp-0.3.0/openmhp/devices/thermocycler-01/descriptor.yaml +61 -0
- openmhp-0.3.0/openmhp/devices/thermocycler-01/driver.py +3 -0
- openmhp-0.3.0/openmhp/devices/thermocycler-01/references/protocols.md +12 -0
- openmhp-0.3.0/openmhp/devices/thermocycler-01/scripts/pcr.py +12 -0
- openmhp-0.3.0/openmhp/directory.py +213 -0
- openmhp-0.3.0/openmhp/discovery.py +113 -0
- openmhp-0.3.0/openmhp/driver.py +380 -0
- openmhp-0.3.0/openmhp/fleet.py +86 -0
- openmhp-0.3.0/openmhp/mcp_bridge.py +374 -0
- openmhp-0.3.0/openmhp/package.py +128 -0
- openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/SKILL.md +104 -0
- openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/madsci.md +25 -0
- openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/opcua.md +30 -0
- openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/pylabrobot.md +28 -0
- openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/ros2.md +30 -0
- openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/sila2.md +23 -0
- openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/scripts/build_manifest.py +18 -0
- openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/scripts/serve_fleet.py +20 -0
- openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/SKILL.md +117 -0
- openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/assets/DEVICE.template.md +33 -0
- openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/assets/descriptor_template.yaml +48 -0
- openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/assets/driver_template.py +45 -0
- openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/references/descriptor.md +23 -0
- openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/scripts/validate_package.py +10 -0
- openmhp-0.3.0/openmhp/skills/openmhp-operate/SKILL.md +70 -0
- openmhp-0.3.0/openmhp/skills_install.py +52 -0
- openmhp-0.3.0/openmhp/transport.py +122 -0
- openmhp-0.3.0/openmhp/validate.py +89 -0
- openmhp-0.3.0/openmhp.egg-info/PKG-INFO +200 -0
- openmhp-0.3.0/openmhp.egg-info/SOURCES.txt +56 -0
- openmhp-0.3.0/openmhp.egg-info/dependency_links.txt +1 -0
- openmhp-0.3.0/openmhp.egg-info/entry_points.txt +4 -0
- openmhp-0.3.0/openmhp.egg-info/requires.txt +4 -0
- openmhp-0.3.0/openmhp.egg-info/top_level.txt +1 -0
- openmhp-0.3.0/pyproject.toml +26 -0
- openmhp-0.3.0/setup.cfg +4 -0
- openmhp-0.3.0/tests/test_adapters.py +279 -0
openmhp-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openmhp
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: OpenMHP: reference implementation of the Open Model Hardware Protocol (MHP), an open protocol for AI agents to operate physical devices safely.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: pyyaml>=6
|
|
9
|
+
Provides-Extra: discovery
|
|
10
|
+
Requires-Dist: zeroconf>=0.130; extra == "discovery"
|
|
11
|
+
|
|
12
|
+
# OpenMHP
|
|
13
|
+
|
|
14
|
+
Reference implementation of the **Open Model Hardware Protocol (MHP)**: an
|
|
15
|
+
open protocol through which an AI agent finds a physical device among
|
|
16
|
+
thousands, learns how to operate it safely, reads from it, writes to it, and
|
|
17
|
+
runs long actions on it.
|
|
18
|
+
|
|
19
|
+
MHP is to instruments and machines what MCP is to software tools, with two
|
|
20
|
+
lessons from MCP's first year built in from the start: the agent's context
|
|
21
|
+
stays flat as the lab grows, and safety is enforced on the device side of the
|
|
22
|
+
wire.
|
|
23
|
+
|
|
24
|
+
| Primitive | Verb | Example |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| **Directory** | `directory/search` | "something idle that can heat a 96-well plate to 95 °C in bay 12" → 5 cards |
|
|
27
|
+
| **Describe** | `device/describe {detail, select}` | card (~40 tokens) → summary (~200) → full spec of the two items you'll use |
|
|
28
|
+
| **Signals** | `signals/read`, `signals/subscribe` | block temperature, arm position, lid state |
|
|
29
|
+
| **Settings** | `settings/write` | target temperature = 95 °C, refused above 105 |
|
|
30
|
+
| **Actions** | `actions/invoke` → job | run PCR protocol, pick plate; device does the work |
|
|
31
|
+
| **Safety** | `safety/limits`, `safety/estop`, `safety/reset` | limits, interlocks, approval levels, e-stop, watchdog |
|
|
32
|
+
|
|
33
|
+
Python ≥ 3.10; PyYAML is the only dependency.
|
|
34
|
+
|
|
35
|
+
**Docs:** [openmhp.com](https://openmhp.com) · [Quickstart](https://openmhp.com/quickstart) · [Add an instrument](https://openmhp.com/add-a-device) · [Adapters](https://openmhp.com/adapters) · [Specification](https://openmhp.com/spec). The site's source is [kushalsinha/openMHP-website](https://github.com/kushalsinha/openMHP-website); `SPEC.md` here is the specification's source of truth.
|
|
36
|
+
|
|
37
|
+
## For scientists: one command
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx openmhp setup
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That installs the runtime into `~/.openmhp`, puts the three Agent Skills where your harness
|
|
44
|
+
looks for them, and registers the MCP server with Claude Code and Codex (other harnesses get a
|
|
45
|
+
one-line config to paste). Then talk to your agent:
|
|
46
|
+
|
|
47
|
+
- *"find the instruments on my network"*: the agent scans for devices that speak MHP and lists them
|
|
48
|
+
- *"add the thermocycler"*: it joins your lab and is searchable from then on
|
|
49
|
+
- *"onboard my hotplate"*: the agent interviews you, writes the device package, validates it, adds it
|
|
50
|
+
- *"run a 30-cycle PCR at 95/58/72 and hold at 4 °C"*: find, read the owner's instructions, dry-run, run, verify
|
|
51
|
+
|
|
52
|
+
Try it without hardware: `npx openmhp demo` adds two simulated instruments. Until the npm and PyPI packages are published, point the launcher at this checkout:
|
|
53
|
+
`OPENMHP_SOURCE=/path/to/openmhp npx ./npm setup`.
|
|
54
|
+
|
|
55
|
+
## For developers: try it in 60 seconds
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install -e ".[discovery]"
|
|
59
|
+
|
|
60
|
+
# 1. code file: orchestrate a robot arm and a thermocycler
|
|
61
|
+
python examples/pcr_run.py
|
|
62
|
+
|
|
63
|
+
# 2. scale: 2,000 devices, find the right one for 1,087 tokens instead of 937,845
|
|
64
|
+
python examples/scale_demo.py
|
|
65
|
+
|
|
66
|
+
# 3. CLI: run devices and a directory over HTTP
|
|
67
|
+
mhp serve local:openmhp.devices.sim_thermocycler:SimThermocycler --http 18921 &
|
|
68
|
+
mhp serve local:openmhp.devices.sim_arm:SimArm --http 18922 &
|
|
69
|
+
mhp serve-directory thermo=http://localhost:18921 arm=http://localhost:18922 --http 18900 &
|
|
70
|
+
mhp http://localhost:18900 find pick plates # directory search
|
|
71
|
+
curl localhost:18921/mhp.json # discovery document
|
|
72
|
+
mhp http://localhost:18921 describe card # card | summary | full
|
|
73
|
+
mhp http://localhost:18921 write target_temperature 200 # refused: LimitViolation
|
|
74
|
+
mhp http://localhost:18921 invoke run_protocol '{"steps":[{"temp":95,"hold_s":5}],"cycles":3}' --wait
|
|
75
|
+
mhp http://localhost:18921 estop
|
|
76
|
+
|
|
77
|
+
# 4. MCP: expose the whole lab to any agent harness through eight tools, whatever its size
|
|
78
|
+
mhp-mcp --directory http://localhost:18900 # stdio
|
|
79
|
+
mhp-mcp --directory http://localhost:18900 --http 18800 # MCP Streamable HTTP at /mcp
|
|
80
|
+
|
|
81
|
+
# 5. adapters + live directory, verified against fakes (no hardware)
|
|
82
|
+
python tests/test_adapters.py
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Already running SiLA 2, PyLabRobot, MADSci, OPC UA or ROS 2?
|
|
86
|
+
|
|
87
|
+
Each device is a few lines with the matching adapter; every gate, tier and job comes for free:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from openmhp.adapters.sila2 import sila_device # also: pylabrobot.plr_device,
|
|
91
|
+
DEVICE = sila_device("10.0.0.12", 50052, # madsci.madsci_node, opcua.opcua_device,
|
|
92
|
+
device={"id": "arm-01", "class": "robot_arm", "location": "bay 3", "tags": ["plates"], # ros2.ros2_device
|
|
93
|
+
"notes": "PF400 plate mover. Light curtain trips safe_zone_clear."},
|
|
94
|
+
signals={"position": "RobotController.Position", "safe_zone_clear": ("SafetyController.SafeZoneClear", "boolean")},
|
|
95
|
+
settings={"speed": ("RobotController.SetSpeed.Speed", {"min": 1, "max": 100})},
|
|
96
|
+
actions={"move_to": ("RobotController.MoveTo", {"observable": True, "interlocks": ["safe_zone_clear"]})},
|
|
97
|
+
estop="RobotController.EmergencyStop")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Anything else with a Python callable goes through `BoundDriver(Signal, Setting, Action)` directly.
|
|
101
|
+
|
|
102
|
+
## Agent Skills
|
|
103
|
+
|
|
104
|
+
`openmhp/skills/` holds three [Agent Skills](https://agentskills.io) any skills-capable harness can load (`mhp skills install` copies them into place):
|
|
105
|
+
|
|
106
|
+
| Skill | Use it to |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `openmhp-onboard-device` | interview a device owner and write a validated descriptor + driver |
|
|
109
|
+
| `openmhp-adapt-fleet` | bring a SiLA 2 / PyLabRobot / MADSci / OPC UA / ROS 2 fleet under MHP, build the directory, publish `mhp-mcp` |
|
|
110
|
+
| `openmhp-operate` | run experiments safely through the eight tools: find → describe → check → dry-run → act → verify |
|
|
111
|
+
|
|
112
|
+
`npx openmhp setup` or `mhp skills install` installs them into `~/.claude/skills`, `~/.codex/skills` and any other harness skills folder that exists.
|
|
113
|
+
|
|
114
|
+
Claude Desktop / Claude Code config for the bridge:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{"mcpServers": {"lab": {"command": "mhp-mcp", "args": ["--directory", "http://directory:18900"]}}}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The bridge exposes `mhp_find`, `mhp_describe`, `mhp_read`, `mhp_write`,
|
|
121
|
+
`mhp_invoke`, `mhp_job`, `mhp_estop`, `mhp_run` (run a script against the
|
|
122
|
+
lab, get back only what it prints) and `mhp_lab` (scan the network, add and
|
|
123
|
+
onboard devices). Every tool carries `input_examples`. There are never
|
|
124
|
+
per-device tools.
|
|
125
|
+
|
|
126
|
+
## A device is a package, like a skill
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
devices/thermocycler-01/
|
|
130
|
+
├── DEVICE.md Level 1: YAML frontmatter = the card (~40 tokens in search results)
|
|
131
|
+
│ Level 2: Markdown body = operating instructions (loaded when chosen)
|
|
132
|
+
├── descriptor.yaml Level 3: limits, interlocks, params, examples (loaded per item)
|
|
133
|
+
├── driver.py Level 3: code
|
|
134
|
+
├── references/ Level 3: SOPs, manual excerpts
|
|
135
|
+
└── scripts/ Level 3: tested mhp_run scripts
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
mhp serve pkg:openmhp/devices/thermocycler-01 --http 18921
|
|
140
|
+
mhp http://localhost:18921 describe card # level 1
|
|
141
|
+
mhp http://localhost:18921 describe summary # level 2, with instructions
|
|
142
|
+
mhp http://localhost:18921 resources scripts/pcr.py # level 3
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Writing a driver
|
|
146
|
+
|
|
147
|
+
A driver is a package plus three hooks. Everything else (limit checks,
|
|
148
|
+
interlocks, approval gating, leases, jobs, e-stop, notifications, detail
|
|
149
|
+
tiers) is inherited.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from openmhp.driver import Driver
|
|
153
|
+
|
|
154
|
+
class MyHotplate(Driver):
|
|
155
|
+
descriptor = {
|
|
156
|
+
"device": {"id": "hotplate-01", "class": "hotplate", "make": "IKA", "model": "C-MAG",
|
|
157
|
+
"location": "fume hood 2", "tags": ["heating", "stirring"],
|
|
158
|
+
"notes": "Fume hood 2. Stir bar rattles above 800 rpm with 50 mL flasks."},
|
|
159
|
+
"physical": {"mass_kg": 3.2, "notes": "Plate surface reaches 500 °C; keep solvents capped."},
|
|
160
|
+
"signals": [{"name": "plate_temperature", "type": "number", "unit": "degC"}],
|
|
161
|
+
"settings": [{"name": "target_temperature", "type": "number", "unit": "degC",
|
|
162
|
+
"limits": {"min": 20, "max": 300}, "approval": "auto"},
|
|
163
|
+
{"name": "stir_rpm", "type": "number", "limits": {"min": 0, "max": 1500}}],
|
|
164
|
+
"actions": [{"name": "shutdown", "duration": "short", "approval": "confirm"}],
|
|
165
|
+
"safety": {"estop": True, "watchdog_s": 5},
|
|
166
|
+
}
|
|
167
|
+
def setup(self): self.dev = serial.Serial("/dev/ttyUSB0")
|
|
168
|
+
def on_read(self, name): return float(self.dev.query("IN_PV_1"))
|
|
169
|
+
def on_write(self, name, value): self.dev.write(f"OUT_SP_1 {value}")
|
|
170
|
+
def on_invoke(self, job): self.dev.write("STOP")
|
|
171
|
+
def on_estop(self): self.dev.write("STOP")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
mhp serve mypkg.hotplate:MyHotplate --http 18921
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Layout
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
openmhp/driver.py Driver base class: primitives, safety gates, jobs, leases, detail tiers, resources
|
|
182
|
+
openmhp/package.py device packages: DEVICE.md frontmatter + body, descriptor.yaml, resources
|
|
183
|
+
openmhp/directory.py Directory: card index + BM25 search + live state pings; serves directory/*
|
|
184
|
+
openmhp/fleet.py the lab's device list (~/.openmhp/fleet.json); in-process hosting of packages
|
|
185
|
+
openmhp/discovery.py mDNS advertise/browse (optional zeroconf) and HTTP probe of /mhp.json
|
|
186
|
+
openmhp/validate.py device package validator (`mhp validate`)
|
|
187
|
+
openmhp/skills_install.py copies bundled skills into harness skill dirs (`mhp skills install`)
|
|
188
|
+
npm/ the `npx openmhp` launcher (Node, no deps): runtime bootstrap, setup, scan/add
|
|
189
|
+
openmhp/adapters/ BoundDriver bindings; sila2, pylabrobot, madsci, opcua, ros2 adapters
|
|
190
|
+
openmhp/skills/ Agent Skills: onboard-device, adapt-fleet, operate (bundled)
|
|
191
|
+
tests/test_adapters.py adapters and live directory against injected fakes
|
|
192
|
+
openmhp/transport.py stdio and HTTP(+SSE) transports; /mhp.json discovery
|
|
193
|
+
openmhp/client.py Device / Lab client SDK (local, stdio, http); Lab is lazy and directory-aware
|
|
194
|
+
openmhp/cli.py `mhp` command, incl. serve and serve-directory
|
|
195
|
+
openmhp/mcp_bridge.py `mhp-mcp`: eight tools, constant in device count; mhp_run
|
|
196
|
+
openmhp/devices/ simulated thermocycler and robot arm, each as a device package
|
|
197
|
+
examples/pcr_run.py cross-device orchestration script
|
|
198
|
+
examples/scale_demo.py 2,000 devices; context cost old way vs MHP way
|
|
199
|
+
SPEC.md the specification, Markdown
|
|
200
|
+
```
|
openmhp-0.3.0/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# OpenMHP
|
|
2
|
+
|
|
3
|
+
Reference implementation of the **Open Model Hardware Protocol (MHP)**: an
|
|
4
|
+
open protocol through which an AI agent finds a physical device among
|
|
5
|
+
thousands, learns how to operate it safely, reads from it, writes to it, and
|
|
6
|
+
runs long actions on it.
|
|
7
|
+
|
|
8
|
+
MHP is to instruments and machines what MCP is to software tools, with two
|
|
9
|
+
lessons from MCP's first year built in from the start: the agent's context
|
|
10
|
+
stays flat as the lab grows, and safety is enforced on the device side of the
|
|
11
|
+
wire.
|
|
12
|
+
|
|
13
|
+
| Primitive | Verb | Example |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| **Directory** | `directory/search` | "something idle that can heat a 96-well plate to 95 °C in bay 12" → 5 cards |
|
|
16
|
+
| **Describe** | `device/describe {detail, select}` | card (~40 tokens) → summary (~200) → full spec of the two items you'll use |
|
|
17
|
+
| **Signals** | `signals/read`, `signals/subscribe` | block temperature, arm position, lid state |
|
|
18
|
+
| **Settings** | `settings/write` | target temperature = 95 °C, refused above 105 |
|
|
19
|
+
| **Actions** | `actions/invoke` → job | run PCR protocol, pick plate; device does the work |
|
|
20
|
+
| **Safety** | `safety/limits`, `safety/estop`, `safety/reset` | limits, interlocks, approval levels, e-stop, watchdog |
|
|
21
|
+
|
|
22
|
+
Python ≥ 3.10; PyYAML is the only dependency.
|
|
23
|
+
|
|
24
|
+
**Docs:** [openmhp.com](https://openmhp.com) · [Quickstart](https://openmhp.com/quickstart) · [Add an instrument](https://openmhp.com/add-a-device) · [Adapters](https://openmhp.com/adapters) · [Specification](https://openmhp.com/spec). The site's source is [kushalsinha/openMHP-website](https://github.com/kushalsinha/openMHP-website); `SPEC.md` here is the specification's source of truth.
|
|
25
|
+
|
|
26
|
+
## For scientists: one command
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx openmhp setup
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That installs the runtime into `~/.openmhp`, puts the three Agent Skills where your harness
|
|
33
|
+
looks for them, and registers the MCP server with Claude Code and Codex (other harnesses get a
|
|
34
|
+
one-line config to paste). Then talk to your agent:
|
|
35
|
+
|
|
36
|
+
- *"find the instruments on my network"*: the agent scans for devices that speak MHP and lists them
|
|
37
|
+
- *"add the thermocycler"*: it joins your lab and is searchable from then on
|
|
38
|
+
- *"onboard my hotplate"*: the agent interviews you, writes the device package, validates it, adds it
|
|
39
|
+
- *"run a 30-cycle PCR at 95/58/72 and hold at 4 °C"*: find, read the owner's instructions, dry-run, run, verify
|
|
40
|
+
|
|
41
|
+
Try it without hardware: `npx openmhp demo` adds two simulated instruments. Until the npm and PyPI packages are published, point the launcher at this checkout:
|
|
42
|
+
`OPENMHP_SOURCE=/path/to/openmhp npx ./npm setup`.
|
|
43
|
+
|
|
44
|
+
## For developers: try it in 60 seconds
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install -e ".[discovery]"
|
|
48
|
+
|
|
49
|
+
# 1. code file: orchestrate a robot arm and a thermocycler
|
|
50
|
+
python examples/pcr_run.py
|
|
51
|
+
|
|
52
|
+
# 2. scale: 2,000 devices, find the right one for 1,087 tokens instead of 937,845
|
|
53
|
+
python examples/scale_demo.py
|
|
54
|
+
|
|
55
|
+
# 3. CLI: run devices and a directory over HTTP
|
|
56
|
+
mhp serve local:openmhp.devices.sim_thermocycler:SimThermocycler --http 18921 &
|
|
57
|
+
mhp serve local:openmhp.devices.sim_arm:SimArm --http 18922 &
|
|
58
|
+
mhp serve-directory thermo=http://localhost:18921 arm=http://localhost:18922 --http 18900 &
|
|
59
|
+
mhp http://localhost:18900 find pick plates # directory search
|
|
60
|
+
curl localhost:18921/mhp.json # discovery document
|
|
61
|
+
mhp http://localhost:18921 describe card # card | summary | full
|
|
62
|
+
mhp http://localhost:18921 write target_temperature 200 # refused: LimitViolation
|
|
63
|
+
mhp http://localhost:18921 invoke run_protocol '{"steps":[{"temp":95,"hold_s":5}],"cycles":3}' --wait
|
|
64
|
+
mhp http://localhost:18921 estop
|
|
65
|
+
|
|
66
|
+
# 4. MCP: expose the whole lab to any agent harness through eight tools, whatever its size
|
|
67
|
+
mhp-mcp --directory http://localhost:18900 # stdio
|
|
68
|
+
mhp-mcp --directory http://localhost:18900 --http 18800 # MCP Streamable HTTP at /mcp
|
|
69
|
+
|
|
70
|
+
# 5. adapters + live directory, verified against fakes (no hardware)
|
|
71
|
+
python tests/test_adapters.py
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Already running SiLA 2, PyLabRobot, MADSci, OPC UA or ROS 2?
|
|
75
|
+
|
|
76
|
+
Each device is a few lines with the matching adapter; every gate, tier and job comes for free:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from openmhp.adapters.sila2 import sila_device # also: pylabrobot.plr_device,
|
|
80
|
+
DEVICE = sila_device("10.0.0.12", 50052, # madsci.madsci_node, opcua.opcua_device,
|
|
81
|
+
device={"id": "arm-01", "class": "robot_arm", "location": "bay 3", "tags": ["plates"], # ros2.ros2_device
|
|
82
|
+
"notes": "PF400 plate mover. Light curtain trips safe_zone_clear."},
|
|
83
|
+
signals={"position": "RobotController.Position", "safe_zone_clear": ("SafetyController.SafeZoneClear", "boolean")},
|
|
84
|
+
settings={"speed": ("RobotController.SetSpeed.Speed", {"min": 1, "max": 100})},
|
|
85
|
+
actions={"move_to": ("RobotController.MoveTo", {"observable": True, "interlocks": ["safe_zone_clear"]})},
|
|
86
|
+
estop="RobotController.EmergencyStop")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Anything else with a Python callable goes through `BoundDriver(Signal, Setting, Action)` directly.
|
|
90
|
+
|
|
91
|
+
## Agent Skills
|
|
92
|
+
|
|
93
|
+
`openmhp/skills/` holds three [Agent Skills](https://agentskills.io) any skills-capable harness can load (`mhp skills install` copies them into place):
|
|
94
|
+
|
|
95
|
+
| Skill | Use it to |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `openmhp-onboard-device` | interview a device owner and write a validated descriptor + driver |
|
|
98
|
+
| `openmhp-adapt-fleet` | bring a SiLA 2 / PyLabRobot / MADSci / OPC UA / ROS 2 fleet under MHP, build the directory, publish `mhp-mcp` |
|
|
99
|
+
| `openmhp-operate` | run experiments safely through the eight tools: find → describe → check → dry-run → act → verify |
|
|
100
|
+
|
|
101
|
+
`npx openmhp setup` or `mhp skills install` installs them into `~/.claude/skills`, `~/.codex/skills` and any other harness skills folder that exists.
|
|
102
|
+
|
|
103
|
+
Claude Desktop / Claude Code config for the bridge:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{"mcpServers": {"lab": {"command": "mhp-mcp", "args": ["--directory", "http://directory:18900"]}}}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The bridge exposes `mhp_find`, `mhp_describe`, `mhp_read`, `mhp_write`,
|
|
110
|
+
`mhp_invoke`, `mhp_job`, `mhp_estop`, `mhp_run` (run a script against the
|
|
111
|
+
lab, get back only what it prints) and `mhp_lab` (scan the network, add and
|
|
112
|
+
onboard devices). Every tool carries `input_examples`. There are never
|
|
113
|
+
per-device tools.
|
|
114
|
+
|
|
115
|
+
## A device is a package, like a skill
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
devices/thermocycler-01/
|
|
119
|
+
├── DEVICE.md Level 1: YAML frontmatter = the card (~40 tokens in search results)
|
|
120
|
+
│ Level 2: Markdown body = operating instructions (loaded when chosen)
|
|
121
|
+
├── descriptor.yaml Level 3: limits, interlocks, params, examples (loaded per item)
|
|
122
|
+
├── driver.py Level 3: code
|
|
123
|
+
├── references/ Level 3: SOPs, manual excerpts
|
|
124
|
+
└── scripts/ Level 3: tested mhp_run scripts
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
mhp serve pkg:openmhp/devices/thermocycler-01 --http 18921
|
|
129
|
+
mhp http://localhost:18921 describe card # level 1
|
|
130
|
+
mhp http://localhost:18921 describe summary # level 2, with instructions
|
|
131
|
+
mhp http://localhost:18921 resources scripts/pcr.py # level 3
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Writing a driver
|
|
135
|
+
|
|
136
|
+
A driver is a package plus three hooks. Everything else (limit checks,
|
|
137
|
+
interlocks, approval gating, leases, jobs, e-stop, notifications, detail
|
|
138
|
+
tiers) is inherited.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from openmhp.driver import Driver
|
|
142
|
+
|
|
143
|
+
class MyHotplate(Driver):
|
|
144
|
+
descriptor = {
|
|
145
|
+
"device": {"id": "hotplate-01", "class": "hotplate", "make": "IKA", "model": "C-MAG",
|
|
146
|
+
"location": "fume hood 2", "tags": ["heating", "stirring"],
|
|
147
|
+
"notes": "Fume hood 2. Stir bar rattles above 800 rpm with 50 mL flasks."},
|
|
148
|
+
"physical": {"mass_kg": 3.2, "notes": "Plate surface reaches 500 °C; keep solvents capped."},
|
|
149
|
+
"signals": [{"name": "plate_temperature", "type": "number", "unit": "degC"}],
|
|
150
|
+
"settings": [{"name": "target_temperature", "type": "number", "unit": "degC",
|
|
151
|
+
"limits": {"min": 20, "max": 300}, "approval": "auto"},
|
|
152
|
+
{"name": "stir_rpm", "type": "number", "limits": {"min": 0, "max": 1500}}],
|
|
153
|
+
"actions": [{"name": "shutdown", "duration": "short", "approval": "confirm"}],
|
|
154
|
+
"safety": {"estop": True, "watchdog_s": 5},
|
|
155
|
+
}
|
|
156
|
+
def setup(self): self.dev = serial.Serial("/dev/ttyUSB0")
|
|
157
|
+
def on_read(self, name): return float(self.dev.query("IN_PV_1"))
|
|
158
|
+
def on_write(self, name, value): self.dev.write(f"OUT_SP_1 {value}")
|
|
159
|
+
def on_invoke(self, job): self.dev.write("STOP")
|
|
160
|
+
def on_estop(self): self.dev.write("STOP")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
mhp serve mypkg.hotplate:MyHotplate --http 18921
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Layout
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
openmhp/driver.py Driver base class: primitives, safety gates, jobs, leases, detail tiers, resources
|
|
171
|
+
openmhp/package.py device packages: DEVICE.md frontmatter + body, descriptor.yaml, resources
|
|
172
|
+
openmhp/directory.py Directory: card index + BM25 search + live state pings; serves directory/*
|
|
173
|
+
openmhp/fleet.py the lab's device list (~/.openmhp/fleet.json); in-process hosting of packages
|
|
174
|
+
openmhp/discovery.py mDNS advertise/browse (optional zeroconf) and HTTP probe of /mhp.json
|
|
175
|
+
openmhp/validate.py device package validator (`mhp validate`)
|
|
176
|
+
openmhp/skills_install.py copies bundled skills into harness skill dirs (`mhp skills install`)
|
|
177
|
+
npm/ the `npx openmhp` launcher (Node, no deps): runtime bootstrap, setup, scan/add
|
|
178
|
+
openmhp/adapters/ BoundDriver bindings; sila2, pylabrobot, madsci, opcua, ros2 adapters
|
|
179
|
+
openmhp/skills/ Agent Skills: onboard-device, adapt-fleet, operate (bundled)
|
|
180
|
+
tests/test_adapters.py adapters and live directory against injected fakes
|
|
181
|
+
openmhp/transport.py stdio and HTTP(+SSE) transports; /mhp.json discovery
|
|
182
|
+
openmhp/client.py Device / Lab client SDK (local, stdio, http); Lab is lazy and directory-aware
|
|
183
|
+
openmhp/cli.py `mhp` command, incl. serve and serve-directory
|
|
184
|
+
openmhp/mcp_bridge.py `mhp-mcp`: eight tools, constant in device count; mhp_run
|
|
185
|
+
openmhp/devices/ simulated thermocycler and robot arm, each as a device package
|
|
186
|
+
examples/pcr_run.py cross-device orchestration script
|
|
187
|
+
examples/scale_demo.py 2,000 devices; context cost old way vs MHP way
|
|
188
|
+
SPEC.md the specification, Markdown
|
|
189
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""OpenMHP - reference implementation of the Open Model Hardware Protocol (MHP).
|
|
2
|
+
|
|
3
|
+
MHP is to physical devices what MCP is to software tools: a small JSON-RPC
|
|
4
|
+
protocol through which an AI agent discovers a device, learns how to use it
|
|
5
|
+
safely, reads from it, writes to it, and runs long-running actions on it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
PROTOCOL_VERSION = "2026-09-09"
|
|
9
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Adapters: put hardware already controlled by another layer behind MHP.
|
|
2
|
+
|
|
3
|
+
from openmhp.adapters import BoundDriver, Signal, Setting, Action # any Python callable
|
|
4
|
+
from openmhp.adapters.sila2 import sila_device # SiLA 2 servers
|
|
5
|
+
from openmhp.adapters.pylabrobot import plr_device # PyLabRobot machines
|
|
6
|
+
from openmhp.adapters.madsci import madsci_node # MADSci nodes
|
|
7
|
+
from openmhp.adapters.opcua import opcua_device # OPC UA servers / PLCs
|
|
8
|
+
from openmhp.adapters.ros2 import ros2_device # ROS 2 nodes
|
|
9
|
+
|
|
10
|
+
Each returns a Driver; serve it with `mhp serve`, register it in a Directory,
|
|
11
|
+
or expose it through `mhp-mcp` like any native MHP device.
|
|
12
|
+
"""
|
|
13
|
+
from .base import Action, BoundDriver, Setting, Signal, params_from_signature
|
|
14
|
+
|
|
15
|
+
__all__ = ["Action", "BoundDriver", "Setting", "Signal", "params_from_signature"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Bindings: the few-lines way to put any controllable thing behind MHP.
|
|
2
|
+
|
|
3
|
+
An adapter author describes a device as three small lists of *bindings*, each
|
|
4
|
+
pairing an MHP name with a callable into the underlying control layer. The
|
|
5
|
+
BoundDriver turns them into a full MHP driver with every safety gate, the
|
|
6
|
+
detail tiers, jobs and notifications inherited from Driver.
|
|
7
|
+
|
|
8
|
+
dev = BoundDriver(
|
|
9
|
+
device={"id": "hotplate-01", "class": "hotplate", "notes": "Fume hood 2."},
|
|
10
|
+
signals=[Signal("plate_temperature", read=lambda: plc.read(0x10), unit="degC")],
|
|
11
|
+
settings=[Setting("target_temperature", write=lambda v: plc.write(0x20, v),
|
|
12
|
+
unit="degC", limits={"min": 20, "max": 300})],
|
|
13
|
+
actions=[Action("shutdown", run=lambda job, p: plc.write(0x21, 0), approval="confirm")],
|
|
14
|
+
estop=lambda: plc.write(0x21, 0),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
Ecosystem adapters (sila2, pylabrobot, madsci, opcua, ros2) are just functions
|
|
18
|
+
that build these bindings by introspecting their layer.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
from ..driver import Driver, Job
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Signal:
|
|
30
|
+
name: str
|
|
31
|
+
read: Callable[[], Any]
|
|
32
|
+
type: str = "number"
|
|
33
|
+
unit: str | None = None
|
|
34
|
+
notes: str | None = None
|
|
35
|
+
|
|
36
|
+
def spec(self) -> dict:
|
|
37
|
+
return _clean({"name": self.name, "type": self.type, "unit": self.unit, "notes": self.notes})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Setting:
|
|
42
|
+
name: str
|
|
43
|
+
write: Callable[[Any], None]
|
|
44
|
+
read: Callable[[], Any] | None = None
|
|
45
|
+
type: str = "number"
|
|
46
|
+
unit: str | None = None
|
|
47
|
+
limits: dict | None = None
|
|
48
|
+
approval: str = "auto"
|
|
49
|
+
interlocks: list[str] = field(default_factory=list)
|
|
50
|
+
notes: str | None = None
|
|
51
|
+
|
|
52
|
+
def spec(self) -> dict:
|
|
53
|
+
return _clean({"name": self.name, "type": self.type, "unit": self.unit, "limits": self.limits,
|
|
54
|
+
"approval": self.approval, "interlocks": self.interlocks or None, "notes": self.notes})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Action:
|
|
59
|
+
name: str
|
|
60
|
+
run: Callable[[Job, dict], Any] # run(job, params) -> result; may call driver.progress(job, x)
|
|
61
|
+
duration: str = "short"
|
|
62
|
+
approval: str = "auto"
|
|
63
|
+
interlocks: list[str] = field(default_factory=list)
|
|
64
|
+
params: dict | None = None
|
|
65
|
+
limits: dict | None = None
|
|
66
|
+
examples: list[dict] | None = None
|
|
67
|
+
concurrent: bool = False
|
|
68
|
+
notes: str | None = None
|
|
69
|
+
|
|
70
|
+
def spec(self) -> dict:
|
|
71
|
+
return _clean({"name": self.name, "duration": self.duration, "approval": self.approval,
|
|
72
|
+
"interlocks": self.interlocks or None, "params": self.params, "limits": self.limits,
|
|
73
|
+
"examples": self.examples, "concurrent": self.concurrent or None, "notes": self.notes})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _clean(d: dict) -> dict:
|
|
77
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class BoundDriver(Driver):
|
|
81
|
+
"""A Driver assembled from bindings. Works with every transport and the directory."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, device: dict, signals: list[Signal] = (), settings: list[Setting] = (),
|
|
84
|
+
actions: list[Action] = (), *, physical: dict | None = None, safety: dict | None = None,
|
|
85
|
+
estop: Callable[[], None] | None = None, setup: Callable[[], None] | None = None,
|
|
86
|
+
extra: dict | None = None):
|
|
87
|
+
self._signals = {s.name: s for s in signals}
|
|
88
|
+
self._settings = {s.name: s for s in settings}
|
|
89
|
+
self._actions = {a.name: a for a in actions}
|
|
90
|
+
self._estop_fn, self._setup_fn = estop, setup
|
|
91
|
+
interlocks = sorted({i for b in [*settings, *actions] for i in b.interlocks})
|
|
92
|
+
self.descriptor = {
|
|
93
|
+
"device": device,
|
|
94
|
+
"physical": physical or {},
|
|
95
|
+
"signals": [s.spec() for s in signals],
|
|
96
|
+
"settings": [s.spec() for s in settings],
|
|
97
|
+
"actions": [a.spec() for a in actions],
|
|
98
|
+
"safety": {"estop": estop is not None, "interlocks": interlocks, **(safety or {})},
|
|
99
|
+
**(extra or {}),
|
|
100
|
+
}
|
|
101
|
+
super().__init__()
|
|
102
|
+
|
|
103
|
+
def setup(self):
|
|
104
|
+
if self._setup_fn:
|
|
105
|
+
self._setup_fn()
|
|
106
|
+
|
|
107
|
+
def on_read(self, name):
|
|
108
|
+
if name in self._signals:
|
|
109
|
+
return self._signals[name].read()
|
|
110
|
+
s = self._settings.get(name)
|
|
111
|
+
if s and s.read:
|
|
112
|
+
return s.read()
|
|
113
|
+
raise KeyError(name)
|
|
114
|
+
|
|
115
|
+
def on_write(self, name, value):
|
|
116
|
+
self._settings[name].write(value)
|
|
117
|
+
|
|
118
|
+
def on_invoke(self, job: Job):
|
|
119
|
+
return self._actions[job.action].run(job, job.params)
|
|
120
|
+
|
|
121
|
+
def on_estop(self):
|
|
122
|
+
if self._estop_fn:
|
|
123
|
+
self._estop_fn()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def params_from_signature(fn: Callable, skip: tuple[str, ...] = ("self",)) -> dict:
|
|
127
|
+
"""Best-effort MHP `params` doc from a Python signature."""
|
|
128
|
+
import inspect
|
|
129
|
+
out = {}
|
|
130
|
+
try:
|
|
131
|
+
sig = inspect.signature(fn)
|
|
132
|
+
except (TypeError, ValueError):
|
|
133
|
+
return out
|
|
134
|
+
for n, p in sig.parameters.items():
|
|
135
|
+
if n in skip or p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
|
136
|
+
continue
|
|
137
|
+
ann = "" if p.annotation is inspect._empty else getattr(p.annotation, "__name__", str(p.annotation))
|
|
138
|
+
dflt = "" if p.default is inspect._empty else f" (default {p.default!r})"
|
|
139
|
+
out[n] = (ann + dflt).strip() or "any"
|
|
140
|
+
return out
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""MADSci node -> MHP.
|
|
2
|
+
|
|
3
|
+
from openmhp.adapters.madsci import madsci_node
|
|
4
|
+
dev = madsci_node("http://192.168.1.40:2000",
|
|
5
|
+
device={"id": "pf400-01", "class": "robot_arm", "location": "bay 2",
|
|
6
|
+
"notes": "MADSci-managed PF400."})
|
|
7
|
+
|
|
8
|
+
MADSci nodes already speak REST with an action vocabulary, so the adapter
|
|
9
|
+
reads the node's own description and needs no hand-written bindings:
|
|
10
|
+
|
|
11
|
+
GET {url}/info node info incl. actions and their args -> MHP actions
|
|
12
|
+
GET {url}/state node state dict -> MHP signals (one per key)
|
|
13
|
+
GET {url}/status node status (busy, errored, ...) -> MHP state
|
|
14
|
+
POST {url}/action {"action_name", "args"} <- actions/invoke
|
|
15
|
+
GET {url}/action/{id} action result / status -> jobs/progress
|
|
16
|
+
POST {url}/admin/{cmd} safety_stop, reset <- safety/estop, safety/reset
|
|
17
|
+
|
|
18
|
+
Path names are class attributes so a lab can adjust them if its MADSci
|
|
19
|
+
version differs. `http` may be injected (any object with get/post returning
|
|
20
|
+
JSON-able dicts) for tests.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import time
|
|
26
|
+
import urllib.request
|
|
27
|
+
|
|
28
|
+
from .base import Action, BoundDriver, Signal
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Http:
|
|
32
|
+
def __init__(self, base: str, timeout: float = 30):
|
|
33
|
+
self.base, self.timeout = base.rstrip("/"), timeout
|
|
34
|
+
|
|
35
|
+
def _req(self, method, path, body=None):
|
|
36
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
37
|
+
req = urllib.request.Request(self.base + path, data=data, method=method,
|
|
38
|
+
headers={"Content-Type": "application/json"})
|
|
39
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
40
|
+
raw = r.read()
|
|
41
|
+
return json.loads(raw) if raw else {}
|
|
42
|
+
|
|
43
|
+
def get(self, path): return self._req("GET", path)
|
|
44
|
+
def post(self, path, body=None): return self._req("POST", path, body or {})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MadsciPaths:
|
|
48
|
+
info, state, status, action, action_result, admin = "/info", "/state", "/status", "/action", "/action/{id}", "/admin/{cmd}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def madsci_node(url: str, *, device: dict, http=None, paths: type = MadsciPaths, poll_s: float = 0.5,
|
|
52
|
+
approval: dict | None = None, physical: dict | None = None) -> BoundDriver:
|
|
53
|
+
http = http or _Http(url)
|
|
54
|
+
info = http.get(paths.info)
|
|
55
|
+
approval = approval or {}
|
|
56
|
+
node_actions = info.get("actions") or {}
|
|
57
|
+
if isinstance(node_actions, list): # some versions return a list of {name, args, description}
|
|
58
|
+
node_actions = {a["name"]: a for a in node_actions}
|
|
59
|
+
|
|
60
|
+
state_keys = list((http.get(paths.state) or {}).keys())
|
|
61
|
+
|
|
62
|
+
def sig(key):
|
|
63
|
+
return Signal(key, read=lambda: (http.get(paths.state) or {}).get(key), type="string")
|
|
64
|
+
|
|
65
|
+
def action(name, meta):
|
|
66
|
+
def run(job, params):
|
|
67
|
+
res = http.post(paths.action, {"action_name": name, "args": params})
|
|
68
|
+
aid = res.get("action_id") or res.get("id")
|
|
69
|
+
status = res.get("status")
|
|
70
|
+
while aid and status not in ("succeeded", "failed", "cancelled", None):
|
|
71
|
+
time.sleep(poll_s)
|
|
72
|
+
res = http.get(paths.action_result.format(id=aid))
|
|
73
|
+
status = res.get("status")
|
|
74
|
+
if job.cancel_requested:
|
|
75
|
+
break
|
|
76
|
+
if status == "failed":
|
|
77
|
+
raise RuntimeError(res.get("errors") or res.get("error") or "MADSci action failed")
|
|
78
|
+
return res
|
|
79
|
+
args = meta.get("args") or {}
|
|
80
|
+
params = {k: (v.get("description") or v.get("type") or "any") if isinstance(v, dict) else str(v) for k, v in args.items()} or None
|
|
81
|
+
return Action(name, run=run, duration="long", params=params, approval=approval.get(name, "auto"),
|
|
82
|
+
notes=meta.get("description"))
|
|
83
|
+
|
|
84
|
+
dev = dict(device)
|
|
85
|
+
dev.setdefault("make", "MADSci")
|
|
86
|
+
dev.setdefault("model", info.get("node_type") or info.get("module_name") or "node")
|
|
87
|
+
|
|
88
|
+
driver = BoundDriver(
|
|
89
|
+
device=dev, physical=physical,
|
|
90
|
+
signals=[sig(k) for k in state_keys],
|
|
91
|
+
actions=[action(n, m if isinstance(m, dict) else {}) for n, m in node_actions.items()],
|
|
92
|
+
estop=lambda: http.post(paths.admin.format(cmd="safety_stop")),
|
|
93
|
+
extra={"madsci": {"url": url, "node_id": info.get("node_id")}},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# MADSci also knows whether it is busy: fold that into the MHP state on ping.
|
|
97
|
+
_ping = driver.rpc_ping
|
|
98
|
+
|
|
99
|
+
def rpc_ping(p, client):
|
|
100
|
+
out = _ping(p, client)
|
|
101
|
+
try:
|
|
102
|
+
st = http.get(paths.status) or {}
|
|
103
|
+
if st.get("errored"):
|
|
104
|
+
out["madsci"] = "errored"
|
|
105
|
+
elif st.get("busy") and out["state"] == "idle":
|
|
106
|
+
out["state"] = "busy"
|
|
107
|
+
except Exception: # noqa: BLE001
|
|
108
|
+
pass
|
|
109
|
+
return out
|
|
110
|
+
driver.rpc_ping = rpc_ping
|
|
111
|
+
return driver
|