Metadata-Version: 2.4
Name: lucen-device
Version: 0.3.1
Summary: MouseMouse robot-side driver (`lucen-device`): runs only what a person approved, verified offline
License-Expression: Apache-2.0
License-File: LICENSE
Requires-Dist: typer>=0.20
Requires-Dist: lucen-cli>=0.1.0,<0.2
Requires-Dist: lucen-sdk>=0.1.0,<0.2
Requires-Dist: pyjwt[crypto]>=2.10
Requires-Dist: cryptography>=42
Requires-Dist: websockets>=14,<16
Requires-Dist: msgpack>=1.0
Requires-Dist: numpy>=2.0,<3
Requires-Python: >=3.12
Project-URL: Homepage, https://mousemouse.ai
Project-URL: Documentation, https://mousemouse.ai/docs/connect-your-robot
Project-URL: Repository, https://github.com/ICE-CUBA/lucen-hub
Description-Content-Type: text/markdown

# `lucen-device` — the robot side of the consent layer

`lucen-device` runs on the robot's own computer. It holds two things the hub
never sees — the device's Ed25519 private key and whatever the robot needs to
start a policy — and acts only on approvals a **person** signed in the web app,
which it verifies **offline** against the hub's published signing key.

The hub's rule is that no API key of any scope can put a policy on a robot
(`POST /v1/devices/{id}/approvals` is `session`-only). This package is how a
policy gets there anyway: the hub records consent, the box does the handoff.

## Install

From PyPI, as a tool on the robot's own computer (`uv`, or `pipx install
lucen-device`):

```bash
uv tool install lucen-device
```

A paired box needs nothing else. A box you set up by hand with an API key
(`register`, below) runs `lucen pull` and `lucen auth login` too, and `uv tool
install` exposes only the requested package's commands, so ask for the CLI's
as well:

```bash
uv tool install --with-executables-from lucen-cli lucen-device
```

A MouseMouse hub built from its API image also serves its own copy of these
wheels for machines that cannot reach PyPI: `curl -fsSL <hub API>/install.sh |
sh -s -- device` installs the version built from the hub's own commit and
points the driver at that hub; see the hub's docs page *Run your own hub*.

## Pair (K1) — no person's key on the robot

```
lucen-device pair K7QF-2M9D --hub https://hub.example/api   # a code a person issued on the hub
lucen-device pair --hub https://hub.example/api             # no code: the box asks for one and shows it
```

The box generates its Ed25519 key, declares itself (adapter and provenance,
fail-safe probe, any `--accept` contracts) and prints its **key fingerprint**.
A signed-in person opens the pairing on the hub, compares that fingerprint,
picks the trust mode and confirms ("Not my robot" drops it; an unconfirmed
claim expires with the code, ten minutes). The box then collects — signing with
its key — a **device credential** that acts only as this device, the hub's
signing key and the robot card's interface, all pinned in `device.json`. The
owner revokes the credential from the hub (disconnect); every later call is
`401`, and a running policy stops.

**Trust mode.** `registered` (default) runs only the IO contracts this box
registered. `approved` also runs a contract it never registered when a signed
approval names it **and** the policy's `embodiment_fingerprint` equals the
interface fingerprint this box pinned; an **unstamped** policy additionally
needs the approver's acknowledgment in the token and a contract that fits the
pinned interface (joint order, control rate, one action per joint), which the
box checks itself. The person chooses it at
confirmation; afterwards only the box changes it: `lucen-device trust approved`.

## Credentials

A paired box uses its own device credential (in `device.json`, mode 0600). A
box set up with `register` uses the `lucen` CLI's: `lucen auth login` on the box
(a `write` key for the device's owner — it registers and posts telemetry, and
by contract it cannot approve anything), or `LUCEN_API_KEY` / `LUCEN_API_URL` in
the environment. No flag takes a key.

The driver's own state lives in `$LUCEN_DEVICE_CONFIG_DIR` (default
`~/.config/lucen-device`): `device.json` (the registration and the hub signing
key pinned at registration), `device_key.pem` (mode 0600, refused if looser) and
`models/<approval id>/` (each pulled repo).

## Register

```
lucen-device register --robot lucen/laika --name lab-laika --adapter virtual \
    --accept-from ./omni/omni_c4_ff800.onnx        # and/or --accept <fingerprint>
```

Registration generates the keypair, fetches and **pins** the hub's signing key
(`kid` + PEM + issuer), and tells the hub which IO contracts this box will run —
as fingerprints (`sha256` of a stamped ONNX manifest's canonical `contract`
block). `--accept-from` reads one out of a policy file; a device that accepts
no contract cannot be registered, because it could never be handed anything.
If the hub signs with an ephemeral key (no `DEVICE_APPROVAL_SIGNING_KEY`), the
command warns you: approvals will stop verifying when the hub restarts, and
`lucen-device signing-key --refresh` re-pins.

## Serve

```
lucen-device serve            # poll every 10 s, forever
lucen-device serve --once     # handle what is issued now, then exit
```

For every approval the hub lists as `issued` for this device:

1. **verify the token offline** — `alg` is `EdDSA`, `kid` is the pinned one,
   the signature holds, `exp`/`nbf`/`iat` hold (10 s leeway), `aud` is
   `lucen-device`, `iss` is the pinned issuer, `sub` is this device;
2. cross-check the listing against the signature (id and digest);
3. **pull the model repo** with `lucen pull`'s code (every file digest-checked),
   then compare the ONNX to the digest **inside the token**;
4. read the ONNX's own `lucen_manifest` and **refuse a contract this box did not
   register** (or one that differs from what the hub pre-checked);
5. load the ONE adapter this box registered, check the embodiment, pass it the
   robot card's safe stop, walk its staged start;
6. start the adapter, post `started`, heartbeat every `heartbeat_s` seconds
   (2 s by default; `--heartbeat` overrides it), stop at `expires_at` on this
   box's clock (or at `--max-run-seconds`, a local cap), post `stopped`;
7. **stop when the link is lost.** A heartbeat the hub does not acknowledge
   within the interval is a miss and a warning. `link_lost_heartbeats` misses
   in a row (5 by default, about ten seconds) are link loss: the adapter is
   held (`hold()`, when it has one) and then stopped, in the same order a
   person's Stop uses, and the approval is over on the robot's side. Nothing
   restarts the policy. The signed `stopped` (reason `link_lost`, with the
   misses it counted and `stop_sequence`) waits in the outbox and is posted
   when the link returns; `serve --once` waits up to a minute for that, then
   exits and warns. The rule counts answers, never clocks, so clock skew
   cannot fire it. Both numbers live in `device.json` (`heartbeat_s`,
   `link_lost_heartbeats`).

The trail says `verified` (step 1) and `pulled` (step 3) before `started`. A
person's **Stop** arrives on a heartbeat's answer: the adapter is held
(`hold()`, when it has one) and then stopped at once, in M17d's fail-safe
order, and `stopped` says `stop_requested` with `stop_sequence: [hold, stop]`,
within one heartbeat plus a round trip. Only a signed-in person can press it;
no API key can. A robot that cannot reach the hub does not wait for the Stop:
it stops the policy itself about ten seconds after it loses the hub (step 7).
Stop is a convenience over the network, not a safety system: keep a hardware
emergency stop within reach.

Every refusal is posted back as a signed `refused` event with a machine reason
(`bad_signature`, `unknown_kid`, `expired`, `wrong_device`, `digest_mismatch`,
`unstamped`, `contract_mismatch`, `adapter_refused`, …) so the person who
approved can read why nothing happened. Every event is signed with the device
key; the hub verifies it against the key you registered.

## Adapters

**Adapters are plugins (M17d).** The adapter is the only part of the driver that
knows a robot: it takes a verified policy (or a fresh action chunk) and makes
the robot do it. Three ship here; any other robot installs its own — see
`examples/device-adapter/` (141 lines, a fake robot, both protocols) and the
docs page `/docs/connect-your-robot`.

```
lucen-device adapters                      # what is installed — reads metadata, imports nothing
lucen-device adapters --check example-arm  # import ONE, list its protocols, probe its fail-safe
```

An adapter is a class registered in the entry-point group
`lucen_device.adapters` by an installed distribution (the built-ins register
through the same group), or named directly for unpackaged code:
`--adapter my_robot.adapter:MyArm` / `--adapter ./my_arm.py:MyArm`.
**Loading one is code execution on the robot box.** So: the operator names it
at `register` and the driver loads that one and no other; listing never
imports; a name two distributions claim — a third party registering `virtual`
included — is refused; `serve` refuses (`adapter_changed`) when the registered
name now resolves to a different distribution or class; and the distribution,
version and class go into the device's `adapter_info` on the hub and into every
signed `started` event.

| Protocol | Methods | |
|---|---|---|
| `policy` | `start / stop / status / is_running` | run the approved file on the robot |
| `chunks` | `begin / observe / apply / hold / resume / stop / status / is_running` | consume a hub endpoint |
| `staged_start` (optional) | `stage(name, interface)` | `"safe"`, then `"default_pose"`, before the policy |
| `safe_stop` (optional) | `set_safe_stop(mode)` | the robot card's `safety.safe_stop` decides what hold / stop mean |

- **`virtual`** — every protocol. Validates the manifest and runs a clock at the
  policy's control rate for the approved window. It simulates **nothing**: no
  inference (the ONNX is never loaded), no physics, no actuators. It exists so
  the whole consent path runs in CI on a laptop, and every heartbeat says so.
- **`command`** — `policy` only: shells out to the box's own start/stop/status
  commands, from `device.json` `adapter_config` (`register --adapter-config
  FILE.json`) or `LUCEN_DEVICE_START_CMD` / `LUCEN_DEVICE_STOP_CMD` /
  `LUCEN_DEVICE_STATUS_CMD`, **never from the hub**. Placeholders:
  `{model_path}`, `{manifest_path}`, `{interface_path}`, `{seconds}`,
  `{approval_id}`, `{safe_stop}`; a shell string or a JSON list; an unknown
  placeholder is refused before anything starts. **Stop**: `stop_cmd` first
  (30 s cap), then `SIGTERM` to the start command's whole process group (it
  runs in its own session, so a runner forked by `sh -c` is reached), then
  `SIGKILL` after 10 s. **Unverified on hardware** — there is no robot here;
  the plumbing is tested with a shell command standing in for the runner.
- **`lucen-legged`** — the deprecated alias of `command` (M13's name for it;
  there was never anything legged in it). Existing registrations keep serving.

**The robot card's interface, on the box.** `register` pins the robot card's
embodiment `interface` and `interface_fingerprint` (refresh on purpose:
`lucen-device interface --refresh`). `safety.safe_stop` reaches
`set_safe_stop()` — and if the card asks for something other than `hold` and
the adapter has no such hook, nothing starts; `default_pose` reaches the staged
start; a policy stamped with a different `embodiment_fingerprint` is refused
(`embodiment_mismatch`), and when either side is silent the `started` event
records the check as **not made**.

## Other commands

```
lucen-device status                 # registration, adapter provenance, issued approvals, last events
lucen-device trust [MODE]           # show the trust mode, or change it (the box's call alone, signed)
lucen-device signing-key [--refresh] # the pinned hub key; --refresh re-pins it
lucen-device interface [--refresh]   # the pinned robot-card interface fingerprint
lucen-device version
```


## Consuming a hub endpoint (M15)

An approval may name an **endpoint** instead of a file to run: "let this
device consume hub endpoint X" — a skill-layer model (action chunks at 1–10 Hz)
served by `lucen serve` on a GPU box. The driver then does not pull the model.
It opens a session on the hub with the approval token (`POST
/v1/endpoints/{id}/sessions`), checks that the hub's and the server's
`model_sha256` are the digest inside the token and that the served contract is
one this box registered, connects over openpi's protocol with the session token
as the `Api-Key` header, and runs the loop at the contract's control rate.

**The fail-safe is local and mandatory.** One chunk period is the deadline; a
chunk that arrives late is dropped, never applied; two consecutive misses or a
dropped link call the adapter's `hold()` (freeze a safe posture, or the
adapter's own stop), post `degraded`, and reconnect until the window ends; when
chunks flow again the adapter `resume()`s and `recovered` is posted. Only an
adapter that implements `begin / observe / apply / hold / resume / stop` **and
whose `hold()` then `stop()` run clean on a fresh instance** may consume an
endpoint: `register` establishes `endpoint_capable` from the adapter's own code
and that probe — never from a list of names (`virtual` and the example yes,
`command` no) — and the hub refuses a request naming an endpoint on any other
device. `hold()` and `stop()` must therefore work in any state and never raise.
The loop does not trust them anyway: a `hold()` that raises or does not return
within a second is answered with `stop()`, a `degraded` event saying
`hold_failed`, and the end of the run; an exception out of `observe()` /
`apply()` holds and ends it; and a watchdog thread holds and stops a loop that
has made no progress for ten seconds (a coarse backstop, not a real-time
guarantee — the robot's own controller must also stop when commands stop). A queued endpoint or an unreachable server is
*deferred* (the approval stays issued; the next poll tries again), not refused.

The device reports its own metering — chunks applied, round-trip p50/p95,
deadline misses, how often it held — to the session on the hub every heartbeat;
the endpoint page shows it beside the server's numbers.

## Changelog

- **0.3.1** (2026-10-13): the install section says what `uv tool install`
  exposes (`lucen-device` alone) and the line that adds `lucen` beside it. No
  code change.
- **0.3.0**: the first release on PyPI.

Apache-2.0. © 2026 Lucen Robotics.
