openvibers/openvibe.link Commits

Pass Bot's LOOI headlight to the driver as its own non-motion kind (#158)

OP OpenVibers committed 2 days ago · 1b62d76051 · 13 files, +601 −26

* Link: pass Bot's LOOI headlight to the driver

A headlight is Bot's own non-motion kind ({"on": true|false}, never a
toggle). Link now knows it, checks Bot's provenance against the frame
(session, seq, issued_at, 300 ms original lifetime and a ≤250 ms
deadline), keeps at least 200 ms of that original lifetime on receipt,
and refuses expiry/rate before a plugin sees it. It routes to a plugin
that describes the headlight capability and writes the note's envelope:
deadline_ms ≤ 250, expires_at_mono_ns at the original expiry on the
shared QPC/CLOCK_MONOTONIC clock, operator, turn_epoch and provenance
with Link's measured clock uncertainty. At most two are accepted in any
rolling second (one shared budget for Bot and local controls), and a
refused one is an ack with stage=headlight_refused, admitted=false.

The driver's ack data (headlight_pending/headlight_refused) is relayed
to Bot; a nack keeps the driver's message. Headlights are not motion:
they are not in GuardedKinds and never renew a drive/head deadline.
Tested with a fake Bot and a fake Python headlight driver end to end.
Status 0.57.0; docs/protocol.md, plugins.md and hardware-looi.md updated.

* Link protocol: document headlight's deadline as well as motion

* Link docs: show the headlight frame both sides see

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex febc915..4a2e098 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,14 +5,15 @@
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
-- "version": "0.56.0",
-- "release": "v0.56.0: sandbox skill jobs never receive the owner's plan login files, even when the owner enabled those files for other functions. Skill code runs with the scanned inputs in its job directory and no coding CLI credentials.",
++ "version": "0.57.0",
++ "release": "v0.57.0: LOOI headlights \u2014 a Bot headlight command ({\"on\": true|false}, not a toggle) reaches the community LOOI driver as its own non-motion kind, with Bot's provenance, a 300 ms original lifetime and a deadline of at most 250 ms, at least 200 ms of the original lifetime left on receipt, expires_at_mono_ns on the shared clock, a shared 2 requests/second budget with local controls, and the driver's headlight_pending/headlight_refused ack data relayed to Bot. Drive and head deadlines are unchanged.",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"deployed": false,
"deployment": "none of its own: people install it on their machines and robots (curl -fsSL https://openvibe.link/install | sh, or a release binary); it connects out to OpenVibe.Bot (wss://openvibe.bot/device) and pairs through OpenVibe.Bot or OpenVibe.Network. On 2026-10-22 production held 1 platform node with 4 pairings and 5 node principals on Network, and 3 robots with 5 devices on Bot.",
"features": [
"Git on this computer (G3): `openvibe-link clone <link>` clones into the root you chose in Link after you approve it there, reporting progress to git.openvibe.codes; `openvibe-link git connect <link>` publishes the repositories under that root, read-only, browsed through Link while the machine is online, and carries a repository page's \"Clone to <this computer>\" to the same approval there",
++ "LOOI headlights: Bot's explicit on/off custom action reaches the community driver as kind \"headlight\" (never actuator), with provenance, 300 ms lifetime/250 ms deadline, 200 ms original reserve on receipt, expires_at_mono_ns on the shared clock, a 2 requests/second budget shared with local controls, and stage=headlight_pending/headlight_refused ack data",
"pairing with a code approved on openvibe.bot (or through Network), one outbound WebSocket, no open ports",
"safety: deadlines, owner limits clamped before any driver, heartbeats and a deadman, a persisted e-stop and the local kill switch (openvibe-link stop)",
"plugins as separate processes (JSON lines): dryrun, adeept_adr036, cozmo, relay and ros2 (rclpy or rosbridge); the community LOOI driver's contract; signed community profiles (openvibe-link profile verify)",
@@@ -34,5 +35,5 @@
"a tray on macOS (approvals there are a dialog with Deny as its default)",
"the computer plugin's browser on Windows"
],
-- "updated": "2026-11-10"
++ "updated": "2026-11-11"
}
ddiff --git a/docs/hardware-looi.md b/docs/hardware-looi.md
iindex 0b290d3..4685ab6 100644
---- a/docs/hardware-looi.md
++++ b/docs/hardware-looi.md
@@@ -40,6 +40,7 @@ The driver is a Link plugin ([plugins.md](plugins.md)): JSON lines over stdin an
|---|---|---|
| `drive` | `{"throttle": -1..1, "steer": -1..1}` | tracks: left = throttle + steer, right = throttle − steer (steer > 0 turns right), normalised so neither exceeds 1 |
| `actuator` | `{"name": "head", "value": -1..1}` | head tilt, -1 fully down, 1 fully up |
++| `headlight` | `{"on": true}` / `{"on": false}` | the headlights, explicit on or off; not motion, never a toggle |
| `halt` | — | stop everything now; it always passes, even while stopped |
Every `drive` has a `deadline_ms`. The tracks must stop when it passes without a newer command (the panel re-sends a
@@@ -47,6 +48,57 @@ held control every 150 ms). With the Python runtime (`openvibe_plugin`, [plugins
`motion_kinds = {"drive"}`, and it enforces the deadline, the 1 s heartbeat, stdin closing, `stop`, `estop` and
`halt` for you.
++## Headlights
++
++The owner and the People Operators the owner allows get one labelled Headlights button beside the head slider on the
++LOOI panel (the full panel and the channel's Robot tab; never the overlay or the one-row controls, and never an
++anonymous viewer or a plain watcher). A press sends Bot `{"kind":"headlight","value":{"on":true|false}}`; Bot's gate
++decides the role and stamps the trusted fields, and the browser never sends a subject, role, deadline or provenance.
++
++What the driver receives from OpenVibe Link (the note's exact non-motion envelope; `deadline_ms` and
++`expires_at_mono_ns` are filled by the Link at write time):
++
++```json
++{
++ "op": "command",
++ "id": "cmd_…",
++ "kind": "headlight",
++ "value": {"on": true},
++ "deadline_ms": 250,
++ "expires_at_mono_ns": 123456789012345,
++ "operator": {"subject": "usr_…", "role": "owner"},
++ "turn_epoch": 1738065600100,
++ "provenance": {
++ "session": "sess_…",
++ "seq": 7,
++ "issued_at_ms": 1738065600000,
++ "expires_at_ms": 1738065600300,
++ "clock_uncertainty_ms": 12
++ }
++}
++```
++
++- The numbers are the contract: 300 ms original lifetime, a deadline of at most 250 ms, and at least 200 ms of the
++ original lifetime left when the Link reads the line (Link nacks `expired` otherwise). It is not a motion kind and
++ must never renew a drive/head deadline.
++- At most two are accepted per rolling second on the machine, shared with the owner's local control; Link/plugin
++ must not retry automatically.
++- An accepted command is an `ack` with `data` `{"stage":"headlight_pending","admitted":true}`: accepted, **not
++ confirmed**. A busy or rate refusal is an `ack` with `data` `{"stage":"headlight_refused","admitted":false}`: the
++ panel says "Busy, try again in a moment." and keeps its displayed state. A `nack` is a rejection and the panel
++ says "The robot refused: <reason>".
++- Telemetry: `sensors.p7_headlight` {`commanded`: `true`/`false`/`null`, `pending`: boolean,
++ `physical_state_verified`: `false`}. `commanded` is what was asked, not what the robot did; the panel labels null
++ Unknown, shows Turning on…/Turning off… while pending, and keeps "Not confirmed on the robot" beside the state.
++- Access is Bot's, not the driver's: the owner, and People Operators when the owner's allow list gives them
++ headlights. Remote light also needs the driver's own preconditions (its Go Live enrollment, ARMED, STOP clear and
++ no pending neutral/head work). It grants no motion and nothing while the robot is stopped.
++
++The Node and panel route is exercised in Link's tests with a fake Bot and a fake headlight driver; the real
++Bluetooth frames and the physical state are still unverified on a robot. The community information FED2 03=ON /
++00=OFF is not verified by this contract. Test on the robot: the physical light changes, STOP/busy leave it as it was,
++and a lost Link never leaves it on.
++
The deadline is the original one on every OS, Windows included (Link 0.30): the Link passes the instant a command
expires on the clock it shares with the driver, so time the line spends in the pipe counts against it. For live motion,
also set `strict_timing = True` on the plugin (or `"strict_timing": true` on it in `config.json`): a command whose
ddiff --git a/docs/plugins.md b/docs/plugins.md
iindex 415d84d..de3d1a4 100644
---- a/docs/plugins.md
++++ b/docs/plugins.md
@@@ -63,7 +63,16 @@ describes itself and waits for EOF without opening any hardware.
| `voice_answer` | `id`, `state` (working, answered, refused), `text`, `url` | (voice plugins) Vibe's answer to a `voice_ask` event the plugin raised: speak and show it |
`value` has already been validated and clamped to the owner's limits by the core ([protocol.md](protocol.md#commands)).
--`deadline_ms` is relative to receipt and already capped. Since Link 0.18 the core counts it down to the moment it writes
++`deadline_ms` is relative to receipt and already capped.
++
++`provenance` is where a custom action came from, passed unchanged from Bot: `session`, `seq`, `issued_at_ms`,
++`expires_at_ms` and the core's measured `clock_uncertainty_ms` ([protocol.md](protocol.md#commands)). `headlight`
++carries it (300 ms original lifetime, ≤250 ms deadline) together with `expires_at_mono_ns`, the original expiry on the
++shared clock. A headlight is not motion: the runtime's `motion_kinds` must not include it and it must never renew a
++drive or head deadline. Its accepted answer is an `ack` with `data` `{"stage":"headlight_pending","admitted":true}`
++(accepted, not confirmed), a busy/rate refusal is an `ack` with `{"stage":"headlight_refused","admitted":false}`;
++`nack` is a rejection. Report `sensors.p7_headlight {commanded, pending, physical_state_verified}` in telemetry,
++never a physical claim: `physical_state_verified` is `false`. Since Link 0.18 the core counts it down to the moment it writes
the line, and adds `expires_at_mono_ns`: the instant the command expires on the clock every process on the machine
shares, `CLOCK_MONOTONIC` on Linux and macOS and, since 0.30, the raw `QueryPerformanceCounter` on Windows. `hello` names
that clock in `shared_clock` (`clock_monotonic` or `qpc`, 0.30). A plugin that reads it when the line arrives takes off
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 513adac..3860132 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -157,7 +157,7 @@ server's own counter, never the heartbeat's.
|-----------------|--------------------------------------------------------------------------------------------------|
| `hello` | `session_id`, `device_id`, `robot_ids` [], `server_time` (RFC 3339) — on every authenticated connection |
| `config` | `heartbeat_ms`, `limits` {`max_speed` 0..1, `max_turn` 0..1, `max_command_ms`, `heartbeat_ms`}, `allowed_commands` [kinds], `estop_latched`, `roles` [ids] — right after `hello` |
--| `command` | `id` (server-minted: the idempotency and ack key), `ref` (the operator's own id, logs only), `kind`, `value`, `deadline_ms` (absolute, motion kinds only), `operator` {`subject`, `role`}, `robot_id`; Link only: `target` (plugin name) |
++| `command` | `id` (server-minted: the idempotency and ack key), `ref` (the operator's own id, logs only), `kind`, `value`, `deadline_ms` (absolute, motion kinds and `headlight`), `operator` {`subject`, `role`}, `robot_id`; Link only: `target` (plugin name); `headlight` only: `provenance` {`session`, `seq`, `issued_at_ms`, `expires_at_ms`, `clock_uncertainty_ms`} |
| `estop` | `latched` (true: the e-stop latched; false: the owner cleared it), `by`, `at` |
| `heartbeat_ack` | `echo` (the heartbeat's `t`, or null), `t` (the same send time when the server has it), `server_time` (RFC 3339); the Link measures RTT from the echoed send time |
| `error` | `code`, `detail` — a frame the server refused; logged, never fatal |
@@@ -210,6 +210,7 @@ reading until the next one.
| `say` | `{"text": "…"}` (1–1000 bytes; plugins may cap lower) | no |
| `display` | `{"text": "…"}`, `{"face": "happy"}` or `{"image_png_b64": "…"}` | no |
| `button` | `{"name": "forward"}` press; `{"name": …, "state": "down"\|"up"}` hold (names from the robot's profile) | guarded |
++| `headlight` | `{"on": true}` or `{"on": false}` — explicit, never a toggle; Bot's provenance and a 300 ms original lifetime/250 ms deadline | no |
| `point` | `{"x": 0..1, "y": 0..1}` normalised click on the video, for the [`relay`](plugins.md) plugin | guarded |
| `halt` | `{}` — stop every actuator now (not latched); always accepted | — |
| `exec` | `{"argv": ["prog", "arg"…], "cwd"?, "timeout_s"?, "stdin"?}`: run a program on the machine ([computer.md](computer.md)) | computer |
@@@ -223,7 +224,7 @@ Sign conventions: `throttle`/`x` positive = forward; `steer` positive = turn rig
servo in their config to make the hardware match).
`deadline_ms` is an **absolute** instant in Unix milliseconds on the server's clock, present on `drive`, `actuator`,
--`ptz` and `button` (a held button is re-sent, so its deadline releases the hold — see the [`relay`](plugins.md) plugin). A device's clock may be minutes off, so the Link never compares it with its own clock: it estimates the
++`ptz`, `button` and `headlight` (a held button is re-sent, so its deadline releases the hold — see the [`relay`](plugins.md) plugin). A device's clock may be minutes off, so the Link never compares it with its own clock: it estimates the
server's clock from the `ts` of every frame on the connection (the largest `ts` − receive time: the skew less the
fastest transit, kept for the whole connection so a run of delayed frames cannot reopen an expired deadline) and
compares `deadline_ms` with that at receipt. A command that arrives at or after its deadline →
@@@ -231,6 +232,40 @@ compares `deadline_ms` with that at receipt. A command that arrives at or after
300 ms. Always capped at `max_command_ms` (default 1000). A held control is re-sent by the panel every 150 ms, each
with a new `id`. Plugins still get a relative `deadline_ms` and keep their own stop timer ([plugins.md](plugins.md)).
++**Headlights.** `headlight` is the one custom non-motion kind with its own timing: `{"on": true|false}`, explicit and
++never a toggle. Bot mints one with an original 300 ms lifetime and a `deadline_ms` at most 250 ms after issue, and
++stamps `provenance` on the frame: `session` (the hello's id), `seq` and `issued_at_ms` (this frame's envelope seq and
++ts), `expires_at_ms` (the original 300 ms expiry) and `clock_uncertainty_ms` (Bot's last measured link round trip).
++The Link checks provenance against the frame, nacks `bad_value` when they disagree or the lifetime/deadline are not
++Bot's, nacks `expired` when less than 200 ms of the original 300 ms lifetime is left on receipt (or the deadline
++already passed), and only then routes it to the driver. The driver gets `deadline_ms` ≤ 250,
++`expires_at_mono_ns` at the original expiry on the shared QPC/monotonic clock, the frame's `operator` and
++`turn_epoch`, and Bot's `provenance` with the Link's own measured `clock_uncertainty_ms`. A headlight is never motion:
++it does not renew a drive or head deadline. At most two are accepted in any rolling second on one machine, shared
++with the owner's local controls. A rate/busy refusal is an `ack` with `data`
++`{"stage":"headlight_refused","admitted":false}`; a refusal from the driver is the same. An accepted one is an `ack`
++with `{"stage":"headlight_pending","admitted":true}` — accepted, **not confirmed on the hardware**. A `nack` is a
++rejection. The driver reports `sensors.p7_headlight` {`commanded`: true|false|null, `pending`: bool,
++`physical_state_verified`: false} in telemetry; a panel shows the commanded state, never a physical reading.
++
++The frame Bot sends and the line the driver receives (the driver's copy has a relative `deadline_ms`, the original
++expiry on the shared clock, and Link's own clock bound):
++
++```json
++{ "v": 1, "seq": 10, "ts": 1738065600000, "type": "command", "id": "cmd_01J8Z4H…", "ref": "op_hl…",
++ "kind": "headlight", "value": { "on": true }, "deadline_ms": 1738065600250,
++ "operator": { "subject": "usr_01J8…", "role": "owner" }, "robot_id": "rob_…",
++ "provenance": { "session": "sess_…", "seq": 10, "issued_at_ms": 1738065600000,
++ "expires_at_ms": 1738065600300, "clock_uncertainty_ms": 12 } }
++```
++```json
++{ "op": "command", "id": "cmd_01J8Z4H…", "kind": "headlight", "value": { "on": true },
++ "deadline_ms": 250, "expires_at_mono_ns": 123456789012345,
++ "operator": { "subject": "usr_01J8…", "role": "owner" }, "turn_epoch": 1738065600100,
++ "provenance": { "session": "sess_…", "seq": 10, "issued_at_ms": 1738065600000,
++ "expires_at_ms": 1738065600300, "clock_uncertainty_ms": 12 } }
++```
++
What the Link does with a command, in order:
1. A repeated `id` is answered with the first result and never executed again (the cache lives for 10 minutes and
@@@ -239,7 +274,9 @@ What the Link does with a command, in order:
3. `halt` → every plugin stops, `ack`.
4. No `config` yet on this connection → `nack not_ready`. Kind not in `config.allowed_commands` → `nack not_allowed`.
5. `drive`, `ptz`, `actuator`, `button` and `point` while the local kill switch is latched → `nack local_stop`; while
-- the remote e-stop is latched → `nack estopped`; past its `deadline_ms` → `nack expired`.
++ the remote e-stop is latched → `nack estopped`; past its `deadline_ms` → `nack expired`. `headlight` is checked by
++ its own provenance/lifetime rules above instead (it is not motion and is not refused by the latch here; the driver
++ applies STOP and its own preconditions).
6. The value is clamped in the core, before any plugin sees it: `throttle`, `x`, `y` to ±`max_speed`; `steer`,
`rotation` to ±`max_turn`; `pan`, `tilt`, `zoom` and a numeric actuator `value` to ±1; a `point`'s `x`/`y` must be
numbers in 0..1. The limits are the stricter of the server's `config.limits` and the local config's `limits`.
ddiff --git a/internal/agent/agent.go b/internal/agent/agent.go
iindex 941a4bd..d897a87 100644
---- a/internal/agent/agent.go
++++ b/internal/agent/agent.go
@@@ -140,7 +140,10 @@ type Agent struct {
// the mouse or keyboard and for InputGuard after, so an approved click cannot press another request's Approve.
inputs map[string]bool
inputEnded time.Time
-- trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
++ // headlightAt: the last accepted headlight instants on this machine (at most HeadlightPerSecond), for the shared
++ // 2 requests/second budget. It is one budget for Bot's commands and the owner's local controls.
++ headlightAt []mono.Instant
++ trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
// panel is Link's own window (nil until Run started it). panelOff are the capability switches turned off in it and
// panelFirst is its first-run answer, both kept in state/panel.json; activity is what "What happened here" lists,
@@@ -164,6 +167,17 @@ var ErrQuit = errors.New("quit by its owner")
// InputGuard is how long after an input command `approve` stays refused.
const InputGuard = 2 * time.Second
++// Headlights (Maticus's 2026-11-11 handoff, docs/hardware-looi.md): Bot mints a headlight with an original 300 ms
++// lifetime and a deadline of at most 250 ms; by the time it reaches this machine at least 200 ms of that original
++// lifetime must be left, so the driver never gets a command that was already most of the way through its life.
++// At most two are accepted in any rolling second, shared by every source (Bot and the owner's local controls).
++const (
++ HeadlightLifetimeMS = 300
++ HeadlightDeadlineMS = 250
++ HeadlightReserveMS = 200
++ HeadlightPerSecond = 2
++)
++
// trayTTL is how long the computer plugin trusts one "the tray shows your approvals"; the tray says it again
// every poll and the core passes it on at most every trayRetell.
const (
@@@ -716,6 +730,10 @@ func (n *Agent) command(c protocol.Command, sentMS int64, seq uint64, received m
nack(protocol.FaultNotAllowed, c.Kind+" is not in this robot's allowed_commands")
return
}
++ if c.Kind == protocol.KindHeadlight {
++ n.headlight(c, entry, sentMS, seq, received, limits, nack)
++ return
++ }
if n.capabilityOff(c.Kind) {
nack(protocol.FaultNotAllowed, "turned off in Link: "+capabilityForKind[c.Kind]+" is off on this computer")
return
@@@ -795,7 +813,97 @@ func (n *Agent) command(c protocol.Command, sentMS int64, seq uint64, received m
r := wait()
var msg protocol.Message
if r.OK {
-- msg = protocol.Ack{ID: c.ID, LatencyMS: mono.Since(received).Milliseconds()}
++ if c.Kind == protocol.KindHeadlight && len(r.Data) > 0 {
++ // The LOOI driver's accepted-not-confirmed answer: stage=headlight_pending, admitted=true (or
++ // headlight_refused, admitted=false when busy). It rides back to Bot in the ack's data.
++ msg = protocol.AckData{ID: c.ID, LatencyMS: mono.Since(received).Milliseconds(), Data: r.Data}
++ } else {
++ msg = protocol.Ack{ID: c.ID, LatencyMS: mono.Since(received).Milliseconds()}
++ }
++ } else {
++ msg = protocol.Nack{ID: c.ID, FaultCode: r.FaultCode, Message: r.Message}
++ }
++ n.dedup.Complete(entry, msg)
++ n.send(msg)
++ }()
++}
++
++// headlight passes one Bot-minted headlight to its driver. It is not a motion kind: it never renews a drive or
++// head deadline. Bot's provenance is checked against the frame (session, seq, ts) and the original lifetime and
++// deadline, at least 200 ms of the original 300 ms lifetime must be left when the frame is read, the shared
++// 2 requests/second budget is spent, and the plugin gets deadline_ms ≤ 250 with expires_at_mono_ns at the
++// original expiry on the shared clock. Its ack's data (stage headlight_pending/headlight_refused) is relayed to Bot.
++func (n *Agent) headlight(c protocol.Command, entry *safety.Entry, sentMS int64, seq uint64, received mono.Instant, limits safety.Limits, nack func(code, msg string)) {
++ pv := c.Provenance
++ if pv == nil {
++ nack(protocol.FaultBadValue, "headlight needs Bot's provenance")
++ return
++ }
++ if pv.Session == "" || pv.Session != n.session {
++ nack(protocol.FaultBadValue, "headlight provenance is for another link session")
++ return
++ }
++ if pv.Seq != seq || pv.IssuedAtMS != sentMS {
++ nack(protocol.FaultBadValue, "headlight provenance does not match its frame")
++ return
++ }
++ if pv.ExpiresAtMS-pv.IssuedAtMS != HeadlightLifetimeMS {
++ nack(protocol.FaultBadValue, fmt.Sprintf("headlight original lifetime is %d ms, not %d", pv.ExpiresAtMS-pv.IssuedAtMS, HeadlightLifetimeMS))
++ return
++ }
++ if c.DeadlineMS <= pv.IssuedAtMS || c.DeadlineMS > pv.IssuedAtMS+HeadlightDeadlineMS {
++ nack(protocol.FaultBadValue, fmt.Sprintf("headlight deadline is %d ms after issue, not 1..%d", c.DeadlineMS-pv.IssuedAtMS, HeadlightDeadlineMS))
++ return
++ }
++ left, unc, synced := n.timeLeft(c.DeadlineMS, sentMS, received)
++ if !synced {
++ nack(protocol.FaultNotReady, "the link's clock is not synchronized yet: no round trip has been measured on this connection")
++ return
++ }
++ if left < time.Millisecond {
++ nack(protocol.FaultExpired, fmt.Sprintf("the deadline passed %d ms before the command arrived (counting %d ms of clock uncertainty)", max(0, ceilMS(-left)), unc))
++ return
++ }
++ // The original expiry is what provenance.expires_at_ms says; what is left of it is the deadline's time left plus
++ // the gap between the two. At least HeadlightReserveMS of that original 300 ms must remain on receipt.
++ originalLeft := left + time.Duration(pv.ExpiresAtMS-c.DeadlineMS)*time.Millisecond
++ if originalLeft < HeadlightReserveMS*time.Millisecond {
++ nack(protocol.FaultExpired, fmt.Sprintf("only %d ms of the headlight's original %d ms lifetime was left on receipt (need at least %d)", max(0, originalLeft.Milliseconds()), HeadlightLifetimeMS, HeadlightReserveMS))
++ return
++ }
++ value, err := limits.Clamp(c.Kind, c.Value)
++ if err != nil {
++ var fe *safety.FaultError
++ if errors.As(err, &fe) {
++ nack(fe.Code, fe.Message)
++ } else {
++ nack(protocol.FaultBadValue, err.Error())
++ }
++ return
++ }
++ p := n.mgr.Route(c.Kind, c.Target, "")
++ if p == nil {
++ nack(protocol.FaultUnsupported, "no driver on this device handles "+c.Kind)
++ return
++ }
++ if !n.admitHeadlight(received) {
++ msg := protocol.AckData{ID: c.ID, LatencyMS: mono.Since(received).Milliseconds(),
++ Data: json.RawMessage(`{"stage":"headlight_refused","admitted":false}`)}
++ n.dedup.Complete(entry, msg)
++ n.send(msg)
++ return
++ }
++ prov := &plugins.Provenance{Session: pv.Session, Seq: pv.Seq, IssuedAtMS: pv.IssuedAtMS, ExpiresAtMS: pv.ExpiresAtMS, ClockUncertaintyMS: &unc}
++ wait := p.BeginWithCap(n.ctx, c.ID, c.Kind, value, received.Add(originalLeft), HeadlightDeadlineMS, c.Operator, c.TurnEpoch, prov)
++ go func() {
++ r := wait()
++ var msg protocol.Message
++ if r.OK {
++ if len(r.Data) > 0 {
++ msg = protocol.AckData{ID: c.ID, LatencyMS: mono.Since(received).Milliseconds(), Data: r.Data}
++ } else {
++ msg = protocol.Ack{ID: c.ID, LatencyMS: mono.Since(received).Milliseconds()}
++ }
} else {
msg = protocol.Nack{ID: c.ID, FaultCode: r.FaultCode, Message: r.Message}
}
@@@ -804,6 +912,21 @@ func (n *Agent) command(c protocol.Command, sentMS int64, seq uint64, received m
}()
}
++// admitHeadlight spends one of the two headlight slots in any rolling second, shared by every source on this
++// machine. A refused request is not recorded, so the next one a moment later can still go.
++func (n *Agent) admitHeadlight(at mono.Instant) bool {
++ n.mu.Lock()
++ defer n.mu.Unlock()
++ if len(n.headlightAt) >= HeadlightPerSecond && at-n.headlightAt[0] < mono.Instant(time.Second) {
++ return false
++ }
++ n.headlightAt = append(n.headlightAt, at)
++ if len(n.headlightAt) > HeadlightPerSecond {
++ n.headlightAt = n.headlightAt[len(n.headlightAt)-HeadlightPerSecond:]
++ }
++ return true
++}
++
// computer runs one exec, fs or screen command: no clamping, a long reply window (the server's deadline, at most
// MaxComputerMS, else DefaultComputerMS), refused while the local stop or an e-stop is latched when it would change the
// machine, and answered with the plugin's data. The plugin applies its owner's control level.
ddiff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
iindex f5f1f69..491be8b 100644
---- a/internal/agent/agent_test.go
++++ b/internal/agent/agent_test.go
@@@ -61,6 +61,13 @@ func start(t *testing.T) *env {
// startWith is start with prep run on the fake server before pairing; ready false returns once the device has
// connected, without waiting for its first status.
func startWith(t *testing.T, prep func(*fakebot.Server), ready bool) *env {
++ t.Helper()
++ return startWithPlugin(t, prep, ready, nil)
++}
++
++// startWithPlugin is startWith with the one plugin replaced (the headlight tests run a tiny Python driver that
++// describes the headlight kind; every other test runs the bundled dry-run plugin).
++func startWithPlugin(t *testing.T, prep func(*fakebot.Server), ready bool, plugin *config.PluginConfig) *env {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("end-to-end tests run on unix")
@@@ -99,6 +106,9 @@ func startWith(t *testing.T, prep func(*fakebot.Server), ready bool) *env {
cfg.Python = py
cfg.Plugins = []config.PluginConfig{{Name: "dryrun", Config: map[string]any{"record": record, "log_commands": false},
Env: map[string]string{"PYTHONPATH": filepath.Join(root, "plugins/sdk") + string(os.PathListSeparator) + filepath.Join(root, "plugins/dryrun")}}}
++ if plugin != nil {
++ cfg.Plugins = []config.PluginConfig{*plugin}
++ }
cfg.Video = config.VideoConfig{Source: "auto", Width: 64, Height: 48, FPS: 10, WHIPURL: srv.URL() + "/whip/" + creds.DeviceID}
n, err := New(Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", HeartbeatInterval: 100 * time.Millisecond,
@@@ -527,3 +537,202 @@ func TestVideoAndTelemetry(t *testing.T) {
}
}
}
++
++// ── LOOI headlights (Maticus, 2026-11-11) ────────────────────────────────────────────────────────────────────────
++// Bot mints a headlight with an original 300 ms lifetime and a ≤250 ms deadline; Link passes it to the driver as
++// the note's envelope (deadline_ms ≤ 250, expires_at_mono_ns on the shared clock, Bot's provenance, operator and
++// turn_epoch), holds 200 ms of the original lifetime on receipt, shares two requests a second across sources, and
++// relays the driver's stage=headlight_pending/headlight_refused ack data back to Bot.
++
++func headlightPlugin(t *testing.T) (*env, string) {
++ t.Helper()
++ py, err := exec.LookPath("python3")
++ if err != nil {
++ t.Skip("python3 not installed")
++ }
++ root, _ := filepath.Abs("../..")
++ record := filepath.Join(t.TempDir(), "headlight.jsonl")
++ e := startWithPlugin(t, nil, true, &config.PluginConfig{
++ Name: "headlight",
++ Command: []string{py, filepath.Join(root, "internal/agent/testdata/headlight_plugin.py")},
++ Config: map[string]any{"record": record},
++ })
++ return e, record
++}
++
++type headlightRec struct {
++ ID string `json:"id"`
++ Kind string `json:"kind"`
++ Value json.RawMessage `json:"value"`
++ DeadlineMS int `json:"deadline_ms"`
++ ExpiresAtMonoNS *int64 `json:"expires_at_mono_ns"`
++ Operator *protocol.Operator `json:"operator"`
++ TurnEpoch int64 `json:"turn_epoch"`
++ Provenance *struct {
++ Session string `json:"session"`
++ Seq uint64 `json:"seq"`
++ IssuedAtMS int64 `json:"issued_at_ms"`
++ ExpiresAtMS int64 `json:"expires_at_ms"`
++ ClockUncertaintyMS *int64 `json:"clock_uncertainty_ms"`
++ } `json:"provenance"`
++}
++
++func readHeadlight(t *testing.T, path string) []headlightRec {
++ t.Helper()
++ deadline := time.Now().Add(5 * time.Second)
++ for time.Now().Before(deadline) {
++ b, err := os.ReadFile(path)
++ if err == nil && len(b) > 0 {
++ var out []headlightRec
++ for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
++ var r headlightRec
++ if json.Unmarshal([]byte(line), &r) == nil {
++ out = append(out, r)
++ }
++ }
++ if len(out) > 0 {
++ return out
++ }
++ }
++ time.Sleep(5 * time.Millisecond)
++ }
++ t.Fatalf("the headlight plugin recorded no command in %s", path)
++ return nil
++}
++
++func ackData(t *testing.T, r protocol.Message) map[string]any {
++ t.Helper()
++ ad, ok := r.(protocol.AckData)
++ if !ok {
++ t.Fatalf("not an ack with data: %#v", r)
++ }
++ var out map[string]any
++ if err := json.Unmarshal(ad.Data, &out); err != nil {
++ t.Fatalf("ack data %s: %v", ad.Data, err)
++ }
++ return out
++}
++
++func headlightCommand(t *testing.T, e *env, id string, on bool, deadline int, prov *protocol.CommandProvenance) protocol.Message {
++ t.Helper()
++ cmd := protocol.Command{ID: id, Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":` + map[bool]string{true: "true", false: "false"}[on] + `}`), Operator: &protocol.Operator{Subject: "usr_test", Role: "operator"}, TurnEpoch: 4242, Provenance: prov}
++ if err := e.conn.Command(cmd, deadline); err != nil {
++ t.Fatal(err)
++ }
++ r, err := e.conn.Reply(id, 5*time.Second)
++ if err != nil {
++ t.Fatal(err)
++ }
++ return r
++}
++
++func TestHeadlightEnvelopeAndAckData(t *testing.T) {
++ e, record := headlightPlugin(t)
++ r := headlightCommand(t, e, "hl-1", true, HeadlightDeadlineMS, &protocol.CommandProvenance{})
++ if d := ackData(t, r); d["stage"] != "headlight_pending" || d["admitted"] != true {
++ t.Fatalf("ack data %v", d)
++ }
++ // The fake plugin reports telemetry; the Agent relays it to Bot.
++ if _, err := e.conn.Expect(protocol.TypeTelemetry, 5*time.Second, func(m protocol.Message) bool {
++ t, ok := m.(protocol.Telemetry)
++ if !ok {
++ return false
++ }
++ s, _ := t.Sensors["p7_headlight"].(map[string]any)
++ return s != nil && s["commanded"] == true
++ }); err != nil {
++ t.Fatal(err)
++ }
++ got := readHeadlight(t, record)[0]
++ if got.Kind != protocol.KindHeadlight || string(got.Value) != `{"on":true}` {
++ t.Fatalf("plugin got %+v", got)
++ }
++ if got.DeadlineMS < 1 || got.DeadlineMS > HeadlightDeadlineMS {
++ t.Fatalf("plugin deadline_ms %d", got.DeadlineMS)
++ }
++ if got.ExpiresAtMonoNS == nil || *got.ExpiresAtMonoNS == 0 {
++ t.Fatalf("no expires_at_mono_ns: %+v", got)
++ }
++ if got.Operator == nil || got.Operator.Subject != "usr_test" || got.Operator.Role != "operator" || got.TurnEpoch != 4242 {
++ t.Fatalf("operator/turn_epoch: %+v", got)
++ }
++ p := got.Provenance
++ if p == nil || p.Session == "" || p.Seq == 0 || p.IssuedAtMS == 0 || p.ExpiresAtMS-p.IssuedAtMS != HeadlightLifetimeMS {
++ t.Fatalf("provenance: %+v", p)
++ }
++ if p.Session != e.conn.SessionID || p.ClockUncertaintyMS == nil {
++ t.Fatalf("provenance session/clock uncertainty: %+v (hello %s)", p, e.conn.SessionID)
++ }
++}
++
++func TestHeadlightReserveAndRefusals(t *testing.T) {
++ e, _ := headlightPlugin(t)
++ // A frame that spent 150 ms on the way has only 150 ms of its original 300 ms lifetime left: never sent.
++ cmd := protocol.Command{ID: "hl-late", Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":false}`), Provenance: &protocol.CommandProvenance{}}
++ if err := e.conn.CommandLate(cmd, HeadlightDeadlineMS, 150*time.Millisecond); err != nil {
++ t.Fatal(err)
++ }
++ r, err := e.conn.Reply("hl-late", 5*time.Second)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if n, ok := r.(protocol.Nack); !ok || n.FaultCode != protocol.FaultExpired {
++ t.Fatalf("the late headlight was not refused expired: %#v", r)
++ }
++ // Provenance is checked against the frame: another session, another seq, another lifetime or a deadline over
++ // 250 ms is a bad value, never a plugin call.
++ cases := []struct {
++ name string
++ deadline int
++ prov *protocol.CommandProvenance
++ }{
++ {"session", HeadlightDeadlineMS, &protocol.CommandProvenance{Session: "sess_other"}},
++ {"seq", HeadlightDeadlineMS, &protocol.CommandProvenance{Seq: 999}},
++ {"lifetime", HeadlightDeadlineMS, &protocol.CommandProvenance{ExpiresAtMS: time.Now().Add(400 * time.Millisecond).UnixMilli()}},
++ {"deadline", HeadlightDeadlineMS + 10, &protocol.CommandProvenance{}},
++ }
++ for _, c := range cases {
++ cmd := protocol.Command{ID: "hl-" + c.name, Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":true}`), Provenance: c.prov}
++ if err := e.conn.Command(cmd, c.deadline); err != nil {
++ t.Fatal(err)
++ }
++ r, err := e.conn.Reply("hl-"+c.name, 5*time.Second)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if n, ok := r.(protocol.Nack); !ok || n.FaultCode != protocol.FaultBadValue {
++ t.Fatalf("%s: %#v", c.name, r)
++ }
++ }
++ // Explicit on/off only: no toggle, no other key.
++ bad := protocol.Command{ID: "hl-bad", Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":"yes"}`), Provenance: &protocol.CommandProvenance{}}
++ if err := e.conn.Command(bad, HeadlightDeadlineMS); err != nil {
++ t.Fatal(err)
++ }
++ r, err = e.conn.Reply("hl-bad", 5*time.Second)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if n, ok := r.(protocol.Nack); !ok || n.FaultCode != protocol.FaultBadValue {
++ t.Fatalf("odd on: %#v", r)
++ }
++}
++
++func TestHeadlightTwoPerSecond(t *testing.T) {
++ e, _ := headlightPlugin(t)
++ for i := 0; i < HeadlightPerSecond; i++ {
++ r := headlightCommand(t, e, "hl-rate-"+string(rune('a'+i)), true, HeadlightDeadlineMS, &protocol.CommandProvenance{})
++ if d := ackData(t, r); d["admitted"] != true {
++ t.Fatalf("request %d refused: %v", i, d)
++ }
++ }
++ r := headlightCommand(t, e, "hl-rate-c", false, HeadlightDeadlineMS, &protocol.CommandProvenance{})
++ if d := ackData(t, r); d["stage"] != "headlight_refused" || d["admitted"] != false {
++ t.Fatalf("the third in a second was not refused: %v", d)
++ }
++ time.Sleep(1100 * time.Millisecond)
++ r = headlightCommand(t, e, "hl-rate-d", false, HeadlightDeadlineMS, &protocol.CommandProvenance{})
++ if d := ackData(t, r); d["admitted"] != true {
++ t.Fatalf("the next second was refused: %v", d)
++ }
++}
ddiff --git a/internal/agent/testdata/headlight_plugin.py b/internal/agent/testdata/headlight_plugin.py
nnew file mode 100755
iindex 0000000..ed8ae6b
---- /dev/null
++++ b/internal/agent/testdata/headlight_plugin.py
@@@ -0,0 +1,45 @@
++#!/usr/bin/env python3
++"""A tiny headlight driver for the Agent tests: describes the headlight kind, records each command line it receives
++(config.record), answers with the LOOI accepted-not-confirmed data, and reports p7_headlight telemetry."""
++import json
++import sys
++
++record = None
++
++
++def send(obj):
++ print(json.dumps(obj, separators=(",", ":")), flush=True)
++
++
++def note(line):
++ if not record:
++ return
++ with open(record, "a") as f:
++ f.write(json.dumps(line, separators=(",", ":")) + "\n")
++
++
++for raw in sys.stdin:
++ try:
++ msg = json.loads(raw)
++ except ValueError:
++ continue
++ op = msg.get("op")
++ if op == "hello":
++ config = msg.get("config") or {}
++ record = config.get("record")
++ send({"op": "describe", "driver": "headlight", "version": "1", "capabilities": {"headlight": {}}})
++ if msg.get("probe"):
++ continue
++ send({"op": "ready"})
++ send({"op": "telemetry", "sensors": {"p7_headlight": {
++ "commanded": None, "pending": False, "physical_state_verified": False}}})
++ elif op == "command":
++ on = (msg.get("value") or {}).get("on")
++ note({"id": msg.get("id"), "kind": msg.get("kind"), "value": msg.get("value"),
++ "deadline_ms": msg.get("deadline_ms"), "expires_at_mono_ns": msg.get("expires_at_mono_ns"),
++ "operator": msg.get("operator"), "turn_epoch": msg.get("turn_epoch"),
++ "provenance": msg.get("provenance")})
++ send({"op": "ack", "id": msg.get("id"),
++ "data": {"stage": "headlight_pending", "admitted": True}})
++ send({"op": "telemetry", "sensors": {"p7_headlight": {
++ "commanded": on, "pending": True, "physical_state_verified": False}}})
ddiff --git a/internal/fakebot/fakebot.go b/internal/fakebot/fakebot.go
iindex 8139b0b..1fc6676 100644
---- a/internal/fakebot/fakebot.go
++++ b/internal/fakebot/fakebot.go
@@@ -349,7 +349,8 @@ func (s *Server) device(w http.ResponseWriter, r *http.Request) {
_ = c.SendRaw(b)
}
} else {
-- _ = c.Send(protocol.Hello{SessionID: "sess_" + token()[:12], DeviceID: dev, RobotIDs: []string{c.RobotID},
++ c.SessionID = "sess_" + token()[:12]
++ _ = c.Send(protocol.Hello{SessionID: c.SessionID, DeviceID: dev, RobotIDs: []string{c.RobotID},
ServerTime: time.Now().UTC().Format(time.RFC3339Nano)})
_ = c.Send(cfg)
}
@@@ -372,18 +373,19 @@ func (s *Server) NextConn(timeout time.Duration) (*Conn, error) {
// Conn is one device connection as the server sees it.
type Conn struct {
-- DeviceID string
-- RobotID string
-- srv *Server
-- ws *websocket.Conn
-- wmu sync.Mutex
-- seq uint64
-- recv chan protocol.Frame
-- done chan struct{}
-- autoAck atomic.Bool
-- muted atomic.Bool
-- frames []protocol.Frame
-- fmu sync.Mutex
++ DeviceID string
++ RobotID string
++ SessionID string // the hello session this fake Bot minted, for provenance tests
++ srv *Server
++ ws *websocket.Conn
++ wmu sync.Mutex
++ seq uint64
++ recv chan protocol.Frame
++ done chan struct{}
++ autoAck atomic.Bool
++ muted atomic.Bool
++ frames []protocol.Frame
++ fmu sync.Mutex
}
// Send writes one frame to the device.
@@@ -451,6 +453,26 @@ func (c *Conn) sendAt(ts int64, m protocol.Message) error {
c.wmu.Lock()
defer c.wmu.Unlock()
c.seq++
++ // A command that carries Bot's provenance (a headlight) is stamped like Bot does when it is sent: the
++ // connection's session, this frame's seq and ts, and the original expiry in ExpiresAtMS (300 ms later unless
++ // the test set one). Tests can therefore set an empty CommandProvenance and still exercise the real frame.
++ if cmd, ok := m.(protocol.Command); ok && cmd.Provenance != nil {
++ p := *cmd.Provenance
++ if p.Session == "" {
++ p.Session = c.SessionID
++ }
++ if p.Seq == 0 {
++ p.Seq = c.seq
++ }
++ if p.IssuedAtMS == 0 {
++ p.IssuedAtMS = ts
++ }
++ if p.ExpiresAtMS == 0 {
++ p.ExpiresAtMS = ts + 300
++ }
++ cmd.Provenance = &p
++ m = cmd
++ }
b, err := protocol.Encode(c.seq, ts, m)
if err != nil {
return err
ddiff --git a/internal/plugins/manager.go b/internal/plugins/manager.go
iindex 4a98c92..aa1d39f 100644
---- a/internal/plugins/manager.go
++++ b/internal/plugins/manager.go
@@@ -17,6 +17,7 @@ var capabilityFor = map[string]string{
protocol.KindDisplay: "display",
protocol.KindButton: "button",
protocol.KindPoint: "point",
++ protocol.KindHeadlight: "headlight",
protocol.KindExec: "terminal",
protocol.KindFS: "files",
protocol.KindScreen: "screen",
ddiff --git a/internal/plugins/plugin.go b/internal/plugins/plugin.go
iindex 9ecf449..0752107 100644
---- a/internal/plugins/plugin.go
++++ b/internal/plugins/plugin.go
@@@ -189,6 +189,9 @@ type timed struct {
expires mono.Instant // on the machine's precise clock, the one the plugin shares
queued mono.Instant
motion bool // a guarded or motion kind: strict timing applies to it
++ // maxDeadlineMS caps deadline_ms below what is left of expires: a headlight is written with Bot's 250 ms
++ // deadline while expires_at_mono_ns carries its original 300 ms expiry, so the driver sees both.
++ maxDeadlineMS int
}
// render is the command's line as it is written now, or the fault it is refused with instead: expired when less than a
@@@ -203,7 +206,11 @@ func (c *timed) render(strict bool) (b []byte, refused string) {
if strict && c.motion && !hasMono {
return nil, protocol.FaultTimingUnavailable
}
-- c.msg["deadline_ms"] = int64(left / time.Millisecond)
++ deadlineMS := int64(left / time.Millisecond)
++ if c.maxDeadlineMS > 0 && deadlineMS > int64(c.maxDeadlineMS) {
++ deadlineMS = int64(c.maxDeadlineMS)
++ }
++ c.msg["deadline_ms"] = deadlineMS
if hasMono {
// The instant itself: the core keeps it on the clock it shares with the plugin, so it is passed exactly.
c.msg["expires_at_mono_ns"] = int64(c.expires)
@@@ -466,6 +473,13 @@ func (p *Plugin) BeginWith(ctx context.Context, id, kind string, value json.RawM
return p.begin(ctx, id, kind, value, 0, expires, op, turnEpoch, prov)
}
++// BeginWithCap is BeginWith with a cap on the deadline_ms written to the plugin, below what is left of expires. A
++// headlight has Bot's 250 ms deadline and its original 300 ms expiry: the driver gets deadline_ms ≤ 250 and
++// expires_at_mono_ns at the original expiry, so neither queue time nor the writer's clock adds time.
++func (p *Plugin) BeginWithCap(ctx context.Context, id, kind string, value json.RawMessage, expires mono.Instant, maxDeadlineMS int, op *protocol.Operator, turnEpoch int64, prov *Provenance) func() Reply {
++ return p.begin(ctx, id, kind, value, maxDeadlineMS, expires, op, turnEpoch, prov)
++}
++
// begin queues a command: with deadline_ms as given when expires is zero, else timed (BeginWith).
func (p *Plugin) begin(ctx context.Context, id, kind string, value json.RawMessage, deadlineMS int, expires mono.Instant, op *protocol.Operator, turnEpoch int64, prov *Provenance) func() Reply {
now := func(r Reply) func() Reply { return func() Reply { return r } }
@@@ -539,7 +553,7 @@ func (p *Plugin) begin(ctx context.Context, id, kind string, value json.RawMessa
}
queued := line{b: append(raw, '\n')}
if expires != 0 {
-- queued = line{cmd: &timed{id: id, msg: immutable, expires: expires, queued: mono.Now(), motion: p.motionKind(kind)}}
++ queued = line{cmd: &timed{id: id, msg: immutable, expires: expires, queued: mono.Now(), motion: p.motionKind(kind), maxDeadlineMS: deadlineMS}}
}
p.mu.Lock()
binding := &ApprovalRequest{Nonce: hex.EncodeToString(nonce[:]), Digest: sha256.Sum256(raw), id: id, expires: expiry, queue: p.queue, reply: ch}
ddiff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go
iindex 1466b78..7e4ccc8 100644
---- a/internal/protocol/protocol.go
++++ b/internal/protocol/protocol.go
@@@ -159,6 +159,10 @@ const (
KindHalt = "halt"
KindButton = "button"
KindPoint = "point"
++ // KindHeadlight switches a robot's lights on or off, explicitly ({"on": true|false}), and is not motion: it
++ // never renews a drive/head deadline. It is the community LOOI driver's remote light (docs/hardware-looi.md)
++ // and carries Bot's provenance and a short original expiry, checked by the Agent before it reaches a plugin.
++ KindHeadlight = "headlight"
// The machine's own computer (the `computer` plugin, docs/plugins.md): run a program, use a file, capture the
// screen. Their ack carries the answer in data; the owner's control level on the machine decides what runs.
@@@ -201,7 +205,7 @@ const (
)
// Kinds lists every command kind the Link understands.
--var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser, KindScreenStream, KindDesk, KindDeskStream, KindAI}
++var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindHeadlight, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser, KindScreenStream, KindDesk, KindDeskStream, KindAI}
// ComputerKinds move nothing: they carry long deadlines (DefaultComputerMS, at most MaxComputerMS) and no clamping.
var ComputerKinds = map[string]bool{KindExec: true, KindFS: true, KindScreen: true, KindProbe: true, KindInput: true, KindBrowser: true, KindScreenStream: true, KindDesk: true, KindDeskStream: true, KindAI: true}
@@@ -370,7 +374,8 @@ type Command struct {
Ref string `json:"ref,omitempty"`
Kind string `json:"kind"`
Value json.RawMessage `json:"value,omitempty"`
-- // DeadlineMS is an absolute instant in epoch milliseconds on the server's clock, present on motion kinds only.
++ // DeadlineMS is an absolute instant in epoch milliseconds on the server's clock, present on motion kinds and the
++ // non-motion headlight custom action.
DeadlineMS int64 `json:"deadline_ms,omitempty"`
Operator *Operator `json:"operator,omitempty"`
// TurnEpoch is the robot's control epoch when Bot decided the command (contracts 0.213.0, Bot#155): it rises when a
@@@ -379,6 +384,22 @@ type Command struct {
TurnEpoch int64 `json:"turn_epoch,omitempty"`
RobotID string `json:"robot_id,omitempty"`
Target string `json:"target,omitempty"` // Link only: a driver name; default: the first driver with the capability
++ // Provenance is Bot's own stamp for a custom action (kind headlight): which link session and frame it was
++ // minted on, when Bot sent it, and the original instant it expires. Bot fills it; the Agent checks it against
++ // the frame's session, seq and ts and passes it to the plugin with expires_at_mono_ns on the shared clock.
++ Provenance *CommandProvenance `json:"provenance,omitempty"`
++}
++
++// CommandProvenance is where Bot minted a custom-action command (kind headlight), passed unchanged to the driver
++// as `provenance` (docs/plugins.md). Session is the hello session id of the connection, Seq the frame's envelope
++// seq, IssuedAtMS the frame's ts, and ExpiresAtMS the original instant Bot minted it to expire (300 ms later).
++// ClockUncertaintyMS is the Agent's own measured bound, filled by the Agent.
++type CommandProvenance struct {
++ Session string `json:"session,omitempty"`
++ Seq uint64 `json:"seq,omitempty"`
++ IssuedAtMS int64 `json:"issued_at_ms,omitempty"`
++ ExpiresAtMS int64 `json:"expires_at_ms,omitempty"`
++ ClockUncertaintyMS *int64 `json:"clock_uncertainty_ms,omitempty"`
}
// Estop is sent when the e-stop latches (Latched true) or the owner clears it (Latched false).
ddiff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go
iindex f03ad39..9ddd43a 100644
---- a/internal/protocol/protocol_test.go
++++ b/internal/protocol/protocol_test.go
@@@ -452,3 +452,36 @@ func TestHeartbeatCarriesTheFleet(t *testing.T) {
t.Fatalf("an empty fleet is absent, not []: %s", plain)
}
}
++
++// A headlight is Bot's custom non-motion kind: the frame carries its own provenance (session, seq, issued/expires
++// time and the measured clock bound), which Link checks against the envelope and passes to the driver unchanged.
++func TestHeadlightFrameCarriesBotProvenance(t *testing.T) {
++ raw := []byte(`{"v":1,"seq":7,"ts":1738065600000,"type":"command","id":"cmd_hl","ref":"op_hl","kind":"headlight","value":{"on":true},"deadline_ms":1738065600250,"operator":{"subject":"usr_01J8","role":"owner"},"turn_epoch":1738065600100,"robot_id":"rob_1","provenance":{"session":"sess_1","seq":7,"issued_at_ms":1738065600000,"expires_at_ms":1738065600300,"clock_uncertainty_ms":12}}`)
++ f, err := Decode(raw)
++ if err != nil {
++ t.Fatal(err)
++ }
++ c, ok := f.Msg.(Command)
++ if !ok {
++ t.Fatalf("%T", f.Msg)
++ }
++ if c.Kind != KindHeadlight || string(c.Value) != `{"on":true}` || c.DeadlineMS != 1738065600250 {
++ t.Fatalf("headlight: %+v", c)
++ }
++ if c.Operator == nil || c.Operator.Subject != "usr_01J8" || c.Operator.Role != "owner" || c.TurnEpoch != 1738065600100 {
++ t.Fatalf("operator/turn_epoch: %+v", c.Operator)
++ }
++ p := c.Provenance
++ if p == nil || p.Session != "sess_1" || p.Seq != 7 || p.IssuedAtMS != 1738065600000 ||
++ p.ExpiresAtMS != 1738065600300 || p.ClockUncertaintyMS == nil || *p.ClockUncertaintyMS != 12 {
++ t.Fatalf("provenance: %+v", p)
++ }
++ // It is a known kind, so Link routes it rather than nacking unsupported, and it is not motion.
++ known := false
++ for _, k := range Kinds {
++ known = known || k == KindHeadlight
++ }
++ if !known || GuardedKinds[KindHeadlight] || ComputerKinds[KindHeadlight] {
++ t.Fatalf("headlight kind: known=%v guarded=%v computer=%v", known, GuardedKinds[KindHeadlight], ComputerKinds[KindHeadlight])
++ }
++}
ddiff --git a/internal/safety/limits.go b/internal/safety/limits.go
iindex 165aa0b..d28fac1 100644
---- a/internal/safety/limits.go
++++ b/internal/safety/limits.go
@@@ -159,6 +159,14 @@ func (l Limits) Clamp(kind string, value json.RawMessage) (json.RawMessage, erro
}
v[key] = f
}
++ case protocol.KindHeadlight:
++ // Explicit, never a toggle: exactly {"on": true|false}.
++ if len(v) != 1 {
++ return nil, badValue("headlight takes only {on: true|false}")
++ }
++ if _, ok := v["on"].(bool); !ok {
++ return nil, badValue("headlight on must be true or false")
++ }
}
out, err := json.Marshal(v)
if err != nil {