Add a measure bind that feeds a heater's measured temperature #138

Merged
Claude merged 9 commits from feat/yn-22-measure-bind into feat/yaml-nodes 2026-10-17 19:53:22 +00:00
Collaborator

BindAction::Measure hands each sensor reading from a source temperature endpoint to a heater: SourceEvent gains a scalar form decoded from the sensor state frame, a MeasureStrategy passes it to the new Endpoint::onMeasure, and HeaterEndpoint reports it as its measured value when built with a null measured function (the function form stays for the hand-written node). When the source node goes silent for three heartbeats, the bind reports an unknown reading, which keeps the blower's full-speed fallback. The generator only emits a Measure bind on a flow5d; the runtime does not police the target type beyond picking the strategy.

strategyFor switches on the target's endpoint type, with the Measure choice inside the shared on/off case and default returning null. A Mode bind has no target (it is sourced from the input it sits on), so one guard before the switch handles the null pointer. Source decoding loops over a named array of one small decoder per frame kind.

MCP2515 filter: the class selection moves into class_filter.h, which is host-testable. When the required classes plus a node's interests need more than four slots (Event and Sensor together), the mask drops the low class bit so each slot admits a pair. Extra frames are ignored as before; RXB0 is untouched. TWAI and SocketCAN are unaffected. Spec 9.5 and section 12 each get a short addition.

YAML: a flow5d takes one action: measure bind from a temperature endpoint, local or node: remote. It is rejected on any other endpoint, from any other source type (including a temperature setting), twice on one heater, and with disabled_modes or timing fields. A remote measure source adds Sensor to the interests. The heater's measured argument is nullptr with a measure bind and its device is heaterDemand.spaceHeating() with water heating; both compose and a test pins that. Schema, README and the heater fixture are updated.

Tested: native 647, Python 140; pio run and the heater fixture build pass.

YN-22

`BindAction::Measure` hands each sensor reading from a source temperature endpoint to a heater: `SourceEvent` gains a scalar form decoded from the sensor state frame, a `MeasureStrategy` passes it to the new `Endpoint::onMeasure`, and `HeaterEndpoint` reports it as its measured value when built with a null measured function (the function form stays for the hand-written node). When the source node goes silent for three heartbeats, the bind reports an unknown reading, which keeps the blower's full-speed fallback. The generator only emits a Measure bind on a flow5d; the runtime does not police the target type beyond picking the strategy. `strategyFor` switches on the target's endpoint type, with the Measure choice inside the shared on/off case and `default` returning null. A Mode bind has no target (it is sourced from the input it sits on), so one guard before the switch handles the null pointer. Source decoding loops over a named array of one small decoder per frame kind. MCP2515 filter: the class selection moves into `class_filter.h`, which is host-testable. When the required classes plus a node's interests need more than four slots (Event and Sensor together), the mask drops the low class bit so each slot admits a pair. Extra frames are ignored as before; RXB0 is untouched. TWAI and SocketCAN are unaffected. Spec 9.5 and section 12 each get a short addition. YAML: a `flow5d` takes one `action: measure` bind from a `temperature` endpoint, local or `node:` remote. It is rejected on any other endpoint, from any other source type (including a temperature setting), twice on one heater, and with `disabled_modes` or timing fields. A remote measure source adds `Sensor` to the interests. The heater's measured argument is `nullptr` with a measure bind and its device is `heaterDemand.spaceHeating()` with water heating; both compose and a test pins that. Schema, README and the heater fixture are updated. Tested: native 647, Python 140; `pio run` and the heater fixture build pass. YN-22
Describe the relaxed class mask in the filter section
Some checks failed
Build and test / build (pull_request) Has been cancelled
Build and test / release (pull_request) Has been cancelled
1680d790c1
Claude left a comment

Two small gaps, no blockers. The RXB0 invariant is untouched. The relaxed mask admits System, Alarm, Query, Event and Sensor, and no slot ends up at zero by accident. The in-place dedupe is safe, because the write index never passes the read index.

  1. class_filter.h: if folding pairs still leaves more than four classes, the slot loop silently drops the later wanted ones. For example, Command, Event, Sensor and Bulk added to the required three fold to pairs 0, 2, 4, 6 and 8, which is five. Nothing today asks for that, but nothing stops it either. Add an assert, or a test that pins the behaviour.
  2. Missing tests for the cross-trigger guard in Binds::onFrame (event.scalar != (bind.action == BindAction::Measure)) and for Measure on a non-heater target returning false from add. Reverting the guard, or the heater check in strategyFor, fails no test.

Note: Measure does not check the reading's unit or type. A mis-pointed endpoint index would feed any scalar in as a temperature. The YAML side should validate this.

Two small gaps, no blockers. The RXB0 invariant is untouched. The relaxed mask admits System, Alarm, Query, Event and Sensor, and no slot ends up at zero by accident. The in-place dedupe is safe, because the write index never passes the read index. 1. class_filter.h: if folding pairs still leaves more than four classes, the slot loop silently drops the later wanted ones. For example, Command, Event, Sensor and Bulk added to the required three fold to pairs 0, 2, 4, 6 and 8, which is five. Nothing today asks for that, but nothing stops it either. Add an assert, or a test that pins the behaviour. 2. Missing tests for the cross-trigger guard in `Binds::onFrame` (`event.scalar != (bind.action == BindAction::Measure)`) and for Measure on a non-heater target returning false from `add`. Reverting the guard, or the heater check in `strategyFor`, fails no test. Note: Measure does not check the reading's unit or type. A mis-pointed endpoint index would feed any scalar in as a temperature. The YAML side should validate this.
Pin the scalar guard and the measure target check in tests
All checks were successful
Build and test / build (pull_request) Successful in 5m22s
Build and test / release (pull_request) Has been skipped
29f586b34e
Claude changed target branch from feat/yaml-nodes to feat/yaml-flow5d 2026-10-17 09:15:40 +00:00
rob force-pushed feat/yn-22-measure-bind from 29f586b34e
All checks were successful
Build and test / build (pull_request) Successful in 5m22s
Build and test / release (pull_request) Has been skipped
to c7b626e385
Some checks failed
Build and test / build (pull_request) Has been cancelled
Build and test / release (pull_request) Has been cancelled
2026-10-17 09:15:42 +00:00
Compare
Claude left a comment

Nothing blocking. Three small things.

  • scripts/node_schema.py:39: the schema description still says binds resolve to "a switch, touch or occupancy endpoint". A measure bind resolves to a temperature. Fix it and regenerate nodes/node.schema.json.
  • nodes/README.md, Binds section: the action list has no measure, and "Outputs take toggle and follow binds only" (line ~207) is now wrong for a flow5d. Add a bullet there, or fix the sentence.
  • scripts/yaml_node.py, resolve_bind: "Measure" if bind["action"] == measureAction else bindActions[...] appears twice. Compute action once above the source is None branch and use it in both places. parse_binds also tests raw.get("action") == measureAction three times, so hoist it into a local. The error "is not toggle or follow" is now incomplete.
Nothing blocking. Three small things. - scripts/node_schema.py:39: the schema description still says binds resolve to "a switch, touch or occupancy endpoint". A measure bind resolves to a temperature. Fix it and regenerate nodes/node.schema.json. - nodes/README.md, Binds section: the action list has no `measure`, and "Outputs take `toggle` and `follow` binds only" (line ~207) is now wrong for a flow5d. Add a bullet there, or fix the sentence. - scripts/yaml_node.py, resolve_bind: `"Measure" if bind["action"] == measureAction else bindActions[...]` appears twice. Compute `action` once above the `source is None` branch and use it in both places. parse_binds also tests `raw.get("action") == measureAction` three times, so hoist it into a local. The error "is not toggle or follow" is now incomplete.
Tidy the measure bind action handling and docs
All checks were successful
Build and test / build (pull_request) Successful in 4m25s
Build and test / release (pull_request) Has been skipped
48ed902032
Claude changed target branch from feat/yaml-flow5d to feat/yaml-water 2026-10-17 09:40:49 +00:00
rob force-pushed feat/yn-22-measure-bind from 48ed902032
All checks were successful
Build and test / build (pull_request) Successful in 4m25s
Build and test / release (pull_request) Has been skipped
to e935f6ab63
All checks were successful
Build and test / build (pull_request) Successful in 5m27s
Build and test / release (pull_request) Has been skipped
2026-10-17 09:40:50 +00:00
Compare
rob requested changes 2026-10-17 16:22:25 +00:00
Dismissed
@ -25,1 +25,4 @@
event.on = occupancy.occupied != 0;
return event;
}
if (can::SensorScalar::decode(frame, event.reading)) {
Owner

Surely the containing method now should be named decodeSourceEvent at this point?

I'd also like to see the if blocks broken out in to methods if this method is going to be expanded upon

Surely the containing method now should be named `decodeSourceEvent` at this point? I'd also like to see the if blocks broken out in to methods if this method is going to be expanded upon
@ -118,2 +132,3 @@
const BindStrategy* strategyFor(can::EndpointType type) {
const BindStrategy* strategyFor(const Bind& bind) {
if (bind.action == BindAction::Mode) return &modeStrategy;
Owner

To keep it neat, put this in the default case of the switch below

To keep it neat, put this in the `default` case of the `switch` below
@ -120,0 +133,4 @@
const BindStrategy* strategyFor(const Bind& bind) {
if (bind.action == BindAction::Mode) return &modeStrategy;
const can::EndpointType type = bind.target->descriptor().type;
if (bind.action == BindAction::Measure) return type == can::EndpointType::Heater ? &measureStrategy : nullptr;
Owner

Turn this around so that the type check is in the switch with a body that contains the action check which returns measureStrategy

Turn this around so that the type check is in the switch with a body that contains the action check which returns `measureStrategy`
Split the bind source decoding and switch strategyFor on the type
All checks were successful
Build and test / build (pull_request) Successful in 2m40s
Build and test / release (pull_request) Has been skipped
a9896a9d2c
Author
Collaborator

Review of a9896a9: clean. No findings.

Review of a9896a9: clean. No findings.
rob force-pushed feat/yn-22-measure-bind from a9896a9d2c
All checks were successful
Build and test / build (pull_request) Successful in 2m40s
Build and test / release (pull_request) Has been skipped
to 543f07d054
All checks were successful
Build and test / build (pull_request) Successful in 5m25s
Build and test / release (pull_request) Has been skipped
2026-10-17 17:08:50 +00:00
Compare
Claude left a comment

Clean. Nothing to act on.

Clean. Nothing to act on.
rob requested changes 2026-10-17 19:35:30 +00:00
Dismissed
@ -133,2 +145,3 @@
constexpr can::EndpointType noEndpointType = static_cast<can::EndpointType>(0);
const BindStrategy* strategyFor(const Bind& bind) {
if (bind.action == BindAction::Mode) return &modeStrategy;
Owner

I was thinking something like this:

switch (bind.target->descriptor().type) {
	case can::EndpointType::Dimmer:
		return &dimmerStrategy;
	case can::EndpointType::Switch:
	case can::EndpointType::Fan:
	case can::EndpointType::ColourLight:
	case can::EndpointType::Heater:
		return bind.action == BindAction::Measure ? &measureStrategy : &onOffStrategy;
	default:
		return nullptr;
		return bind.action == BindAction::Mode ? &modeStrategy : nullptr;
}

Why has it become so complex?

I was thinking something like this: ```cpp switch (bind.target->descriptor().type) { case can::EndpointType::Dimmer: return &dimmerStrategy; case can::EndpointType::Switch: case can::EndpointType::Fan: case can::EndpointType::ColourLight: case can::EndpointType::Heater: return bind.action == BindAction::Measure ? &measureStrategy : &onOffStrategy; default: return nullptr; return bind.action == BindAction::Mode ? &modeStrategy : nullptr; } ``` Why has it become so complex?
@ -33,0 +39,4 @@
}
SourceEvent decodeSourceEvent(const can::Frame& frame) {
SourceEvent event = binaryEvent(frame);
Owner

Turn this list of ifs in to a loop

Turn this list of `if`s in to a loop
Loop over the source decoders and flatten strategyFor
All checks were successful
Build and test / build (pull_request) Successful in 2m14s
Build and test / release (pull_request) Has been skipped
f1e3703b80
Claude left a comment

One minor point, no blocker. strategyFor now accepts any action on any non-null target, so Add no longer rejects a Mode bind with a target, or a Measure bind on a Dimmer. The OnOff/Dimmer onSource has no case for them, so they do nothing. But onSourceLost still calls target->onFailsafe, so a lost source would fail-safe the target. Only a hand-written bind can hit this, since the generator emits Measure on a flow5d alone. Accepted by design, so I'm only flagging it.

One minor point, no blocker. strategyFor now accepts any action on any non-null target, so Add no longer rejects a Mode bind with a target, or a Measure bind on a Dimmer. The OnOff/Dimmer onSource has no case for them, so they do nothing. But onSourceLost still calls target->onFailsafe, so a lost source would fail-safe the target. Only a hand-written bind can hit this, since the generator emits Measure on a flow5d alone. Accepted by design, so I'm only flagging it.
rob requested changes 2026-10-17 19:44:09 +00:00
Dismissed
@ -43,3 +42,1 @@
if (!event.valid) event = occupancyEvent(frame);
if (!event.valid) event = scalarEvent(frame);
return event;
for (SourceEvent (*decode)(const can::Frame&) : {binaryEvent, occupancyEvent, scalarEvent}) {
Owner

Move this collection to the class level

Move this collection to the class level
@ -156,3 +154,2 @@
case can::EndpointType::Heater:
if (bind.action == BindAction::Measure) return &measureStrategy;
return levels ? &onOffStrategy : nullptr;
return bind.action == BindAction::Measure ? static_cast<const BindStrategy*>(&measureStrategy) : &onOffStrategy;
Owner

Do we need the cast here?

Do we need the cast here?
Author
Collaborator

On the Measure ternary: the cast was only there because the two branches have different pointer types, so I have replaced the ternary with two statements and the cast is gone (also: the decoder array is now a named constant beside the strategy instances). Not pushed yet, waiting for the rebase onto feat/yaml-nodes.

On the Measure ternary: the cast was only there because the two branches have different pointer types, so I have replaced the ternary with two statements and the cast is gone (also: the decoder array is now a named constant beside the strategy instances). Not pushed yet, waiting for the rebase onto feat/yaml-nodes.
rob force-pushed feat/yn-22-measure-bind from f1e3703b80
All checks were successful
Build and test / build (pull_request) Successful in 2m14s
Build and test / release (pull_request) Has been skipped
to a5b0dbf190
All checks were successful
Build and test / build (pull_request) Successful in 2m43s
Build and test / release (pull_request) Has been skipped
2026-10-17 19:47:27 +00:00
Compare
Claude changed target branch from feat/yaml-water to feat/yaml-nodes 2026-10-17 19:47:34 +00:00
Claude left a comment

Clean. Nothing to act on.

Clean. Nothing to act on.
rob approved these changes 2026-10-17 19:52:58 +00:00
Claude merged commit ea3832feee into feat/yaml-nodes 2026-10-17 19:53:22 +00:00
Sign in to join this conversation.
No reviewers
rob
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
CamperCan/CampervanControlSystems!138
No description provided.