Skip to content

Fix: Recalculate PID error when thermostat turns off#172

Open
DeRRudi77 wants to merge 2 commits intoAlexwijn:developfrom
DeRRudi77:fix/recalculate-error-on-thermostat-off
Open

Fix: Recalculate PID error when thermostat turns off#172
DeRRudi77 wants to merge 2 commits intoAlexwijn:developfrom
DeRRudi77:fix/recalculate-error-on-thermostat-off

Conversation

@DeRRudi77
Copy link
Copy Markdown

@DeRRudi77 DeRRudi77 commented Apr 17, 2026

Problem

When using SAT together with Better Thermostat (or similar climate entities), turning off the thermostat does not trigger a recalculation of the PID error value. The error stays stale until the next temperature setpoint change, causing SAT to keep heating based on outdated error values.

This affects all three ways a thermostat can be configured in SAT:

  • As a connected thermostat (CONF_THERMOSTAT)
  • As a main climate / radiator (CONF_RADIATORS)
  • As a secondary climate / room (CONF_ROOMS)

Root Cause

Three code paths were not properly handling HVAC mode changes to OFF:

  1. _async_thermostat_changed — When the connected thermostat changed state (e.g. to OFF) without changing the target temperature, the handler called async_set_target_temperature() which early-returned because the temperature hadn't changed. PID was never recalculated.

  2. _async_main_climate_changed — When a main climate/radiator changed state, the handler only called schedule_control_heating_loop() but never _async_control_pid(). PID was never recalculated.

  3. Area.state — When a room climate turned OFF, _async_climate_changed correctly called _async_control_pid(True), but Area.state did not filter HVACMode.OFF — only STATE_UNKNOWN and STATE_UNAVAILABLE. So the OFF room still contributed its error value to max_error, making the recalculation ineffective.

Fix

  • area.py: Filter HVACMode.OFF in Area.state so that OFF climates return None for state, target_temperature, current_temperature, error, and weight — fully excluding them from all calculations.
  • climate.py: In _async_thermostat_changed, separate state changes from temperature changes. On state change, call _async_control_pid(True) directly. On temperature change, call async_set_target_temperature as before.
  • climate.py: In _async_main_climate_changed, add _async_control_pid(True) when the main climate state changes.

Tests

  • Added tests/test_area.py with 13 unit tests covering Area.state, error, weight, and temperature properties for OFF, HEAT, UNKNOWN, and UNAVAILABLE states.
  • Added 2 integration tests in tests/test_climate.py verifying PID recalculation when the connected thermostat or radiator turns OFF.

Summary by CodeRabbit

  • Bug Fixes

    • Climate state-change handling now separates mode transitions from temperature-only updates to ensure correct control-loop behavior
    • Area state filtering treats devices in OFF/unavailable/unknown modes as unavailable
  • Tests

    • Added tests covering area state/filtering, temperature properties, error/weight behavior, and PID recalculation on climate mode changes

When a connected thermostat or radiator changed to OFF, the PID error
value stayed stale until the next temperature change. Three fixes:

- Filter HVACMode.OFF in Area.state so OFF rooms are excluded from
  error, weight, and heating curve calculations
- Trigger _async_control_pid(True) in _async_thermostat_changed on
  state changes (separate from temperature changes)
- Trigger _async_control_pid(True) in _async_main_climate_changed on
  state changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a6f5137b-38e0-48ba-9ed1-1ba06c30bb61

📥 Commits

Reviewing files that changed from the base of the PR and between 9305703 and b9de9cd.

📒 Files selected for processing (1)
  • tests/test_climate.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_climate.py

Walkthrough

Area treats climate HVACMode.OFF as unavailable; thermostat/radiator change handlers now separate HVAC-mode changes from temperature-only updates, calling _async_control_pid(True) and scheduling the control loop only on HVAC-mode transitions; new tests validate Area behavior and PID-triggering logic.

Changes

Cohort / File(s) Summary
Area logic
custom_components/sat/area.py
Area.state now treats HVACMode.OFF like STATE_UNKNOWN/STATE_UNAVAILABLE, returning None for those cases.
Climate change handling
custom_components/sat/climate.py
Refactored _async_thermostat_changed and _async_main_climate_changed to: trigger PID reset (_async_control_pid(True)) and schedule control loop only on HVAC-mode changes; handle temperature-only changes by calling async_set_target_temperature(..., cascade=False) without resetting PID.
Tests
tests/test_area.py, tests/test_climate.py
Added tests: test_area.py covers Area.state/error/weight/temperatures for OFF vs HEAT modes; test_climate.py adds async tests asserting _async_control_pid(True) is called on HVAC-mode transitions for thermostat and radiator entities.

Sequence Diagram(s)

sequenceDiagram
  participant HA as HomeAssistant
  participant Sat as SatClimate
  participant PID as PIDController

  HA->>Sat: state change (new_state)
  alt HVAC-mode changed
    Sat->>Sat: _async_control_pid(True)
    Sat->>Sat: schedule_control_heating_loop()
  else Temperature-only changed
    Sat->>Sat: async_set_target_temperature(..., cascade=False)
  end
  Sat->>PID: control loop runs (scheduled)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • Bugfix release #84: Changes to thermostat handling and use of cascade=False in async_set_target_temperature that this PR builds upon.
  • More bugfixes #86: Related modifications to climate and area state-change handling impacting similar code paths.

Suggested reviewers

  • Alexwijn
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: fixing PID error recalculation when a thermostat turns off, which is the core issue addressed across the modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/test_area.py (1)

22-30: Mock returns the same state for any entity_id lookup.

hass.states.get.return_value = state returns the same State object regardless of which entity is requested. This is fine for the current tests since Area.current_temperature's SENSOR_TEMPERATURE_ID override path isn't exercised, but if a future test adds a sensor_temperature_id attribute, the sensor lookup at area.py Line 58 will return the climate state instead of a sensor state, producing misleading results. Consider using side_effect keyed by entity_id for forward-compatibility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_area.py` around lines 22 - 30, The mock currently sets
hass.states.get.return_value = state so every entity_id returns the same State;
change mock_hass_with_climate_state to set hass.states.get.side_effect that
checks the incoming entity_id and returns the created State only for the climate
entity_id (and None or a sensible default for others) so future lookups (e.g.
Area.current_temperature's SENSOR_TEMPERATURE_ID path) will not receive the
climate State; locate mock_hass_with_climate_state and replace the return_value
usage on hass.states.get with a side_effect closure or function that keys off
the entity_id.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@custom_components/sat/climate.py`:
- Around line 613-620: Detect and handle the edge cases around temperature-only
updates and duplicate PID resets: introduce a local flag (e.g.,
pid_reset_performed) in the state change handler so when the state branch calls
self._async_control_pid(True) and schedule_control_heating_loop() you set
pid_reset_performed=True; in the temperature branch, if the new temperature
equals self._target_temperature (so async_set_target_temperature would
early-return) explicitly call self._async_control_pid(True) and
self.schedule_control_heating_loop() only if pid_reset_performed is False
(otherwise skip to avoid a duplicate reset), and if the temperature differs,
call await self.async_set_target_temperature(...) as before. This references the
symbols async_set_target_temperature, _async_control_pid,
schedule_control_heating_loop, and self._target_temperature so you can locate
and update the logic.

In `@tests/test_climate.py`:
- Line 212: The current assertion using "assert climate.pid.last_error !=
initial_error or climate.pid.last_error == climate.error" is weak and can pass
trivially; change the test to directly assert that the PID reset handler ran
after the state change by patching or spying on climate._async_control_pid and
asserting it was called with reset=True (or alternatively assert a deterministic
side-effect such as pwm.reset() being called or climate._calculated_setpoint is
None). Locate references to climate.pid.last_error, initial_error, climate.error
and replace the weak check with an explicit mock/assert on _async_control_pid
(or the chosen side-effect) to ensure the handler invocation is tested reliably.

---

Nitpick comments:
In `@tests/test_area.py`:
- Around line 22-30: The mock currently sets hass.states.get.return_value =
state so every entity_id returns the same State; change
mock_hass_with_climate_state to set hass.states.get.side_effect that checks the
incoming entity_id and returns the created State only for the climate entity_id
(and None or a sensible default for others) so future lookups (e.g.
Area.current_temperature's SENSOR_TEMPERATURE_ID path) will not receive the
climate State; locate mock_hass_with_climate_state and replace the return_value
usage on hass.states.get with a side_effect closure or function that keys off
the entity_id.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 247526f5-d100-4c07-8b91-f1a335dc12b0

📥 Commits

Reviewing files that changed from the base of the PR and between 7d43560 and 9305703.

📒 Files selected for processing (4)
  • custom_components/sat/area.py
  • custom_components/sat/climate.py
  • tests/test_area.py
  • tests/test_climate.py

Comment thread custom_components/sat/climate.py
Comment thread tests/test_climate.py Outdated
Use patch.object spy instead of weak error comparison to
directly verify _async_control_pid(True) is called on state change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant