openvibers/openvibe.link Commits
Pass Bot's LOOI headlight to the driver as its own non-motion kind (#158)
Changes
@@@ -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"}@@@ -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 commandexpires 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@@@ -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 writesthe line, and adds `expires_at_mono_ns`: the instant the command expires on the clock every process on the machineshares, `CLOCK_MONOTONIC` on Linux and macOS and, since 0.30, the raw `QueryPerformanceCounter` on Windows. `hello` namesthat clock in `shared_clock` (`clock_monotonic` or `qpc`, 0.30). A plugin that reads it when the line arrives takes off@@@ -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 rigservo 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 theserver's clock from the `ts` of every frame on the connection (the largest `ts` − receive time: the skew less thefastest transit, kept for the whole connection so a run of delayed frames cannot reopen an expired deadline) andcompares `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 after300 ms. Always capped at `max_command_ms` (default 1000). A held control is re-sent by the panel every 150 ms, eachwith 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 benumbers in 0..1. The limits are the stricter of the server's `config.limits` and the local config's `limits`.@@@ -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]boolinputEnded 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 mnack(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 mr := wait()var msg protocol.Messageif 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.@@@ -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 = pycfg.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)++ }++}nnew file mode 100755@@@ -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}}})@@@ -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@@@ -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",@@@ -189,6 +189,9 @@ type timed struct {expires mono.Instant // on the machine's precise clock, the one the plugin sharesqueued mono.Instantmotion 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"] = deadlineMSif 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.RawMreturn 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}@@@ -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).@@@ -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])++ }++}@@@ -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 {
@@ -5,14 +5,15 @@
@@ -5,14 +5,15 @@
"wave": "T15",
"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)",
"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",
"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",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"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.",
"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": [
"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",
"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",
"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)",
"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)",
"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 @@
@@ -34,5 +35,5 @@
"a tray on macOS (approvals there are a dialog with Deny as its default)",
"a tray on macOS (approvals there are a dialog with Deny as its default)",
"the computer plugin's browser on Windows"
"the computer plugin's browser on Windows"
],
],
- "updated": "2026-11-10"
+ "updated": "2026-11-11"
}
}
@@ -40,6 +40,7 @@ The driver is a Link plugin ([plugins.md](plugins.md)): JSON lines over stdin an
@@ -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 |
| `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 |
| `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 |
| `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
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
@@ -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
`motion_kinds = {"drive"}`, and it enforces the deadline, the 1 s heartbeat, stdin closing, `stop`, `estop` and
`halt` for you.
`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
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,
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
also set `strict_timing = True` on the plugin (or `"strict_timing": true` on it in `config.json`): a command whose
@@ -63,7 +63,16 @@ describes itself and waits for EOF without opening any hardware.
@@ -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 |
| `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)).
`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
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
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
that clock in `shared_clock` (`clock_monotonic` or `qpc`, 0.30). A plugin that reads it when the line arrives takes off
@@ -157,7 +157,7 @@ server's own counter, never the heartbeat's.
@@ -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 |
| `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` |
| `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` |
| `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 |
| `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 |
| `error` | `code`, `detail` — a frame the server refused; logged, never fatal |
@@ -210,6 +210,7 @@ reading until the next one.
@@ -210,6 +210,7 @@ reading until the next one.
| `say` | `{"text": "…"}` (1–1000 bytes; plugins may cap lower) | no |
| `say` | `{"text": "…"}` (1–1000 bytes; plugins may cap lower) | no |
| `display` | `{"text": "…"}`, `{"face": "happy"}` or `{"image_png_b64": "…"}` | 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 |
| `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 |
| `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 | — |
| `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 |
| `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
@@ -223,7 +224,7 @@ Sign conventions: `throttle`/`x` positive = forward; `steer` positive = turn rig
servo in their config to make the hardware match).
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`,
`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
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
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 →
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
@@ -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
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)).
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:
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
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:
@@ -239,7 +274,9 @@ What the Link does with a command, in order:
3. `halt` → every plugin stops, `ack`.
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`.
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
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`,
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
`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`.
numbers in 0..1. The limits are the stricter of the server's `config.limits` and the local config's `limits`.
@@ -140,7 +140,10 @@ type Agent struct {
@@ -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.
// the mouse or keyboard and for InputGuard after, so an approved click cannot press another request's Approve.
inputs map[string]bool
inputs map[string]bool
inputEnded time.Time
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
// 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,
// 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")
@@ -164,6 +167,17 @@ var ErrQuit = errors.New("quit by its owner")
// InputGuard is how long after an input command `approve` stays refused.
// InputGuard is how long after an input command `approve` stays refused.
const InputGuard = 2 * time.Second
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
// 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.
// every poll and the core passes it on at most every trayRetell.
const (
const (
@@ -716,6 +730,10 @@ func (n *Agent) command(c protocol.Command, sentMS int64, seq uint64, received m
@@ -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")
nack(protocol.FaultNotAllowed, c.Kind+" is not in this robot's allowed_commands")
return
return
}
}
+ if c.Kind == protocol.KindHeadlight {
+ n.headlight(c, entry, sentMS, seq, received, limits, nack)
+ return
+ }
if n.capabilityOff(c.Kind) {
if n.capabilityOff(c.Kind) {
nack(protocol.FaultNotAllowed, "turned off in Link: "+capabilityForKind[c.Kind]+" is off on this computer")
nack(protocol.FaultNotAllowed, "turned off in Link: "+capabilityForKind[c.Kind]+" is off on this computer")
return
return
@@ -795,7 +813,97 @@ func (n *Agent) command(c protocol.Command, sentMS int64, seq uint64, received m
@@ -795,7 +813,97 @@ func (n *Agent) command(c protocol.Command, sentMS int64, seq uint64, received m
r := wait()
r := wait()
var msg protocol.Message
var msg protocol.Message
if r.OK {
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 {
} else {
msg = protocol.Nack{ID: c.ID, FaultCode: r.FaultCode, Message: r.Message}
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
@@ -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
// 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
// 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.
// machine, and answered with the plugin's data. The plugin applies its owner's control level.
@@ -61,6 +61,13 @@ func start(t *testing.T) *env {
@@ -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
// 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.
// connected, without waiting for its first status.
func startWith(t *testing.T, prep func(*fakebot.Server), ready bool) *env {
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()
t.Helper()
if runtime.GOOS == "windows" {
if runtime.GOOS == "windows" {
t.Skip("end-to-end tests run on unix")
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 {
@@ -99,6 +106,9 @@ func startWith(t *testing.T, prep func(*fakebot.Server), ready bool) *env {
cfg.Python = py
cfg.Python = py
cfg.Plugins = []config.PluginConfig{{Name: "dryrun", Config: map[string]any{"record": record, "log_commands": false},
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")}}}
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}
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,
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) {
@@ -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)
+ }
+}
new file mode 100755
new file mode 100755
@@ -0,0 +1,45 @@
@@ -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}}})
@@ -349,7 +349,8 @@ func (s *Server) device(w http.ResponseWriter, r *http.Request) {
@@ -349,7 +349,8 @@ func (s *Server) device(w http.ResponseWriter, r *http.Request) {
_ = c.SendRaw(b)
_ = c.SendRaw(b)
}
}
} else {
} 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)})
ServerTime: time.Now().UTC().Format(time.RFC3339Nano)})
_ = c.Send(cfg)
_ = c.Send(cfg)
}
}
@@ -372,18 +373,19 @@ func (s *Server) NextConn(timeout time.Duration) (*Conn, error) {
@@ -372,18 +373,19 @@ func (s *Server) NextConn(timeout time.Duration) (*Conn, error) {
// Conn is one device connection as the server sees it.
// Conn is one device connection as the server sees it.
type Conn struct {
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.
// Send writes one frame to the device.
@@ -451,6 +453,26 @@ func (c *Conn) sendAt(ts int64, m protocol.Message) error {
@@ -451,6 +453,26 @@ func (c *Conn) sendAt(ts int64, m protocol.Message) error {
c.wmu.Lock()
c.wmu.Lock()
defer c.wmu.Unlock()
defer c.wmu.Unlock()
c.seq++
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)
b, err := protocol.Encode(c.seq, ts, m)
if err != nil {
if err != nil {
return err
return err
@@ -17,6 +17,7 @@ var capabilityFor = map[string]string{
@@ -17,6 +17,7 @@ var capabilityFor = map[string]string{
protocol.KindDisplay: "display",
protocol.KindDisplay: "display",
protocol.KindButton: "button",
protocol.KindButton: "button",
protocol.KindPoint: "point",
protocol.KindPoint: "point",
+ protocol.KindHeadlight: "headlight",
protocol.KindExec: "terminal",
protocol.KindExec: "terminal",
protocol.KindFS: "files",
protocol.KindFS: "files",
protocol.KindScreen: "screen",
protocol.KindScreen: "screen",
@@ -189,6 +189,9 @@ type timed struct {
@@ -189,6 +189,9 @@ type timed struct {
expires mono.Instant // on the machine's precise clock, the one the plugin shares
expires mono.Instant // on the machine's precise clock, the one the plugin shares
queued mono.Instant
queued mono.Instant
motion bool // a guarded or motion kind: strict timing applies to it
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
// 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) {
@@ -203,7 +206,11 @@ func (c *timed) render(strict bool) (b []byte, refused string) {
if strict && c.motion && !hasMono {
if strict && c.motion && !hasMono {
return nil, protocol.FaultTimingUnavailable
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 {
if hasMono {
// The instant itself: the core keeps it on the clock it shares with the plugin, so it is passed exactly.
// 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)
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
@@ -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)
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).
// 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 {
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 } }
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
@@ -539,7 +553,7 @@ func (p *Plugin) begin(ctx context.Context, id, kind string, value json.RawMessa
}
}
queued := line{b: append(raw, '\n')}
queued := line{b: append(raw, '\n')}
if expires != 0 {
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()
p.mu.Lock()
binding := &ApprovalRequest{Nonce: hex.EncodeToString(nonce[:]), Digest: sha256.Sum256(raw), id: id, expires: expiry, queue: p.queue, reply: ch}
binding := &ApprovalRequest{Nonce: hex.EncodeToString(nonce[:]), Digest: sha256.Sum256(raw), id: id, expires: expiry, queue: p.queue, reply: ch}
@@ -159,6 +159,10 @@ const (
@@ -159,6 +159,10 @@ const (
KindHalt = "halt"
KindHalt = "halt"
KindButton = "button"
KindButton = "button"
KindPoint = "point"
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
// 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.
// screen. Their ack carries the answer in data; the owner's control level on the machine decides what runs.
@@ -201,7 +205,7 @@ const (
@@ -201,7 +205,7 @@ const (
)
)
// Kinds lists every command kind the Link understands.
// 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.
// 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}
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 {
@@ -370,7 +374,8 @@ type Command struct {
Ref string `json:"ref,omitempty"`
Ref string `json:"ref,omitempty"`
Kind string `json:"kind"`
Kind string `json:"kind"`
Value json.RawMessage `json:"value,omitempty"`
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"`
DeadlineMS int64 `json:"deadline_ms,omitempty"`
Operator *Operator `json:"operator,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
// 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 {
@@ -379,6 +384,22 @@ type Command struct {
TurnEpoch int64 `json:"turn_epoch,omitempty"`
TurnEpoch int64 `json:"turn_epoch,omitempty"`
RobotID string `json:"robot_id,omitempty"`
RobotID string `json:"robot_id,omitempty"`
Target string `json:"target,omitempty"` // Link only: a driver name; default: the first driver with the capability
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).
// Estop is sent when the e-stop latches (Latched true) or the owner clears it (Latched false).
@@ -452,3 +452,36 @@ func TestHeartbeatCarriesTheFleet(t *testing.T) {
@@ -452,3 +452,36 @@ func TestHeartbeatCarriesTheFleet(t *testing.T) {
t.Fatalf("an empty fleet is absent, not []: %s", plain)
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])
+ }
+}
@@ -159,6 +159,14 @@ func (l Limits) Clamp(kind string, value json.RawMessage) (json.RawMessage, erro
@@ -159,6 +159,14 @@ func (l Limits) Clamp(kind string, value json.RawMessage) (json.RawMessage, erro
}
}
v[key] = f
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)
out, err := json.Marshal(v)
if err != nil {
if err != nil {