openmhp 0.3.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.
- openmhp/__init__.py +9 -0
- openmhp/adapters/__init__.py +15 -0
- openmhp/adapters/base.py +140 -0
- openmhp/adapters/madsci.py +111 -0
- openmhp/adapters/opcua.py +72 -0
- openmhp/adapters/pylabrobot.py +77 -0
- openmhp/adapters/ros2.py +116 -0
- openmhp/adapters/sila2.py +93 -0
- openmhp/cli.py +128 -0
- openmhp/client.py +236 -0
- openmhp/devices/__init__.py +1 -0
- openmhp/devices/arm-01/DEVICE.md +37 -0
- openmhp/devices/arm-01/descriptor.yaml +79 -0
- openmhp/devices/arm-01/driver.py +2 -0
- openmhp/devices/arm-01/references/locations.md +12 -0
- openmhp/devices/arm-01/scripts/plate_to_thermocycler.py +10 -0
- openmhp/devices/sim_arm.py +77 -0
- openmhp/devices/sim_thermocycler.py +67 -0
- openmhp/devices/thermocycler-01/DEVICE.md +39 -0
- openmhp/devices/thermocycler-01/descriptor.yaml +61 -0
- openmhp/devices/thermocycler-01/driver.py +3 -0
- openmhp/devices/thermocycler-01/references/protocols.md +12 -0
- openmhp/devices/thermocycler-01/scripts/pcr.py +12 -0
- openmhp/directory.py +213 -0
- openmhp/discovery.py +113 -0
- openmhp/driver.py +380 -0
- openmhp/fleet.py +86 -0
- openmhp/mcp_bridge.py +374 -0
- openmhp/package.py +128 -0
- openmhp/skills/openmhp-adapt-fleet/SKILL.md +104 -0
- openmhp/skills/openmhp-adapt-fleet/references/madsci.md +25 -0
- openmhp/skills/openmhp-adapt-fleet/references/opcua.md +30 -0
- openmhp/skills/openmhp-adapt-fleet/references/pylabrobot.md +28 -0
- openmhp/skills/openmhp-adapt-fleet/references/ros2.md +30 -0
- openmhp/skills/openmhp-adapt-fleet/references/sila2.md +23 -0
- openmhp/skills/openmhp-adapt-fleet/scripts/build_manifest.py +18 -0
- openmhp/skills/openmhp-adapt-fleet/scripts/serve_fleet.py +20 -0
- openmhp/skills/openmhp-onboard-device/SKILL.md +117 -0
- openmhp/skills/openmhp-onboard-device/assets/DEVICE.template.md +33 -0
- openmhp/skills/openmhp-onboard-device/assets/descriptor_template.yaml +48 -0
- openmhp/skills/openmhp-onboard-device/assets/driver_template.py +45 -0
- openmhp/skills/openmhp-onboard-device/references/descriptor.md +23 -0
- openmhp/skills/openmhp-onboard-device/scripts/validate_package.py +10 -0
- openmhp/skills/openmhp-operate/SKILL.md +70 -0
- openmhp/skills_install.py +52 -0
- openmhp/transport.py +122 -0
- openmhp/validate.py +89 -0
- openmhp-0.3.0.dist-info/METADATA +200 -0
- openmhp-0.3.0.dist-info/RECORD +52 -0
- openmhp-0.3.0.dist-info/WHEEL +5 -0
- openmhp-0.3.0.dist-info/entry_points.txt +4 -0
- openmhp-0.3.0.dist-info/top_level.txt +1 -0
openmhp/__init__.py
ADDED
|
@@ -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"]
|
openmhp/adapters/base.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""OPC UA -> MHP. (Also the pattern for Modbus and other register-style layers.)
|
|
2
|
+
|
|
3
|
+
from openmhp.adapters.opcua import opcua_device
|
|
4
|
+
dev = opcua_device("opc.tcp://furnace-plc:4840",
|
|
5
|
+
device={"id": "furnace-02", "class": "oven", "location": "line 4",
|
|
6
|
+
"notes": "Sintering furnace. Door interlock is hardwired; MHP mirrors it."},
|
|
7
|
+
signals={"zone1_temperature": ("ns=2;s=Furnace.Zone1.PV", "degC"),
|
|
8
|
+
"door_closed": ("ns=2;s=Furnace.DoorClosed", "boolean")},
|
|
9
|
+
settings={"zone1_setpoint": ("ns=2;s=Furnace.Zone1.SP", {"min": 20, "max": 1400}, "degC")},
|
|
10
|
+
actions={"start_program": ("ns=2;s=Furnace", "ns=2;s=Furnace.Start",
|
|
11
|
+
{"interlocks": ["door_closed"], "approval": "confirm"})},
|
|
12
|
+
estop=("ns=2;s=Furnace", "ns=2;s=Furnace.Abort"))
|
|
13
|
+
|
|
14
|
+
Mapping
|
|
15
|
+
Variable node (read) -> signal (nodeid, unit?)
|
|
16
|
+
Variable node (write) -> setting (nodeid, limits?, unit?)
|
|
17
|
+
Method node -> action (object nodeid, method nodeid, opts?); params are passed
|
|
18
|
+
positionally in the order of the `params` doc you give
|
|
19
|
+
Alarms & conditions -> not mapped in 0.2; poll a signal instead
|
|
20
|
+
|
|
21
|
+
Uses python-opcua (`opcua.Client`) or asyncua's sync wrapper
|
|
22
|
+
(`asyncua.sync.Client`); pass `client=` to inject either, or a fake for tests
|
|
23
|
+
(needs get_node(id) -> node with get_value/set_value, and call_method).
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from .base import Action, BoundDriver, Setting, Signal
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _connect(url: str):
|
|
31
|
+
try:
|
|
32
|
+
from asyncua.sync import Client
|
|
33
|
+
except ImportError:
|
|
34
|
+
from opcua import Client
|
|
35
|
+
c = Client(url)
|
|
36
|
+
c.connect()
|
|
37
|
+
return c
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def opcua_device(url: str = "", *, device: dict, signals: dict | None = None, settings: dict | None = None,
|
|
41
|
+
actions: dict | None = None, estop: tuple | None = None, physical: dict | None = None,
|
|
42
|
+
client=None) -> BoundDriver:
|
|
43
|
+
client = client or _connect(url)
|
|
44
|
+
node = client.get_node
|
|
45
|
+
|
|
46
|
+
def sig(name, spec):
|
|
47
|
+
nid, unit = (spec, None) if isinstance(spec, str) else (spec[0], spec[1] if len(spec) > 1 else None)
|
|
48
|
+
typ = "boolean" if unit == "boolean" else "number"
|
|
49
|
+
return Signal(name, read=lambda: node(nid).get_value(), unit=None if typ == "boolean" else unit, type=typ)
|
|
50
|
+
|
|
51
|
+
def setting(name, spec):
|
|
52
|
+
nid, limits, unit = (spec, None, None) if isinstance(spec, str) else (spec + (None, None))[:3]
|
|
53
|
+
return Setting(name, write=lambda v: node(nid).set_value(v), read=lambda: node(nid).get_value(),
|
|
54
|
+
limits=limits, unit=unit)
|
|
55
|
+
|
|
56
|
+
def action(name, spec):
|
|
57
|
+
obj, meth, opts = (spec + ({},))[:3]
|
|
58
|
+
order = list((opts.get("params") or {}).keys())
|
|
59
|
+
|
|
60
|
+
def run(job, params):
|
|
61
|
+
args = [params[k] for k in order] if order else list(params.values())
|
|
62
|
+
return node(obj).call_method(meth, *args)
|
|
63
|
+
return Action(name, run=run, **opts)
|
|
64
|
+
|
|
65
|
+
return BoundDriver(
|
|
66
|
+
device=device, physical=physical,
|
|
67
|
+
signals=[sig(n, s) for n, s in (signals or {}).items()],
|
|
68
|
+
settings=[setting(n, s) for n, s in (settings or {}).items()],
|
|
69
|
+
actions=[action(n, s) for n, s in (actions or {}).items()],
|
|
70
|
+
estop=(lambda: node(estop[0]).call_method(estop[1])) if estop else None,
|
|
71
|
+
extra={"opcua": {"url": url}},
|
|
72
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""PyLabRobot -> MHP.
|
|
2
|
+
|
|
3
|
+
from pylabrobot.liquid_handling import LiquidHandler
|
|
4
|
+
from pylabrobot.liquid_handling.backends import STAR
|
|
5
|
+
from pylabrobot.resources import STARLetDeck
|
|
6
|
+
from openmhp.adapters.pylabrobot import plr_device
|
|
7
|
+
|
|
8
|
+
lh = LiquidHandler(backend=STAR(), deck=STARLetDeck())
|
|
9
|
+
dev = plr_device(lh, device={"id": "star-01", "class": "liquid_handler", "location": "bay 1",
|
|
10
|
+
"notes": "Hamilton STARlet. Keep the deck clear of loose tips."},
|
|
11
|
+
actions=["pick_up_tips", "drop_tips", "aspirate", "dispense", "move_plate"],
|
|
12
|
+
limits={"aspirate": {"vols": [0, 1000]}})
|
|
13
|
+
|
|
14
|
+
Every public coroutine on the machine becomes an MHP action (or only the ones
|
|
15
|
+
you name), with `params` documented from its Python signature. setup()/stop()
|
|
16
|
+
are wired to the driver's lifecycle and e-stop. Coroutines run on a private
|
|
17
|
+
event loop thread so the synchronous MHP driver can call them.
|
|
18
|
+
|
|
19
|
+
Works with any pylabrobot Machine (LiquidHandler, PlateReader, Centrifuge,
|
|
20
|
+
Incubator, ...). `machine` may be a fake with async methods for testing.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import inspect
|
|
26
|
+
import threading
|
|
27
|
+
|
|
28
|
+
from .base import Action, BoundDriver, Signal, params_from_signature
|
|
29
|
+
|
|
30
|
+
_SKIP = {"setup", "stop", "serialize", "deserialize", "save", "load", "assign_child_resource",
|
|
31
|
+
"unassign_child_resource", "get_resource"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Loop:
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.loop = asyncio.new_event_loop()
|
|
37
|
+
threading.Thread(target=self.loop.run_forever, daemon=True).start()
|
|
38
|
+
|
|
39
|
+
def run(self, coro, timeout=None):
|
|
40
|
+
return asyncio.run_coroutine_threadsafe(coro, self.loop).result(timeout)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def plr_device(machine, *, device: dict, actions: list[str] | None = None, signals: dict | None = None,
|
|
44
|
+
limits: dict | None = None, approval: dict | None = None, physical: dict | None = None,
|
|
45
|
+
setup: bool = True, action_timeout_s: float = 3600) -> BoundDriver:
|
|
46
|
+
loop = _Loop()
|
|
47
|
+
limits, approval = limits or {}, approval or {}
|
|
48
|
+
|
|
49
|
+
names = actions or [n for n, m in inspect.getmembers(machine, inspect.iscoroutinefunction)
|
|
50
|
+
if not n.startswith("_") and n not in _SKIP]
|
|
51
|
+
|
|
52
|
+
def make_action(name):
|
|
53
|
+
fn = getattr(machine, name)
|
|
54
|
+
|
|
55
|
+
def run(job, params):
|
|
56
|
+
res = loop.run(fn(**params), timeout=action_timeout_s)
|
|
57
|
+
return res if isinstance(res, (dict, list, str, int, float, bool, type(None))) else str(res)
|
|
58
|
+
return Action(name, run=run, duration="long", params=params_from_signature(fn) or None,
|
|
59
|
+
limits=limits.get(name), approval=approval.get(name, "auto"),
|
|
60
|
+
notes=(inspect.getdoc(fn) or "").split("\n")[0] or None)
|
|
61
|
+
|
|
62
|
+
sigs = [Signal("setup_finished", read=lambda: bool(getattr(machine, "setup_finished", False)), type="boolean")]
|
|
63
|
+
for n, fn in (signals or {}).items():
|
|
64
|
+
sigs.append(Signal(n, read=fn))
|
|
65
|
+
|
|
66
|
+
def do_setup():
|
|
67
|
+
if setup and hasattr(machine, "setup"):
|
|
68
|
+
loop.run(machine.setup())
|
|
69
|
+
|
|
70
|
+
def do_stop():
|
|
71
|
+
if hasattr(machine, "stop"):
|
|
72
|
+
loop.run(machine.stop(), timeout=30)
|
|
73
|
+
|
|
74
|
+
return BoundDriver(device=device, physical=physical, signals=sigs,
|
|
75
|
+
actions=[make_action(n) for n in names], estop=do_stop, setup=do_setup,
|
|
76
|
+
extra={"pylabrobot": {"machine": type(machine).__name__,
|
|
77
|
+
"backend": type(getattr(machine, "backend", None)).__name__}})
|
openmhp/adapters/ros2.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""ROS 2 -> MHP.
|
|
2
|
+
|
|
3
|
+
from openmhp.adapters.ros2 import ros2_device
|
|
4
|
+
dev = ros2_device("mhp_ur5e",
|
|
5
|
+
device={"id": "ur5e-01", "class": "robot_arm", "location": "cell 7",
|
|
6
|
+
"notes": "UR5e on the assembly cell. 5 kg payload."},
|
|
7
|
+
signals={"joint_positions": ("/joint_states", "sensor_msgs/msg/JointState", "position"),
|
|
8
|
+
"estop_clear": ("/safety/estop_clear", "std_msgs/msg/Bool", "data")},
|
|
9
|
+
settings={"speed_scale": ("/speed_scaling", "std_msgs/msg/Float64", "data", {"min": 0.05, "max": 1.0})},
|
|
10
|
+
actions={"move_to_joints": ("/follow_joint_trajectory",
|
|
11
|
+
"control_msgs/action/FollowJointTrajectory",
|
|
12
|
+
{"interlocks": ["estop_clear"], "duration": "long"})},
|
|
13
|
+
estop=("/ur_hardware_interface/dashboard/stop", "std_srvs/srv/Trigger"))
|
|
14
|
+
|
|
15
|
+
Mapping
|
|
16
|
+
topic (subscribe) -> signal (topic, msg_type, field path) last message cached
|
|
17
|
+
topic (publish) -> setting (topic, msg_type, field, limits?)
|
|
18
|
+
action server -> action (name, action_type, opts); goal built from params,
|
|
19
|
+
feedback -> jobs/progress if it has a `progress` field,
|
|
20
|
+
cancel_requested -> cancel_goal
|
|
21
|
+
service (Trigger) -> estop (name, srv_type)
|
|
22
|
+
|
|
23
|
+
Needs rclpy and the message packages on the PYTHONPATH (source your ROS 2
|
|
24
|
+
workspace first). The node spins on a background thread.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
from .base import Action, BoundDriver, Setting, Signal
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _field(msg, path: str):
|
|
35
|
+
for part in path.split("."):
|
|
36
|
+
msg = getattr(msg, part)
|
|
37
|
+
return list(msg) if hasattr(msg, "__iter__") and not isinstance(msg, (str, bytes)) else msg
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ros2_device(node_name: str, *, device: dict, signals: dict | None = None, settings: dict | None = None,
|
|
41
|
+
actions: dict | None = None, estop: tuple | None = None, physical: dict | None = None,
|
|
42
|
+
spin: bool = True) -> BoundDriver:
|
|
43
|
+
import rclpy
|
|
44
|
+
from rclpy.action import ActionClient
|
|
45
|
+
from rosidl_runtime_py.utilities import get_message, get_action, get_service
|
|
46
|
+
from rosidl_runtime_py.set_message import set_message_fields
|
|
47
|
+
|
|
48
|
+
if not rclpy.ok():
|
|
49
|
+
rclpy.init()
|
|
50
|
+
node = rclpy.create_node(node_name)
|
|
51
|
+
if spin:
|
|
52
|
+
threading.Thread(target=rclpy.spin, args=(node,), daemon=True).start()
|
|
53
|
+
|
|
54
|
+
cache: dict[str, object] = {}
|
|
55
|
+
|
|
56
|
+
def sig(name, spec):
|
|
57
|
+
topic, mtype, path = spec
|
|
58
|
+
node.create_subscription(get_message(mtype), topic, lambda m, t=topic: cache.__setitem__(t, m), 10)
|
|
59
|
+
return Signal(name, read=lambda: _field(cache[topic], path) if topic in cache else None,
|
|
60
|
+
notes=f"{topic} ({mtype}).{path}")
|
|
61
|
+
|
|
62
|
+
def setting(name, spec):
|
|
63
|
+
topic, mtype, field, *rest = spec
|
|
64
|
+
pub = node.create_publisher(get_message(mtype), topic, 10)
|
|
65
|
+
|
|
66
|
+
def write(v):
|
|
67
|
+
msg = get_message(mtype)()
|
|
68
|
+
set_message_fields(msg, {field: v})
|
|
69
|
+
pub.publish(msg)
|
|
70
|
+
return Setting(name, write=write, limits=rest[0] if rest else None, notes=f"publishes {topic}.{field}")
|
|
71
|
+
|
|
72
|
+
def action(name, spec):
|
|
73
|
+
aname, atype, opts = (spec + ({},))[:3]
|
|
74
|
+
A = get_action(atype)
|
|
75
|
+
ac = ActionClient(node, A, aname)
|
|
76
|
+
|
|
77
|
+
def run(job, params):
|
|
78
|
+
ac.wait_for_server(timeout_sec=5.0)
|
|
79
|
+
goal = A.Goal()
|
|
80
|
+
set_message_fields(goal, params)
|
|
81
|
+
done = {}
|
|
82
|
+
|
|
83
|
+
def on_feedback(fb):
|
|
84
|
+
prog = getattr(fb.feedback, "progress", None)
|
|
85
|
+
if prog is not None:
|
|
86
|
+
driver.progress(job, float(prog))
|
|
87
|
+
fut = ac.send_goal_async(goal, feedback_callback=on_feedback)
|
|
88
|
+
while not fut.done():
|
|
89
|
+
time.sleep(0.05)
|
|
90
|
+
handle = fut.result()
|
|
91
|
+
if not handle.accepted:
|
|
92
|
+
raise RuntimeError("goal rejected")
|
|
93
|
+
rfut = handle.get_result_async()
|
|
94
|
+
while not rfut.done():
|
|
95
|
+
if job.cancel_requested:
|
|
96
|
+
handle.cancel_goal_async()
|
|
97
|
+
time.sleep(0.1)
|
|
98
|
+
res = rfut.result().result
|
|
99
|
+
return {f: _field(res, f) for f in res.get_fields_and_field_types()}
|
|
100
|
+
return Action(name, run=run, **{"duration": "long", **opts})
|
|
101
|
+
|
|
102
|
+
def make_estop():
|
|
103
|
+
sname, stype = estop
|
|
104
|
+
S = get_service(stype)
|
|
105
|
+
cli = node.create_client(S, sname)
|
|
106
|
+
return lambda: cli.call_async(S.Request())
|
|
107
|
+
|
|
108
|
+
driver = BoundDriver(
|
|
109
|
+
device=device, physical=physical,
|
|
110
|
+
signals=[sig(n, s) for n, s in (signals or {}).items()],
|
|
111
|
+
settings=[setting(n, s) for n, s in (settings or {}).items()],
|
|
112
|
+
actions=[action(n, s) for n, s in (actions or {}).items()],
|
|
113
|
+
estop=make_estop() if estop else None,
|
|
114
|
+
extra={"ros2": {"node": node_name}},
|
|
115
|
+
)
|
|
116
|
+
return driver
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""SiLA 2 -> MHP.
|
|
2
|
+
|
|
3
|
+
from openmhp.adapters.sila2 import sila_device
|
|
4
|
+
dev = sila_device("10.0.0.12", 50052,
|
|
5
|
+
device={"id": "arm-01", "class": "robot_arm", "location": "bay 3",
|
|
6
|
+
"notes": "PF400 plate mover. Light curtain trips SafeZoneClear."},
|
|
7
|
+
signals={"position": "RobotController.Position",
|
|
8
|
+
"safe_zone_clear": ("SafetyController.SafeZoneClear", "boolean")},
|
|
9
|
+
settings={"speed": ("RobotController.SetSpeed.Speed", {"min": 1, "max": 100})},
|
|
10
|
+
actions={"move_to": "RobotController.MoveTo",
|
|
11
|
+
"home": ("RobotController.Home", {"interlocks": ["safe_zone_clear"]})},
|
|
12
|
+
estop="RobotController.EmergencyStop")
|
|
13
|
+
|
|
14
|
+
Mapping
|
|
15
|
+
SiLA property (observable or not) -> signal "Feature.Property"
|
|
16
|
+
SiLA unobservable command with one parameter -> setting "Feature.Command.Parameter"
|
|
17
|
+
SiLA command (observable or not) -> action "Feature.Command"; observable ones
|
|
18
|
+
report progress and honour cancel via the
|
|
19
|
+
command instance
|
|
20
|
+
SiLA LockController -> MHP leases are enforced by the driver itself
|
|
21
|
+
|
|
22
|
+
Uses the `sila2` Python package's client (pip install sila2). `client` may be
|
|
23
|
+
injected for tests or for servers needing custom TLS setup.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .base import Action, BoundDriver, Setting, Signal
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _connect(host: str, port: int, insecure: bool):
|
|
34
|
+
from sila2.client import SilaClient # lazy: only needed for real hardware
|
|
35
|
+
return SilaClient(host, port, insecure=insecure)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve(client, dotted: str):
|
|
39
|
+
obj = client
|
|
40
|
+
for part in dotted.split("."):
|
|
41
|
+
obj = getattr(obj, part)
|
|
42
|
+
return obj
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sila_device(host: str = "", port: int = 50052, *, device: dict, signals: dict | None = None,
|
|
46
|
+
settings: dict | None = None, actions: dict | None = None, estop: str | None = None,
|
|
47
|
+
physical: dict | None = None, insecure: bool = True, client=None, poll_s: float = 0.25) -> BoundDriver:
|
|
48
|
+
client = client or _connect(host, port, insecure)
|
|
49
|
+
|
|
50
|
+
def sig(name, spec):
|
|
51
|
+
path, typ = (spec, "number") if isinstance(spec, str) else spec
|
|
52
|
+
return Signal(name, read=lambda: _resolve(client, path).get(), type=typ)
|
|
53
|
+
|
|
54
|
+
def setting(name, spec):
|
|
55
|
+
path, opts = (spec, {}) if isinstance(spec, str) else (spec[0], spec[1] if isinstance(spec[1], dict) and "min" not in spec[1] and "max" not in spec[1] else {"limits": spec[1]})
|
|
56
|
+
feat_cmd, param = path.rsplit(".", 1)
|
|
57
|
+
return Setting(name, write=lambda v: _resolve(client, feat_cmd)(**{param: v}), **opts)
|
|
58
|
+
|
|
59
|
+
def action(name, spec):
|
|
60
|
+
path, opts = (spec, {}) if isinstance(spec, str) else spec
|
|
61
|
+
|
|
62
|
+
def run(job, params):
|
|
63
|
+
result = _resolve(client, path)(**params)
|
|
64
|
+
if hasattr(result, "done"): # observable command instance
|
|
65
|
+
while not result.done:
|
|
66
|
+
if job.cancel_requested and hasattr(result, "cancel"):
|
|
67
|
+
result.cancel()
|
|
68
|
+
prog = getattr(result, "progress", None)
|
|
69
|
+
if prog is not None:
|
|
70
|
+
driver.progress(job, float(prog))
|
|
71
|
+
time.sleep(poll_s)
|
|
72
|
+
result = result.get_responses()
|
|
73
|
+
return _plain(result)
|
|
74
|
+
return Action(name, run=run, duration="long" if opts.pop("observable", False) else opts.pop("duration", "short"), **opts)
|
|
75
|
+
|
|
76
|
+
driver = BoundDriver(
|
|
77
|
+
device=device, physical=physical,
|
|
78
|
+
signals=[sig(n, s) for n, s in (signals or {}).items()],
|
|
79
|
+
settings=[setting(n, s) for n, s in (settings or {}).items()],
|
|
80
|
+
actions=[action(n, s) for n, s in (actions or {}).items()],
|
|
81
|
+
estop=(lambda: _resolve(client, estop)()) if estop else None,
|
|
82
|
+
extra={"sila2": {"host": host, "port": port}},
|
|
83
|
+
)
|
|
84
|
+
return driver
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _plain(obj: Any) -> Any:
|
|
88
|
+
"""SiLA response objects -> JSON-able."""
|
|
89
|
+
if hasattr(obj, "_asdict"):
|
|
90
|
+
return obj._asdict()
|
|
91
|
+
if hasattr(obj, "__dict__") and not isinstance(obj, (str, int, float, bool)):
|
|
92
|
+
return {k: v for k, v in vars(obj).items() if not k.startswith("_")}
|
|
93
|
+
return obj
|