openvibers/openvibe.link Commits

Node: a driver can latch the local stop; Windows user-session start; --driver looi (#35)

OP OpenVibers committed 11 Oct 2026 · f4c27db36d · 19 files, +452 −16

LOOI owners are building a Generic LOOI driver for the Node: a bridge plugin driving LOOI's base over Bluetooth from a Windows PC, while the robot's phone streams on its own. This gives that driver what it needs from the Node. Bot's matching `looi` profile follows in OpenVibe.Bot.

**A driver can latch the Node's local stop.**
- **The op:** a new plugin → core message, `{"op": "local_stop", "reason": "…"}`. In Python it is `self.local_stop("reason")`. It is for a driver's own stop: a button, a hotkey on the bridge, a sensor it trusts.
- **What happens:** the core stops every plugin at once and latches the **local** stop, persisted with its reason (cleaned of control characters, at most 120 characters, first reason kept). `estop_state` carries `local_stop: true` and that reason, so Bot shows it.
- **Releasing it:** as with `openvibe-node stop` today, only `openvibe-node resume` on the machine releases it. The owner's remote clear does not, and nothing a plugin sends releases a latch.
- **No crossing resume:** the Python runtime refuses motion from the call on, and ignores a `resume` that crosses it on the wire until the core's `estop` confirms it.
- **The CLI's stop:** `openvibe-node stop` now records "openvibe-node stop" as its reason.

**Windows: `openvibe-node install --user-session --home DIR`.**
- **Why:** Bluetooth through the Windows runtime may not work from the LocalSystem service.
- **How:** this starts the Node at sign-in, in the user's own session, from the user's Run registry key, with no administrator needed. The Node runs in a console window that shows its log; the Windows service keeps none yet.
- **Safety:** closing the window stops the Node and every actuator, and nothing restarts it until the next sign-in.
- **Undo:** `uninstall --user-session` removes it. It needs `--home` (the directory you paired with). Other systems refuse the flag with a pointer to the service.

**Installer: `--driver looi`.**
- **What it writes:** a bridge config with video off (LOOI's camera is its phone) and the `looi` plugin.
- **Without the driver:** the driver is the community's, so if `openvibe_looi` is not in the Node's environment yet, the installer says how to install it, and that the robot does not move until it is.

**Docs:**
- New `docs/hardware-looi.md`: the command contract (`drive` throttle/steer, `actuator` head, `halt`, deadlines and telemetry), stopping, and installing on Linux/macOS and Windows. It also states that the robot itself must stop when Bluetooth or commands stop.
- `plugins.md`: the `local_stop` op.
- `install.md`: the user-session start.
- HANDOFF: the two latches are stated correctly; the local one is never cleared by the owner.
- README: the driver list.

**Tests:**
- Go: `TestPluginLocalStop` (latch, reason, survives the owner's clear, resume lifts it), `TestLocalStopIsForwarded`, `TestLocalStopReason`, and `TestUserSession*` (command line, refused off Windows).
- Python: `test_local_stop_latches_and_ignores_a_crossing_resume`.
- `go test ./...`, the plugin suites, shellcheck, and builds for Windows and macOS all pass.

Changes

ddiff --git a/README.md b/README.md
iindex 657efd2..f11c43a 100644
---- a/README.md
++++ b/README.md
@@@ -8,8 +8,8 @@ for AI agents) can use the device. It connects out, needs no open ports, and kee
curl -fsSL https://openvibe.bot/install | sh -s -- --robot rob_… --code ABCD-1234 --driver adeept
```
--openvibe.bot shows this command with the robot's id and a fresh code. `--driver` is `adeept`, `adeept-mecanum`, `cozmo`
--or `none` (dry run; the old `--robot adeept` form still works but is deprecated). A robot paired through
++openvibe.bot shows this command with the robot's id and a fresh code. `--driver` is `adeept`, `adeept-mecanum`, `cozmo`,
++`looi` (the community's Generic LOOI driver, [docs/hardware-looi.md](docs/hardware-looi.md)) or `none` (dry run; the old `--robot adeept` form still works but is deprecated). A robot paired through
OpenVibe.Network gets `--network <URL> --pairing pair_… --code ABCD-1234` instead of `--robot`. Details, manual
install, Windows and configuration: [docs/install.md](docs/install.md).
@@@ -27,7 +27,8 @@ openvibe-node resume
publishing with pion, and the system service (systemd, launchd, Windows).
- **Plugins** (separate processes, JSON lines over stdin/stdout): [`dryrun`](plugins/dryrun), the Adeept 4WD Smart Car
Kit [`adeept_adr036`](plugins/adeept_adr036/README.md), [`cozmo`](plugins/cozmo/README.md) through PyCozmo, and
-- [`relay`](plugins/relay) for existing Live hardware clients. Each plugin stops its own actuators when stdin closes,
++ [`relay`](plugins/relay) for existing Live hardware clients. LOOI's driver is the community's
++ ([docs/hardware-looi.md](docs/hardware-looi.md) is its contract). Each plugin stops its own actuators when stdin closes,
at a command's deadline and after 1 s without a heartbeat, so the core dying never leaves motors running.
- **ESP32 library** ([`esp32/`](esp32/README.md), `OpenVibeNode`): the device side of the protocol for ESP32 boards —
pairing, the control link, drive/actuator commands, telemetry, heartbeat, deadman and e-stop — but no jobs or video.
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex 3d58628..ccdd9fd 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -50,7 +50,8 @@ Usage:
openvibe-node credential import store the credential from a rotate response, read as JSON on stdin
openvibe-node run [--dry-run]
openvibe-node install [--user NAME]
-- openvibe-node uninstall
++ openvibe-node install --user-session --home DIR Windows: start at sign-in in your own session (Bluetooth bridges)
++ openvibe-node uninstall [--user-session]
openvibe-node status [--json]
openvibe-node stop stop every actuator now and hold them stopped
openvibe-node resume release the stop
@@@ -101,6 +102,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
dryRun = fs.Bool("dry-run", false, "run only the dry-run plugin and the test pattern (run)")
asJSON = fs.Bool("json", false, "print JSON (status, plugins)")
svcUser = fs.String("user", "", "run the service as this account (install)")
++ session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")
)
// Accept flags anywhere: split positionals out.
var positional []string
@@@ -147,8 +149,16 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
case "run":
err = cmdRun(g, *dryRun)
case "install":
++ if *session {
++ err = cmdInstallUserSession(g, stdout)
++ break
++ }
err = cmdInstall(g, *svcUser, stdout)
case "uninstall":
++ if *session {
++ err = cmdUninstallUserSession(stdout)
++ break
++ }
err = service.Control(serviceOptions(g, ""), "stop")
if uerr := service.Control(serviceOptions(g, ""), "uninstall"); uerr != nil {
err = uerr
ddiff --git a/cmd/openvibe-node/session.go b/cmd/openvibe-node/session.go
nnew file mode 100644
iindex 0000000..9172c16
---- /dev/null
++++ b/cmd/openvibe-node/session.go
@@@ -0,0 +1,84 @@
++package main
++
++import (
++ "errors"
++ "fmt"
++ "io"
++ "os"
++ "path/filepath"
++ "strings"
++)
++
++// The user-session start (Windows): instead of the LocalSystem service, the Node starts when you sign in, in your
++// own session, from your Run key (HKCU\Software\Microsoft\Windows\CurrentVersion\Run; no administrator needed). This
++// is for drivers that need what only a signed-in session has, such as Bluetooth through the Windows runtime for a
++// bridge robot (docs/hardware-looi.md). It runs in a console window that shows the log; closing the window stops the
++// Node, and every actuator stops with it (the plugins' heartbeat and stdin rules). Nothing restarts it until the next
++// sign-in.
++
++const (
++ runKey = `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
++ runValueName = "OpenVibe Node"
++)
++
++var errNeedHome = errors.New(`--user-session needs --home DIR: the directory you paired with, for example --home "%LOCALAPPDATA%\OpenVibe Node"`)
++
++// userSessionCommand is the command line the Run key starts: the executable, `run`, and the Node's own directory.
++func userSessionCommand(exe, home, logLevel string) string {
++ parts := []string{quoteArg(exe), "run", "--home", quoteArg(home)}
++ if logLevel != "" && logLevel != "info" {
++ parts = append(parts, "--log-level", logLevel)
++ }
++ return strings.Join(parts, " ")
++}
++
++// quoteArg quotes one Windows command-line argument (paths with spaces, such as "OpenVibe Node").
++func quoteArg(s string) string {
++ if s != "" && !strings.ContainsAny(s, " \t\"") {
++ return s
++ }
++ return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
++}
++
++func cmdInstallUserSession(g *globals, stdout io.Writer) error {
++ if err := userSessionSupported(); err != nil {
++ return err
++ }
++ if g.home == "" {
++ return errNeedHome
++ }
++ home, err := filepath.Abs(g.home)
++ if err != nil {
++ return err
++ }
++ if err := g.paths.Ensure(); err != nil {
++ return fmt.Errorf("cannot create %s: %w", home, err)
++ }
++ exe, err := os.Executable()
++ if err != nil {
++ return err
++ }
++ line := userSessionCommand(exe, home, g.logLevel)
++ if err := registerUserSession(line); err != nil {
++ return err
++ }
++ fmt.Fprintln(stdout, "The Node now starts when you sign in, in its own window:", line)
++ if err := startUserSession(exe, home, g.logLevel); err != nil {
++ fmt.Fprintln(stdout, "It did not start now (", err, "); sign out and in, or run the line above.")
++ } else {
++ fmt.Fprintln(stdout, "Started it now. Closing its window stops the Node and every actuator.")
++ }
++ fmt.Fprintln(stdout, "Remove the sign-in start with `openvibe-node uninstall --user-session`.")
++ return nil
++}
++
++func cmdUninstallUserSession(stdout io.Writer) error {
++ if err := userSessionSupported(); err != nil {
++ return err
++ }
++ if err := unregisterUserSession(); err != nil {
++ return err
++ }
++ fmt.Fprintln(stdout, "The Node no longer starts at sign-in. Close its window to stop the running one; config and credential are kept.")
++ return nil
++}
ddiff --git a/cmd/openvibe-node/session_other.go b/cmd/openvibe-node/session_other.go
nnew file mode 100644
iindex 0000000..3ef11ec
---- /dev/null
++++ b/cmd/openvibe-node/session_other.go
@@@ -0,0 +1,12 @@
++//go:build !windows
++
++package main
++
++import "errors"
++
++var errUserSessionWindowsOnly = errors.New("--user-session is for Windows; on Linux and macOS install the service (sudo openvibe-node install), or start `openvibe-node run --home DIR` from your desktop's autostart")
++
++func userSessionSupported() error { return errUserSessionWindowsOnly }
++func registerUserSession(string) error { return errUserSessionWindowsOnly }
++func unregisterUserSession() error { return errUserSessionWindowsOnly }
++func startUserSession(string, string, string) error { return errUserSessionWindowsOnly }
ddiff --git a/cmd/openvibe-node/session_test.go b/cmd/openvibe-node/session_test.go
nnew file mode 100644
iindex 0000000..10ad8cf
---- /dev/null
++++ b/cmd/openvibe-node/session_test.go
@@@ -0,0 +1,29 @@
++package main
++
++import (
++ "bytes"
++ "runtime"
++ "strings"
++ "testing"
++)
++
++func TestUserSessionCommand(t *testing.T) {
++ got := userSessionCommand(`C:\Program Files\OpenVibe\openvibe-node.exe`, `C:\Users\m\AppData\Local\OpenVibe Node`, "info")
++ want := `"C:\Program Files\OpenVibe\openvibe-node.exe" run --home "C:\Users\m\AppData\Local\OpenVibe Node"`
++ if got != want {
++ t.Fatalf("got %s\nwant %s", got, want)
++ }
++ if got := userSessionCommand(`C:\bin\node.exe`, `D:\ov`, "debug"); got != `C:\bin\node.exe run --home D:\ov --log-level debug` {
++ t.Fatalf("plain paths and the log level: %s", got)
++ }
++}
++
++func TestUserSessionRefusedOffWindows(t *testing.T) {
++ if runtime.GOOS == "windows" {
++ t.Skip("the refusal is for other systems")
++ }
++ var out, errb bytes.Buffer
++ if code := run([]string{"install", "--user-session", "--home", t.TempDir()}, &out, &errb); code == 0 || !strings.Contains(errb.String(), "for Windows") {
++ t.Fatalf("code %d, stderr %q", code, errb.String())
++ }
++}
ddiff --git a/cmd/openvibe-node/session_windows.go b/cmd/openvibe-node/session_windows.go
nnew file mode 100644
iindex 0000000..9b3e85c
---- /dev/null
++++ b/cmd/openvibe-node/session_windows.go
@@@ -0,0 +1,36 @@
++//go:build windows
++
++package main
++
++import (
++ "fmt"
++ "os/exec"
++ "strings"
++)
++
++func userSessionSupported() error { return nil }
++
++func registerUserSession(line string) error {
++ out, err := exec.Command("reg", "add", runKey, "/v", runValueName, "/t", "REG_SZ", "/d", line, "/f").CombinedOutput()
++ if err != nil {
++ return fmt.Errorf("reg add %s: %v: %s", runKey, err, strings.TrimSpace(string(out)))
++ }
++ return nil
++}
++
++func unregisterUserSession() error {
++ out, err := exec.Command("reg", "delete", runKey, "/v", runValueName, "/f").CombinedOutput()
++ if err != nil && !strings.Contains(strings.ToLower(string(out)), "unable to find") {
++ return fmt.Errorf("reg delete %s: %v: %s", runKey, err, strings.TrimSpace(string(out)))
++ }
++ return nil
++}
++
++// startUserSession starts the Node now in a window of its own, as the Run key will at the next sign-in.
++func startUserSession(exe, home, logLevel string) error {
++ args := []string{"/c", "start", "OpenVibe Node", exe, "run", "--home", home}
++ if logLevel != "" && logLevel != "info" {
++ args = append(args, "--log-level", logLevel)
++ }
++ return exec.Command("cmd", args...).Start()
++}
ddiff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
iindex c8c9eac..0c2b53c 100644
---- a/docs/HANDOFF.md
++++ b/docs/HANDOFF.md
@@@ -57,8 +57,9 @@ mode 0600 and its contents never appear in logs, errors or `status`.
only the time left, and the plugin stops at that deadline unless a newer command arrived. Held controls are re-sent by the operator every 150 ms.
- Stop all actuators on: 2 missed heartbeats, link loss, core shutdown, plugin crash (the core restarts it with backoff
and it starts stopped), `openvibe-node stop`, an `estop`.
--- The e-stop is latched locally (persisted across restarts) and only a server `estop` `latched:false` from the owner (or the local
-- `resume`) clears it. Owner limits (`max_speed`, `max_turn`) clamp every value in the core before it reaches a plugin.
++- Two latches, both persisted across restarts. The **remote** e-stop: a server `estop` sets it, and the owner's `estop`
++ `latched:false` or the local `resume` clears it. The **local** stop (`openvibe-node stop`, or a driver's `local_stop`):
++ only the local `resume` clears it, never the owner. Owner limits (`max_speed`, `max_turn`) clamp every value in the core before it reaches a plugin.
- Never run or expose a kit's stock control server (the Adeept kit ships one on 0.0.0.0:8888 with the fixed login
`admin:123456` and an MJPEG stream on :5000 — the Node replaces it; `install.sh` disables it if it finds it enabled).
ddiff --git a/docs/hardware-looi.md b/docs/hardware-looi.md
nnew file mode 100644
iindex 0000000..5a4664d
---- /dev/null
++++ b/docs/hardware-looi.md
@@@ -0,0 +1,90 @@
++# LOOI on the OpenVibe Node
++
++LOOI is the tracked desk robot whose face is a phone. Its base talks Bluetooth Low Energy, so it is a **bridge**
++robot: the Node runs on a computer next to it (Windows, macOS or Linux), and a driver plugin drives the base over
++Bluetooth. The phone streams its own video, for example from its browser to the owner's OpenVibe.Live channel, so the
++Node publishes none.
++
++The Generic LOOI driver is written by the community (LOOI owners), not bundled with the Node. This page is the
++contract it implements and how to run it.
++
++## On openvibe.bot
++
++Add a robot with the **LOOI desktop robot** profile (`looi`). Its panel has a drive pad and a head-tilt control, and
++it shows battery and link latency. The profile's limits apply before any value reaches the driver: speed at most 0.5,
++turn at most 0.6, every motion command lasts at most 300 ms unless it is re-sent, heartbeat 1 s. The owner can lower
++them per robot.
++
++Who may drive is Bot's job, not the driver's: the owner, invited operators, an optional public queue with timed
++turns, cooldowns and per-role allowed commands, all enforced by Bot's command gate before a command reaches the Node.
++The e-stop is latched in Bot and only the owner clears it.
++
++To show the panel under a Live stream, bind the robot to the channel on the Live dashboard (the robot card). The
++channel page then shows the robot's panel beside the phone's stream.
++
++## What the driver receives
++
++The driver is a Node plugin ([plugins.md](plugins.md)): JSON lines over stdin and stdout. For `looi` it receives:
++
++| command | value | meaning |
++|---|---|---|
++| `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 |
++| `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
++held control every 150 ms). With the Python runtime (`openvibe_plugin`, [plugins/sdk](../plugins/sdk)) set
++`motion_kinds = {"drive"}`, and it enforces the deadline, the 1 s heartbeat, stdin closing, `stop`, `estop` and
++`halt` for you.
++
++The driver should send telemetry with `battery` (`{"percent": 0..100}` or `{"volts": …}`) about twice a second.
++
++## Stopping
++
++- **The robot must stop on its own when commands stop.** The Node and the runtime stop the driver's motors on a
++ passed deadline, a missed heartbeat or a closed stdin, but if the bridge computer sleeps, crashes or loses
++ Bluetooth, only the robot can stop itself. Before any motor test, check that the base stops when the Bluetooth link
++ drops or commands stop arriving. If it does not, keep every command short-lived so it cannot run on.
++- **A stop on the robot itself.** If the driver has its own stop (a button, a hotkey, a sensor it trusts), call
++ `self.local_stop("reason")` (or send `{"op": "local_stop", "reason": "…"}`). Every actuator stops at once, the
++ Node latches its **local** stop (persisted across restarts), and the panel shows the robot as stopped on the robot.
++ The owner's remote clear does not lift it: only `openvibe-node resume` on the bridge computer does, and then the
++ owner clears the panel's e-stop. Running `openvibe-node stop` does the same from a terminal.
++
++## Installing the driver
++
++The Node runs a plugin named `looi` as `python -m openvibe_looi`, so the driver is a Python package with that module,
++installed into the Python the Node uses.
++
++**Linux or macOS bridge.** `curl -fsSL https://openvibe.bot/install | sh -s -- --robot rob_… --code XXXX-XXXX
++--driver looi` installs and pairs the Node as a bridge with video off. Then install the driver into the Node's
++environment (`sudo /var/lib/openvibe-node/venv/bin/pip install <the driver>`) and restart the Node's service. Until
++the driver is installed the panel shows the plugin's fault and the robot never moves.
++
++**Windows bridge.** Bluetooth through the Windows runtime may not work from a service running as LocalSystem, so
++run the Node in your own session:
++
++```bat
++set OVHOME=%LOCALAPPDATA%\OpenVibe Node
++openvibe-node pair XXXX-XXXX --robot rob_… --kind bridge --home "%OVHOME%"
++openvibe-node install --user-session --home "%OVHOME%"
++```
++
++`config.json` in that directory:
++
++```json
++{
++ "server": "https://openvibe.bot",
++ "device_kind": "bridge",
++ "python": "C:\\Users\\you\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
++ "plugins": [{"name": "looi"}],
++ "video": {"source": "off"}
++}
++```
++
++`install --user-session` starts the Node when you sign in, in a console window that shows its log (Windows keeps no
++service log yet). Closing the window stops the Node and every actuator; nothing restarts it until the next sign-in.
++`openvibe-node uninstall --user-session` removes the sign-in start.
++
++Try the driver without the robot first: `openvibe-node plugins` asks each plugin to describe itself without touching
++hardware, and `openvibe-node run --dry-run` drives only the simulated plugin.
ddiff --git a/docs/install.md b/docs/install.md
iindex 92696a6..3d6b376 100644
---- a/docs/install.md
++++ b/docs/install.md
@@@ -13,7 +13,7 @@ ports, works behind any home router), and a local kill switch stops everything i
curl -fsSL https://openvibe.bot/install | sh -s -- --robot rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X --code ABCD-1234 --driver adeept
```
-- The shape is `--robot rob_… --code CODE [--driver adeept|adeept-mecanum|cozmo|none]`. `--robot` is the robot's
++ The shape is `--robot rob_… --code CODE [--driver adeept|adeept-mecanum|cozmo|looi|none]`. `--robot` is the robot's
`rob_…` id and `--code` the pairing code (both from openvibe.bot; the code may also be the first argument).
`--driver` names the plugin: `adeept` (Adeept 4WD Smart Car, ordinary wheels), `adeept-mecanum`, `cozmo`, or
`none` (the default: the dry-run plugin with a test-pattern camera, for trying the Node on a laptop). `--name NAME`
@@@ -75,6 +75,12 @@ openvibe-node --home ~/node run --dry-run # foreground; Ctrl-C stops it
and `openvibe-node install` (the service runs as LocalSystem; files go to `%ProgramData%\OpenVibe Node`). Robot plugins
need Python; set `"python"` in `config.json`.
++A driver that needs your signed-in session (Bluetooth through the Windows runtime, as a LOOI bridge does) runs the
++Node there instead, with no administrator: `openvibe-node pair <CODE> --home "%LOCALAPPDATA%\OpenVibe Node"`, then
++`openvibe-node install --user-session --home "%LOCALAPPDATA%\OpenVibe Node"`. It starts when you sign in, in a
++console window that shows its log; closing the window stops the Node and every actuator. `openvibe-node uninstall
++--user-session` removes it. See [hardware-looi.md](hardware-looi.md).
++
## Commands
| command | what it does |
@@@ -83,6 +89,8 @@ need Python; set `"python"` in `config.json`.
| `openvibe-node run [--dry-run]` | run in the foreground (what the service runs) |
| `openvibe-node install [--user NAME]` | install and start the service |
| `openvibe-node uninstall` | stop and remove the service (config and credential are kept) |
++| `openvibe-node install --user-session --home DIR` | Windows: start at sign-in in your own session instead of as a service |
++| `openvibe-node uninstall --user-session` | Windows: remove that sign-in start |
| `openvibe-node status [--json]` | link, stop latch, limits, plugins, video, battery, running jobs |
| `openvibe-node stop` | **kill switch**: stop every actuator now and hold it stopped |
| `openvibe-node resume` | release the local stop (and a server e-stop) |
ddiff --git a/docs/plugins.md b/docs/plugins.md
iindex afc5e34..04cf0fe 100644
---- a/docs/plugins.md
++++ b/docs/plugins.md
@@@ -69,6 +69,7 @@ describes itself and waits for EOF without opening any hardware.
| `telemetry` | `battery` {`volts`, `percent`}, `rssi`, `sensors` {…}, `faults` […] |
| `event` | `name` (`cliff`, `picked_up`, `low_battery`, `heartbeat_lost`, `stop_failed`, …), any fields |
| `video` | `format` (`jpeg`), `path`, optional `width`, `height`, `seq` |
++| `local_stop` | `reason` — the driver's own stop (a button, a sensor it trusts): latches the Node's local stop, see below |
For `video`, the plugin writes each frame to a file (write to a temporary name, then rename, and rotate a few names so
a slow reader never sees a partial file) and announces it with `{"op": "video", "format": "jpeg", "path": …}`
@@@ -87,6 +88,13 @@ A plugin must stop every actuator itself — the core dying must never leave mot
- on `stop`, `estop` and a `halt` command;
- when its own sensors say so (Cozmo: cliff, pick-up).
++**A driver's own stop.** A driver with a stop of its own (a stop button on the robot, a hotkey on a bridge computer,
++a sensor it trusts) sends `local_stop` (Python: `self.local_stop("reason")`). The core stops every plugin at once and
++latches its **local** stop with that reason, persisted across restarts; Bot shows the robot as stopped on the robot.
++Only `openvibe-node resume` on that machine releases it: the owner's remote clear does not, and nothing a plugin sends
++releases a latch. The Python runtime refuses motion from the moment it is called and ignores a `resume` that crosses it
++on the wire. `openvibe-node stop` sets the same latch from a terminal.
++
`stop` must be idempotent and safe at any time, including before setup. Handlers must not block: slow work (speech
synthesis, file I/O) goes on a thread. Nothing but protocol lines may go to stdout; the Python runtime enforces this by
pointing file descriptor 1 at stderr and keeping a private copy for the protocol, because hardware libraries print
ddiff --git a/install/install.sh b/install/install.sh
iindex 70e1493..bc55b3f 100755
---- a/install/install.sh
++++ b/install/install.sh
@@@ -1,7 +1,7 @@
#!/bin/sh
# OpenVibe Node installer.
#
--# curl -fsSL https://openvibe.bot/install | sh -s -- --robot <rob_…> --code <CODE> [--driver adeept|adeept-mecanum|cozmo|none]
++# curl -fsSL https://openvibe.bot/install | sh -s -- --robot <rob_…> --code <CODE> [--driver adeept|adeept-mecanum|cozmo|looi|none]
# curl -fsSL https://openvibe.bot/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> [--driver …]
#
# Detects the OS and CPU, downloads the openvibe-node binary and the plugin bundle (checking their SHA-256), creates
@@@ -16,7 +16,8 @@
# --network URL pair through OpenVibe.Network at URL (the machine gets a node credential, then Bot binds it).
# --pairing ID the pair_… id of that Network pairing; a code given here instead (with no --code) is the code.
# --name NAME the device's name on openvibe.bot (default: the hostname).
--# --driver KIND adeept (ordinary wheels), adeept-mecanum, cozmo (bridge), none (dry run). Default: none.
++# --driver KIND adeept (ordinary wheels), adeept-mecanum, cozmo (bridge), looi (bridge, Bluetooth), none (dry run).
++# Default: none.
# --no-service install and pair, but do not install the service.
# --local DIR install from DIR (openvibe-node-<os>-<arch> and openvibe-node-plugins.tar.gz) instead of
# downloading.
@@@ -46,7 +47,7 @@ TAG="latest"
usage() {
echo "usage: install.sh [--robot rob_… | --network URL --pairing pair_…] [--code CODE | CODE] [--name NAME]"
-- echo " [--driver adeept|adeept-mecanum|cozmo|none] [--no-service] [--local DIR] [--version TAG]"
++ echo " [--driver adeept|adeept-mecanum|cozmo|looi|none] [--no-service] [--local DIR] [--version TAG]"
echo " curl -fsSL https://openvibe.bot/install | sh -s -- --robot rob_… --code ABCD-1234 --driver adeept"
echo " curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing pair_… --code ABCD-1234"
}
@@@ -83,7 +84,7 @@ while [ $# -gt 0 ]; do
esac
done
--case "$DRIVER" in adeept|adeept-mecanum|cozmo|none) ;; *) die "--driver must be adeept, adeept-mecanum, cozmo or none" ;; esac
++case "$DRIVER" in adeept|adeept-mecanum|cozmo|looi|none) ;; *) die "--driver must be adeept, adeept-mecanum, cozmo, looi or none" ;; esac
case "$ROBOT_ID" in ""|rob_*) ;; *) die "--robot must be a rob_… id" ;; esac
# --pairing takes the pair_… id; with no --code, anything else there is the code itself.
case "$PAIRING" in ""|pair_*) ;; *) [ -z "$CODE" ] || die "--pairing must be a pair_… id"; CODE="$PAIRING"; PAIRING="" ;; esac
@@@ -315,6 +316,16 @@ cozmo)
KIND=bridge
command -v espeak-ng >/dev/null 2>&1 || say "tip: install espeak-ng so Cozmo can speak (sudo apt install espeak-ng)"
;;
++looi)
++ # LOOI's camera is its phone, which streams on its own (docs/hardware-looi.md): the Node publishes no video.
++ PLUGINS='[{"name": "looi"}]'
++ KIND=bridge
++ VIDEO='"source": "off"'
++ if ! "$STATE/venv/bin/python" -c 'import openvibe_looi' >/dev/null 2>&1; then
++ say "the Generic LOOI driver is not in $STATE/venv yet: install it with \"$SUDO $PIP install <the driver>\", then"
++ say "restart the Node's service. Until then the panel shows the plugin's fault and LOOI never moves."
++ fi
++ ;;
esac
if [ ! -f "$CONF/config.json" ]; then
$SUDO sh -c "umask 077; cat > '$CONF/config.json'" <<EOF
ddiff --git a/internal/node/botcontract_test.go b/internal/node/botcontract_test.go
iindex 40a567b..d54f93c 100644
---- a/internal/node/botcontract_test.go
++++ b/internal/node/botcontract_test.go
@@@ -284,6 +284,33 @@ func TestOwnerLatchAcrossReconnects(t *testing.T) {
}
}
++// A driver's own stop (`local_stop` from the plugin) is the local latch with the driver's reason: Bot sees it as a
++// local stop, the owner's clear does not lift it, and only the local resume does.
++func TestPluginLocalStop(t *testing.T) {
++ e := start(t)
++ e.node.output(plugins.Output{Kind: plugins.OutLocalStop, Plugin: "dryrun", Data: map[string]any{"reason": "LOOI stop button"}})
++ m, err := e.conn.Expect(protocol.TypeEstopState, 5*time.Second, func(m protocol.Message) bool {
++ s := m.(protocol.EstopState)
++ return s.Latched && s.LocalStop
++ })
++ if err != nil {
++ t.Fatal(err)
++ }
++ if r := m.Msg.(protocol.EstopState).Reason; r != "LOOI stop button" {
++ t.Fatalf("reason %q", r)
++ }
++ if st := e.node.latch.State(); !st.Local || st.LocalReason != "LOOI stop button" {
++ t.Fatalf("latch %+v", st)
++ }
++ e.conn.Send(protocol.Estop{Latched: false, By: "usr_owner", At: time.Now().UTC().Format(time.RFC3339Nano)})
++ e.expectEstopState(true, true)
++ if c := nackCode(e.cmd(protocol.KindDrive, `{"throttle":0.3}`, 300)); c != protocol.FaultLocalStop {
++ t.Fatalf("after the owner's clear: %q", c)
++ }
++ e.ctl(localctl.CmdResume)
++ e.expectEstopState(false, false)
++}
++
// A remote clear never clears the local latch (`openvibe-node stop`): the device keeps refusing motion and keeps
// reporting itself latched, across a reconnect, until the local resume (which clears both latches on the device).
// Its latched:false after the resume is only a report; Bot's latch stays for the owner to clear.
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 6930691..b12ea5f 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -520,7 +520,15 @@ func (n *Node) sendEstopState() {
n.mu.Unlock()
st := n.latch.State()
n.send(protocol.EstopState{Latched: st.Stopped(), By: "device", At: time.Now().UTC().Format(time.RFC3339Nano),
-- RobotID: robot, LocalStop: st.Local, Reason: st.RemoteReason})
++ RobotID: robot, LocalStop: st.Local, Reason: estopReason(st)})
++}
++
++// estopReason is what stopped the device: the local stop's reason while it holds, else the server's.
++func estopReason(st safety.LatchState) string {
++ if st.Local && st.LocalReason != "" {
++ return st.LocalReason
++ }
++ return st.RemoteReason
}
// ---- plugin output ----
@@@ -571,6 +579,17 @@ func (n *Node) output(o plugins.Output) {
j.Push(p)
}
}
++ case plugins.OutLocalStop:
++ // A driver's own stop: everything stops at once, then the local latch is persisted with its reason. It is
++ // never a clear: nothing a plugin says releases a latch.
++ n.mgr.EstopAll()
++ reason, _ := o.Data["reason"].(string)
++ if reason = safety.CleanReason(reason); reason == "" {
++ reason = o.Plugin + " stop"
++ }
++ if _, err := n.latch.SetLocalWith(reason); err != nil {
++ n.log.Error("a plugin latched the local stop, but the latch could not be saved", "plugin", o.Plugin, "err", err)
++ }
case plugins.OutFault, plugins.OutState:
n.sendStatus()
}
@@@ -858,7 +877,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
switch r.Cmd {
case localctl.CmdStop:
n.mgr.EstopAll() // at once, then persist
-- if _, err := n.latch.SetLocal(); err != nil {
++ if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {
return localctl.Response{OK: true, Error: "stopped, but the latch could not be saved: " + err.Error()}
}
return localctl.Response{OK: true}
ddiff --git a/internal/plugins/plugin.go b/internal/plugins/plugin.go
iindex 55368a6..d3802f5 100644
---- a/internal/plugins/plugin.go
++++ b/internal/plugins/plugin.go
@@@ -73,6 +73,9 @@ const (
OutVideo = "video"
OutFault = "fault"
OutState = "state"
++ // OutLocalStop: the driver latched the Node's local stop (its own stop button, a sensor it trusts). Only
++ // `openvibe-node resume` on the machine releases it.
++ OutLocalStop = "local_stop"
)
// Output is something a plugin said that the core forwards: telemetry, an event, a video frame, a fault or a state
@@@ -524,6 +527,10 @@ func (p *Plugin) readStdout(r io.Reader, described chan struct{}) {
p.emit(Output{Kind: OutTelemetry, Data: m}, false)
case "event":
p.emit(Output{Kind: OutEvent, Data: m}, true)
++ case "local_stop":
++ reason, _ := m["reason"].(string)
++ p.log.Warn("plugin latched the local stop", "reason", truncate(reason, 120))
++ p.emit(Output{Kind: OutLocalStop, Data: m}, true)
case "video":
p.emit(Output{Kind: OutVideo, Data: m}, false)
default:
ddiff --git a/internal/plugins/plugins_test.go b/internal/plugins/plugins_test.go
iindex 9ee0dd6..49ef506 100644
---- a/internal/plugins/plugins_test.go
++++ b/internal/plugins/plugins_test.go
@@@ -64,6 +64,9 @@ func fakePlugin(logPath string, in io.Reader, out io.Writer) {
}
w.Encode(map[string]any{"op": "ready"})
w.Encode(map[string]any{"op": "event", "name": "booted"})
++ if mode == "local_stop" {
++ w.Encode(map[string]any{"op": "local_stop", "reason": "stop button"})
++ }
case "command":
switch m["kind"] {
case "crash":
@@@ -189,6 +192,14 @@ func TestHelloDescribeCommand(t *testing.T) {
}
}
++func TestLocalStopIsForwarded(t *testing.T) {
++ fx := start(t, "local_stop")
++ fx.waitState(StateReady)
++ if o := fx.waitOutput(OutLocalStop); o.Data["reason"] != "stop button" || o.Plugin != "fake" {
++ t.Fatalf("%+v", o)
++ }
++}
++
func TestRouting(t *testing.T) {
fx := start(t, "")
fx.waitState(StateReady)
ddiff --git a/internal/safety/latch.go b/internal/safety/latch.go
iindex 9b766c9..3e78087 100644
---- a/internal/safety/latch.go
++++ b/internal/safety/latch.go
@@@ -9,6 +9,7 @@ import (
"io/fs"
"os"
"path/filepath"
++ "strings"
"sync"
"time"
)
@@@ -16,7 +17,8 @@ import (
// LatchState is the persisted e-stop state. Two independent latches:
//
// - Remote: set by a server `estop`; cleared by a server `estop` with latched:false (the owner) or a local `resume`.
--// - Local: set by `openvibe-node stop` on the machine itself; cleared only by `openvibe-node resume`.
++// - Local: set on the machine itself — `openvibe-node stop`, or a driver's own stop (`local_stop`); cleared only by
++// `openvibe-node resume`. A remote clear never touches it.
//
// Anything that moves is refused while either is set.
type LatchState struct {
@@@ -24,6 +26,7 @@ type LatchState struct {
RemoteReason string `json:"remote_reason,omitempty"`
RemoteAt time.Time `json:"remote_at,omitempty"`
Local bool `json:"local"`
++ LocalReason string `json:"local_reason,omitempty"`
LocalAt time.Time `json:"local_at,omitempty"`
}
@@@ -144,15 +147,36 @@ func (l *Latch) ClearRemote() (LatchState, error) {
}
// SetLocal latches the local kill switch.
--func (l *Latch) SetLocal() (LatchState, error) {
++func (l *Latch) SetLocal() (LatchState, error) { return l.SetLocalWith("") }
++
++// SetLocalWith latches the local stop and records why (the first reason stands until the resume: a stop pressed
++// twice keeps saying what stopped it first).
++func (l *Latch) SetLocalWith(reason string) (LatchState, error) {
++ reason = CleanReason(reason)
return l.update(func(s *LatchState) {
if !s.Local {
s.LocalAt = time.Now().UTC()
++ s.LocalReason = reason
}
s.Local = true
})
}
++// CleanReason makes a stop reason safe to show: no control characters, at most 120 characters.
++func CleanReason(reason string) string {
++ out := make([]rune, 0, len(reason))
++ for _, r := range reason {
++ if r < 0x20 || r == 0x7f {
++ r = ' '
++ }
++ out = append(out, r)
++ if len(out) == 120 {
++ break
++ }
++ }
++ return strings.TrimSpace(string(out))
++}
++
// Resume clears both latches (`openvibe-node resume`).
func (l *Latch) Resume() (LatchState, error) {
return l.update(func(s *LatchState) { *s = LatchState{} })
ddiff --git a/internal/safety/safety_test.go b/internal/safety/safety_test.go
iindex 2d407fe..93d359f 100644
---- a/internal/safety/safety_test.go
++++ b/internal/safety/safety_test.go
@@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
++ "strings"
"testing"
"time"
@@@ -39,6 +40,28 @@ func TestLatchPersistsAcrossRestart(t *testing.T) {
}
}
++func TestLocalStopReason(t *testing.T) {
++ p := filepath.Join(t.TempDir(), "latch.json")
++ l, _ := OpenLatch(p)
++ l.SetLocalWith("stop\x07 button\non the robot")
++ l.SetLocalWith("second press")
++ l2, _ := OpenLatch(p)
++ if st := l2.State(); !st.Local || st.LocalReason != "stop button on the robot" {
++ t.Fatalf("reason not cleaned, kept first, or not persisted: %+v", st)
++ }
++ l.ClearRemote()
++ if st := l.State(); !st.Local || st.LocalReason == "" {
++ t.Fatalf("a remote clear touched the local stop: %+v", st)
++ }
++ l.Resume()
++ if st := l.State(); st.Local || st.LocalReason != "" {
++ t.Fatalf("resume left the reason: %+v", st)
++ }
++ if got := CleanReason(strings.Repeat("x", 200)); len(got) != 120 {
++ t.Fatalf("not capped: %d", len(got))
++ }
++}
++
func TestCorruptLatchFailsSafe(t *testing.T) {
p := filepath.Join(t.TempDir(), "latch.json")
os.WriteFile(p, []byte("{nope"), 0o600)
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex 4755f08..679b647 100644
---- a/plugins/sdk/openvibe_plugin/__init__.py
++++ b/plugins/sdk/openvibe_plugin/__init__.py
@@@ -120,6 +120,13 @@ class Plugin:
if self.runtime is not None:
self.runtime.send(dict(op="telemetry", **fields))
++ def local_stop(self, reason=""):
++ """Latch the Node's local stop: the driver's own stop button, or a sensor it trusts. Every actuator stops now,
++ the panel shows the robot as stopped on the robot, and only ``openvibe-node resume`` on this machine releases
++ it (the owner's remote clear does not). Safe to call from any thread."""
++ if self.runtime is not None:
++ self.runtime.local_stop(reason)
++
def log(self, msg, *args):
sys.stderr.write((msg % args if args else msg) + "\n")
sys.stderr.flush()
@@@ -143,6 +150,8 @@ class Runtime:
self.probe = False
self.ready = False
self.estopped = False
++ # True from a local_stop until the core's estop confirms it: a resume crossing it on the wire is ignored.
++ self.local_stop_pending = False
self.motion_deadline = None
self.last_heartbeat = None
self.heartbeat_lost = False
@@@ -174,6 +183,13 @@ class Runtime:
self.plugin.log("stop failed (%s): %r", reason, e)
self.send({"op": "event", "name": "stop_failed", "reason": reason, "error": str(e)})
++ def local_stop(self, reason=""):
++ with self.lock:
++ self.estopped = True
++ self.local_stop_pending = True
++ self.safe_stop("local_stop")
++ self.send({"op": "local_stop", "reason": str(reason or "")[:120]})
++
def tick(self):
"""Run time-based safety checks. Called by the loop at least every 20 ms."""
now = self.clock()
@@@ -212,9 +228,13 @@ class Runtime:
self.safe_stop("stop")
elif op == "estop":
self.estopped = True
++ self.local_stop_pending = False
self.safe_stop("estop")
elif op == "resume":
-- self.estopped = False
++ if self.local_stop_pending:
++ self.plugin.log("resume ignored: the local stop has not reached the core yet")
++ else:
++ self.estopped = False
else:
self.plugin.log("unknown op %r", op)
ddiff --git a/plugins/sdk/tests/test_runtime.py b/plugins/sdk/tests/test_runtime.py
iindex f701cf2..3e7644f 100644
---- a/plugins/sdk/tests/test_runtime.py
++++ b/plugins/sdk/tests/test_runtime.py
@@@ -147,6 +147,21 @@ class RuntimeTest(unittest.TestCase):
rt.feed_line(json.dumps({"op": "command", "id": "c4", "kind": "drive"}))
self.assertEqual(lines(out)[-1]["op"], "ack")
++ def test_local_stop_latches_and_ignores_a_crossing_resume(self):
++ rt, p, out, clock = make()
++ self.hello(rt)
++ rt.feed_line(json.dumps({"op": "command", "id": "c1", "kind": "drive"}))
++ p.local_stop("stop button")
++ self.assertFalse(p.moving)
++ self.assertEqual(lines(out)[-1], {"op": "local_stop", "reason": "stop button"})
++ rt.feed_line(json.dumps({"op": "resume"})) # sent before the core saw the local stop
++ rt.feed_line(json.dumps({"op": "command", "id": "c2", "kind": "drive"}))
++ self.assertEqual(lines(out)[-1]["fault_code"], "estopped")
++ rt.feed_line(json.dumps({"op": "estop"})) # the core latched it
++ rt.feed_line(json.dumps({"op": "resume"})) # `openvibe-node resume` on the machine
++ rt.feed_line(json.dumps({"op": "command", "id": "c3", "kind": "drive"}))
++ self.assertEqual(lines(out)[-1]["op"], "ack")
++
def test_halt_always_accepted(self):
rt, p, out, clock = make()
self.hello(rt)