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.
Files changed (58) hide show
  1. openmhp-0.3.0/PKG-INFO +200 -0
  2. openmhp-0.3.0/README.md +189 -0
  3. openmhp-0.3.0/openmhp/__init__.py +9 -0
  4. openmhp-0.3.0/openmhp/adapters/__init__.py +15 -0
  5. openmhp-0.3.0/openmhp/adapters/base.py +140 -0
  6. openmhp-0.3.0/openmhp/adapters/madsci.py +111 -0
  7. openmhp-0.3.0/openmhp/adapters/opcua.py +72 -0
  8. openmhp-0.3.0/openmhp/adapters/pylabrobot.py +77 -0
  9. openmhp-0.3.0/openmhp/adapters/ros2.py +116 -0
  10. openmhp-0.3.0/openmhp/adapters/sila2.py +93 -0
  11. openmhp-0.3.0/openmhp/cli.py +128 -0
  12. openmhp-0.3.0/openmhp/client.py +236 -0
  13. openmhp-0.3.0/openmhp/devices/__init__.py +1 -0
  14. openmhp-0.3.0/openmhp/devices/arm-01/DEVICE.md +37 -0
  15. openmhp-0.3.0/openmhp/devices/arm-01/descriptor.yaml +79 -0
  16. openmhp-0.3.0/openmhp/devices/arm-01/driver.py +2 -0
  17. openmhp-0.3.0/openmhp/devices/arm-01/references/locations.md +12 -0
  18. openmhp-0.3.0/openmhp/devices/arm-01/scripts/plate_to_thermocycler.py +10 -0
  19. openmhp-0.3.0/openmhp/devices/sim_arm.py +77 -0
  20. openmhp-0.3.0/openmhp/devices/sim_thermocycler.py +67 -0
  21. openmhp-0.3.0/openmhp/devices/thermocycler-01/DEVICE.md +39 -0
  22. openmhp-0.3.0/openmhp/devices/thermocycler-01/descriptor.yaml +61 -0
  23. openmhp-0.3.0/openmhp/devices/thermocycler-01/driver.py +3 -0
  24. openmhp-0.3.0/openmhp/devices/thermocycler-01/references/protocols.md +12 -0
  25. openmhp-0.3.0/openmhp/devices/thermocycler-01/scripts/pcr.py +12 -0
  26. openmhp-0.3.0/openmhp/directory.py +213 -0
  27. openmhp-0.3.0/openmhp/discovery.py +113 -0
  28. openmhp-0.3.0/openmhp/driver.py +380 -0
  29. openmhp-0.3.0/openmhp/fleet.py +86 -0
  30. openmhp-0.3.0/openmhp/mcp_bridge.py +374 -0
  31. openmhp-0.3.0/openmhp/package.py +128 -0
  32. openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/SKILL.md +104 -0
  33. openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/madsci.md +25 -0
  34. openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/opcua.md +30 -0
  35. openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/pylabrobot.md +28 -0
  36. openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/ros2.md +30 -0
  37. openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/references/sila2.md +23 -0
  38. openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/scripts/build_manifest.py +18 -0
  39. openmhp-0.3.0/openmhp/skills/openmhp-adapt-fleet/scripts/serve_fleet.py +20 -0
  40. openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/SKILL.md +117 -0
  41. openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/assets/DEVICE.template.md +33 -0
  42. openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/assets/descriptor_template.yaml +48 -0
  43. openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/assets/driver_template.py +45 -0
  44. openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/references/descriptor.md +23 -0
  45. openmhp-0.3.0/openmhp/skills/openmhp-onboard-device/scripts/validate_package.py +10 -0
  46. openmhp-0.3.0/openmhp/skills/openmhp-operate/SKILL.md +70 -0
  47. openmhp-0.3.0/openmhp/skills_install.py +52 -0
  48. openmhp-0.3.0/openmhp/transport.py +122 -0
  49. openmhp-0.3.0/openmhp/validate.py +89 -0
  50. openmhp-0.3.0/openmhp.egg-info/PKG-INFO +200 -0
  51. openmhp-0.3.0/openmhp.egg-info/SOURCES.txt +56 -0
  52. openmhp-0.3.0/openmhp.egg-info/dependency_links.txt +1 -0
  53. openmhp-0.3.0/openmhp.egg-info/entry_points.txt +4 -0
  54. openmhp-0.3.0/openmhp.egg-info/requires.txt +4 -0
  55. openmhp-0.3.0/openmhp.egg-info/top_level.txt +1 -0
  56. openmhp-0.3.0/pyproject.toml +26 -0
  57. openmhp-0.3.0/setup.cfg +4 -0
  58. 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
+ ```
@@ -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