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)

OP OpenVibers committed 17 h ago · 7bd30a0ed4 · 14 files, +128 −30

A deploy of Bot closes every link with 1012. The Link stopped the plugins with link_lost, which P7's tray latches, so every Bot deploy meant Clear STOP, ARM and Go Live by hand (Maticus, 11-12). A 1012 close is now ErrServerRestart and the stop's cause is link_restart: stop the motors, latch nothing. Every close is logged with close_code and close_reason, or "the link dropped with no close frame". The headlight floor is 50 ms of the 300 ms lifetime (was 200), the floor P7's tray uses; inside a 250 ms deadline that is always met, so the deadline decides. Plugin SDK 0.37.0 lists link_restart. Tests: a 1012 close logs its code and maps to ErrServerRestart; the agent stops a plugin with link_restart; a headlight 150 ms late is admitted, 260 ms late is expired.

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 1150e17..9d2ffa7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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,
ddiff --git a/docs/hardware-looi.md b/docs/hardware-looi.md
iindex 8938d44..3a7d562 100644
---- a/docs/hardware-looi.md
++++ b/docs/hardware-looi.md
@@@ -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.
- At most two are accepted per rolling second on the machine, shared with the owner's local control; Link/plugin
must not retry automatically.
ddiff --git a/docs/plugins.md b/docs/plugins.md
iindex e9976e0..360bea4 100644
---- a/docs/plugins.md
++++ b/docs/plugins.md
@@@ -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 cha
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
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:
@@@ -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". It
latches nothing in the core or the runtime, so an adapter may revoke remote motion without touching a newer local
owner's command.
- `on_resume` is information. Its default does nothing, and the runtime re-arms nothing, so P7 is not re-armed and
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 9b5c3d7..9216788 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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
ts), `expires_at_ms` (the original 300 ms expiry) and `clock_uncertainty_ms` (Bot's last measured link round trip).
The Link checks provenance against the frame, nacks `bad_value` when they disagree or the lifetime/deadline are not
--Bot's, nacks `expired` when less than 200 ms of the original 300 ms lifetime is left on receipt (or the deadline
++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,
`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:
ddiff --git a/internal/agent/agent.go b/internal/agent/agent.go
iindex 782c0f7..4b811b9 100644
---- a/internal/agent/agent.go
++++ b/internal/agent/agent.go
@@@ -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 = 300
HeadlightDeadlineMS = 250
-- HeadlightReserveMS = 200
++ HeadlightReserveMS = 50
HeadlightPerSecond = 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 = false
n.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)) {
ddiff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
iindex 0d21402..76ad392 100644
---- a/internal/agent/agent_test.go
++++ b/internal/agent/agent_test.go
@@@ -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++ {
ddiff --git a/internal/agent/testdata/headlight_plugin.py b/internal/agent/testdata/headlight_plugin.py
iindex ed8ae6b..7d7e8d3 100755
---- a/internal/agent/testdata/headlight_plugin.py
++++ b/internal/agent/testdata/headlight_plugin.py
@@@ -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"),
ddiff --git a/internal/link/link.go b/internal/link/link.go
iindex c9cb38b..1ede5e1 100644
---- a/internal/link/link.go
++++ b/internal/link/link.go
@@@ -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 ErrServerRestart
case protocol.CloseReplaced:
return errReplaced
case 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 {
ddiff --git a/internal/link/link_test.go b/internal/link/link_test.go
iindex 42dd150..e194ba1 100644
---- a/internal/link/link_test.go
++++ b/internal/link/link_test.go
@@@ -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)
ddiff --git a/internal/plugins/manager.go b/internal/plugins/manager.go
iindex 3dd574a..cf9070c 100644
---- a/internal/plugins/manager.go
++++ b/internal/plugins/manager.go
@@@ -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)
ddiff --git a/internal/plugins/plugin.go b/internal/plugins/plugin.go
iindex d476879..eefd106 100644
---- a/internal/plugins/plugin.go
++++ b/internal/plugins/plugin.go
@@@ -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.
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex a192e9e..1f9a686 100644
---- a/plugins/sdk/openvibe_plugin/__init__.py
++++ b/plugins/sdk/openvibe_plugin/__init__.py
@@@ -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 = 1
HEARTBEAT_TIMEOUT_S = 1.0
DEFAULT_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 is
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
own deadline passed; motion_only is True: stop what moves on its own), "heartbeat" (the core went quiet),
ddiff --git a/plugins/sdk/pyproject.toml b/plugins/sdk/pyproject.toml
iindex e0f9ab8..286aed7 100644
---- a/plugins/sdk/pyproject.toml
++++ b/plugins/sdk/pyproject.toml
@@@ -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" }
ddiff --git a/plugins/sdk/tests/test_runtime.py b/plugins/sdk/tests/test_runtime.py
iindex 86908a4..cbfdf9b 100644
---- a/plugins/sdk/tests/test_runtime.py
++++ b/plugins/sdk/tests/test_runtime.py
@@@ -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())