openvibers/openvibe.link Commits
Link v0.62.0: Bot's restarts stop plugins as link_restart, closes are logged, a headlight needs 50 ms left (#170)
Changes
@@@ -5,8 +5,8 @@"wave": "T15","plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)","kind": "client",-- "version": "0.61.0",-- "release": "v0.61.0: a headlight is no longer refused on a slow link. Link checks the 200 ms it must have left of a headlight's 300 ms lifetime against its estimate of the server's clock, and counts the clock's uncertainty once, where it decides whether a deadline has already passed. Counting it twice refused every headlight on a connection whose fastest round trip was slow (164 ms of uncertainty read as \"only 136 ms of 300 left\" on a fresh command, while drive commands went through). The fake Bot can answer heartbeats late to test a slow path.",++ "version": "0.62.0",++ "release": "v0.62.0: Bot's restarts no longer leave P7 latched. A deploy of Bot closes every link with 1012; the Link now stops the plugins with cause `link_restart` (not `link_lost`), so a driver can stop its motors without latching a stop of its own that the owner then clears by hand. Every close is logged with its code and reason, or \"the link dropped with no close frame\". A headlight needs 50 ms of its 300 ms lifetime left on arrival (was 200), the floor P7's tray already uses. Plugin SDK 0.37.0 lists the new cause.","runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library","database": "none","deployed": false,@@@ -78,8 +78,9 @@ What the driver receives from OpenVibe Link (the note's exact non-motion envelop}```--- The numbers are the contract: 300 ms original lifetime, a deadline of at most 250 ms, and at least 200 ms of the-- original lifetime left when the Link reads the line (Link nacks `expired` otherwise). It is not a motion kind and++- The numbers are the contract: 300 ms original lifetime, a deadline of at most 250 ms, and at least 50 ms of the++ original lifetime left when the Link reads the line (Link nacks `expired` otherwise; 200 until Link 0.62.0, which++ matched it to P7's tray). It is not a motion kind andmust never renew a drive/head deadline.- At most two are accepted per rolling second on the machine, shared with the owner's local control; Link/pluginmust not retry automatically.@@@ -248,7 +248,8 @@ Asked next by the same authors (2026-10-27, P7 LOOI shadow integration), and cha| op | `cause` | when | latches ||---|---|---|---|| `stop` | `halt` | Bot's halt: a turn ended, a viewer handoff, a release, a timeout, an unbind (with `turn_epoch`) | no |-- | `stop` | `link_lost` | the link to Bot dropped (two missed heartbeats, a close) | no |++ | `stop` | `link_lost` | the link to Bot dropped (two missed heartbeats, a close, no close frame) | no |++ | `stop` | `link_restart` | Bot closed the link to restart (close 1012, a deploy); it reconnects within seconds (0.62.0) | no || `stop` | `shutdown` | the Link is stopping (service stop, `openvibe-link quit`), or the core is stopping this process | no || `stop` | `timeout` | a command got no answer from the driver within its window | no || `estop` | `remote` | the platform's stop (a robot owner or staff), with its `reason` | yes, until the owner clears it |@@@ -268,6 +269,9 @@ Asked next by the same authors (2026-10-27, P7 LOOI shadow integration), and chainformation. Never ARM P7, clear its physical latch or restore a remote grant there; the next command still passesevery check. Across a plugin restart the latch arrives again as `estop` `latched` with `replayed: true`, and the haltepoch comes in `hello`. Across a Link reconnect the link's loss is a `stop` (`link_lost`); an old server stop latch set while offline is applied from config before any command.++ A planned restart of Bot (a deploy closes every link with 1012) is `link_restart` from 0.62.0: stop the motors, and++ there is no need to latch P7's own STOP for it, so the owner does not have to clear it and ARM again by hand. The++ Link logs every close with its code and reason, or "the link dropped with no close frame".Reviewed again by the same authors against v0.17.0 (2026-10-27, `632207f`), and changed in Link 0.18.0:@@@ -327,7 +331,7 @@ Reviewed again by the same authors against v0.17.0 (2026-10-27, `632207f`), and- `info.cause` says which (answer 7).- `info.halt_epoch` is the newest halt epoch the runtime knows, and comes from `hello` after a restart.- `info.turn_epoch` is the epoch of the halt that caused this stop.-- - An ordinary stop (`halt`, `link_lost`, `shutdown`, `timeout`, `deadline`, `heartbeat`) only says "stop now". It++ - An ordinary stop (`halt`, `link_lost`, `link_restart`, `shutdown`, `timeout`, `deadline`, `heartbeat`) only says "stop now". Itlatches nothing in the core or the runtime, so an adapter may revoke remote motion without touching a newer localowner's command.- `on_resume` is information. Its default does nothing, and the runtime re-arms nothing, so P7 is not re-armed and@@@ -245,7 +245,7 @@ never a toggle. Bot mints one with an original 300 ms lifetime and a `deadline_mstamps `provenance` on the frame: `session` (the hello's id), `seq` and `issued_at_ms` (this frame's envelope seq andts), `expires_at_ms` (the original 300 ms expiry) and `clock_uncertainty_ms` (Bot's last measured link round trip).The Link checks provenance against the frame, nacks `bad_value` when they disagree or the lifetime/deadline are not--Bot's, nacks `expired` when less than 200 ms of the original 300 ms lifetime is left on receipt (or the deadline++Bot's, nacks `expired` when less than 50 ms of the original 300 ms lifetime is left on receipt (200 before 0.62.0) (or the deadlinealready passed), and only then routes it to the driver. The driver gets `deadline_ms` ≤ 250,`expires_at_mono_ns` at the original expiry on the shared QPC/monotonic clock, the frame's `operator` and`turn_epoch`, and Bot's `provenance` with the Link's own measured `clock_uncertainty_ms`. A headlight is never motion:@@@ -182,13 +182,14 @@ var ErrQuit = errors.New("quit by its owner")const InputGuard = 2 * time.Second// Headlights (Maticus's 2026-11-11 handoff, docs/hardware-looi.md): Bot mints a headlight with an original 300 ms--// lifetime and a deadline of at most 250 ms; by the time it reaches this machine at least 200 ms of that original--// lifetime must be left, so the driver never gets a command that was already most of the way through its life.++// lifetime and a deadline of at most 250 ms; by the time it reaches this machine at least 50 ms of that original++// lifetime must be left (0.62.0; it was 200 until Maticus, 11-12: P7's tray accepts a headlight with 50 ms or more, and++// the stricter floor here refused ones the tray would have taken).// At most two are accepted in any rolling second, shared by every source (Bot and the owner's local controls).const (HeadlightLifetimeMS = 300HeadlightDeadlineMS = 250-- HeadlightReserveMS = 200++ HeadlightReserveMS = 50HeadlightPerSecond = 2)@@@ -441,8 +442,15 @@ func (n *Agent) Connected() {}func (n *Agent) Disconnected(err error) {-- n.log.Warn("link lost: stopping every actuator", "err", err)-- n.mgr.StopAll(plugins.StopLinkLost)++ // Bot closing with 1012 is a planned restart (a deploy): the stop says so, so a driver can stop its motors without++ // latching a stop of its own that the owner would then have to clear by hand (Maticus, 11-12).++ if errors.Is(err, link.ErrServerRestart) {++ n.log.Warn("link closed for a server restart: stopping every actuator", "err", err)++ n.mgr.StopAll(plugins.StopLinkRestart)++ } else {++ n.log.Warn("link lost: stopping every actuator", "err", err)++ n.mgr.StopAll(plugins.StopLinkLost)++ }n.mu.Lock()n.configured = falsen.mu.Unlock()@@@ -884,7 +892,7 @@ func (n *Agent) command(c protocol.Command, sentMS int64, seq uint64, received m// headlight passes one Bot-minted headlight to its driver. It is not a motion kind: it never renews a drive or// head deadline. Bot's provenance is checked against the frame (session, seq, ts) and the original lifetime and--// deadline, at least 200 ms of the original 300 ms lifetime must be left when the frame is read, the shared++// deadline, at least 50 ms of the original 300 ms lifetime must be left when the frame is read, the shared// 2 requests/second budget is spent, and the plugin gets deadline_ms ≤ 250 with expires_at_mono_ns at the// original expiry on the shared clock. Its ack's data (stage headlight_pending/headlight_refused) is relayed to Bot.func (n *Agent) headlight(c protocol.Command, entry *safety.Entry, sentMS int64, seq uint64, received mono.Instant, limits safety.Limits, nack func(code, msg string)) {@@@ -26,6 +26,7 @@ import ("github.com/OpenVibers/OpenVibe.Link/internal/protocol""github.com/OpenVibers/OpenVibe.Link/internal/safety""github.com/OpenVibers/OpenVibe.Link/internal/video"++ "github.com/gorilla/websocket")func init() {@@@ -541,7 +542,7 @@ func TestVideoAndTelemetry(t *testing.T) {// ── LOOI headlights (Maticus, 2026-11-11) ────────────────────────────────────────────────────────────────────────// Bot mints a headlight with an original 300 ms lifetime and a ≤250 ms deadline; Link passes it to the driver as// the note's envelope (deadline_ms ≤ 250, expires_at_mono_ns on the shared clock, Bot's provenance, operator and--// turn_epoch), holds 200 ms of the original lifetime on receipt, shares two requests a second across sources, and++// turn_epoch), holds 50 ms of the original lifetime on receipt, shares two requests a second across sources, and// relays the driver's stage=headlight_pending/headlight_refused ack data back to Bot.func headlightPlugin(t *testing.T) (*env, string) {@@@ -710,12 +711,25 @@ func TestHeadlightReserveOnASlowLink(t *testing.T) {func TestHeadlightReserveAndRefusals(t *testing.T) {e, _ := headlightPlugin(t)-- // A frame that spent 150 ms on the way has only 150 ms of its original 300 ms lifetime left: never sent.++ // 0.62.0: 50 ms of the original 300 ms is enough (P7's tray takes 50 or more). A frame that spent 150 ms on the way++ // still has 150 ms left: sent. One that spent 260 ms is past its 250 ms deadline (40 ms of lifetime left): never sent.++ // With the deadline at most 250 ms into a 300 ms life, a command inside its deadline always has over 50 ms left.++ ok := protocol.Command{ID: "hl-150", Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":true}`), Provenance: &protocol.CommandProvenance{}}++ if err := e.conn.CommandLate(ok, HeadlightDeadlineMS, 150*time.Millisecond); err != nil {++ t.Fatal(err)++ }++ r, err := e.conn.Reply("hl-150", 5*time.Second)++ if err != nil {++ t.Fatal(err)++ }++ if d := ackData(t, r); d["admitted"] != true {++ t.Fatalf("a headlight with 150 ms left was refused: %#v", r)++ }cmd := protocol.Command{ID: "hl-late", Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":false}`), Provenance: &protocol.CommandProvenance{}}-- if err := e.conn.CommandLate(cmd, HeadlightDeadlineMS, 150*time.Millisecond); err != nil {++ if err := e.conn.CommandLate(cmd, HeadlightDeadlineMS, 260*time.Millisecond); err != nil {t.Fatal(err)}-- r, err := e.conn.Reply("hl-late", 5*time.Second)++ r, err = e.conn.Reply("hl-late", 5*time.Second)if err != nil {t.Fatal(err)}@@@ -761,6 +775,27 @@ func TestHeadlightReserveAndRefusals(t *testing.T) {}}++// A deploy of Bot closes the link with 1012: every plugin is stopped with cause link_restart, so a driver can stop its++// motors without latching a stop of its own (Maticus, 11-12). Any other end is link_lost.++func TestServerRestartStopsWithLinkRestart(t *testing.T) {++ e, record := headlightPlugin(t)++ e.conn.CloseWith(websocket.CloseServiceRestart, "restarting")++ deadline := time.Now().Add(5 * time.Second)++ for {++ b, _ := os.ReadFile(record + ".stops")++ if strings.Contains(string(b), "link_restart") {++ if strings.Contains(string(b), "link_lost") {++ t.Fatalf("a restart also stopped as link_lost: %q", b)++ }++ return++ }++ if time.Now().After(deadline) {++ t.Fatalf("no link_restart stop: %q", b)++ }++ time.Sleep(20 * time.Millisecond)++ }++}++func TestHeadlightTwoPerSecond(t *testing.T) {e, _ := headlightPlugin(t)for i := 0; i < HeadlightPerSecond; i++ {@@@ -33,6 +33,11 @@ for raw in sys.stdin:send({"op": "ready"})send({"op": "telemetry", "sensors": {"p7_headlight": {"commanded": None, "pending": False, "physical_state_verified": False}}})++ elif op == "stop":++ # Why the core stopped it, beside the record (record.stops): link_lost, link_restart, shutdown...++ if record:++ with open(record + ".stops", "a") as f:++ f.write((msg.get("cause") or "") + "\n")elif op == "command":on = (msg.get("value") or {}).get("on")note({"id": msg.get("id"), "kind": msg.get("kind"), "value": msg.get("value"),@@@ -6,7 +6,9 @@// the server's hello arrives: OpenVibe.Bot authenticates after the upgrade and answers anything sent earlier with// error bot.not_paired.//--// Close codes: 4000 (another connection with this credential replaced this one) reconnects with the normal backoff;++// Close codes: 1012 (Bot is restarting, a deploy) is ErrServerRestart, so the stop it causes says so; every close is++// logged with its code and reason, and a connection that ended with no close frame says that instead.++// 4000 (another connection with this credential replaced this one) reconnects with the normal backoff;// 4002 (credential refused) and 4003 (revoked) keep retrying, never sooner than CredentialRetryMin, and log what to// do: pair again, or import the owner's rotation. Bot's error frames are logged, never fatal.//@@@ -330,6 +332,10 @@ func (l *Link) backoff(err error, attempt int) time.Duration {return l.opt.BackoffMin/2 + jitter}++// ErrServerRestart is the end of a session Bot closed with 1012 (service restart: a deploy). The link reconnects with++// its normal backoff; the agent stops every actuator with cause link_restart instead of link_lost.++var ErrServerRestart = errors.New("the server closed the link to restart (1012); reconnecting")++var (errUnauthorized = errors.New("the server refused the device credential (wrong, rotated or revoked): pair again with `openvibe-link pair <CODE>`, or, if the owner rotated it, pipe the rotate response into `openvibe-link credential import`")errRevoked = errors.New("the owner revoked this device or rotated its credential: pair again with `openvibe-link pair <CODE>`, or, if the owner rotated it, pipe the rotate response into `openvibe-link credential import`")@@@ -347,6 +353,8 @@ func closeErr(err error) error {return nil}switch ce.Code {++ case websocket.CloseServiceRestart:++ return ErrServerRestartcase protocol.CloseReplaced:return errReplacedcase protocol.CloseInvalid:@@@ -492,6 +500,14 @@ func (l *Link) session(ctx context.Context) error {_, data, err := conn.ReadMessage()if err != nil {if sessCtx.Err() == nil {++ // What ended it, on stderr (Maticus, 11-12): the server's close code and reason, or that no close++ // frame came (the connection dropped: a network cut, or a server that exited before closing).++ var wc *websocket.CloseError++ if errors.As(err, &wc) {++ l.log.Warn("the server closed the link", "close_code", wc.Code, "close_reason", scrub(wc.Text, cred))++ } else {++ l.log.Warn("the link dropped with no close frame", "err", scrub(err.Error(), cred))++ }if ce := closeErr(err); ce != nil {end(ce)} else {@@@ -518,6 +518,31 @@ func TestCredentialBackoffFloor(t *testing.T) {}}++// Bot closing with 1012 (a deploy) ends the session as ErrServerRestart, and the log names the close code and reason;++// a connection that ends with no close frame says so (Maticus, 11-12: "print the close code").++func TestServerRestartCloseIsLogged(t *testing.T) {++ srv, _, rec, logs, _ := setup(t, time.Second)++ conn, err := srv.NextConn(3 * time.Second)++ if err != nil {++ t.Fatal(err)++ }++ waitFrame(t, rec, protocol.TypeConfig)++ conn.CloseWith(websocket.CloseServiceRestart, "restarting for a deploy")++ if got := <-rec.gotDown; !errors.Is(got, ErrServerRestart) {++ t.Fatalf("1012 ended the session with %v", got)++ }++ deadline := time.Now().Add(3 * time.Second)++ for !strings.Contains(logs.String(), "close_code=1012") || !strings.Contains(logs.String(), "restarting for a deploy") {++ if time.Now().After(deadline) {++ t.Fatalf("the close was not logged: %s", logs.String())++ }++ time.Sleep(10 * time.Millisecond)++ }++ if closeErr(&websocket.CloseError{Code: websocket.CloseServiceRestart}) != ErrServerRestart {++ t.Fatal("1012 does not map to ErrServerRestart")++ }++}++func waitLastError(t *testing.T, l *Link, want string) {t.Helper()deadline := time.Now().Add(3 * time.Second)@@@ -80,7 +80,7 @@ func (m *Manager) Get(name string) *Plugin {return nil}--// StopAll tells every plugin to stop every actuator, saying why (StopLinkLost, StopShutdown).++// StopAll tells every plugin to stop every actuator, saying why (StopLinkLost, StopLinkRestart, StopShutdown).func (m *Manager) StopAll(cause string) {for _, p := range m.plugins {p.Stop(cause)@@@ -300,12 +300,15 @@ func (p *Plugin) enqueue(l line) error {const (StopHalt = "halt" // Bot's halt: the controlling turn ended (release, handoff, timeout, unbind)StopLinkLost = "link_lost" // the link to Bot was lost-- StopShutdown = "shutdown" // the Link is stopping (service stop, `openvibe-link quit`)-- StopTimeout = "timeout" // a command got no answer from the driver within its window-- EstopRemote = "remote" // an stop from the platform (a robot owner or staff)-- EstopLocal = "local" // `openvibe-link stop` on the device-- EstopDriver = "driver" // a driver's own local stop (a plugin reported one)-- EstopLatched = "latched" // the stop was already latched when the Link or this process started++ // StopLinkRestart: Bot closed the link to restart (close 1012, a deploy). It reconnects within seconds; a driver++ // stops its motors as for link_lost and need not latch anything of its own (0.62.0).++ StopLinkRestart = "link_restart"++ StopShutdown = "shutdown" // the Link is stopping (service stop, `openvibe-link quit`)++ StopTimeout = "timeout" // a command got no answer from the driver within its window++ EstopRemote = "remote" // an stop from the platform (a robot owner or staff)++ EstopLocal = "local" // `openvibe-link stop` on the device++ EstopDriver = "driver" // a driver's own local stop (a plugin reported one)++ EstopLatched = "latched" // the stop was already latched when the Link or this process started)// Stop tells the plugin to stop every actuator, saying why.@@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run"RUNTIME_CAUSES", "SHARED_CLOCK", "SHARED_CLOCK_NAME", "TIMING_UNAVAILABLE", "__version__"]# The Node release this runtime ships with (pyproject.toml carries the same).--__version__ = "0.36.0"++__version__ = "0.37.0"PROTOCOL_VERSION = 1HEARTBEAT_TIMEOUT_S = 1.0DEFAULT_DEADLINE_MS = 300@@@ -175,7 +175,7 @@ def _provenance(p):# Why a driver is stopped (StopInfo.cause). From the core: an ordinary stop revokes remote motion and latches nothing;# an emergency latches the platform's stop until its owner clears it.--STOP_CAUSES = ("halt", "link_lost", "shutdown", "timeout")++STOP_CAUSES = ("halt", "link_lost", "link_restart", "shutdown", "timeout")EMERGENCY_CAUSES = ("remote", "local", "driver", "latched")# From the runtime itself: a motion command's own deadline, the core going quiet, the handler failing, stdin closing.RUNTIME_CAUSES = ("setup", "deadline", "heartbeat", "handler_error", "eof")@@@ -186,7 +186,8 @@ class StopInfo:is stopped").cause, from the core: "halt" (Bot ended the controlling turn: a release, a handoff to the next viewer, a timeout or-- an unbind; turn_epoch is that halt's epoch), "link_lost" (the link to Bot dropped), "shutdown" (the Node is++ an unbind; turn_epoch is that halt's epoch), "link_lost" (the link to Bot dropped), "link_restart" (Bot closed the++ link to restart, a deploy: back within seconds; Link 0.62.0), "shutdown" (the Node isstopping), "timeout" (a command got no answer within its window). From the runtime: "setup" (the stop every setupends with, so all actuators start stopped), "deadline" (a motion command'sown deadline passed; motion_only is True: stop what moves on its own), "heartbeat" (the core went quiet),@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"[project]name = "openvibe-plugin"--version = "0.36.0"++version = "0.37.0"description = "Runtime for OpenVibe Link plugins (JSON lines over stdin/stdout, deadlines, heartbeat, stop)"requires-python = ">=3.9"license = { text = "AGPL-3.0-or-later" }@@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)def test_the_package_reports_the_node_release(self):-- self.assertEqual(openvibe_plugin.__version__, "0.36.0")++ self.assertEqual(openvibe_plugin.__version__, "0.37.0")with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
"wave": "T15",
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
"kind": "client",
- "version": "0.61.0",
- "release": "v0.61.0: a headlight is no longer refused on a slow link. Link checks the 200 ms it must have left of a headlight's 300 ms lifetime against its estimate of the server's clock, and counts the clock's uncertainty once, where it decides whether a deadline has already passed. Counting it twice refused every headlight on a connection whose fastest round trip was slow (164 ms of uncertainty read as \"only 136 ms of 300 left\" on a fresh command, while drive commands went through). The fake Bot can answer heartbeats late to test a slow path.",
+ "version": "0.62.0",
+ "release": "v0.62.0: Bot's restarts no longer leave P7 latched. A deploy of Bot closes every link with 1012; the Link now stops the plugins with cause `link_restart` (not `link_lost`), so a driver can stop its motors without latching a stop of its own that the owner then clears by hand. Every close is logged with its code and reason, or \"the link dropped with no close frame\". A headlight needs 50 ms of its 300 ms lifetime left on arrival (was 200), the floor P7's tray already uses. Plugin SDK 0.37.0 lists the new cause.",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"deployed": false,
@@ -78,8 +78,9 @@ What the driver receives from OpenVibe Link (the note's exact non-motion envelop
@@ -78,8 +78,9 @@ What the driver receives from OpenVibe Link (the note's exact non-motion envelop
}
}
```
```
-- The numbers are the contract: 300 ms original lifetime, a deadline of at most 250 ms, and at least 200 ms of the
- original lifetime left when the Link reads the line (Link nacks `expired` otherwise). It is not a motion kind and
+- The numbers are the contract: 300 ms original lifetime, a deadline of at most 250 ms, and at least 50 ms of the
+ original lifetime left when the Link reads the line (Link nacks `expired` otherwise; 200 until Link 0.62.0, which
+ matched it to P7's tray). It is not a motion kind and
must never renew a drive/head deadline.
must never renew a drive/head deadline.
- At most two are accepted per rolling second on the machine, shared with the owner's local control; Link/plugin
- At most two are accepted per rolling second on the machine, shared with the owner's local control; Link/plugin
must not retry automatically.
must not retry automatically.
@@ -248,7 +248,8 @@ Asked next by the same authors (2026-10-27, P7 LOOI shadow integration), and cha
@@ -248,7 +248,8 @@ Asked next by the same authors (2026-10-27, P7 LOOI shadow integration), and cha
| op | `cause` | when | latches |
| op | `cause` | when | latches |
|---|---|---|---|
|---|---|---|---|
| `stop` | `halt` | Bot's halt: a turn ended, a viewer handoff, a release, a timeout, an unbind (with `turn_epoch`) | no |
| `stop` | `halt` | Bot's halt: a turn ended, a viewer handoff, a release, a timeout, an unbind (with `turn_epoch`) | no |
- | `stop` | `link_lost` | the link to Bot dropped (two missed heartbeats, a close) | no |
+ | `stop` | `link_lost` | the link to Bot dropped (two missed heartbeats, a close, no close frame) | no |
+ | `stop` | `link_restart` | Bot closed the link to restart (close 1012, a deploy); it reconnects within seconds (0.62.0) | no |
| `stop` | `shutdown` | the Link is stopping (service stop, `openvibe-link quit`), or the core is stopping this process | no |
| `stop` | `shutdown` | the Link is stopping (service stop, `openvibe-link quit`), or the core is stopping this process | no |
| `stop` | `timeout` | a command got no answer from the driver within its window | no |
| `stop` | `timeout` | a command got no answer from the driver within its window | no |
| `estop` | `remote` | the platform's stop (a robot owner or staff), with its `reason` | yes, until the owner clears it |
| `estop` | `remote` | the platform's stop (a robot owner or staff), with its `reason` | yes, until the owner clears it |
@@ -268,6 +269,9 @@ Asked next by the same authors (2026-10-27, P7 LOOI shadow integration), and cha
@@ -268,6 +269,9 @@ Asked next by the same authors (2026-10-27, P7 LOOI shadow integration), and cha
information. Never ARM P7, clear its physical latch or restore a remote grant there; the next command still passes
information. Never ARM P7, clear its physical latch or restore a remote grant there; the next command still passes
every check. Across a plugin restart the latch arrives again as `estop` `latched` with `replayed: true`, and the halt
every check. Across a plugin restart the latch arrives again as `estop` `latched` with `replayed: true`, and the halt
epoch comes in `hello`. Across a Link reconnect the link's loss is a `stop` (`link_lost`); an old server stop latch set while offline is applied from config before any command.
epoch comes in `hello`. Across a Link reconnect the link's loss is a `stop` (`link_lost`); an old server stop latch set while offline is applied from config before any command.
+ A planned restart of Bot (a deploy closes every link with 1012) is `link_restart` from 0.62.0: stop the motors, and
+ there is no need to latch P7's own STOP for it, so the owner does not have to clear it and ARM again by hand. The
+ Link logs every close with its code and reason, or "the link dropped with no close frame".
Reviewed again by the same authors against v0.17.0 (2026-10-27, `632207f`), and changed in Link 0.18.0:
Reviewed again by the same authors against v0.17.0 (2026-10-27, `632207f`), and changed in Link 0.18.0:
@@ -327,7 +331,7 @@ Reviewed again by the same authors against v0.17.0 (2026-10-27, `632207f`), and
@@ -327,7 +331,7 @@ Reviewed again by the same authors against v0.17.0 (2026-10-27, `632207f`), and
- `info.cause` says which (answer 7).
- `info.cause` says which (answer 7).
- `info.halt_epoch` is the newest halt epoch the runtime knows, and comes from `hello` after a restart.
- `info.halt_epoch` is the newest halt epoch the runtime knows, and comes from `hello` after a restart.
- `info.turn_epoch` is the epoch of the halt that caused this stop.
- `info.turn_epoch` is the epoch of the halt that caused this stop.
- - An ordinary stop (`halt`, `link_lost`, `shutdown`, `timeout`, `deadline`, `heartbeat`) only says "stop now". It
+ - An ordinary stop (`halt`, `link_lost`, `link_restart`, `shutdown`, `timeout`, `deadline`, `heartbeat`) only says "stop now". It
latches nothing in the core or the runtime, so an adapter may revoke remote motion without touching a newer local
latches nothing in the core or the runtime, so an adapter may revoke remote motion without touching a newer local
owner's command.
owner's command.
- `on_resume` is information. Its default does nothing, and the runtime re-arms nothing, so P7 is not re-armed and
- `on_resume` is information. Its default does nothing, and the runtime re-arms nothing, so P7 is not re-armed and
@@ -245,7 +245,7 @@ never a toggle. Bot mints one with an original 300 ms lifetime and a `deadline_m
@@ -245,7 +245,7 @@ never a toggle. Bot mints one with an original 300 ms lifetime and a `deadline_m
stamps `provenance` on the frame: `session` (the hello's id), `seq` and `issued_at_ms` (this frame's envelope seq and
stamps `provenance` on the frame: `session` (the hello's id), `seq` and `issued_at_ms` (this frame's envelope seq and
ts), `expires_at_ms` (the original 300 ms expiry) and `clock_uncertainty_ms` (Bot's last measured link round trip).
ts), `expires_at_ms` (the original 300 ms expiry) and `clock_uncertainty_ms` (Bot's last measured link round trip).
The Link checks provenance against the frame, nacks `bad_value` when they disagree or the lifetime/deadline are not
The Link checks provenance against the frame, nacks `bad_value` when they disagree or the lifetime/deadline are not
-Bot's, nacks `expired` when less than 200 ms of the original 300 ms lifetime is left on receipt (or the deadline
+Bot's, nacks `expired` when less than 50 ms of the original 300 ms lifetime is left on receipt (200 before 0.62.0) (or the deadline
already passed), and only then routes it to the driver. The driver gets `deadline_ms` ≤ 250,
already passed), and only then routes it to the driver. The driver gets `deadline_ms` ≤ 250,
`expires_at_mono_ns` at the original expiry on the shared QPC/monotonic clock, the frame's `operator` and
`expires_at_mono_ns` at the original expiry on the shared QPC/monotonic clock, the frame's `operator` and
`turn_epoch`, and Bot's `provenance` with the Link's own measured `clock_uncertainty_ms`. A headlight is never motion:
`turn_epoch`, and Bot's `provenance` with the Link's own measured `clock_uncertainty_ms`. A headlight is never motion:
@@ -182,13 +182,14 @@ var ErrQuit = errors.New("quit by its owner")
@@ -182,13 +182,14 @@ var ErrQuit = errors.New("quit by its owner")
const InputGuard = 2 * time.Second
const InputGuard = 2 * time.Second
// Headlights (Maticus's 2026-11-11 handoff, docs/hardware-looi.md): Bot mints a headlight with an original 300 ms
// Headlights (Maticus's 2026-11-11 handoff, docs/hardware-looi.md): Bot mints a headlight with an original 300 ms
-// lifetime and a deadline of at most 250 ms; by the time it reaches this machine at least 200 ms of that original
-// lifetime must be left, so the driver never gets a command that was already most of the way through its life.
+// lifetime and a deadline of at most 250 ms; by the time it reaches this machine at least 50 ms of that original
+// lifetime must be left (0.62.0; it was 200 until Maticus, 11-12: P7's tray accepts a headlight with 50 ms or more, and
+// the stricter floor here refused ones the tray would have taken).
// At most two are accepted in any rolling second, shared by every source (Bot and the owner's local controls).
// At most two are accepted in any rolling second, shared by every source (Bot and the owner's local controls).
const (
const (
HeadlightLifetimeMS = 300
HeadlightLifetimeMS = 300
HeadlightDeadlineMS = 250
HeadlightDeadlineMS = 250
- HeadlightReserveMS = 200
+ HeadlightReserveMS = 50
HeadlightPerSecond = 2
HeadlightPerSecond = 2
)
)
@@ -441,8 +442,15 @@ func (n *Agent) Connected() {
@@ -441,8 +442,15 @@ func (n *Agent) Connected() {
}
}
func (n *Agent) Disconnected(err error) {
func (n *Agent) Disconnected(err error) {
- n.log.Warn("link lost: stopping every actuator", "err", err)
- n.mgr.StopAll(plugins.StopLinkLost)
+ // Bot closing with 1012 is a planned restart (a deploy): the stop says so, so a driver can stop its motors without
+ // latching a stop of its own that the owner would then have to clear by hand (Maticus, 11-12).
+ if errors.Is(err, link.ErrServerRestart) {
+ n.log.Warn("link closed for a server restart: stopping every actuator", "err", err)
+ n.mgr.StopAll(plugins.StopLinkRestart)
+ } else {
+ n.log.Warn("link lost: stopping every actuator", "err", err)
+ n.mgr.StopAll(plugins.StopLinkLost)
+ }
n.mu.Lock()
n.mu.Lock()
n.configured = false
n.configured = false
n.mu.Unlock()
n.mu.Unlock()
@@ -884,7 +892,7 @@ func (n *Agent) command(c protocol.Command, sentMS int64, seq uint64, received m
@@ -884,7 +892,7 @@ func (n *Agent) command(c protocol.Command, sentMS int64, seq uint64, received m
// headlight passes one Bot-minted headlight to its driver. It is not a motion kind: it never renews a drive or
// headlight passes one Bot-minted headlight to its driver. It is not a motion kind: it never renews a drive or
// head deadline. Bot's provenance is checked against the frame (session, seq, ts) and the original lifetime and
// head deadline. Bot's provenance is checked against the frame (session, seq, ts) and the original lifetime and
-// deadline, at least 200 ms of the original 300 ms lifetime must be left when the frame is read, the shared
+// deadline, at least 50 ms of the original 300 ms lifetime must be left when the frame is read, the shared
// 2 requests/second budget is spent, and the plugin gets deadline_ms ≤ 250 with expires_at_mono_ns at the
// 2 requests/second budget is spent, and the plugin gets deadline_ms ≤ 250 with expires_at_mono_ns at the
// original expiry on the shared clock. Its ack's data (stage headlight_pending/headlight_refused) is relayed to Bot.
// original expiry on the shared clock. Its ack's data (stage headlight_pending/headlight_refused) is relayed to Bot.
func (n *Agent) headlight(c protocol.Command, entry *safety.Entry, sentMS int64, seq uint64, received mono.Instant, limits safety.Limits, nack func(code, msg string)) {
func (n *Agent) headlight(c protocol.Command, entry *safety.Entry, sentMS int64, seq uint64, received mono.Instant, limits safety.Limits, nack func(code, msg string)) {
@@ -26,6 +26,7 @@ import (
@@ -26,6 +26,7 @@ import (
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
"github.com/OpenVibers/OpenVibe.Link/internal/safety"
"github.com/OpenVibers/OpenVibe.Link/internal/safety"
"github.com/OpenVibers/OpenVibe.Link/internal/video"
"github.com/OpenVibers/OpenVibe.Link/internal/video"
+ "github.com/gorilla/websocket"
)
)
func init() {
func init() {
@@ -541,7 +542,7 @@ func TestVideoAndTelemetry(t *testing.T) {
@@ -541,7 +542,7 @@ func TestVideoAndTelemetry(t *testing.T) {
// ── LOOI headlights (Maticus, 2026-11-11) ────────────────────────────────────────────────────────────────────────
// ── LOOI headlights (Maticus, 2026-11-11) ────────────────────────────────────────────────────────────────────────
// Bot mints a headlight with an original 300 ms lifetime and a ≤250 ms deadline; Link passes it to the driver as
// Bot mints a headlight with an original 300 ms lifetime and a ≤250 ms deadline; Link passes it to the driver as
// the note's envelope (deadline_ms ≤ 250, expires_at_mono_ns on the shared clock, Bot's provenance, operator and
// the note's envelope (deadline_ms ≤ 250, expires_at_mono_ns on the shared clock, Bot's provenance, operator and
-// turn_epoch), holds 200 ms of the original lifetime on receipt, shares two requests a second across sources, and
+// turn_epoch), holds 50 ms of the original lifetime on receipt, shares two requests a second across sources, and
// relays the driver's stage=headlight_pending/headlight_refused ack data back to Bot.
// relays the driver's stage=headlight_pending/headlight_refused ack data back to Bot.
func headlightPlugin(t *testing.T) (*env, string) {
func headlightPlugin(t *testing.T) (*env, string) {
@@ -710,12 +711,25 @@ func TestHeadlightReserveOnASlowLink(t *testing.T) {
@@ -710,12 +711,25 @@ func TestHeadlightReserveOnASlowLink(t *testing.T) {
func TestHeadlightReserveAndRefusals(t *testing.T) {
func TestHeadlightReserveAndRefusals(t *testing.T) {
e, _ := headlightPlugin(t)
e, _ := headlightPlugin(t)
- // A frame that spent 150 ms on the way has only 150 ms of its original 300 ms lifetime left: never sent.
+ // 0.62.0: 50 ms of the original 300 ms is enough (P7's tray takes 50 or more). A frame that spent 150 ms on the way
+ // still has 150 ms left: sent. One that spent 260 ms is past its 250 ms deadline (40 ms of lifetime left): never sent.
+ // With the deadline at most 250 ms into a 300 ms life, a command inside its deadline always has over 50 ms left.
+ ok := protocol.Command{ID: "hl-150", Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":true}`), Provenance: &protocol.CommandProvenance{}}
+ if err := e.conn.CommandLate(ok, HeadlightDeadlineMS, 150*time.Millisecond); err != nil {
+ t.Fatal(err)
+ }
+ r, err := e.conn.Reply("hl-150", 5*time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d := ackData(t, r); d["admitted"] != true {
+ t.Fatalf("a headlight with 150 ms left was refused: %#v", r)
+ }
cmd := protocol.Command{ID: "hl-late", Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":false}`), Provenance: &protocol.CommandProvenance{}}
cmd := protocol.Command{ID: "hl-late", Kind: protocol.KindHeadlight, Value: json.RawMessage(`{"on":false}`), Provenance: &protocol.CommandProvenance{}}
- if err := e.conn.CommandLate(cmd, HeadlightDeadlineMS, 150*time.Millisecond); err != nil {
+ if err := e.conn.CommandLate(cmd, HeadlightDeadlineMS, 260*time.Millisecond); err != nil {
t.Fatal(err)
t.Fatal(err)
}
}
- r, err := e.conn.Reply("hl-late", 5*time.Second)
+ r, err = e.conn.Reply("hl-late", 5*time.Second)
if err != nil {
if err != nil {
t.Fatal(err)
t.Fatal(err)
}
}
@@ -761,6 +775,27 @@ func TestHeadlightReserveAndRefusals(t *testing.T) {
@@ -761,6 +775,27 @@ func TestHeadlightReserveAndRefusals(t *testing.T) {
}
}
}
}
+// A deploy of Bot closes the link with 1012: every plugin is stopped with cause link_restart, so a driver can stop its
+// motors without latching a stop of its own (Maticus, 11-12). Any other end is link_lost.
+func TestServerRestartStopsWithLinkRestart(t *testing.T) {
+ e, record := headlightPlugin(t)
+ e.conn.CloseWith(websocket.CloseServiceRestart, "restarting")
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ b, _ := os.ReadFile(record + ".stops")
+ if strings.Contains(string(b), "link_restart") {
+ if strings.Contains(string(b), "link_lost") {
+ t.Fatalf("a restart also stopped as link_lost: %q", b)
+ }
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("no link_restart stop: %q", b)
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+}
+
func TestHeadlightTwoPerSecond(t *testing.T) {
func TestHeadlightTwoPerSecond(t *testing.T) {
e, _ := headlightPlugin(t)
e, _ := headlightPlugin(t)
for i := 0; i < HeadlightPerSecond; i++ {
for i := 0; i < HeadlightPerSecond; i++ {
@@ -33,6 +33,11 @@ for raw in sys.stdin:
@@ -33,6 +33,11 @@ for raw in sys.stdin:
send({"op": "ready"})
send({"op": "ready"})
send({"op": "telemetry", "sensors": {"p7_headlight": {
send({"op": "telemetry", "sensors": {"p7_headlight": {
"commanded": None, "pending": False, "physical_state_verified": False}}})
"commanded": None, "pending": False, "physical_state_verified": False}}})
+ elif op == "stop":
+ # Why the core stopped it, beside the record (record.stops): link_lost, link_restart, shutdown...
+ if record:
+ with open(record + ".stops", "a") as f:
+ f.write((msg.get("cause") or "") + "\n")
elif op == "command":
elif op == "command":
on = (msg.get("value") or {}).get("on")
on = (msg.get("value") or {}).get("on")
note({"id": msg.get("id"), "kind": msg.get("kind"), "value": msg.get("value"),
note({"id": msg.get("id"), "kind": msg.get("kind"), "value": msg.get("value"),
@@ -6,7 +6,9 @@
@@ -6,7 +6,9 @@
// the server's hello arrives: OpenVibe.Bot authenticates after the upgrade and answers anything sent earlier with
// the server's hello arrives: OpenVibe.Bot authenticates after the upgrade and answers anything sent earlier with
// error bot.not_paired.
// error bot.not_paired.
//
//
-// Close codes: 4000 (another connection with this credential replaced this one) reconnects with the normal backoff;
+// Close codes: 1012 (Bot is restarting, a deploy) is ErrServerRestart, so the stop it causes says so; every close is
+// logged with its code and reason, and a connection that ended with no close frame says that instead.
+// 4000 (another connection with this credential replaced this one) reconnects with the normal backoff;
// 4002 (credential refused) and 4003 (revoked) keep retrying, never sooner than CredentialRetryMin, and log what to
// 4002 (credential refused) and 4003 (revoked) keep retrying, never sooner than CredentialRetryMin, and log what to
// do: pair again, or import the owner's rotation. Bot's error frames are logged, never fatal.
// do: pair again, or import the owner's rotation. Bot's error frames are logged, never fatal.
//
//
@@ -330,6 +332,10 @@ func (l *Link) backoff(err error, attempt int) time.Duration {
@@ -330,6 +332,10 @@ func (l *Link) backoff(err error, attempt int) time.Duration {
return l.opt.BackoffMin/2 + jitter
return l.opt.BackoffMin/2 + jitter
}
}
+// ErrServerRestart is the end of a session Bot closed with 1012 (service restart: a deploy). The link reconnects with
+// its normal backoff; the agent stops every actuator with cause link_restart instead of link_lost.
+var ErrServerRestart = errors.New("the server closed the link to restart (1012); reconnecting")
+
var (
var (
errUnauthorized = errors.New("the server refused the device credential (wrong, rotated or revoked): pair again with `openvibe-link pair <CODE>`, or, if the owner rotated it, pipe the rotate response into `openvibe-link credential import`")
errUnauthorized = errors.New("the server refused the device credential (wrong, rotated or revoked): pair again with `openvibe-link pair <CODE>`, or, if the owner rotated it, pipe the rotate response into `openvibe-link credential import`")
errRevoked = errors.New("the owner revoked this device or rotated its credential: pair again with `openvibe-link pair <CODE>`, or, if the owner rotated it, pipe the rotate response into `openvibe-link credential import`")
errRevoked = errors.New("the owner revoked this device or rotated its credential: pair again with `openvibe-link pair <CODE>`, or, if the owner rotated it, pipe the rotate response into `openvibe-link credential import`")
@@ -347,6 +353,8 @@ func closeErr(err error) error {
@@ -347,6 +353,8 @@ func closeErr(err error) error {
return nil
return nil
}
}
switch ce.Code {
switch ce.Code {
+ case websocket.CloseServiceRestart:
+ return ErrServerRestart
case protocol.CloseReplaced:
case protocol.CloseReplaced:
return errReplaced
return errReplaced
case protocol.CloseInvalid:
case protocol.CloseInvalid:
@@ -492,6 +500,14 @@ func (l *Link) session(ctx context.Context) error {
@@ -492,6 +500,14 @@ func (l *Link) session(ctx context.Context) error {
_, data, err := conn.ReadMessage()
_, data, err := conn.ReadMessage()
if err != nil {
if err != nil {
if sessCtx.Err() == nil {
if sessCtx.Err() == nil {
+ // What ended it, on stderr (Maticus, 11-12): the server's close code and reason, or that no close
+ // frame came (the connection dropped: a network cut, or a server that exited before closing).
+ var wc *websocket.CloseError
+ if errors.As(err, &wc) {
+ l.log.Warn("the server closed the link", "close_code", wc.Code, "close_reason", scrub(wc.Text, cred))
+ } else {
+ l.log.Warn("the link dropped with no close frame", "err", scrub(err.Error(), cred))
+ }
if ce := closeErr(err); ce != nil {
if ce := closeErr(err); ce != nil {
end(ce)
end(ce)
} else {
} else {
@@ -518,6 +518,31 @@ func TestCredentialBackoffFloor(t *testing.T) {
@@ -518,6 +518,31 @@ func TestCredentialBackoffFloor(t *testing.T) {
}
}
}
}
+// Bot closing with 1012 (a deploy) ends the session as ErrServerRestart, and the log names the close code and reason;
+// a connection that ends with no close frame says so (Maticus, 11-12: "print the close code").
+func TestServerRestartCloseIsLogged(t *testing.T) {
+ srv, _, rec, logs, _ := setup(t, time.Second)
+ conn, err := srv.NextConn(3 * time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ waitFrame(t, rec, protocol.TypeConfig)
+ conn.CloseWith(websocket.CloseServiceRestart, "restarting for a deploy")
+ if got := <-rec.gotDown; !errors.Is(got, ErrServerRestart) {
+ t.Fatalf("1012 ended the session with %v", got)
+ }
+ deadline := time.Now().Add(3 * time.Second)
+ for !strings.Contains(logs.String(), "close_code=1012") || !strings.Contains(logs.String(), "restarting for a deploy") {
+ if time.Now().After(deadline) {
+ t.Fatalf("the close was not logged: %s", logs.String())
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ if closeErr(&websocket.CloseError{Code: websocket.CloseServiceRestart}) != ErrServerRestart {
+ t.Fatal("1012 does not map to ErrServerRestart")
+ }
+}
+
func waitLastError(t *testing.T, l *Link, want string) {
func waitLastError(t *testing.T, l *Link, want string) {
t.Helper()
t.Helper()
deadline := time.Now().Add(3 * time.Second)
deadline := time.Now().Add(3 * time.Second)
@@ -80,7 +80,7 @@ func (m *Manager) Get(name string) *Plugin {
@@ -80,7 +80,7 @@ func (m *Manager) Get(name string) *Plugin {
return nil
return nil
}
}
-// StopAll tells every plugin to stop every actuator, saying why (StopLinkLost, StopShutdown).
+// StopAll tells every plugin to stop every actuator, saying why (StopLinkLost, StopLinkRestart, StopShutdown).
func (m *Manager) StopAll(cause string) {
func (m *Manager) StopAll(cause string) {
for _, p := range m.plugins {
for _, p := range m.plugins {
p.Stop(cause)
p.Stop(cause)
@@ -300,12 +300,15 @@ func (p *Plugin) enqueue(l line) error {
@@ -300,12 +300,15 @@ func (p *Plugin) enqueue(l line) error {
const (
const (
StopHalt = "halt" // Bot's halt: the controlling turn ended (release, handoff, timeout, unbind)
StopHalt = "halt" // Bot's halt: the controlling turn ended (release, handoff, timeout, unbind)
StopLinkLost = "link_lost" // the link to Bot was lost
StopLinkLost = "link_lost" // the link to Bot was lost
- StopShutdown = "shutdown" // the Link is stopping (service stop, `openvibe-link quit`)
- StopTimeout = "timeout" // a command got no answer from the driver within its window
- EstopRemote = "remote" // an stop from the platform (a robot owner or staff)
- EstopLocal = "local" // `openvibe-link stop` on the device
- EstopDriver = "driver" // a driver's own local stop (a plugin reported one)
- EstopLatched = "latched" // the stop was already latched when the Link or this process started
+ // StopLinkRestart: Bot closed the link to restart (close 1012, a deploy). It reconnects within seconds; a driver
+ // stops its motors as for link_lost and need not latch anything of its own (0.62.0).
+ StopLinkRestart = "link_restart"
+ StopShutdown = "shutdown" // the Link is stopping (service stop, `openvibe-link quit`)
+ StopTimeout = "timeout" // a command got no answer from the driver within its window
+ EstopRemote = "remote" // an stop from the platform (a robot owner or staff)
+ EstopLocal = "local" // `openvibe-link stop` on the device
+ EstopDriver = "driver" // a driver's own local stop (a plugin reported one)
+ EstopLatched = "latched" // the stop was already latched when the Link or this process started
)
)
// Stop tells the plugin to stop every actuator, saying why.
// Stop tells the plugin to stop every actuator, saying why.
@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
"RUNTIME_CAUSES", "SHARED_CLOCK", "SHARED_CLOCK_NAME", "TIMING_UNAVAILABLE", "__version__"]
"RUNTIME_CAUSES", "SHARED_CLOCK", "SHARED_CLOCK_NAME", "TIMING_UNAVAILABLE", "__version__"]
# The Node release this runtime ships with (pyproject.toml carries the same).
# The Node release this runtime ships with (pyproject.toml carries the same).
-__version__ = "0.36.0"
+__version__ = "0.37.0"
PROTOCOL_VERSION = 1
PROTOCOL_VERSION = 1
HEARTBEAT_TIMEOUT_S = 1.0
HEARTBEAT_TIMEOUT_S = 1.0
DEFAULT_DEADLINE_MS = 300
DEFAULT_DEADLINE_MS = 300
@@ -175,7 +175,7 @@ def _provenance(p):
@@ -175,7 +175,7 @@ def _provenance(p):
# Why a driver is stopped (StopInfo.cause). From the core: an ordinary stop revokes remote motion and latches nothing;
# Why a driver is stopped (StopInfo.cause). From the core: an ordinary stop revokes remote motion and latches nothing;
# an emergency latches the platform's stop until its owner clears it.
# an emergency latches the platform's stop until its owner clears it.
-STOP_CAUSES = ("halt", "link_lost", "shutdown", "timeout")
+STOP_CAUSES = ("halt", "link_lost", "link_restart", "shutdown", "timeout")
EMERGENCY_CAUSES = ("remote", "local", "driver", "latched")
EMERGENCY_CAUSES = ("remote", "local", "driver", "latched")
# From the runtime itself: a motion command's own deadline, the core going quiet, the handler failing, stdin closing.
# From the runtime itself: a motion command's own deadline, the core going quiet, the handler failing, stdin closing.
RUNTIME_CAUSES = ("setup", "deadline", "heartbeat", "handler_error", "eof")
RUNTIME_CAUSES = ("setup", "deadline", "heartbeat", "handler_error", "eof")
@@ -186,7 +186,8 @@ class StopInfo:
@@ -186,7 +186,8 @@ class StopInfo:
is stopped").
is stopped").
cause, from the core: "halt" (Bot ended the controlling turn: a release, a handoff to the next viewer, a timeout or
cause, from the core: "halt" (Bot ended the controlling turn: a release, a handoff to the next viewer, a timeout or
- an unbind; turn_epoch is that halt's epoch), "link_lost" (the link to Bot dropped), "shutdown" (the Node is
+ an unbind; turn_epoch is that halt's epoch), "link_lost" (the link to Bot dropped), "link_restart" (Bot closed the
+ link to restart, a deploy: back within seconds; Link 0.62.0), "shutdown" (the Node is
stopping), "timeout" (a command got no answer within its window). From the runtime: "setup" (the stop every setup
stopping), "timeout" (a command got no answer within its window). From the runtime: "setup" (the stop every setup
ends with, so all actuators start stopped), "deadline" (a motion command's
ends with, so all actuators start stopped), "deadline" (a motion command's
own deadline passed; motion_only is True: stop what moves on its own), "heartbeat" (the core went quiet),
own deadline passed; motion_only is True: stop what moves on its own), "heartbeat" (the core went quiet),
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
[project]
name = "openvibe-plugin"
name = "openvibe-plugin"
-version = "0.36.0"
+version = "0.37.0"
description = "Runtime for OpenVibe Link plugins (JSON lines over stdin/stdout, deadlines, heartbeat, stop)"
description = "Runtime for OpenVibe Link plugins (JSON lines over stdin/stdout, deadlines, heartbeat, stop)"
requires-python = ">=3.9"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
license = { text = "AGPL-3.0-or-later" }
@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
def test_the_package_reports_the_node_release(self):
def test_the_package_reports_the_node_release(self):
- self.assertEqual(openvibe_plugin.__version__, "0.36.0")
+ self.assertEqual(openvibe_plugin.__version__, "0.37.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())