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