Add endpoint binds and a switch input to the node runtime #118

Merged
Claude merged 5 commits from feat/endpoint-binds into feat/yaml-nodes 2026-10-15 14:49:12 +00:00
Collaborator

Runtime half of the YAML node generator (YN-1). A ctrl::Bind ties an output endpoint to a source (node, endpoint index) with an action: Toggle, ToggleOnChange (latching switch) or Follow (OR across follow sources). Binds are edge-triggered, so a command holds until the next source edge. A silent source node (3 missed heartbeats) triggers the target's own failsafe. Local binds loop back through Node::send, so they work with the bus lost. Also adds SwitchInput (active low, pull-up, 50 ms debounce), an interests pass-through on UpdateStack::begin, and spec section 9.5.

Decisions: latching is not a flag on SwitchInput. The source emits plain Pressed/Released either way, so the generator picks ToggleOnChange for a latching source. Endpoint gains a virtual isOn() so a bind can toggle any target.

Tested: new test_binds and test_switch_input in the native env (mutating the edge check, the loopback and the staleness check each fails a test); full native suite and pio run pass. No existing node edited.

Runtime half of the YAML node generator (YN-1). A `ctrl::Bind` ties an output endpoint to a source (node, endpoint index) with an action: `Toggle`, `ToggleOnChange` (latching switch) or `Follow` (OR across follow sources). Binds are edge-triggered, so a command holds until the next source edge. A silent source node (3 missed heartbeats) triggers the target's own failsafe. Local binds loop back through `Node::send`, so they work with the bus lost. Also adds `SwitchInput` (active low, pull-up, 50 ms debounce), an `interests` pass-through on `UpdateStack::begin`, and spec section 9.5. Decisions: `latching` is not a flag on `SwitchInput`. The source emits plain Pressed/Released either way, so the generator picks `ToggleOnChange` for a latching source. `Endpoint` gains a virtual `isOn()` so a bind can toggle any target. Tested: new `test_binds` and `test_switch_input` in the native env (mutating the edge check, the loopback and the staleness check each fails a test); full native suite and `pio run` pass. No existing node edited.
Add endpoint binds and a switch input to the node runtime
All checks were successful
Build and test / build (pull_request) Successful in 1m51s
Build and test / release (pull_request) Has been skipped
edc0623f55
Claude left a comment

Three findings.

Three findings.
@ -0,0 +37,4 @@
bind.lastHeardMs = now;
if (!isLevel || bind.sourceEndpoint != endpoint || bind.sourceOn == on) continue;
bind.sourceOn = on;
Author
Collaborator

Spurious toggle after a source returns. sourceOn starts false and is reset to false on source silence (loop). So the first frame from a source that is already pressed/occupied (boot, or reconnect after a silent period) counts as an edge. Toggle fires on a PIR already occupied; ToggleOnChange flips on a latching switch that never moved. Suggest: first frame after heard == false only records sourceOn, with no action (Follow can still apply it). Add a test for it.

Spurious toggle after a source returns. `sourceOn` starts false and is reset to false on source silence (loop). So the first frame from a source that is already pressed/occupied (boot, or reconnect after a silent period) counts as an edge. Toggle fires on a PIR already occupied; ToggleOnChange flips on a latching switch that never moved. Suggest: first frame after `heard == false` only records `sourceOn`, with no action (Follow can still apply it). Add a test for it.
@ -0,0 +64,4 @@
void Binds::loop(Node& node, uint32_t now) {
for (uint8_t index = 0; index < count_; ++index) {
Bind& bind = *binds_[index];
Author
Collaborator

Failsafe only fires for a source that was heard at least once. A remote source that never appears leaves the target with no failsafe. If that is by design, say so in 9.5; otherwise start lastHeardMs from bind time.

Failsafe only fires for a source that was heard at least once. A remote source that never appears leaves the target with no failsafe. If that is by design, say so in 9.5; otherwise start `lastHeardMs` from bind time.
@ -28,6 +29,8 @@ constexpr uint32_t heartbeatMs = 1000;
constexpr uint8_t missedHeartbeatsBeforeAbsent = 3;
// Staggers descriptor emission by node id so a fleet powering up together, or answering one broadcast query, doesn't burst.
inline bool seenRecently(uint32_t lastSeen, uint32_t now) { return (now - lastSeen) < (heartbeatMs * missedHeartbeatsBeforeAbsent); }
Author
Collaborator

seenRecently was inserted between the stagger comment and descriptorStaggerMs, so the comment now describes the wrong function. Move seenRecently above the comment.

`seenRecently` was inserted between the stagger comment and `descriptorStaggerMs`, so the comment now describes the wrong function. Move `seenRecently` above the comment.
Author
Collaborator

Findings 1 and 3 fixed in the follow-up commit (first contact no longer toggles, with a test; comment moved). Finding 2 left as is: a source never heard leaves the target at its boot failsafe, which is already the failsafe position, so there is nothing to apply.

Findings 1 and 3 fixed in the follow-up commit (first contact no longer toggles, with a test; comment moved). Finding 2 left as is: a source never heard leaves the target at its boot failsafe, which is already the failsafe position, so there is nothing to apply.
Ignore a source already on at first contact for toggle binds
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
f21e5f9a33
Claude left a comment

First-contact guard is defeated by any non-level frame from the source. Fix inline.

First-contact guard is defeated by any non-level frame from the source. Fix inline.
@ -0,0 +33,4 @@
for (uint8_t index = 0; index < count_; ++index) {
Bind& bind = *binds_[index];
if (bind.sourceNode != source) continue;
const bool firstContact = !bind.heard;
Author
Collaborator

firstContact is true only for the first frame of any kind from the source. A heartbeat or any other frame sets heard, so the next level report no longer counts as first. Trace: node boots, source heartbeat arrives (heard=true), then the source's repeat of EVT_OCCUPANCY occupied (spec 1126: repeated on refresh) arrives. sourceOn false -> true, firstContact false, Toggle flips the light. That is the exact bug the commit is meant to fix, and it will hit in practice because heartbeats come every 1 s. Track first level report per bind (e.g. a separate synced flag set when a matching level frame is recorded, cleared with heard in Binds::loop) and keep heard for liveness only. The new test only passes because no frame precedes the first event; the hear() calls added to the other tests are what hide this case. Add a test: heartbeat, then occupied, assert light off.

`firstContact` is true only for the first frame of any kind from the source. A heartbeat or any other frame sets `heard`, so the next level report no longer counts as first. Trace: node boots, source heartbeat arrives (heard=true), then the source's repeat of `EVT_OCCUPANCY occupied` (spec 1126: repeated on refresh) arrives. sourceOn false -> true, firstContact false, Toggle flips the light. That is the exact bug the commit is meant to fix, and it will hit in practice because heartbeats come every 1 s. Track first level report per bind (e.g. a separate `synced` flag set when a matching level frame is recorded, cleared with `heard` in `Binds::loop`) and keep `heard` for liveness only. The new test only passes because no frame precedes the first event; the `hear()` calls added to the other tests are what hide this case. Add a test: heartbeat, then occupied, assert light off.
Track bind sync from level reports, not any frame
All checks were successful
Build and test / build (pull_request) Successful in 1m39s
Build and test / release (pull_request) Has been skipped
0153b30659
Claude left a comment

No new defects in 0153b30. Only open point is the known trade-off: bind.cpp onFrame swallows a first Pressed for Toggle/ToggleOnChange when it is the first level report after contact. A switch is edge-driven, so after a consumer reboot the user's first press does nothing. If that matters, treat Pressed from a binary source as always actionable and apply the first-report skip to occupancy only.

No new defects in 0153b30. Only open point is the known trade-off: bind.cpp onFrame swallows a first Pressed for Toggle/ToggleOnChange when it is the first level report after contact. A switch is edge-driven, so after a consumer reboot the user's first press does nothing. If that matters, treat Pressed from a binary source as always actionable and apply the first-report skip to occupancy only.
Fix follow after a quiet source and a fresh first press
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
beb8ff226d
Claude left a comment

Two minor points. No blockers.

Two minor points. No blockers.
@ -0,0 +11,4 @@
if (binary.event != can::BinaryEvent::Pressed && binary.event != can::BinaryEvent::Released) return false;
endpoint = binary.endpoint;
on = binary.event == can::BinaryEvent::Pressed;
freshPress = on && binary.durationMs == 0;
Author
Collaborator

Duration 0 also appears on a re-report within the same millisecond as the press, because emitState sends now - pressedAtMs_. That is negligible in practice. It is also the only thing that stops a held press toggling at first contact, so the 'zero means fresh' rule is worth a one-line note in binary_input.cpp or the spec.

Duration 0 also appears on a re-report within the same millisecond as the press, because emitState sends now - pressedAtMs_. That is negligible in practice. It is also the only thing that stops a held press toggling at first contact, so the 'zero means fresh' rule is worth a one-line note in binary_input.cpp or the spec.
@ -0,0 +76,4 @@
bind.synced = false;
bind.sourceOn = false;
bind.target->onFailsafe(node, now);
if (bind.action == BindAction::Follow && anyFollowSourceOn(bind.target)) bind.target->setOn(true, now);
Author
Collaborator

The spec says the failsafe is not applied while another follow source is on, but the code runs onFailsafe and then forces the target back on. On a strip that can start a fade-out and then a fade-in. If both sources lapse in the same pass, the first one turns the target on and the second then fails it off. Skip onFailsafe when anyFollowSourceOn(bind.target) is true, after clearing this bind's sourceOn. That matches the spec and drops the extra setOn.

The spec says the failsafe is not applied while another follow source is on, but the code runs onFailsafe and then forces the target back on. On a strip that can start a fade-out and then a fade-in. If both sources lapse in the same pass, the first one turns the target on and the second then fails it off. Skip onFailsafe when anyFollowSourceOn(bind.target) is true, after clearing this bind's sourceOn. That matches the spec and drops the extra setOn.
Skip the failsafe while another follow source is on
All checks were successful
Build and test / build (pull_request) Successful in 1m41s
Build and test / release (pull_request) Has been skipped
d38492d531
Claude merged commit 238d1812ee into feat/yaml-nodes 2026-10-15 14:49:12 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/CampervanControlSystems!118
No description provided.