Regulate the cabin by blower speed from a stored heater setpoint #91

Merged
rob merged 10 commits from feat/heater-cabin-control into feat/heater-blower-output 2026-10-10 14:23:37 +00:00
Collaborator

The Flow 5D has no thermostat, so the node now regulates the cabin by blower speed. CMD_HEATER setpoints are stored (key setpoint, 0.1 °C, 50 to 300, default 200) and reported in STATE_HEATER. Each Si7021 reading runs a pure PI controller (blower_control.h) whose level goes to the blower motor with origin automation. This replaces the edge logic from #90.

Priority: heater link lost gives full speed, heater blower off gives motor off and a reset, cabin unknown or fault gives full speed, otherwise PI. Kp, Ki and the minimum level are untuned starting values and need the bench. A manual command holds for the ten second lockout, then the next reading overrides it.

Stacks on #89 and #90.

Tested: pio test -e native (494 tests) and pio run -e node_heater. Not tested on hardware.

Tickets: CCS-WHP-13, CCS-WHP-14. The Home Assistant side is CCS-HA-56.

The Flow 5D has no thermostat, so the node now regulates the cabin by blower speed. `CMD_HEATER` setpoints are stored (key `setpoint`, 0.1 °C, 50 to 300, default 200) and reported in `STATE_HEATER`. Each Si7021 reading runs a pure PI controller (`blower_control.h`) whose level goes to the blower motor with origin automation. This replaces the edge logic from #90. Priority: heater link lost gives full speed, heater blower off gives motor off and a reset, cabin unknown or fault gives full speed, otherwise PI. Kp, Ki and the minimum level are untuned starting values and need the bench. A manual command holds for the ten second lockout, then the next reading overrides it. Stacks on #89 and #90. Tested: `pio test -e native` (494 tests) and `pio run -e node_heater`. Not tested on hardware. Tickets: CCS-WHP-13, CCS-WHP-14. The Home Assistant side is CCS-HA-56.
Add Si7021 cabin temperature and humidity to the heater node
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
08b6a5b24e
Fix humidity in the semantic class table and pin its value
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
c1ea6c75b1
Default the humidity reading to the uint16 unknown sentinel
All checks were successful
Build and test / build (pull_request) Successful in 1m38s
Build and test / release (pull_request) Has been skipped
3d3357a592
Drive the heater blower motor from the MOSFET on D2
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
ebd38a0524
Retry the blower edge when the lockout refuses it
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
341e4a40ca
Skip the blower edge while the motor is locked out
All checks were successful
Build and test / build (pull_request) Successful in 1m35s
Build and test / release (pull_request) Has been skipped
f7c56f6bee
Regulate the cabin by blower speed from a stored heater setpoint
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
fbac6d591b
Claude left a comment

Two small gaps; nothing blocking.

Two small gaps; nothing blocking.
@ -92,0 +117,4 @@
const float secondsSinceReading = (now - lastCabinReadingMs) / 1000.0f;
lastCabinSequence = cabinSensor.sequence();
lastCabinReadingMs = now;
const uint16_t level = blowerControl.update(heater.linkLost(), heater.blowerOn(), cabinSensor.temperature(), heater.setpoint(), secondsSinceReading);
Author
Collaborator

request()/setOn(false) now run every reading, even when the level has not changed. Each goes through noteChange(), so the fan likely re-broadcasts state every ~10 s, including while idle at 0. Only call when the level differs from the last one applied (the old code guarded on change). Not verified against noteChange().

request()/setOn(false) now run every reading, even when the level has not changed. Each goes through noteChange(), so the fan likely re-broadcasts state every ~10 s, including while idle at 0. Only call when the level differs from the last one applied (the old code guarded on change). Not verified against noteChange().
@ -253,0 +258,4 @@
uint16_t stored = 0;
TEST_ASSERT_TRUE(rig.store.load("setpoint", stored));
TEST_ASSERT_EQUAL_UINT16(220, stored);
}
Author
Collaborator

Only the happy path is tested. Missing: clamp to 50/300, a sentinel setpoint leaving the stored value alone, and begin() reloading a stored value (and rejecting an out-of-range one). Each is a branch added in this commit that nothing would catch if broken.

Only the happy path is tested. Missing: clamp to 50/300, a sentinel setpoint leaving the stored value alone, and begin() reloading a stored value (and rejecting an out-of-range one). Each is a branch added in this commit that nothing would catch if broken.
Skip the idle blower write and test the setpoint edge cases
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
17e3b63e65
Claude left a comment

One finding, in src/node_heater/main.cpp loop(): BlowerControl::update runs only when cabinSensor.sequence() changes, and the Si7021 reads every 10 s (conversionIntervalMs). So link loss (full speed), blowerOn going true or false, and a setpoint change all take up to 10 s to reach the motor. The old code reacted on the same loop pass. The link-loss path is the one that matters, because it is the safety default. Fix: call update() every loop. Keep dtSeconds tied to elapsed time since the last call, or advance the integral only when the sequence changes.

One finding, in src/node_heater/main.cpp loop(): BlowerControl::update runs only when cabinSensor.sequence() changes, and the Si7021 reads every 10 s (conversionIntervalMs). So link loss (full speed), blowerOn going true or false, and a setpoint change all take up to 10 s to reach the motor. The old code reacted on the same loop pass. The link-loss path is the one that matters, because it is the safety default. Fix: call update() every loop. Keep dtSeconds tied to elapsed time since the last call, or advance the integral only when the sequence changes.
Run the blower controller every pass and integrate per reading
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
eca188558c
Claude left a comment

Three findings on eca1885.

Three findings on eca1885.
@ -92,0 +116,4 @@
const uint32_t now = ctrl::nowMs();
const bool newReading = cabinSensor.sequence() != lastCabinSequence;
const float secondsSinceReading = newReading ? (now - lastCabinReadingMs) / 1000.0f : 0.0f;
if (newReading) {
Author
Collaborator

lastCabinReadingMs starts at 0, so the first reading integrates dt = uptime (all the time since boot, not one interval). Seed it from now on the first reading, or skip integration for it.

lastCabinReadingMs starts at 0, so the first reading integrates dt = uptime (all the time since boot, not one interval). Seed it from now on the first reading, or skip integration for it.
@ -92,0 +122,4 @@
}
const uint16_t level = blowerControl.update(heater.linkLost(), heater.blowerOn(), cabinSensor.temperature(), heater.setpoint(), secondsSinceReading);
if ((newReading || level != appliedBlowerLevel) && !blowerMotor.lockedOut(now)) {
appliedBlowerLevel = level;
Author
Collaborator

Link loss (full blower, a heater-protection action) is sent as Origin::Automation and gated by !lockedOut. A manual blower change in the last 10 s therefore delays the full-speed response until the lockout ends. Origin::Failsafe overrides the lockout (origin.h), so send it as Failsafe when heater.linkLost(). Not new in this commit, but the new guard makes it explicit.

Link loss (full blower, a heater-protection action) is sent as Origin::Automation and gated by !lockedOut. A manual blower change in the last 10 s therefore delays the full-speed response until the lockout ends. Origin::Failsafe overrides the lockout (origin.h), so send it as Failsafe when heater.linkLost(). Not new in this commit, but the new guard makes it explicit.
@ -0,0 +35,4 @@
ctrl::BlowerControl control;
TEST_ASSERT_EQUAL_UINT16(0, control.update(false, true, cabinAt(200), setpoint, readingIntervalSeconds));
TEST_ASSERT_EQUAL_UINT16(1000, control.update(true, true, cabinAt(200), setpoint, 0.0f));
}
Author
Collaborator

This test passes on the old code too: linkLost returns maxLevel before dt is read. It does not cover the change (dt 0 freezes the integral; main.cpp only passes dt on a new reading). Replace it with one that proves the freeze, e.g. a reading with dt>0 that builds an integral, then update(..., 0.0f) twice and assert the level is unchanged. The main.cpp gating (write only on new reading or changed level) is untested.

This test passes on the old code too: linkLost returns maxLevel before dt is read. It does not cover the change (dt 0 freezes the integral; main.cpp only passes dt on a new reading). Replace it with one that proves the freeze, e.g. a reading with dt>0 that builds an integral, then update(..., 0.0f) twice and assert the level is unchanged. The main.cpp gating (write only on new reading or changed level) is untested.
Pin the blower level holding between readings
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
083f0fb5f6
Apply the link-loss blower demand with failsafe origin
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
7d9de0af46
Claude left a comment

No findings on 7d9de0a.

No findings on 7d9de0a.
Note the Si7021 wiring in the heater node
All checks were successful
Build and test / build (pull_request) Successful in 1m56s
Build and test / release (pull_request) Has been skipped
1f46064102
rob changed target branch from main to feat/heater-blower-output 2026-10-10 13:21:04 +00:00
Poll the Si7021 every 100 ms and report cabin readings on change
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
079194a00d
Test the status change on its own in the readout tests
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
8596e44854
Limit blower writes to once a second and re-assert every 10 s
All checks were successful
Build and test / build (pull_request) Successful in 1m32s
Build and test / release (pull_request) Has been skipped
0ca99e367c
Merge pull request 'Poll the Si7021 every 100 ms and report cabin readings on change' (#92) from feat/heater-sensor-rate into feat/heater-cabin-control
All checks were successful
Build and test / build (pull_request) Successful in 2m32s
Build and test / release (pull_request) Has been skipped
1909942941
Reviewed-on: #92
rob merged commit db713f3874 into feat/heater-blower-output 2026-10-10 14:23:37 +00:00
rob deleted branch feat/heater-cabin-control 2026-10-10 14:23:37 +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!91
No description provided.