lucen-device 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.
- lucen_device/__init__.py +13 -0
- lucen_device/adapter_runtime.py +214 -0
- lucen_device/adapters/__init__.py +203 -0
- lucen_device/adapters/capabilities.py +127 -0
- lucen_device/adapters/command.py +468 -0
- lucen_device/adapters/lucen_legged.py +11 -0
- lucen_device/adapters/registry.py +323 -0
- lucen_device/adapters/virtual.py +313 -0
- lucen_device/approval.py +203 -0
- lucen_device/config.py +259 -0
- lucen_device/embodiment.py +90 -0
- lucen_device/endpoint.py +644 -0
- lucen_device/enroll.py +234 -0
- lucen_device/events.py +60 -0
- lucen_device/hub.py +303 -0
- lucen_device/link.py +86 -0
- lucen_device/main.py +714 -0
- lucen_device/outbox.py +222 -0
- lucen_device/serve.py +1125 -0
- lucen_device/stage_process.py +257 -0
- lucen_device/trial.py +524 -0
- lucen_device/trial_log.py +465 -0
- lucen_device/trust.py +130 -0
- lucen_device-0.3.0.dist-info/METADATA +256 -0
- lucen_device-0.3.0.dist-info/RECORD +28 -0
- lucen_device-0.3.0.dist-info/WHEEL +4 -0
- lucen_device-0.3.0.dist-info/entry_points.txt +8 -0
- lucen_device-0.3.0.dist-info/licenses/LICENSE +202 -0
lucen_device/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""MouseMouse robot-side driver — the device consent layer's other half (M13).
|
|
2
|
+
|
|
3
|
+
`lucen-device` runs on the robot's own computer and holds two things the hub
|
|
4
|
+
never sees: the device's Ed25519 private key and whatever the robot needs to
|
|
5
|
+
start a policy (a bus, an SSH target, a launch command). It talks to the hub
|
|
6
|
+
with an ordinary `write`-scoped API key — which, by the hub's own contract,
|
|
7
|
+
cannot approve anything — and acts only on approvals a human signed in the
|
|
8
|
+
web app, verified **offline** against the hub's published key.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.3.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Driving an adapter the driver did not write (M17d).
|
|
2
|
+
|
|
3
|
+
Everything :mod:`lucen_device.serve` and :mod:`lucen_device.endpoint` do to an
|
|
4
|
+
adapter goes through here, because since M17d the adapter may be anybody's
|
|
5
|
+
code:
|
|
6
|
+
|
|
7
|
+
- :func:`describe` — the ``adapter_info`` the hub stores at registration:
|
|
8
|
+
where the code came from, which protocols it implements, what the fail-safe
|
|
9
|
+
probe found;
|
|
10
|
+
- :func:`resolve_registered` — load the ONE adapter ``device.json`` names and
|
|
11
|
+
refuse (:class:`AdapterChanged`) when that name no longer resolves to the
|
|
12
|
+
distribution and class that were registered. A new *version* of the same
|
|
13
|
+
distribution is allowed — robots get updates — and is what the ``started``
|
|
14
|
+
event reports;
|
|
15
|
+
- :func:`prepare` — the optional hooks, in order: ``set_safe_stop(mode)`` from
|
|
16
|
+
the robot card, then the staged start (``safe`` -> ``default_pose``);
|
|
17
|
+
- :func:`safe_status` / :func:`safe_stop` — ``status()`` and ``stop()`` under a
|
|
18
|
+
timeout and an exception barrier: a heartbeat must not be able to crash or
|
|
19
|
+
stall a run, and a ``stop()`` that raises must still be reported.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any, Final
|
|
27
|
+
|
|
28
|
+
from lucen_device import __version__
|
|
29
|
+
from lucen_device.adapters import (
|
|
30
|
+
START_STAGES,
|
|
31
|
+
AdapterError,
|
|
32
|
+
Capabilities,
|
|
33
|
+
LoadedAdapter,
|
|
34
|
+
guarded_call,
|
|
35
|
+
inspect_adapter,
|
|
36
|
+
load_adapter,
|
|
37
|
+
probe_fail_safe,
|
|
38
|
+
)
|
|
39
|
+
from lucen_device.adapters.registry import construct
|
|
40
|
+
from lucen_device.config import DeviceConfig
|
|
41
|
+
from lucen_device.embodiment import safe_stop_mode
|
|
42
|
+
|
|
43
|
+
STATUS_TIMEOUT_S: Final[float] = 2.0
|
|
44
|
+
#: `command`'s documented stop is up to 30 s of stop_cmd + 10 s TERM + 5 s KILL.
|
|
45
|
+
STOP_TIMEOUT_S: Final[float] = 60.0
|
|
46
|
+
STATUS_MAX_BYTES: Final[int] = 16 * 1024
|
|
47
|
+
#: Q8: a person's STOP holds first — the fail-safe's own budget (endpoint.py's
|
|
48
|
+
#: `HOLD_TIMEOUT_S`): a hold that does not return in a second is not a hold.
|
|
49
|
+
STOP_HOLD_TIMEOUT_S: Final[float] = 1.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AdapterChanged(AdapterError):
|
|
53
|
+
"""The registered adapter name now resolves to different code."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _norm(value: Any) -> str | None:
|
|
57
|
+
return re.sub(r"[-_.]+", "-", str(value)).lower() if value else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def describe(
|
|
61
|
+
loaded: LoadedAdapter, config: dict[str, Any], *, probe: bool = True
|
|
62
|
+
) -> tuple[dict[str, Any], Capabilities, bool]:
|
|
63
|
+
"""(`adapter_info`, capabilities, endpoint_capable) for a loaded adapter.
|
|
64
|
+
|
|
65
|
+
Endpoint-capable means BOTH halves hold: the class implements the chunk
|
|
66
|
+
protocol, and `hold()` then `stop()` ran clean on a fresh instance.
|
|
67
|
+
"""
|
|
68
|
+
found = inspect_adapter(loaded.klass)
|
|
69
|
+
verdict: str | None = None
|
|
70
|
+
capable = False
|
|
71
|
+
if "chunks" in found.protocols and probe:
|
|
72
|
+
capable, verdict = probe_fail_safe(lambda: construct(loaded, config))
|
|
73
|
+
info = {
|
|
74
|
+
**loaded.provenance(),
|
|
75
|
+
"driver_version": __version__,
|
|
76
|
+
"protocols": list(found.protocols),
|
|
77
|
+
"fail_safe_probe": verdict,
|
|
78
|
+
}
|
|
79
|
+
return info, found, capable
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_registered(config: DeviceConfig) -> LoadedAdapter:
|
|
83
|
+
"""The adapter this box registered — or :class:`AdapterChanged` / :class:`AdapterError`."""
|
|
84
|
+
loaded = load_adapter(config.load_spec)
|
|
85
|
+
if loaded.name != config.adapter:
|
|
86
|
+
raise AdapterChanged(
|
|
87
|
+
f"`{config.load_spec}` now calls itself {loaded.name!r}; this box registered "
|
|
88
|
+
f"{config.adapter!r}. Re-register (`lucen-device register --force`) to run it."
|
|
89
|
+
)
|
|
90
|
+
registered = config.adapter_info
|
|
91
|
+
if registered: # a pre-M17d registration recorded nothing to compare with
|
|
92
|
+
now = loaded.provenance()
|
|
93
|
+
for key in ("source", "target", "distribution"):
|
|
94
|
+
before, after = registered.get(key), now.get(key)
|
|
95
|
+
same = _norm(before) == _norm(after) if key == "distribution" else before == after
|
|
96
|
+
if not same:
|
|
97
|
+
raise AdapterChanged(
|
|
98
|
+
f"adapter {config.adapter!r} was registered from {key} {before!r} and now "
|
|
99
|
+
f"resolves to {after!r}. The driver runs what the operator registered: "
|
|
100
|
+
"re-register (`lucen-device register --force`) if this is intended."
|
|
101
|
+
)
|
|
102
|
+
return loaded
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def provenance_for_events(loaded: LoadedAdapter, config: DeviceConfig) -> dict[str, Any]:
|
|
106
|
+
"""What ran, for the signed trail — plus the registered version when it has moved."""
|
|
107
|
+
out: dict[str, Any] = {
|
|
108
|
+
"adapter": loaded.name,
|
|
109
|
+
"adapter_info": {**loaded.provenance(), "driver_version": __version__},
|
|
110
|
+
}
|
|
111
|
+
registered = config.adapter_info.get("version") if config.adapter_info else None
|
|
112
|
+
if registered and registered != loaded.version:
|
|
113
|
+
out["adapter_info"] = {**out["adapter_info"], "registered_version": registered}
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def prepare(adapter: Any, found: Capabilities, interface: dict[str, Any] | None) -> dict[str, Any]:
|
|
118
|
+
"""`set_safe_stop`, then the staged start. Facts for the `started` event, or AdapterError."""
|
|
119
|
+
facts: dict[str, Any] = {}
|
|
120
|
+
mode = safe_stop_mode(interface)
|
|
121
|
+
if "safe_stop" in found.protocols:
|
|
122
|
+
_hook(lambda: adapter.set_safe_stop(mode or "hold"), f"set_safe_stop({mode or 'hold'})")
|
|
123
|
+
facts["safe_stop"] = mode or "hold"
|
|
124
|
+
elif mode not in (None, "hold"):
|
|
125
|
+
raise AdapterError(
|
|
126
|
+
f"the robot card declares safe_stop `{mode}`, and this adapter has no "
|
|
127
|
+
"`set_safe_stop()` — its hold()/stop() cannot be told what a safe stop is on this "
|
|
128
|
+
"robot. Nothing was started."
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
facts["safe_stop"] = "adapter_default"
|
|
132
|
+
if "staged_start" in found.protocols:
|
|
133
|
+
for stage in START_STAGES:
|
|
134
|
+
_hook(lambda s=stage: adapter.stage(s, interface), f"stage `{stage}`")
|
|
135
|
+
facts["staged_start"] = list(START_STAGES)
|
|
136
|
+
return facts
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _hook(call: Any, what: str) -> None:
|
|
140
|
+
try:
|
|
141
|
+
call()
|
|
142
|
+
except AdapterError as exc:
|
|
143
|
+
raise AdapterError(f"{what}: {exc}") from exc
|
|
144
|
+
except Exception as exc: # third-party code
|
|
145
|
+
raise AdapterError(f"{what} raised {type(exc).__name__}: {exc}") from exc
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def safe_status(adapter: Any, *, timeout: float = STATUS_TIMEOUT_S) -> dict[str, Any]:
|
|
149
|
+
"""`adapter.status()` as plain, bounded JSON — never an exception, never a stall."""
|
|
150
|
+
box: list[Any] = []
|
|
151
|
+
problem = guarded_call(lambda: box.append(adapter.status()), timeout=timeout)
|
|
152
|
+
if problem is not None:
|
|
153
|
+
return {"status_error": f"status() {problem}"}
|
|
154
|
+
status = box[0] if box else None
|
|
155
|
+
if not isinstance(status, dict):
|
|
156
|
+
return {"status_error": f"status() returned {type(status).__name__}, not a dict"}
|
|
157
|
+
try:
|
|
158
|
+
encoded = json.dumps(status, default=str)
|
|
159
|
+
except (TypeError, ValueError) as exc:
|
|
160
|
+
return {"status_error": f"status() is not JSON: {exc}"}
|
|
161
|
+
if len(encoded) > STATUS_MAX_BYTES:
|
|
162
|
+
return {"status_error": f"status() is {len(encoded)} bytes; the cap is {STATUS_MAX_BYTES}"}
|
|
163
|
+
plain: dict[str, Any] = json.loads(encoded)
|
|
164
|
+
return plain
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def safe_stop(adapter: Any, *, timeout: float = STOP_TIMEOUT_S) -> str | None:
|
|
168
|
+
"""`adapter.stop()`; None when it returned, else what went wrong (reported, not raised)."""
|
|
169
|
+
problem = guarded_call(adapter.stop, timeout=timeout)
|
|
170
|
+
return None if problem is None else f"stop() {problem}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def hold_then_stop(
|
|
174
|
+
adapter: Any, reason: str, *, hold_timeout: float = STOP_HOLD_TIMEOUT_S
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
"""Q8: a person's STOP, in M17d's fail-safe order — `hold(reason)`, then `stop()`.
|
|
177
|
+
|
|
178
|
+
`hold()` is part of the chunk protocol, so an adapter without one (a
|
|
179
|
+
reflex-only plugin) goes straight to `stop()`. A hold that raises or hangs
|
|
180
|
+
does not delay the stop: it is reported, and `stop()` runs regardless.
|
|
181
|
+
Returns what happened, for the signed `stopped` event.
|
|
182
|
+
"""
|
|
183
|
+
sequence: list[str] = []
|
|
184
|
+
facts: dict[str, Any] = {}
|
|
185
|
+
hold = getattr(adapter, "hold", None)
|
|
186
|
+
if callable(hold):
|
|
187
|
+
problem = guarded_call(lambda: hold(reason), timeout=hold_timeout)
|
|
188
|
+
sequence.append("hold")
|
|
189
|
+
if problem is not None:
|
|
190
|
+
facts["hold_error"] = f"hold() {problem}"
|
|
191
|
+
stop_problem = safe_stop(adapter)
|
|
192
|
+
sequence.append("stop")
|
|
193
|
+
if stop_problem is not None:
|
|
194
|
+
facts["stop_error"] = stop_problem
|
|
195
|
+
facts["stop_sequence"] = sequence
|
|
196
|
+
return facts
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def summary(status: dict[str, Any], **facts: Any) -> dict[str, Any]:
|
|
200
|
+
"""An event summary in which the DRIVER's facts win over the adapter's status keys."""
|
|
201
|
+
return {**status, **facts}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = [
|
|
205
|
+
"AdapterChanged",
|
|
206
|
+
"describe",
|
|
207
|
+
"hold_then_stop",
|
|
208
|
+
"prepare",
|
|
209
|
+
"provenance_for_events",
|
|
210
|
+
"resolve_registered",
|
|
211
|
+
"safe_status",
|
|
212
|
+
"safe_stop",
|
|
213
|
+
"summary",
|
|
214
|
+
]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Adapters: the one seam between an approved policy and a robot.
|
|
2
|
+
|
|
3
|
+
**Adapters are plugins (M17d).** The driver ships three; anybody else's robot
|
|
4
|
+
gets its own by installing a distribution that registers a class in the
|
|
5
|
+
entry-point group ``lucen_device.adapters`` — the built-ins register through
|
|
6
|
+
the same group — or, for a script nobody packaged, by naming it as
|
|
7
|
+
``module:Class`` / ``path/to/file.py:Class``. See :mod:`.registry`.
|
|
8
|
+
|
|
9
|
+
Loading an adapter is **code execution on the robot box**, with the driver's
|
|
10
|
+
privileges. So the rule is: *the operator names the adapter at* ``lucen-device
|
|
11
|
+
register`` *and the driver loads that one and no other.* Listing what is
|
|
12
|
+
installed reads package metadata and imports nothing; ``serve`` loads exactly
|
|
13
|
+
what ``device.json`` names and refuses when it no longer resolves to the
|
|
14
|
+
distribution and class that were registered. What ran — distribution, version,
|
|
15
|
+
class — goes into the device's ``adapter_info`` on the hub and into every
|
|
16
|
+
signed ``started`` event.
|
|
17
|
+
|
|
18
|
+
Two required protocols, two optional hooks:
|
|
19
|
+
|
|
20
|
+
- :class:`Adapter` (M13, ``policy``) — ``start(model_path, manifest, seconds)
|
|
21
|
+
/ stop() / status() / is_running()``: the robot runs the approved file
|
|
22
|
+
itself (the reflex path). The driver does everything before ``start`` and
|
|
23
|
+
everything after ``stop``.
|
|
24
|
+
- :class:`ChunkConsumer` (M15, ``chunks``) — the robot **consumes a hub
|
|
25
|
+
endpoint**: ``begin(contract, seconds)``, ``observe()`` (what to send),
|
|
26
|
+
``apply(chunk)`` (a fresh action chunk), and the **mandatory local
|
|
27
|
+
fail-safe** ``hold(reason)`` — freeze in a safe posture (or the adapter's
|
|
28
|
+
own stop) when the round trip misses the chunk deadline twice or the link
|
|
29
|
+
drops — plus ``resume()`` when chunks flow again.
|
|
30
|
+
- :class:`StagedStart` (M17d, optional, ``staged_start``) —
|
|
31
|
+
``stage(name, interface)`` is called with ``"safe"`` and then
|
|
32
|
+
``"default_pose"`` before ``start`` / ``begin``: reach the safe state, move
|
|
33
|
+
to the robot card's default pose, only then hand over to the policy.
|
|
34
|
+
- :class:`SafeStopAware` (M17d, optional, ``safe_stop``) —
|
|
35
|
+
``set_safe_stop(mode)`` with the robot card's
|
|
36
|
+
``interface.safety.safe_stop`` (``hold | zero_torque | default_pose``): what
|
|
37
|
+
``hold()`` and ``stop()`` must *mean* on this robot. An adapter that cannot
|
|
38
|
+
do the mode raises, and nothing starts.
|
|
39
|
+
|
|
40
|
+
**Capability by protocol, not by name** (:mod:`.capabilities`): whether a
|
|
41
|
+
device may consume a hub endpoint is what :func:`inspect_adapter` finds in the
|
|
42
|
+
class *and* what :func:`probe_fail_safe` finds when it calls ``hold()`` then
|
|
43
|
+
``stop()`` on a fresh instance. ``hold()`` and ``stop()`` must therefore be
|
|
44
|
+
safe to call in any state — before ``begin``, twice in a row — and must not
|
|
45
|
+
raise. The hub stores the declaration; **the enforcement is in the driver's
|
|
46
|
+
link loop** (:mod:`lucen_device.endpoint`), which treats a ``hold()`` that
|
|
47
|
+
raises or hangs as a reason to ``stop()``.
|
|
48
|
+
|
|
49
|
+
What ships:
|
|
50
|
+
|
|
51
|
+
- ``virtual`` — every protocol. Validates the contract, ticks at the control
|
|
52
|
+
rate, holds when told, and simulates **nothing** (no inference, no physics,
|
|
53
|
+
no actuators) — it exists so the whole consent path and the whole fail-safe
|
|
54
|
+
run in CI on a laptop.
|
|
55
|
+
- ``command`` — ``policy`` only: a shell-out to the box's own start / stop /
|
|
56
|
+
status commands, from local config, never from the hub. No chunk interface,
|
|
57
|
+
so **not** endpoint-capable. ``lucen-legged`` is its deprecated alias.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
from typing import Any, Protocol, runtime_checkable
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AdapterError(Exception):
|
|
67
|
+
"""The adapter refuses to start (bad file, missing config, robot said no)."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Adapter(Protocol):
|
|
71
|
+
name: str
|
|
72
|
+
|
|
73
|
+
def start(self, model_path: Path, manifest: dict[str, Any], seconds: float) -> None: ...
|
|
74
|
+
|
|
75
|
+
def stop(self) -> None: ...
|
|
76
|
+
|
|
77
|
+
def status(self) -> dict[str, Any]: ...
|
|
78
|
+
|
|
79
|
+
def is_running(self) -> bool: ...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@runtime_checkable
|
|
83
|
+
class ChunkConsumer(Protocol):
|
|
84
|
+
"""What an adapter must implement to consume a hub endpoint (M15)."""
|
|
85
|
+
|
|
86
|
+
name: str
|
|
87
|
+
|
|
88
|
+
def begin(self, contract: dict[str, Any], seconds: float) -> None: ...
|
|
89
|
+
|
|
90
|
+
def observe(self) -> dict[str, Any]: ...
|
|
91
|
+
|
|
92
|
+
def apply(self, chunk: Any, *, received_at: float) -> None: ...
|
|
93
|
+
|
|
94
|
+
def hold(self, reason: str) -> None: ...
|
|
95
|
+
|
|
96
|
+
def resume(self) -> None: ...
|
|
97
|
+
|
|
98
|
+
def stop(self) -> None: ...
|
|
99
|
+
|
|
100
|
+
def status(self) -> dict[str, Any]: ...
|
|
101
|
+
|
|
102
|
+
def is_running(self) -> bool: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@runtime_checkable
|
|
106
|
+
class StagedStart(Protocol):
|
|
107
|
+
"""Optional: reach a safe state, then the default pose, before the policy (M17d)."""
|
|
108
|
+
|
|
109
|
+
def stage(self, name: str, interface: dict[str, Any] | None) -> None: ...
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@runtime_checkable
|
|
113
|
+
class SafeStopAware(Protocol):
|
|
114
|
+
"""Optional: let the robot card's `safety.safe_stop` choose what hold / stop mean."""
|
|
115
|
+
|
|
116
|
+
def set_safe_stop(self, mode: str) -> None: ...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
#: The methods the fail-safe rests on; missing any one of them means no endpoints.
|
|
120
|
+
FAIL_SAFE_METHODS: tuple[str, ...] = ("begin", "observe", "apply", "hold", "resume", "stop")
|
|
121
|
+
#: The stages a :class:`StagedStart` adapter is walked through, in order,
|
|
122
|
+
#: before `start()` / `begin()` (which is the third stage: the policy).
|
|
123
|
+
START_STAGES: tuple[str, ...] = ("safe", "default_pose")
|
|
124
|
+
#: `interface.safety.safe_stop` values (docs/openapi.yaml, `EmbodimentSafety`).
|
|
125
|
+
SAFE_STOP_MODES: tuple[str, ...] = ("hold", "zero_torque", "default_pose")
|
|
126
|
+
|
|
127
|
+
# Re-exported below; imported late because both modules need `AdapterError`.
|
|
128
|
+
from lucen_device.adapters.capabilities import ( # noqa: E402
|
|
129
|
+
Capabilities,
|
|
130
|
+
guarded_call,
|
|
131
|
+
inspect_adapter,
|
|
132
|
+
probe_fail_safe,
|
|
133
|
+
)
|
|
134
|
+
from lucen_device.adapters.registry import ( # noqa: E402
|
|
135
|
+
ADAPTER_ID_PATTERN,
|
|
136
|
+
ENTRY_POINT_GROUP,
|
|
137
|
+
AdapterRef,
|
|
138
|
+
LoadedAdapter,
|
|
139
|
+
available_adapters,
|
|
140
|
+
construct,
|
|
141
|
+
load_adapter,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def adapter_class(name: str) -> type[Any]:
|
|
146
|
+
"""The class behind an adapter spec. **Imports it** — only for a spec somebody named."""
|
|
147
|
+
return load_adapter(name).klass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def supports_endpoints(adapter: str | type[Any]) -> bool:
|
|
151
|
+
"""True when the adapter class implements every fail-safe method — hold above all.
|
|
152
|
+
|
|
153
|
+
The static half of the capability; :func:`probe_fail_safe` is the other.
|
|
154
|
+
"""
|
|
155
|
+
klass = adapter_class(adapter) if isinstance(adapter, str) else adapter
|
|
156
|
+
return "chunks" in inspect_adapter(klass).protocols
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def make_adapter(name: str, config: dict[str, Any]) -> Adapter:
|
|
160
|
+
adapter: Adapter = construct(load_adapter(name), config)
|
|
161
|
+
return adapter
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def make_chunk_consumer(name: str, config: dict[str, Any]) -> ChunkConsumer:
|
|
165
|
+
"""The endpoint-use adapter, or :class:`AdapterError` naming the missing fail-safe."""
|
|
166
|
+
loaded = load_adapter(name)
|
|
167
|
+
found = inspect_adapter(loaded.klass)
|
|
168
|
+
if "chunks" not in found.protocols:
|
|
169
|
+
raise AdapterError(
|
|
170
|
+
f"adapter {loaded.name!r} cannot consume an endpoint: it does not implement "
|
|
171
|
+
f"{', '.join(found.missing_chunk_methods)} — the local hold/stop is mandatory "
|
|
172
|
+
"(invariant 3)"
|
|
173
|
+
)
|
|
174
|
+
adapter = construct(loaded, config)
|
|
175
|
+
assert isinstance(adapter, ChunkConsumer)
|
|
176
|
+
return adapter
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = [
|
|
180
|
+
"ADAPTER_ID_PATTERN",
|
|
181
|
+
"ENTRY_POINT_GROUP",
|
|
182
|
+
"FAIL_SAFE_METHODS",
|
|
183
|
+
"SAFE_STOP_MODES",
|
|
184
|
+
"START_STAGES",
|
|
185
|
+
"Adapter",
|
|
186
|
+
"AdapterError",
|
|
187
|
+
"AdapterRef",
|
|
188
|
+
"Capabilities",
|
|
189
|
+
"ChunkConsumer",
|
|
190
|
+
"LoadedAdapter",
|
|
191
|
+
"SafeStopAware",
|
|
192
|
+
"StagedStart",
|
|
193
|
+
"adapter_class",
|
|
194
|
+
"available_adapters",
|
|
195
|
+
"construct",
|
|
196
|
+
"guarded_call",
|
|
197
|
+
"inspect_adapter",
|
|
198
|
+
"load_adapter",
|
|
199
|
+
"make_adapter",
|
|
200
|
+
"make_chunk_consumer",
|
|
201
|
+
"probe_fail_safe",
|
|
202
|
+
"supports_endpoints",
|
|
203
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""What an adapter can do — found in its code and by calling its fail-safe (M17d).
|
|
2
|
+
|
|
3
|
+
Until M17d the hub kept a list of adapter names that may consume an endpoint
|
|
4
|
+
(``{"virtual"}``). With adapters as plugins there is no such list to keep, so
|
|
5
|
+
the capability is established **on the box**, in two halves:
|
|
6
|
+
|
|
7
|
+
1. :func:`inspect_adapter` — *static*: which protocols the class implements.
|
|
8
|
+
``chunks`` needs every method of the fail-safe (``begin / observe / apply /
|
|
9
|
+
hold / resume / stop``) plus ``status`` / ``is_running``, each a real
|
|
10
|
+
callable and not an abstract stub.
|
|
11
|
+
2. :func:`probe_fail_safe` — *dynamic*: construct a fresh instance, call
|
|
12
|
+
``hold("probe")``, then ``stop()``, each under a timeout. An adapter whose
|
|
13
|
+
``hold()`` raises (``NotImplementedError`` included), hangs, or cannot even
|
|
14
|
+
be constructed is **not** endpoint-capable, and the reason is kept.
|
|
15
|
+
|
|
16
|
+
That makes two demands on an adapter author, both stated in the docs:
|
|
17
|
+
``hold()`` and ``stop()`` must be safe to call in any state — before
|
|
18
|
+
``begin()``, twice in a row — and must never raise. They are the two calls the
|
|
19
|
+
driver makes when everything else has already gone wrong.
|
|
20
|
+
|
|
21
|
+
Neither half proves the robot stops. They prove the code path exists and runs;
|
|
22
|
+
the enforcement that matters is in the link loop, which calls ``stop()`` when
|
|
23
|
+
``hold()`` fails at the moment it is needed (:mod:`lucen_device.endpoint`).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import threading
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, Final
|
|
32
|
+
|
|
33
|
+
from lucen_device.adapters import FAIL_SAFE_METHODS, AdapterError
|
|
34
|
+
|
|
35
|
+
POLICY_METHODS: Final[tuple[str, ...]] = ("start", "stop", "status", "is_running")
|
|
36
|
+
CHUNK_METHODS: Final[tuple[str, ...]] = (*FAIL_SAFE_METHODS, "status", "is_running")
|
|
37
|
+
#: How long `hold()` / `stop()` may take in the registration probe.
|
|
38
|
+
PROBE_TIMEOUT_S: Final[float] = 5.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _implements(klass: type[Any], method: str) -> bool:
|
|
42
|
+
found = getattr(klass, method, None)
|
|
43
|
+
return callable(found) and not getattr(found, "__isabstractmethod__", False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Capabilities:
|
|
48
|
+
protocols: tuple[str, ...]
|
|
49
|
+
#: Which methods of the chunk protocol the class lacks (empty when `chunks` is present).
|
|
50
|
+
missing_chunk_methods: tuple[str, ...]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def inspect_adapter(klass: type[Any]) -> Capabilities:
|
|
54
|
+
"""The protocols `klass` implements, from its attributes. Runs nothing."""
|
|
55
|
+
protocols: list[str] = []
|
|
56
|
+
if all(_implements(klass, m) for m in POLICY_METHODS):
|
|
57
|
+
protocols.append("policy")
|
|
58
|
+
missing = tuple(m for m in CHUNK_METHODS if not _implements(klass, m))
|
|
59
|
+
if not missing:
|
|
60
|
+
protocols.append("chunks")
|
|
61
|
+
if _implements(klass, "stage"):
|
|
62
|
+
protocols.append("staged_start")
|
|
63
|
+
if _implements(klass, "set_safe_stop"):
|
|
64
|
+
protocols.append("safe_stop")
|
|
65
|
+
return Capabilities(protocols=tuple(protocols), missing_chunk_methods=missing)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def guarded_call(call: Callable[[], Any], *, timeout: float) -> str | None:
|
|
69
|
+
"""Run `call` on a worker thread. None when it returned; else why it did not.
|
|
70
|
+
|
|
71
|
+
Third-party code on the fail-safe path gets neither the benefit of the
|
|
72
|
+
doubt nor the chance to stall the loop: an exception is a failure, and so
|
|
73
|
+
is not returning in time (the thread is a daemon and is abandoned — there
|
|
74
|
+
is no safe way to kill it, and the caller's next move is `stop()`).
|
|
75
|
+
"""
|
|
76
|
+
outcome: list[str | None] = []
|
|
77
|
+
|
|
78
|
+
def run() -> None:
|
|
79
|
+
try:
|
|
80
|
+
call()
|
|
81
|
+
except BaseException as exc: # this IS the fault barrier, SystemExit included
|
|
82
|
+
outcome.append(f"raised {type(exc).__name__}: {exc}"[:200])
|
|
83
|
+
else:
|
|
84
|
+
outcome.append(None)
|
|
85
|
+
|
|
86
|
+
worker = threading.Thread(target=run, name="lucen-device-guarded", daemon=True)
|
|
87
|
+
worker.start()
|
|
88
|
+
worker.join(timeout)
|
|
89
|
+
if not outcome:
|
|
90
|
+
return f"did not return within {timeout:g} s"
|
|
91
|
+
return outcome[0]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def probe_fail_safe(
|
|
95
|
+
factory: Callable[[], Any], *, timeout: float = PROBE_TIMEOUT_S
|
|
96
|
+
) -> tuple[bool, str]:
|
|
97
|
+
"""(passed, "passed" | "failed: <why>") — `hold()` then `stop()` on a fresh instance."""
|
|
98
|
+
try:
|
|
99
|
+
adapter = factory()
|
|
100
|
+
except AdapterError as exc:
|
|
101
|
+
return False, f"failed: {exc}"[:256]
|
|
102
|
+
except Exception as exc: # third-party constructor
|
|
103
|
+
return False, f"failed: constructor raised {type(exc).__name__}: {exc}"[:256]
|
|
104
|
+
for method in ("hold", "stop"):
|
|
105
|
+
bound = getattr(adapter, method, None)
|
|
106
|
+
if not callable(bound):
|
|
107
|
+
return False, f"failed: no {method}()"
|
|
108
|
+
call = (lambda b=bound: b("probe")) if method == "hold" else bound
|
|
109
|
+
problem = guarded_call(call, timeout=timeout)
|
|
110
|
+
if problem is not None:
|
|
111
|
+
if method == "hold": # leave nothing half-held behind a failed probe
|
|
112
|
+
stop = getattr(adapter, "stop", None)
|
|
113
|
+
if callable(stop):
|
|
114
|
+
guarded_call(stop, timeout=timeout)
|
|
115
|
+
return False, f"failed: {method}() {problem}"[:256]
|
|
116
|
+
return True, "passed"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = [
|
|
120
|
+
"CHUNK_METHODS",
|
|
121
|
+
"POLICY_METHODS",
|
|
122
|
+
"PROBE_TIMEOUT_S",
|
|
123
|
+
"Capabilities",
|
|
124
|
+
"guarded_call",
|
|
125
|
+
"inspect_adapter",
|
|
126
|
+
"probe_fail_safe",
|
|
127
|
+
]
|