openvibers/openvibe.link Commits
Node: a driver can latch the local stop; Windows user-session start; --driver looi (#35)
Changes
@@@ -8,8 +8,8 @@ for AI agents) can use the device. It connects out, needs no open ports, and keecurl -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 throughOpenVibe.Network gets `--network <URL> --pairing pair_… --code ABCD-1234` instead of `--robot`. Details, manualinstall, Windows and configuration: [docs/install.md](docs/install.md).@@@ -27,7 +27,8 @@ openvibe-node resumepublishing with pion, and the system service (systemd, launchd, Windows).- **Plugins** (separate processes, JSON lines over stdin/stdout): [`dryrun`](plugins/dryrun), the Adeept 4WD Smart CarKit [`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.@@@ -50,7 +50,8 @@ Usage:openvibe-node credential import store the credential from a rotate response, read as JSON on stdinopenvibe-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 stoppedopenvibe-node resume release the stop@@@ -101,6 +102,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) intdryRun = 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) intcase "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 = uerrnnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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 }nnew file mode 100644@@@ -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())++ }++}nnew file mode 100644@@@ -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()++}@@@ -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 backoffand 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).nnew file mode 100644@@@ -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.@@@ -13,7 +13,7 @@ ports, works behind any home router), and a local kill switch stops everything icurl -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 itand `openvibe-node install` (the service runs as LocalSystem; files go to `%ProgramData%\OpenVibe Node`). Robot pluginsneed 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) |@@@ -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 soa 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 (speechsynthesis, file I/O) goes on a thread. Nothing but protocol lines may go to stdout; the Python runtime enforces this bypointing file descriptor 1 at stderr and keeping a private copy for the protocol, because hardware libraries print@@@ -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 ]; doesacdone--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" ;; esaccase "$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=bridgecommand -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++ ;;esacif [ ! -f "$CONF/config.json" ]; then$SUDO sh -c "umask 077; cat > '$CONF/config.json'" <<EOF@@@ -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.@@@ -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}@@@ -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:@@@ -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)@@@ -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{} })@@@ -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)@@@ -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 = Falseself.ready = Falseself.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 = Falseself.motion_deadline = Noneself.last_heartbeat = Noneself.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 = Falseself.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 = Falseelse:self.plugin.log("unknown op %r", op)@@@ -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)
@@ -8,8 +8,8 @@ for AI agents) can use the device. It connects out, needs no open ports, and kee
@@ -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
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
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).
install, Windows and configuration: [docs/install.md](docs/install.md).
@@ -27,7 +27,8 @@ openvibe-node resume
@@ -27,7 +27,8 @@ openvibe-node resume
publishing with pion, and the system service (systemd, launchd, Windows).
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
- **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
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.
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 —
- **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.
pairing, the control link, drive/actuator commands, telemetry, heartbeat, deadman and e-stop — but no jobs or video.
@@ -50,7 +50,8 @@ Usage:
@@ -50,7 +50,8 @@ Usage:
openvibe-node credential import store the credential from a rotate response, read as JSON on stdin
openvibe-node credential import store the credential from a rotate response, read as JSON on stdin
openvibe-node run [--dry-run]
openvibe-node run [--dry-run]
openvibe-node install [--user NAME]
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 status [--json]
openvibe-node stop stop every actuator now and hold them stopped
openvibe-node stop stop every actuator now and hold them stopped
openvibe-node resume release the stop
openvibe-node resume release the stop
@@ -101,6 +102,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -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)")
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)")
asJSON = fs.Bool("json", false, "print JSON (status, plugins)")
svcUser = fs.String("user", "", "run the service as this account (install)")
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.
// Accept flags anywhere: split positionals out.
var positional []string
var positional []string
@@ -147,8 +149,16 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -147,8 +149,16 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
case "run":
case "run":
err = cmdRun(g, *dryRun)
err = cmdRun(g, *dryRun)
case "install":
case "install":
+ if *session {
+ err = cmdInstallUserSession(g, stdout)
+ break
+ }
err = cmdInstall(g, *svcUser, stdout)
err = cmdInstall(g, *svcUser, stdout)
case "uninstall":
case "uninstall":
+ if *session {
+ err = cmdUninstallUserSession(stdout)
+ break
+ }
err = service.Control(serviceOptions(g, ""), "stop")
err = service.Control(serviceOptions(g, ""), "stop")
if uerr := service.Control(serviceOptions(g, ""), "uninstall"); uerr != nil {
if uerr := service.Control(serviceOptions(g, ""), "uninstall"); uerr != nil {
err = uerr
err = uerr
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -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 }
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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())
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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()
+}
@@ -57,8 +57,9 @@ mode 0600 and its contents never appear in logs, errors or `status`.
@@ -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.
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
- 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`.
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
- 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).
`admin:123456` and an MJPEG stream on :5000 — the Node replaces it; `install.sh` disables it if it finds it enabled).
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -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.
@@ -13,7 +13,7 @@ ports, works behind any home router), and a local kill switch stops everything i
@@ -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
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).
`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
`--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`
`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
@@ -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
and `openvibe-node install` (the service runs as LocalSystem; files go to `%ProgramData%\OpenVibe Node`). Robot plugins
need Python; set `"python"` in `config.json`.
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
## Commands
| command | what it does |
| command | what it does |
@@ -83,6 +89,8 @@ need Python; set `"python"` in `config.json`.
@@ -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 run [--dry-run]` | run in the foreground (what the service runs) |
| `openvibe-node install [--user NAME]` | install and start the service |
| `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 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 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 stop` | **kill switch**: stop every actuator now and hold it stopped |
| `openvibe-node resume` | release the local stop (and a server e-stop) |
| `openvibe-node resume` | release the local stop (and a server e-stop) |
@@ -69,6 +69,7 @@ describes itself and waits for EOF without opening any hardware.
@@ -69,6 +69,7 @@ describes itself and waits for EOF without opening any hardware.
| `telemetry` | `battery` {`volts`, `percent`}, `rssi`, `sensors` {…}, `faults` […] |
| `telemetry` | `battery` {`volts`, `percent`}, `rssi`, `sensors` {…}, `faults` […] |
| `event` | `name` (`cliff`, `picked_up`, `low_battery`, `heartbeat_lost`, `stop_failed`, …), any fields |
| `event` | `name` (`cliff`, `picked_up`, `low_battery`, `heartbeat_lost`, `stop_failed`, …), any fields |
| `video` | `format` (`jpeg`), `path`, optional `width`, `height`, `seq` |
| `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
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": …}`
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
@@ -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;
- on `stop`, `estop` and a `halt` command;
- when its own sensors say so (Cozmo: cliff, pick-up).
- 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
`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
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
pointing file descriptor 1 at stderr and keeping a private copy for the protocol, because hardware libraries print
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
#!/bin/sh
#!/bin/sh
# OpenVibe Node installer.
# 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 …]
# 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
# Detects the OS and CPU, downloads the openvibe-node binary and the plugin bundle (checking their SHA-256), creates
@@ -16,7 +16,8 @@
@@ -16,7 +16,8 @@
# --network URL pair through OpenVibe.Network at URL (the machine gets a node credential, then Bot binds it).
# --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.
# --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).
# --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.
# --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
# --local DIR install from DIR (openvibe-node-<os>-<arch> and openvibe-node-plugins.tar.gz) instead of
# downloading.
# downloading.
@@ -46,7 +47,7 @@ TAG="latest"
@@ -46,7 +47,7 @@ TAG="latest"
usage() {
usage() {
echo "usage: install.sh [--robot rob_… | --network URL --pairing pair_…] [--code CODE | CODE] [--name NAME]"
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 -- --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"
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
@@ -83,7 +84,7 @@ while [ $# -gt 0 ]; do
esac
esac
done
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
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.
# --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
case "$PAIRING" in ""|pair_*) ;; *) [ -z "$CODE" ] || die "--pairing must be a pair_… id"; CODE="$PAIRING"; PAIRING="" ;; esac
@@ -315,6 +316,16 @@ cozmo)
@@ -315,6 +316,16 @@ cozmo)
KIND=bridge
KIND=bridge
command -v espeak-ng >/dev/null 2>&1 || say "tip: install espeak-ng so Cozmo can speak (sudo apt install espeak-ng)"
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
esac
if [ ! -f "$CONF/config.json" ]; then
if [ ! -f "$CONF/config.json" ]; then
$SUDO sh -c "umask 077; cat > '$CONF/config.json'" <<EOF
$SUDO sh -c "umask 077; cat > '$CONF/config.json'" <<EOF
@@ -284,6 +284,33 @@ func TestOwnerLatchAcrossReconnects(t *testing.T) {
@@ -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
// 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).
// 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.
// Its latched:false after the resume is only a report; Bot's latch stays for the owner to clear.
@@ -520,7 +520,15 @@ func (n *Node) sendEstopState() {
@@ -520,7 +520,15 @@ func (n *Node) sendEstopState() {
n.mu.Unlock()
n.mu.Unlock()
st := n.latch.State()
st := n.latch.State()
n.send(protocol.EstopState{Latched: st.Stopped(), By: "device", At: time.Now().UTC().Format(time.RFC3339Nano),
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 ----
// ---- plugin output ----
@@ -571,6 +579,17 @@ func (n *Node) output(o plugins.Output) {
@@ -571,6 +579,17 @@ func (n *Node) output(o plugins.Output) {
j.Push(p)
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:
case plugins.OutFault, plugins.OutState:
n.sendStatus()
n.sendStatus()
}
}
@@ -858,7 +877,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -858,7 +877,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
switch r.Cmd {
switch r.Cmd {
case localctl.CmdStop:
case localctl.CmdStop:
n.mgr.EstopAll() // at once, then persist
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, Error: "stopped, but the latch could not be saved: " + err.Error()}
}
}
return localctl.Response{OK: true}
return localctl.Response{OK: true}
@@ -73,6 +73,9 @@ const (
@@ -73,6 +73,9 @@ const (
OutVideo = "video"
OutVideo = "video"
OutFault = "fault"
OutFault = "fault"
OutState = "state"
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
// 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{}) {
@@ -524,6 +527,10 @@ func (p *Plugin) readStdout(r io.Reader, described chan struct{}) {
p.emit(Output{Kind: OutTelemetry, Data: m}, false)
p.emit(Output{Kind: OutTelemetry, Data: m}, false)
case "event":
case "event":
p.emit(Output{Kind: OutEvent, Data: m}, true)
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":
case "video":
p.emit(Output{Kind: OutVideo, Data: m}, false)
p.emit(Output{Kind: OutVideo, Data: m}, false)
default:
default:
@@ -64,6 +64,9 @@ func fakePlugin(logPath string, in io.Reader, out io.Writer) {
@@ -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": "ready"})
w.Encode(map[string]any{"op": "event", "name": "booted"})
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":
case "command":
switch m["kind"] {
switch m["kind"] {
case "crash":
case "crash":
@@ -189,6 +192,14 @@ func TestHelloDescribeCommand(t *testing.T) {
@@ -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) {
func TestRouting(t *testing.T) {
fx := start(t, "")
fx := start(t, "")
fx.waitState(StateReady)
fx.waitState(StateReady)
@@ -9,6 +9,7 @@ import (
@@ -9,6 +9,7 @@ import (
"io/fs"
"io/fs"
"os"
"os"
"path/filepath"
"path/filepath"
+ "strings"
"sync"
"sync"
"time"
"time"
)
)
@@ -16,7 +17,8 @@ import (
@@ -16,7 +17,8 @@ import (
// LatchState is the persisted e-stop state. Two independent latches:
// 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`.
// - 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.
// Anything that moves is refused while either is set.
type LatchState struct {
type LatchState struct {
@@ -24,6 +26,7 @@ type LatchState struct {
@@ -24,6 +26,7 @@ type LatchState struct {
RemoteReason string `json:"remote_reason,omitempty"`
RemoteReason string `json:"remote_reason,omitempty"`
RemoteAt time.Time `json:"remote_at,omitempty"`
RemoteAt time.Time `json:"remote_at,omitempty"`
Local bool `json:"local"`
Local bool `json:"local"`
+ LocalReason string `json:"local_reason,omitempty"`
LocalAt time.Time `json:"local_at,omitempty"`
LocalAt time.Time `json:"local_at,omitempty"`
}
}
@@ -144,15 +147,36 @@ func (l *Latch) ClearRemote() (LatchState, error) {
@@ -144,15 +147,36 @@ func (l *Latch) ClearRemote() (LatchState, error) {
}
}
// SetLocal latches the local kill switch.
// 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) {
return l.update(func(s *LatchState) {
if !s.Local {
if !s.Local {
s.LocalAt = time.Now().UTC()
s.LocalAt = time.Now().UTC()
+ s.LocalReason = reason
}
}
s.Local = true
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`).
// Resume clears both latches (`openvibe-node resume`).
func (l *Latch) Resume() (LatchState, error) {
func (l *Latch) Resume() (LatchState, error) {
return l.update(func(s *LatchState) { *s = LatchState{} })
return l.update(func(s *LatchState) { *s = LatchState{} })
@@ -4,6 +4,7 @@ import (
@@ -4,6 +4,7 @@ import (
"encoding/json"
"encoding/json"
"os"
"os"
"path/filepath"
"path/filepath"
+ "strings"
"testing"
"testing"
"time"
"time"
@@ -39,6 +40,28 @@ func TestLatchPersistsAcrossRestart(t *testing.T) {
@@ -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) {
func TestCorruptLatchFailsSafe(t *testing.T) {
p := filepath.Join(t.TempDir(), "latch.json")
p := filepath.Join(t.TempDir(), "latch.json")
os.WriteFile(p, []byte("{nope"), 0o600)
os.WriteFile(p, []byte("{nope"), 0o600)
@@ -120,6 +120,13 @@ class Plugin:
@@ -120,6 +120,13 @@ class Plugin:
if self.runtime is not None:
if self.runtime is not None:
self.runtime.send(dict(op="telemetry", **fields))
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):
def log(self, msg, *args):
sys.stderr.write((msg % args if args else msg) + "\n")
sys.stderr.write((msg % args if args else msg) + "\n")
sys.stderr.flush()
sys.stderr.flush()
@@ -143,6 +150,8 @@ class Runtime:
@@ -143,6 +150,8 @@ class Runtime:
self.probe = False
self.probe = False
self.ready = False
self.ready = False
self.estopped = 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.motion_deadline = None
self.last_heartbeat = None
self.last_heartbeat = None
self.heartbeat_lost = False
self.heartbeat_lost = False
@@ -174,6 +183,13 @@ class Runtime:
@@ -174,6 +183,13 @@ class Runtime:
self.plugin.log("stop failed (%s): %r", reason, e)
self.plugin.log("stop failed (%s): %r", reason, e)
self.send({"op": "event", "name": "stop_failed", "reason": reason, "error": str(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):
def tick(self):
"""Run time-based safety checks. Called by the loop at least every 20 ms."""
"""Run time-based safety checks. Called by the loop at least every 20 ms."""
now = self.clock()
now = self.clock()
@@ -212,9 +228,13 @@ class Runtime:
@@ -212,9 +228,13 @@ class Runtime:
self.safe_stop("stop")
self.safe_stop("stop")
elif op == "estop":
elif op == "estop":
self.estopped = True
self.estopped = True
+ self.local_stop_pending = False
self.safe_stop("estop")
self.safe_stop("estop")
elif op == "resume":
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:
else:
self.plugin.log("unknown op %r", op)
self.plugin.log("unknown op %r", op)
@@ -147,6 +147,21 @@ class RuntimeTest(unittest.TestCase):
@@ -147,6 +147,21 @@ class RuntimeTest(unittest.TestCase):
rt.feed_line(json.dumps({"op": "command", "id": "c4", "kind": "drive"}))
rt.feed_line(json.dumps({"op": "command", "id": "c4", "kind": "drive"}))
self.assertEqual(lines(out)[-1]["op"], "ack")
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):
def test_halt_always_accepted(self):
rt, p, out, clock = make()
rt, p, out, clock = make()
self.hello(rt)
self.hello(rt)