openvibers/openvibe.link Commits

Node: a plugin's ready after a fault ends the fault (#36)

OP OpenVibers committed 11 Oct 2026 · 55c21cb1cb · 3 files, +26 −1

A plugin that reports `fault` (its robot unreachable, a Bluetooth link down) and later `ready` again (it came back) kept its fault in the Node's status. The commands flowed again, but status (and Bot's panel) kept reporting the stale fault. Sending `ready` now ends the fault. `docs/plugins.md` says so.

Found while answering the Generic LOOI driver's authors, whose driver will report a dropped Bluetooth link as `not_connected` and `ready` when it's back.

Test: `TestReadyAfterFaultClearsIt` (a fault, then ready: no fault, and a command reaches the plugin). `go test ./internal/plugins ./internal/node` passes.

Changes

ddiff --git a/docs/plugins.md b/docs/plugins.md
iindex 04cf0fe..432a6b6 100644
---- a/docs/plugins.md
++++ b/docs/plugins.md
@@@ -63,7 +63,7 @@ describes itself and waits for EOF without opening any hardware.
|-------------|---------------------------------------------------------------------------------------------|
| `describe` | `driver`, `version`, `protocol` (1), `capabilities` {…}, `motion_kinds` [kinds] |
| `ready` | — setup finished, every actuator stopped |
--| `fault` | `fault_code`, `message` — the plugin runs but cannot drive (commands get this code as nack) |
++| `fault` | `fault_code`, `message` — the plugin runs but cannot drive (commands get this code as nack); `ready` again when it can (the robot came back) ends the fault |
| `ack` | `id` |
| `nack` | `id`, `fault_code`, `message` |
| `telemetry` | `battery` {`volts`, `percent`}, `rssi`, `sensors` {…}, `faults` […] |
ddiff --git a/internal/plugins/plugin.go b/internal/plugins/plugin.go
iindex d3802f5..802d352 100644
---- a/internal/plugins/plugin.go
++++ b/internal/plugins/plugin.go
@@@ -494,6 +494,10 @@ func (p *Plugin) readStdout(r io.Reader, described chan struct{}) {
}
p.log.Info("plugin described", "driver", d.Driver, "version", d.Version)
case "ready":
++ // Ready again after a fault (the robot came back): the fault is over, so status stops reporting it.
++ p.mu.Lock()
++ p.fault = nil
++ p.mu.Unlock()
p.setState(StateReady)
case "ack", "nack":
id, _ := m["id"].(string)
ddiff --git a/internal/plugins/plugins_test.go b/internal/plugins/plugins_test.go
iindex 49ef506..cf12562 100644
---- a/internal/plugins/plugins_test.go
++++ b/internal/plugins/plugins_test.go
@@@ -62,6 +62,9 @@ func fakePlugin(logPath string, in io.Reader, out io.Writer) {
w.Encode(map[string]any{"op": "fault", "fault_code": "firmware_unsupported", "message": "v1 not v2"})
continue
}
++ if mode == "recover" {
++ w.Encode(map[string]any{"op": "fault", "fault_code": "not_connected", "message": "robot unreachable"})
++ }
w.Encode(map[string]any{"op": "ready"})
w.Encode(map[string]any{"op": "event", "name": "booted"})
if mode == "local_stop" {
@@@ -192,6 +195,24 @@ func TestHelloDescribeCommand(t *testing.T) {
}
}
++// A plugin that faults (the robot unreachable) and then reports ready again (it came back) carries no fault: status
++// stops reporting it and commands reach the plugin again.
++func TestReadyAfterFaultClearsIt(t *testing.T) {
++ fx := start(t, "recover")
++ fx.waitState(StateReady)
++ p := fx.m.Get("fake")
++ deadline := time.Now().Add(5 * time.Second)
++ for p.Info().Fault != nil && time.Now().Before(deadline) {
++ time.Sleep(5 * time.Millisecond)
++ }
++ if f := p.Info().Fault; f != nil || p.Info().State != StateReady {
++ t.Fatalf("after ready: state %s, fault %+v", p.Info().State, f)
++ }
++ if r := p.Command(context.Background(), "c1", "drive", json.RawMessage(`{"throttle":0.2}`), 300); !r.OK {
++ t.Fatalf("a command after recovery: %+v", r)
++ }
++}
++
func TestLocalStopIsForwarded(t *testing.T) {
fx := start(t, "local_stop")
fx.waitState(StateReady)