Skip to content

Refactor heating control to cycle-centric architecture#124

Closed
Alexwijn wants to merge 171 commits intodevelopfrom
feature/cycles
Closed

Refactor heating control to cycle-centric architecture#124
Alexwijn wants to merge 171 commits intodevelopfrom
feature/cycles

Conversation

@Alexwijn
Copy link
Copy Markdown
Owner

@Alexwijn Alexwijn commented Nov 23, 2025

Summary by CodeRabbit

  • New Features

    • Areas UI with per-room weights; per-room PID sensors, requested-setpoint sensor, and Maximum Setpoint number entity.
  • Improvements

    • Cycle-based diagnostics with start/end events and history; richer boiler status and persistence; dynamic regime-aware minimum-setpoint tuning; revamped PID/PWM/heating-curve control; stronger overshoot protection, stale-temperature handling, clearer availability and naming across sensors.
  • Removals

    • PWM management service, legacy flame health, relative-modulation feature, and setpoint adjuster modules.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Nov 23, 2025

Walkthrough

Per-entry SatConfig/SatEntryData and SatDataUpdateCoordinatorFactory introduced; flame-based model replaced by cycle-driven subsystems (CycleTracker/CycleHistory); persistent PID/PWM/Boiler and DynamicMinimumSetpoint added; entities and transports rewired to use entry_data; several legacy modules and PWM service removed.

Changes

Cohort / File(s) Summary
Entrypoint & entry data
\custom_components/sat/init.py`, `custom_components/sat/entry_data.py`, `custom_components/sat/util.py``
Add SatConfig/SatEntryData; refactor async_setup_entry/unload/reload to use per-entry entry_data; move Sentry lifecycle to per-entry; controller factories accept SatConfig. Review entry lifecycle and Sentry init/cleanup.
Cycle subsystem & types
\custom_components/sat/cycles.py`, `custom_components/sat/types.py`, `custom_components/sat/temperature_state.py`**
New cycle model (Cycle, CycleMetrics, CycleHistory, CycleTracker) and events EVENT_SAT_CYCLE_STARTED/ENDED; add enums/types (BoilerStatus, CycleClassification, PWMDecision, Percentiles, DeviceState). Review event payloads and enum surface.
Coordinator & control flow
\custom_components/sat/coordinator.py`, `custom_components/sat/helpers.py`, \custom_components/sat/cycles.py`
Rework to Debouncer-driven ControlLoopSample pipeline; add async_update_control, set_control_context/intent, expose device_state/cycles/last_cycle; add time/state helpers. Review debouncer behavior and public coordinator API.
Boiler & cycle logic
\custom_components/sat/boiler.py`, `tests/test_boiler.py`, \custom_components/sat/cycles.py`
Add persistent Boiler and BoilerState dataclass with lifecycle hooks and Store persistence; detailed status and modulation reliability logic. Review Store keys/versioning and migration.
Areas / area model
\custom_components/sat/area.py`, `tests/test_area.py`**
Redesign Area/Areas to use SatConfig, prebuild PID/heating_curve, room_weight, staleness checks, async_added_to_hass and periodic updates; expose errors/pids/get/ids/items. Review PID wiring and time subscriptions.
PID & persistence
\custom_components/sat/pid.py`, `tests/test_pid.py`, \custom_components/sat/util.py`
PID is storage-backed with async_added_to_hass restore/save; new properties (available,kp,ki,kd,output) and update/reset API; constructed from SatConfig. Review storage schema and dispatcher notifications.
PWM & overshoot
\custom_components/sat/pwm.py`, `custom_components/sat/overshoot_protection.py`, `tests/test_pwm.py`**
New PWMConfig/PWMState; synchronous update API with duty tracking and disable(); overshoot protection reworked to sample-driven, returns Optional[float]. Review timing constants and sampling windows.
Dynamic minimum setpoint
\custom_components/sat/minimum_setpoint.py`, `custom_components/sat/util.py`, `tests/test_overshoot_protection.py`**
New regime-based DynamicMinimumSetpoint with per-regime bucketing, tuning, async persistence. Review regime bucketing and persistence migration.
Climate & heating curve
\custom_components/sat/climate.py`, `custom_components/sat/heating_curve.py`, \tests/test_climate.py`
SatClimate rewired to use entry_data and controllers (Areas, PID, PWM, heating_curve, dynamic minimum); new control methods and state attributes. Review scheduling and new public control methods.
Sensors / entities
\custom_components/sat/sensor.py`, `custom_components/sat/binary_sensor.py`, `custom_components/sat/number.py`, `custom_components/sat/serial/`, `custom_components/sat/simulator/`, \tests/*`
Entities now constructed with SatConfig/entry_data; flame sensors renamed to cycle sensors; added SatPidSensor, SatRequestedSetpoint, SatMaximumSetpointEntity; unique_id/name semantics changed. Review unique_id and entity naming changes.
Transports & coordinators
\custom_components/sat/mqtt/`, `custom_components/sat/esphome/`, `custom_components/sat/serial/`, `custom_components/sat/fake/`, `custom_components/sat/switch/*`**
Transport/coordinator constructors now accept SatConfig; member_id and some returns annotated Optional; DeviceState moved to types. Review instantiation sites and tests for transport constructors.
Removals
\custom_components/sat/flame.py`, `custom_components/sat/relative_modulation.py`, `custom_components/sat/setpoint_adjuster.py`, `custom_components/sat/errors.py`**
Remove legacy Flame, RelativeModulation, SetpointAdjuster, Errors modules; responsibilities replaced by cycle/temperature_state/minimum_setpoint subsystems. Review callers expecting removed modules.
Config flow & translations
\custom_components/sat/config_flow.py`, `custom_components/sat/translations/*`**
Add async_step_areas for per-room weights; flow uses SatMode/HeatingSystem/HeatingMode enums; translations updated. Review config migration and UI options.
Services
\custom_components/sat/services.py` `
Remove PWM service registration; retain reset-integral service. Review external automations referencing PWM service.
Tests & deps
\tests/*.py`, `tests/const.py`, `requirements_test.txt`**
Many new/updated unit tests (PID, PWM, cycles, boiler, overshoot, area); test fixtures updated; test deps pinned; make_config helper added. Review fixtures and pinned dependencies.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Sensor as Device/Sensor
    participant Coordinator as SatDataUpdateCoordinator
    participant Tracker as CycleTracker
    participant History as CycleHistory
    participant HA as Home Assistant

    Note over Sensor,Tracker: Cycle detection & publication
    Sensor->>Coordinator: async_update_control(ControlLoopSample)
    Coordinator->>Tracker: update(ControlLoopSample)
    activate Tracker
    Tracker->>Tracker: detect ON/OFF transitions
    alt ON -> OFF
        Tracker->>Tracker: build Cycle, compute metrics (p50/p90)
        Tracker->>History: record_cycle(Cycle)
        Tracker->>HA: fire EVENT_SAT_CYCLE_ENDED (payload)
    else OFF -> ON
        Tracker->>Tracker: mark started_since, accumulate samples
        Tracker->>HA: fire EVENT_SAT_CYCLE_STARTED
    end
    deactivate Tracker
Loading
sequenceDiagram
    autonumber
    participant Climate as SatClimate
    participant Areas as Areas
    participant PID as PID
    participant HC as HeatingCurve
    participant Coordinator as SatDataUpdateCoordinator
    participant Boiler as Boiler

    Note over Climate,Boiler: Control loop compute & device update
    Climate->>Areas: areas.errors()
    Areas-->>Climate: TemperatureStates/errors
    Climate->>HC: value()
    Climate->>PID: update()
    PID-->>Climate: output (setpoint adjustment)
    Climate->>Coordinator: set_control_intent(intent)
    Coordinator->>Boiler: update(device_state, last_cycle)
    activate Boiler
    Boiler->>Boiler: determine status & modulation_reliability
    Boiler-->>Coordinator: status, modulation_reliable
    deactivate Boiler
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.65% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ 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 'Refactor heating control to cycle-centric architecture' clearly and specifically describes the main architectural change in the PR, focusing on the primary shift from a flame-centric to a cycle-centric design.
✨ Finishing touches
  • 📝 Generate docstrings

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.

@coderabbitai coderabbitai bot changed the title @coderabbitai Refactor heating control to cycle-centric architecture Nov 23, 2025
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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
custom_components/sat/coordinator.py (1)

260-268: minimum_setpoint assumes presence of CONF_MINIMUM_SETPOINT in config

minimum_setpoint currently does:

return max(float(self._config_data.get(CONF_MINIMUM_SETPOINT)), MINIMUM_SETPOINT)

If CONF_MINIMUM_SETPOINT is missing or None in config_data (e.g. due to a migrated or manually edited entry), float(None) will raise a TypeError and break the coordinator.

Safer pattern:

raw = self._config_data.get(CONF_MINIMUM_SETPOINT)
try:
    configured = float(raw) if raw is not None else None
except (TypeError, ValueError):
    configured = None

if configured is None:
    return MINIMUM_SETPOINT

return max(configured, MINIMUM_SETPOINT)

You can keep this concise, but some validation/fallback around the float() cast would make the integration more robust against bad or legacy configs.

custom_components/sat/climate.py (1)

868-944: PID control now hard-requires outside temperature; ensure that’s acceptable

async_control_pid now returns early with a warning when current_outside_temperature is None:

if self.current_outside_temperature is None:
    _LOGGER.warning("Current outside temperature is not available. Skipping PID control.")
    return

Previously, PID might have continued with a default or stale curve. This change is reasonable, but it means:

  • Misconfigured or temporarily unavailable outside sensors will completely block PID updates (and therefore setpoint recalculation).
  • async_control_heating_loop also depends on self.current_outside_temperature and self.heating_curve.value, so both loops will effectively stall until the sensor recovers.

If that’s the desired fail‑safe, this is fine; otherwise, you may want to fall back to a default heating curve or cached outside temperature rather than skipping PID entirely.

🧹 Nitpick comments (15)
custom_components/sat/cycles.py (1)

228-228: Update type hint to explicit Optional.

The timestamp parameter should use explicit Optional or T | None syntax per PEP 484.

Apply this diff:

-    def update(self, boiler_state: BoilerState, timestamp: float = None) -> None:
+    def update(self, boiler_state: BoilerState, timestamp: Optional[float] = None) -> None:
tests/test_climate.py (1)

4-61: Tests correctly exercise new async API and state shape; consider float-comparison robustness

The updated tests align with the new control flow (async_control_pid(reset=True) followed by async_control_heating_loop()) and with the new state structure (max_error.value, heating_curve.value, pwm.state.duty_cycle, pwm.state.last_duty_cycle_percentage, requested_setpoint). Using template.DOMAIN / sensor.DOMAIN in the parametrized config is also consistent with Home Assistant conventions.

Given how many assertions use exact float equality (e.g., duty‑cycle percentages and heating‑curve values), you may want to switch those to pytest.approx to make the tests resilient to harmless rounding changes in the PID/curve math; otherwise, the current expectations look coherent with the refactored API.

Also applies to: 63-111, 113-160

custom_components/sat/heating_curve.py (1)

4-4: Optional return types now correctly reflect nullable state

Switching the three properties to Optional[float] matches how the internal fields are initialized and used (reset to None until autotune/update runs). This is a straightforward typing/style improvement with no behavioral change.

If you’re tightening typing further elsewhere, you might also annotate the backing attributes (_optimal_coefficient, _coefficient_derivative, _last_heating_curve_value) as Optional[float] for consistency, but that’s purely a nicety.

Also applies to: 83-85, 87-89, 91-92

custom_components/sat/pid.py (1)

3-8: Tighten imports from .const and drop unused local constant

Ruff’s F405 warning about HEATING_SYSTEM_UNDERFLOOR stems from the wildcard import; it also makes it harder to see which constants this module actually needs. Additionally, MAX_BOILER_TEMPERATURE_AGE = 300 is no longer used now that boiler‑temperature tracking is gone.

Consider:

  • Replacing from .const import * with explicit imports for the constants used here (e.g., DEADBAND, HEATING_SYSTEM_UNDERFLOOR, and any others referenced below).
  • Removing the now‑unused MAX_BOILER_TEMPERATURE_AGE definition.

This will clear the linter warning and keep the module’s dependencies explicit.

Also applies to: 13-15, 31-46, 207-215

custom_components/sat/sensor.py (1)

13-14: Cycle and boiler sensors align with new enums; verify coordinator defaults and avoid star import

The new SatCycleSensor and the adjusted SatBoilerSensor.available logic look consistent with the cycle‑centric model:

  • SatCycleSensor.native_value returns CycleClassification.INSUFFICIENT_DATA.name when there is no last_cycle, otherwise the classification name, which matches the new enum.
  • SatBoilerSensor.available treating BoilerStatus.INSUFFICIENT_DATA as “unavailable” is reasonable as long as device_status is always a BoilerStatus member (never None).

Two follow‑ups worth considering:

  1. Coordinator defaults: Double‑check that self._coordinator.device_status is initialized to BoilerStatus.INSUFFICIENT_DATA (or another BoilerStatus member) and never None, so native_value’s device_status.name access is always safe.
  2. Imports: from .const import * is triggering Ruff F403/F405 and hides which names come from const. Prefer explicitly importing the constants and enums used in this file (DOMAIN, CLIMATE, COORDINATOR, CONF_MODE, MODE_SERIAL, MODE_SIMULATOR, CONF_MINIMUM_CONSUMPTION, CONF_MAXIMUM_CONSUMPTION, CONF_NAME, CycleClassification, BoilerStatus, etc.) to improve clarity and keep linters happy.

Also applies to: 44-50, 254-269, 271-283

custom_components/sat/minimum_setpoint.py (3)

88-105: Guard on deviation from recent base can be simplified

The value property’s extra safeguard (Lines 98–102) can be expressed more simply and readably:

if self._last_base_setpoint is not None:
    allowed_minimum = max(
        self._config.minimum_setpoint,
        self._last_base_setpoint - self._config.max_deviation_from_recent_base,
    )
    minimum_setpoint = max(minimum_setpoint, allowed_minimum)

This avoids the nested if minimum_setpoint < allowed_minimum while preserving behavior.


203-214: Unnecessary int() around round() when computing the regime bucket

round(base_setpoint / width) already returns an int in Python 3 when called without ndigits, so the outer int(...) is redundant:

-        bucket = int(round(base_setpoint / width))
+        bucket = round(base_setpoint / width)

This addresses the Ruff RUF046 hint without changing semantics.


20-64: Config / helper fields appear unused; consider pruning or wiring through

MinimumSetpointConfig.target_min_on_time_seconds and minimum_relax_factor_when_inactive, and the helper _relax_minimum_when_uncertain, do not appear to be used anywhere in this module.

Dead configuration fields and helpers make the behavior harder to reason about and invite confusion over which relax paths are actually active (the tuning logic currently only uses _relax_toward_base with minimum_relax_factor_when_untunable/minimum_relax_factor_when_uncertain).

Either:

  • Wire these into the tuning logic where they were intended to apply, or
  • Remove them to keep the configuration surface and code path minimal.

Given this is performance/maintainability, it can be deferred but is worth cleaning up.

Also applies to: 352-366

custom_components/sat/pwm.py (2)

91-101: Use explicit is None check for heating curve availability

In update you currently guard with:

if not self._heating_curve.value or requested_setpoint is None or boiler_state.flow_temperature is None:

This treats a zero heating curve value as “missing”, which is unlikely but obscures intent. A clearer and safer version would be:

if self._heating_curve.value is None or requested_setpoint is None or boiler_state.flow_temperature is None:
    ...

This avoids conflating falsy numeric values with absence and makes the precondition clearer.


239-244: Clarify units for last_duty_cycle_percentage in PWMState

PWM.state currently exposes:

return PWMState(
    duty_cycle=self._duty_cycle,
    last_duty_cycle_percentage=round(self._last_duty_cycle_percentage * 100, 2)
    if self._last_duty_cycle_percentage is not None
    else None,
)

So the exposed value is 0–100 (%) while the internal _last_duty_cycle_percentage is 0–1. This is fine, but make sure all consumers (sensors, UI, tests) expect a percentage, not a fraction. If the old API provided a fraction, this is a breaking change and should be documented accordingly.

custom_components/sat/coordinator.py (2)

13-16: Star import from .const obscures which symbols are required

from .const import * together with use of CONF_MINIMUM_SETPOINT, MINIMUM_SETPOINT, and others makes it harder to track dependencies and triggers linter warnings (Ruff F403/F405).

Consider explicitly importing only the names you need in this module (e.g., BoilerStatus, CONF_MINIMUM_SETPOINT, MINIMUM_SETPOINT, mode constants). This will:

  • Make static analysis more accurate.
  • Avoid accidental name collisions.
  • Document dependencies more clearly.

Also applies to: 262-262


330-344: Optional: document unused climate argument for async_control_heating_loop

async_control_heating_loop still takes a climate: Optional[SatClimate] = None argument but no longer uses it. If this is kept only for backwards compatibility with existing callers, a short comment explaining that it is intentionally unused would prevent future cleanups from accidentally removing a still‑relied‑upon parameter.

If compatibility is not required, you could drop the argument and update call sites instead.

custom_components/sat/boiler.py (1)

129-205: Consider splitting _derive_status / _modulation_direction to reduce complexity

Both _derive_status and _modulation_direction have many branches and early returns, which is natural for a state machine but does trip Pylint’s R0911/R0912 warnings.

If this becomes hard to maintain, you might consider:

  • Extracting small predicates for the flame‑off phases (e.g. _status_flame_off(state, previous, now)) vs flame‑on phases.
  • Moving the modulation‑based vs gradient‑based direction logic into separate helpers, or at least documenting expectations for _modulation_reliable vs unreliable cases.

Not urgent, but will make future behavior changes less risky.

Also applies to: 318-350

custom_components/sat/climate.py (2)

195-199: Attribute vs control path for minimum setpoint may diverge in static mode

In extra_state_attributes you expose:

"minimum_setpoint": self.minimum_setpoint.value,

while the control path uses:

@property
def minimum_setpoint_value(self) -> float:
    if self._dynamic_minimum_setpoint:
        return self.minimum_setpoint.value
    return self._coordinator.minimum_setpoint

So when _dynamic_minimum_setpoint is False:

  • PWM/control logic uses the coordinator’s static minimum_setpoint.
  • The attribute minimum_setpoint still reports the dynamic controller’s learned value (which you continue to update inside async_control_heating_loop).

If that divergence is intentional (e.g. surfacing experimental dynamic hints while not yet using them), it would help to document it. Otherwise, consider aligning the attribute with the actual control path:

"minimum_setpoint": self.minimum_setpoint_value,

so the UI/state always reflects the value that will actually be applied.

Also applies to: 415-431, 627-634


963-1016: Dynamic minimum setpoint update is tied to PWM, but runs regardless of config flag

Inside async_control_heating_loop:

if self.pulse_width_modulation_enabled:
    self.pwm.enable()
    self.pwm.update(...)
    self.minimum_setpoint.update(
        cycles=self._coordinator.cycles,
        boiler_state=self._coordinator.state,
        last_cycle=self._coordinator.last_cycle,
        requested_setpoint=self._calculated_setpoint,
    )
else:
    self.pwm.disable()

Notes:

  • The dynamic minimum setpoint controller is updated only when PWM is enabled, which is sensible (tuning based on cycles where PWM is actually in play).
  • However, self.minimum_setpoint.update(...) is called regardless of _dynamic_minimum_setpoint; in purely static mode the dynamic controller’s state is still being learned but never used for control, only (currently) for the minimum_setpoint attribute.

This is not a correctness bug, but if dynamic minimum setpoint is expected to be fully disabled when the option is off, you might want to guard the update as well:

if self._dynamic_minimum_setpoint:
    self.minimum_setpoint.update(...)

Otherwise, consider documenting that the dynamic learner always runs and is just not consulted for control when the option is off.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 46ced16 and 3968306.

📒 Files selected for processing (20)
  • custom_components/sat/__init__.py (3 hunks)
  • custom_components/sat/area.py (1 hunks)
  • custom_components/sat/binary_sensor.py (3 hunks)
  • custom_components/sat/boiler.py (2 hunks)
  • custom_components/sat/climate.py (22 hunks)
  • custom_components/sat/config_flow.py (1 hunks)
  • custom_components/sat/const.py (1 hunks)
  • custom_components/sat/coordinator.py (7 hunks)
  • custom_components/sat/cycles.py (1 hunks)
  • custom_components/sat/flame.py (0 hunks)
  • custom_components/sat/heating_curve.py (2 hunks)
  • custom_components/sat/minimum_setpoint.py (1 hunks)
  • custom_components/sat/mqtt/__init__.py (1 hunks)
  • custom_components/sat/pid.py (5 hunks)
  • custom_components/sat/pwm.py (8 hunks)
  • custom_components/sat/relative_modulation.py (2 hunks)
  • custom_components/sat/sensor.py (4 hunks)
  • custom_components/sat/simulator/__init__.py (2 hunks)
  • custom_components/sat/util.py (3 hunks)
  • tests/test_climate.py (8 hunks)
💤 Files with no reviewable changes (1)
  • custom_components/sat/flame.py
🧰 Additional context used
🧬 Code graph analysis (13)
custom_components/sat/__init__.py (4)
custom_components/sat/serial/sensor.py (1)
  • async_setup_entry (86-105)
tests/test_init.py (1)
  • test_setup_update_unload_entry (11-26)
custom_components/sat/simulator/sensor.py (1)
  • async_setup_entry (11-19)
custom_components/sat/number.py (1)
  • async_setup_entry (10-14)
custom_components/sat/relative_modulation.py (1)
custom_components/sat/const.py (1)
  • RelativeModulationState (222-226)
custom_components/sat/area.py (2)
custom_components/sat/heating_curve.py (2)
  • update (27-30)
  • value (91-92)
custom_components/sat/pid.py (1)
  • update (66-83)
custom_components/sat/pwm.py (3)
custom_components/sat/const.py (1)
  • PWMStatus (216-219)
custom_components/sat/heating_curve.py (3)
  • HeatingCurve (11-92)
  • update (27-30)
  • value (91-92)
custom_components/sat/boiler.py (2)
  • BoilerState (17-27)
  • update (113-127)
custom_components/sat/util.py (5)
custom_components/sat/coordinator.py (4)
  • minimum_setpoint (260-262)
  • get (407-422)
  • maximum_setpoint (265-268)
  • cycles (152-153)
custom_components/sat/minimum_setpoint.py (2)
  • DynamicMinimumSetpoint (72-409)
  • MinimumSetpointConfig (21-63)
custom_components/sat/pwm.py (2)
  • PWM (35-244)
  • PWMConfig (27-32)
custom_components/sat/switch/__init__.py (1)
  • maximum_setpoint (40-41)
custom_components/sat/helpers.py (1)
  • convert_time_str_to_seconds (19-28)
custom_components/sat/mqtt/__init__.py (6)
custom_components/sat/coordinator.py (1)
  • device_id (123-124)
custom_components/sat/simulator/__init__.py (1)
  • device_id (31-32)
custom_components/sat/fake/__init__.py (1)
  • device_id (29-30)
custom_components/sat/serial/__init__.py (1)
  • device_id (38-39)
custom_components/sat/switch/__init__.py (1)
  • device_id (28-29)
custom_components/sat/esphome/__init__.py (1)
  • device_id (54-55)
custom_components/sat/sensor.py (3)
custom_components/sat/coordinator.py (1)
  • last_cycle (156-157)
custom_components/sat/cycles.py (1)
  • last_cycle (149-150)
custom_components/sat/const.py (1)
  • CycleClassification (184-190)
custom_components/sat/coordinator.py (2)
custom_components/sat/boiler.py (4)
  • BoilerState (17-27)
  • Boiler (30-350)
  • update (113-127)
  • status (80-84)
custom_components/sat/cycles.py (7)
  • CycleTracker (204-380)
  • CycleHistory (72-201)
  • CycleStatistics (64-69)
  • Cycle (37-60)
  • update (228-256)
  • last_cycle (149-150)
  • statistics (153-160)
custom_components/sat/pid.py (3)
custom_components/sat/area.py (3)
  • update (142-148)
  • update (154-157)
  • error (65-73)
custom_components/sat/heating_curve.py (1)
  • update (27-30)
custom_components/sat/errors.py (1)
  • Error (37-40)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/boiler.py (1)
  • BoilerState (17-27)
custom_components/sat/const.py (2)
  • CycleClassification (184-190)
  • CycleKind (177-181)
custom_components/sat/cycles.py (5)
  • CycleStatistics (64-69)
  • Cycle (37-60)
  • cycles_last_hour (109-115)
  • duty_ratio_last_15m (118-131)
  • sample_count_4h (100-106)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/cycles.py (5)
custom_components/sat/const.py (2)
  • CycleKind (177-181)
  • CycleClassification (184-190)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/boiler.py (1)
  • BoilerState (17-27)
custom_components/sat/coordinator.py (5)
  • last_cycle (156-157)
  • flame_active (179-180)
  • hot_water_active (187-188)
  • setpoint (165-166)
  • return_temperature (199-200)
custom_components/sat/simulator/__init__.py (2)
  • flame_active (63-64)
  • setpoint (51-52)
custom_components/sat/binary_sensor.py (3)
custom_components/sat/const.py (2)
  • BoilerStatus (193-213)
  • CycleClassification (184-190)
custom_components/sat/coordinator.py (2)
  • device_status (132-134)
  • last_cycle (156-157)
custom_components/sat/cycles.py (1)
  • last_cycle (149-150)
custom_components/sat/climate.py (3)
custom_components/sat/const.py (2)
  • RelativeModulationState (222-226)
  • PWMStatus (216-219)
custom_components/sat/util.py (1)
  • create_dynamic_minimum_setpoint_controller (48-54)
custom_components/sat/pwm.py (6)
  • reset (65-74)
  • state (240-244)
  • enabled (232-233)
  • enable (82-84)
  • disable (86-89)
  • status (236-237)
🪛 Pylint (4.0.3)
custom_components/sat/pwm.py

[refactor] 35-35: Too many instance attributes (19/7)

(R0902)

custom_components/sat/boiler.py

[refactor] 30-30: Too many instance attributes (22/7)

(R0902)


[refactor] 31-31: Too many arguments (12/5)

(R0913)


[refactor] 31-31: Too many positional arguments (12/5)

(R0917)


[refactor] 129-129: Too many return statements (17/6)

(R0911)


[refactor] 129-129: Too many branches (16/12)

(R0912)


[refactor] 318-318: Too many return statements (7/6)

(R0911)

custom_components/sat/minimum_setpoint.py

[refactor] 21-21: Too many instance attributes (18/7)

(R0902)


[refactor] 101-102: Consider using 'minimum_setpoint = max(minimum_setpoint, allowed_minimum)' instead of unnecessary if block

(R1731)


[refactor] 235-235: Too many arguments (6/5)

(R0913)


[refactor] 235-235: Too many positional arguments (6/5)

(R0917)


[refactor] 235-235: Too many return statements (10/6)

(R0911)


[refactor] 235-235: Too many branches (13/12)

(R0912)

custom_components/sat/cycles.py

[refactor] 37-37: Too many instance attributes (16/7)

(R0902)


[refactor] 258-258: Too many local variables (23/15)

(R0914)


[refactor] 341-341: Too many return statements (8/6)

(R0911)

🪛 Ruff (0.14.5)
custom_components/sat/util.py

52-52: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


53-53: CONF_MAXIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


70-70: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports

(F405)


71-71: CONF_AUTOMATIC_DUTY_CYCLE may be undefined, or defined from star imports

(F405)


72-72: CONF_DUTY_CYCLE may be undefined, or defined from star imports

(F405)

custom_components/sat/sensor.py

13-13: from .const import * used; unable to detect undefined names

(F403)


262-262: CycleClassification may be undefined, or defined from star imports

(F405)


268-268: CONF_NAME may be undefined, or defined from star imports

(F405)


282-282: BoilerStatus may be undefined, or defined from star imports

(F405)

custom_components/sat/config_flow.py

592-592: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports

(F405)

custom_components/sat/coordinator.py

14-14: from .const import * used; unable to detect undefined names

(F403)


262-262: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


262-262: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


330-330: Unused method argument: climate

(ARG002)


330-330: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/pid.py

213-213: HEATING_SYSTEM_UNDERFLOOR may be undefined, or defined from star imports

(F405)

custom_components/sat/minimum_setpoint.py

213-213: Value being cast to int is already an integer

Remove unnecessary int call

(RUF046)

custom_components/sat/cycles.py

78-78: Avoid specifying long messages outside the exception class

(TRY003)


81-81: Avoid specifying long messages outside the exception class

(TRY003)


84-84: Avoid specifying long messages outside the exception class

(TRY003)


119-119: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)


209-209: Avoid specifying long messages outside the exception class

(TRY003)


228-228: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/simulator/__init__.py

87-87: Unused method argument: timestamp

(ARG002)


87-87: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)


87-87: Unused method argument: climate

(ARG002)

custom_components/sat/climate.py

180-180: CONF_MODE may be undefined, or defined from star imports

(F405)


180-180: MODE_SWITCH may be undefined, or defined from star imports

(F405)


180-180: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


593-593: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


606-606: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


610-610: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


812-812: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


813-813: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


937-937: DEADBAND may be undefined, or defined from star imports

(F405)


937-937: DEADBAND may be undefined, or defined from star imports

(F405)


994-994: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)

🔇 Additional comments (18)
custom_components/sat/util.py (3)

13-15: LGTM! Import updates align with the new architecture.

The imports correctly reflect the transition to the new configuration-based approach with DynamicMinimumSetpoint, MinimumSetpointConfig, and PWMConfig.


48-54: LGTM! Factory function correctly updated.

The function now returns DynamicMinimumSetpoint with proper MinimumSetpointConfig initialization. The configuration uses sensible defaults for advanced tuning parameters.


67-78: LGTM! PWM controller factory updated correctly.

The function signature and implementation properly reflect the new PWM API that accepts PWMConfig and heating_curve parameters.

custom_components/sat/cycles.py (4)

30-70: LGTM! Well-designed immutable data structures.

The dataclasses use frozen=True and slots=True for immutability and memory efficiency. The Cycle dataclass comprehensively captures all relevant cycle information.


75-98: LGTM! Proper input validation.

The constructor validates all window parameters to ensure they're positive, which is essential for the rolling window calculations.


99-202: LGTM! Rolling window statistics correctly implemented.

The properties properly prune expired data before computing statistics, and duty_ratio_last_15m correctly clamps the result to [0.0, 1.0].


258-380: LGTM! Comprehensive cycle classification logic.

The cycle building and classification logic correctly handles various edge cases and provides detailed classification for minimum setpoint tuning. The classification distinguishes between underheat, overshoot, and short-cycling scenarios.

custom_components/sat/area.py (1)

154-157: LGTM! Updated to match new PID API.

The update method correctly calls area.pid.update(area.error, area.heating_curve.value) to match the refactored PID controller signature that no longer requires boiler_temperature.

custom_components/sat/__init__.py (1)

66-80: LGTM! Simplified unload logic.

The unload flow is cleaner and correctly handles platform unloading, Sentry cleanup, and domain data removal.

custom_components/sat/config_flow.py (1)

592-595: LGTM! Simplified PID configuration UI.

The removal of version selectors simplifies the configuration flow. The conditional logic correctly shows PID parameters only when automatic gains are disabled.

custom_components/sat/binary_sensor.py (1)

196-219: LGTM! Health sensors updated to use cycle-based classification.

The SatCycleHealthSensor correctly uses last_cycle.classification to determine health status, flagging problems when the classification indicates underheat, overshoot, or short-cycling.

custom_components/sat/simulator/__init__.py (1)

87-87: LGTM! Signature updated to match base class.

The method signature correctly matches the updated base class interface. The timestamp and climate parameters are unused in this simulator implementation but are required for interface compatibility.

custom_components/sat/const.py (1)

177-214: New cycle/boiler enums and UNHEALTHY_CYCLES look coherent; watch for legacy string comparisons

The new CycleKind, CycleClassification, BoilerStatus, RelativeModulationState, and UNHEALTHY_CYCLES definitions are internally consistent and give you a clean, typed surface for the cycle‑centric model.

Given the shift from older status types, just ensure any remaining callers aren’t still comparing raw strings (e.g., "central_heating") or the removed FlameStatus values instead of these Enums; that’s the main source of potential regressions here.

Also applies to: 222-234

custom_components/sat/coordinator.py (1)

101-120: Cycle/boiler wiring on updates looks sound

Initializing and wiring the new components:

  • _boiler: Boiler = Boiler()
  • _cycles: CycleHistory = CycleHistory()
  • _cycle_tracker: CycleTracker = CycleTracker(self._cycles)

and then updating both from async_add_listener callbacks and from async_control_heating_loop using the synthesized BoilerState plus a monotonic timestamp ensures that:

  • Cycle tracking always sees the latest derived boiler state.
  • Boiler’s internal status machine and modulation reliability get a consistent time base.

This composition looks coherent and should integrate well with the rest of the refactor.

Also applies to: 330-344

custom_components/sat/boiler.py (1)

30-127: Boiler status derivation and transition tracking look internally consistent

The new Boiler class cleanly separates:

  • Input state (BoilerState, last_cycle, timestamp).
  • Derived status via _derive_status, with clear branches for:
    • Power/availability.
    • Flame‑off phases (overshoot cooling, anti‑cycling, cooling, pump start, waiting for flame, post‑cycle settling, idle).
    • Flame‑on phases (DHW vs space heating, preheat, at‑band, modulation up/down, generic heating).
  • Transition tracking (_track_flame_transitions) that sets _last_flame_on_at, _last_flame_off_at, and _last_flame_off_was_overshoot in a way that matches the downstream checks.

From a correctness perspective this looks solid; the main cost is complexity, which is acceptable given the richer state machine you’re modeling.

custom_components/sat/climate.py (3)

180-199: PWM enable logic: good separation of forced/static/dynamic paths

pulse_width_modulation_enabled now:

  • Immediately disables PWM when _calculated_setpoint is None.
  • Forces PWM on when setpoint management is unsupported or _force_pulse_width_modulation is true (_is_pwm_forced).
  • Respects _overshoot_protection to allow full disabling via config.
  • Switches between:
    • _should_enable_static_pwm() when _dynamic_minimum_setpoint is false, comparing coordinator minimum_setpoint to _calculated_setpoint with and without BOILER_DEADBAND depending on current pwm.enabled.
    • _should_enable_dynamic_pwm() when _dynamic_minimum_setpoint is true, using the learned minimum_setpoint.value plus last cycle health (UNHEALTHY_CYCLES).

This cleanly separates configuration‑driven behavior and uses the dynamic controller only when requested. The “keep current PWM state when last_cycle is None” rule in _should_enable_dynamic_pwm should also help prevent flapping at startup.

Also applies to: 565-613


211-236: Debounced PID scheduling looks correct and should reduce thrash

The new schedule_control_pid / async_control_pid flow:

  • Forces an immediate run when reset=True, cancelling any pending scheduled run.
  • Otherwise, schedules a single run 10 seconds later via async_call_later, with _control_pid_unsub ensuring only one pending run at a time.
  • Clears _control_pid_unsub at the start of async_control_pid so subsequent changes can schedule again.

And async_control_pid itself:

  • Skips when current_outside_temperature is unavailable (with a clear warning).
  • Resets PID and area PIDs when sensor data is too old.
  • Ensures heating curves are updated with the latest outside and target temperatures before PID updates.

This matches the intended debounce behavior and should significantly reduce redundant PID work on noisy sensor changes without sacrificing responsiveness on “reset” events (e.g. HVAC mode/target temperature changes).

Also applies to: 886-903


886-903: Good use of required-values guard before running the heating loop

In async_control_heating_loop you added:

required_values = (
    self.target_temperature,
    self.heating_curve.value,
    self.current_temperature,
    self.current_outside_temperature,
)

if any(value is None for value in required_values):
    return

Combining this with the PID logic that ensures the heating curve is updated before use keeps the heating loop from acting on partial or inconsistent data. This is a good safety guard for a cycle‑based control system.

Also applies to: 945-975

Comment thread custom_components/sat/minimum_setpoint.py Outdated
Comment thread custom_components/sat/mqtt/__init__.py Outdated
Comment thread custom_components/sat/pid.py Outdated
Comment thread custom_components/sat/relative_modulation.py Outdated
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

♻️ Duplicate comments (2)
custom_components/sat/relative_modulation.py (1)

31-39: Relative modulation state and enabled are still inverted w.r.t. PWM.

Current logic:

if self._pulse_width_modulation_enabled:
    return RelativeModulationState.OFF
return RelativeModulationState.PWM_OFF

and:

return self.state != RelativeModulationState.OFF

means:

  • PWM enabled → state = OFFenabled == False
  • PWM disabled → state = PWM_OFFenabled == True

Downstream, SatClimate.relative_modulation_value uses enabled to decide whether to use MINIMUM_RELATIVE_MODULATION or _maximum_relative_modulation, so this inversion causes the system to clamp to the minimum modulation when PWM is actually active.

You likely want enabled to be True when PWM is enabled (and not in COLD/HOT_WATER), which implies swapping the two return values:

-        if self._pulse_width_modulation_enabled:
-            return RelativeModulationState.OFF
-
-        return RelativeModulationState.PWM_OFF
+        if self._pulse_width_modulation_enabled:
+            return RelativeModulationState.PWM_OFF
+
+        return RelativeModulationState.OFF

This aligns enabled with “relative modulation allowed / active” and matches the prior review feedback.

custom_components/sat/pwm.py (1)

17-24: PWM config/state refactor is solid; but maximum_cycles can still produce invalid (negative) OFF times.

The new PWMConfig/PWMState types and state exposure look good and make PWM much easier to introspect.

The earlier concern about maximum_cycles driving invalid timings is still present though:

  • Thresholds are derived solely from maximum_cycles:

    self._on_time_lower_threshold = 180
    self._on_time_upper_threshold = 3600 / max(1, self._config.maximum_cycles)
    self._on_time_maximum_threshold = self._on_time_upper_threshold * 2
  • In the low‑duty special case with flame active:

    on_time = self._on_time_lower_threshold      # 180 s
    off_time = self._on_time_maximum_threshold - self._on_time_lower_threshold

For sufficiently large maximum_cycles (e.g. > ~40), _on_time_maximum_threshold becomes smaller than _on_time_lower_threshold, making off_time negative. That then feeds into transitions using self._duty_cycle[1] and log messages that assume non‑negative durations.

You can harden this by enforcing sane relationships between thresholds and clamping OFF time at 0, e.g.:

-        self._on_time_lower_threshold: float = 180
-        self._on_time_upper_threshold: float = 3600 / max(1, self._config.maximum_cycles)
-        self._on_time_maximum_threshold: float = self._on_time_upper_threshold * 2
+        self._on_time_lower_threshold: float = 180
+        self._on_time_upper_threshold: float = 3600 / max(1, self._config.maximum_cycles)
+        # Ensure the maximum threshold is never below the lower threshold
+        self._on_time_maximum_threshold: float = max(
+            self._on_time_upper_threshold * 2,
+            self._on_time_lower_threshold,
+        )
@@
-            if boiler.flame_active and not boiler.hot_water_active:
-                on_time = self._on_time_lower_threshold
-                off_time = self._on_time_maximum_threshold - self._on_time_lower_threshold
+            if boiler.flame_active and not boiler.hot_water_active:
+                on_time = self._on_time_lower_threshold
+                off_time = max(0, self._on_time_maximum_threshold - self._on_time_lower_threshold)
@@
-        on_time = self._on_time_maximum_threshold
-        off_time = 0
+        on_time = self._on_time_maximum_threshold
+        off_time = 0

(and optionally validating maximum_cycles at config time so these invariants always hold).

This keeps duty times non‑negative and avoids pathological behavior at extreme configuration values.

Also applies to: 47-57, 172-187, 223-227

🧹 Nitpick comments (6)
custom_components/sat/services.py (1)

6-6: Import change is fine; consider tightening the service schema for entity_id.

The switch to only import SERVICE_RESET_INTEGRAL is consistent with dropping the PWM service. However, the service schema still uses list[str] as the validator:

schema=vol.Schema({vol.Required("entity_id"): list[str]})

In Voluptuous, you typically want something like [str] or Home Assistant's cv.entity_ids helper, not list[str] (a typing alias), which may not be interpreted as a “list of strings” validator.

If you want a list of entity IDs without re‑introducing cv, consider:

schema = vol.Schema({vol.Required("entity_id"): [str]})

This keeps the behavior explicit and avoids potential validator quirks.

Also applies to: 23-28

custom_components/sat/const.py (1)

175-238: Cycle enums and UNHEALTHY_CYCLES look coherent; consider making BoilerStatus a str enum for consistency.

The new cycle‑related enums and UNHEALTHY_CYCLES tuple are consistent and easy to consume across the codebase.

One minor consistency nit: BoilerStatus is declared as a plain Enum, whereas CycleKind, CycleClassification, PWMStatus, and RelativeModulationState all inherit from str, Enum. If BoilerStatus values are ever exposed to Home Assistant as state/attributes or used in logging without .value, having it inherit from str, Enum would make its behavior align with the others and avoid surprises when stringifying:

-class BoilerStatus(Enum):
+class BoilerStatus(str, Enum):

Not blocking, but worth considering for API consistency.

custom_components/sat/coordinator.py (1)

95-104: Boiler/cycle wiring and minimum_setpoint floor look good; climate arg in async_control_heating_loop is unused.

The move to a composed Boiler + CycleHistory/CycleTracker and the new state/cycles/last_cycle properties looks coherent, and tightening minimum_setpoint with:

return max(float(self._config_data.get(CONF_MINIMUM_SETPOINT)), MINIMUM_SETPOINT)

is a nice safety improvement.

One small cleanup: async_control_heating_loop now takes a climate: Optional[SatClimate] = None argument, and callers pass climate=self, but the parameter is not used anywhere in the method. This is causing the static analysis ARG002 warning and adds noise to the public API.

Unless you expect to use the climate parameter soon, consider removing it from the signature (and call sites) to keep the coordinator API minimal.

Also applies to: 118-120, 132-149, 151-158, 260-263, 330-344

custom_components/sat/cycles.py (3)

231-232: Refactor timestamp handling for correctness and type safety.

Two issues:

  1. Line 231: PEP 484 prohibits implicit Optional. The parameter should be explicitly typed as Optional[float] = None.
  2. Line 232: The timestamp or monotonic() pattern incorrectly treats 0.0 as falsy, which would use monotonic() even when a valid timestamp of 0.0 is provided. Use an explicit None check instead.

Apply this diff:

-    def update(self, boiler_state: BoilerState, timestamp: float = None) -> None:
-        timestamp = timestamp or monotonic()
+    def update(self, boiler_state: BoilerState, timestamp: Optional[float] = None) -> None:
+        timestamp = timestamp if timestamp is not None else monotonic()
         previously_active = self._last_flame_active
         currently_active = boiler_state.flame_active

284-291: Extract cycle kind thresholds to module-level constants.

The hardcoded thresholds 0.8, 0.2, and 0.1 should be defined as module-level constants (similar to OVERSHOOT_MARGIN_CELSIUS and others) for better maintainability and documentation.

Add these constants after line 30:

 UNDERSHOOT_MARGIN_CELSIUS: float = 2.0  # max_flow <= setpoint - margin -> underheat
+
+# Cycle kind classification thresholds
+CYCLE_KIND_DOMINANT_THRESHOLD: float = 0.8  # Fraction to be considered dominant mode
+CYCLE_KIND_MINOR_THRESHOLD: float = 0.2    # Fraction below which mode is negligible
+CYCLE_KIND_MIXED_THRESHOLD: float = 0.1    # Minimum fraction for mixed mode

Then update the logic:

-        if fraction_dhw > 0.8 and fraction_heating < 0.2:
+        if fraction_dhw > CYCLE_KIND_DOMINANT_THRESHOLD and fraction_heating < CYCLE_KIND_MINOR_THRESHOLD:
             kind = CycleKind.DOMESTIC_HOT_WATER
-        elif fraction_heating > 0.8 and fraction_dhw < 0.2:
+        elif fraction_heating > CYCLE_KIND_DOMINANT_THRESHOLD and fraction_dhw < CYCLE_KIND_MINOR_THRESHOLD:
             kind = CycleKind.CENTRAL_HEATING
-        elif fraction_dhw > 0.1 and fraction_heating > 0.1:
+        elif fraction_dhw > CYCLE_KIND_MIXED_THRESHOLD and fraction_heating > CYCLE_KIND_MIXED_THRESHOLD:
             kind = CycleKind.MIXED
         else:
             kind = CycleKind.UNKNOWN

374-382: Extract short-cycling multiplier to a named constant.

The magic number 2.0 on line 376 should be defined as a module-level constant to document that short-cycling detection requires double the normal low-load threshold.

Add this constant after line 25:

 LOW_LOAD_MAX_DUTY_RATIO_15_M: float = 0.50
+SHORT_CYCLING_MULTIPLIER: float = 2.0  # Short-cycling requires 2x normal low-load cycles/hour

Then update line 376:

         short_cycling_context = (
-                statistics.cycles_last_hour > LOW_LOAD_MIN_CYCLES_PER_HOUR * 2.0
+                statistics.cycles_last_hour > LOW_LOAD_MIN_CYCLES_PER_HOUR * SHORT_CYCLING_MULTIPLIER
                 and statistics.duty_ratio_last_15m < LOW_LOAD_MAX_DUTY_RATIO_15_M
         )
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3968306 and 41577cc.

📒 Files selected for processing (7)
  • custom_components/sat/climate.py (22 hunks)
  • custom_components/sat/const.py (1 hunks)
  • custom_components/sat/coordinator.py (7 hunks)
  • custom_components/sat/cycles.py (1 hunks)
  • custom_components/sat/pwm.py (8 hunks)
  • custom_components/sat/relative_modulation.py (2 hunks)
  • custom_components/sat/services.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
custom_components/sat/relative_modulation.py (1)
custom_components/sat/const.py (1)
  • RelativeModulationState (226-230)
custom_components/sat/pwm.py (4)
custom_components/sat/const.py (1)
  • PWMStatus (220-223)
custom_components/sat/heating_curve.py (3)
  • HeatingCurve (11-92)
  • value (91-92)
  • reset (21-25)
custom_components/sat/boiler.py (1)
  • BoilerState (17-27)
custom_components/sat/setpoint_adjuster.py (1)
  • reset (20-22)
custom_components/sat/cycles.py (4)
custom_components/sat/const.py (2)
  • CycleKind (181-185)
  • CycleClassification (188-194)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/boiler.py (1)
  • BoilerState (17-27)
custom_components/sat/coordinator.py (5)
  • last_cycle (156-157)
  • flame_active (179-180)
  • hot_water_active (187-188)
  • setpoint (165-166)
  • return_temperature (199-200)
🪛 Pylint (4.0.3)
custom_components/sat/pwm.py

[refactor] 35-35: Too many instance attributes (19/7)

(R0902)

custom_components/sat/cycles.py

[refactor] 39-39: Too many instance attributes (16/7)

(R0902)


[refactor] 263-263: Too many local variables (23/15)

(R0914)


[refactor] 346-346: Too many return statements (8/6)

(R0911)

🪛 Ruff (0.14.5)
custom_components/sat/climate.py

180-180: CONF_MODE may be undefined, or defined from star imports

(F405)


180-180: MODE_SWITCH may be undefined, or defined from star imports

(F405)


180-180: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


239-239: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


600-600: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


613-613: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


617-617: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


819-819: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


820-820: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


944-944: DEADBAND may be undefined, or defined from star imports

(F405)


944-944: DEADBAND may be undefined, or defined from star imports

(F405)


1001-1001: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)

custom_components/sat/coordinator.py

14-14: from .const import * used; unable to detect undefined names

(F403)


262-262: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


262-262: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


330-330: Unused method argument: climate

(ARG002)


330-330: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/cycles.py

80-80: Avoid specifying long messages outside the exception class

(TRY003)


83-83: Avoid specifying long messages outside the exception class

(TRY003)


86-86: Avoid specifying long messages outside the exception class

(TRY003)


121-121: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)


211-211: Avoid specifying long messages outside the exception class

(TRY003)


231-231: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

🔇 Additional comments (4)
custom_components/sat/cycles.py (4)

1-30: LGTM! Well-structured imports and constants.

The module-level constants for thresholds and margins are well-documented and appropriately scoped.


32-72: LGTM! Well-designed immutable data structures.

The use of frozen=True and slots=True is excellent for immutability and memory efficiency. The Cycle dataclass intentionally contains many attributes to provide comprehensive cycle telemetry, which is appropriate for this domain.


164-203: LGTM! Robust cycle recording and window management.

The record and prune logic correctly maintains rolling windows, and defensive programming with max(0.0, cycle.duration) ensures robustness even though the duration is already guaranteed non-negative.


293-343: LGTM! Comprehensive cycle state aggregation.

The aggregation logic correctly handles optional values, computes averages and extremes, and constructs a complete Cycle object. The local _avg helper is appropriately scoped.

Comment thread custom_components/sat/climate.py
Comment thread custom_components/sat/cycles.py Outdated
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: 1

♻️ Duplicate comments (1)
custom_components/sat/minimum_setpoint.py (1)

66-70: Guard against re‑processing the same completed cycle in update / tuning

DynamicMinimumSetpoint.update increments regime_state.completed_cycles and calls _maybe_tune_minimum on every invocation where last_cycle is non‑None (Lines 134–136), but RegimeState has no notion of which cycle was last processed. If the caller passes the last completed Cycle repeatedly between new completions (a common pattern for “last completed cycle” APIs), this will:

  • Inflate completed_cycles far beyond the true number of physical cycles.
  • Apply the same classification adjustment multiple times for a single real cycle, quickly driving minimum_setpoint up or down.
  • Make the warmup guard (completed_cycles <= warmup_cycles_before_tuning) ineffective after the first few calls.

This is effectively the same issue that was raised on a previous commit and still appears unresolved in this version.

A robust fix is to track the last processed cycle per regime and only tune once per completed cycle:

@@
-@dataclass(slots=True)
-class RegimeState:
-    minimum_setpoint: float
-    completed_cycles: int = 0
+@dataclass(slots=True)
+class RegimeState:
+    minimum_setpoint: float
+    completed_cycles: int = 0
+    # Last completed cycle end time that was already used for tuning (per regime)
+    last_processed_cycle_end: Optional[float] = None
@@
-        # Update the count of cycles and possibly adjust the learned minimum when a cycle has just completed.
-        if last_cycle is not None:
-            regime_state.completed_cycles += 1
-            self._maybe_tune_minimum(regime_state, boiler_state, cycles, last_cycle, base_setpoint=requested_setpoint)
+        # Update the count of cycles and possibly adjust the learned minimum once per completed cycle.
+        if last_cycle is not None:
+            # Use cycle.end as a stable per-cycle identifier.
+            if regime_state.last_processed_cycle_end != last_cycle.end:
+                regime_state.completed_cycles += 1
+                regime_state.last_processed_cycle_end = last_cycle.end
+                self._maybe_tune_minimum(
+                    regime_state,
+                    boiler_state,
+                    cycles,
+                    last_cycle,
+                    base_setpoint=requested_setpoint,
+                )

This keeps completed_cycles aligned with real cycles and prevents runaway tuning on a single bad cycle, while remaining per‑regime and not affecting persistence (the new field relies on its default on load).

Also applies to: 112-139, 235-332

🧹 Nitpick comments (2)
custom_components/sat/minimum_setpoint.py (2)

88-104: Minor simplification of value clamping logic

The guard against drifting too far below recent bases is correct, but can be slightly simplified without changing behavior:

-        if self._last_base_setpoint is not None:
-            allowed_minimum = max(self._config.minimum_setpoint, self._last_base_setpoint - self._config.max_deviation_from_recent_base)
-            if minimum_setpoint < allowed_minimum:
-                minimum_setpoint = allowed_minimum
+        if self._last_base_setpoint is not None:
+            allowed_minimum = max(
+                self._config.minimum_setpoint,
+                self._last_base_setpoint - self._config.max_deviation_from_recent_base,
+            )
+            minimum_setpoint = max(minimum_setpoint, allowed_minimum)

This matches the linter suggestion and keeps the intent obvious.


333-347: Optional: tighten _is_tunable_regime for “low‑load” semantics

Current gating (hot_water_active, is_inactive, sample_count_4h, cycles_last_hour) is reasonable, but to better match the “low‑load” idea you might also:

  • Incorporate low_load_maximum_duty_ratio_15m here (see previous comment).
  • Optionally rename or document low_load_minimum_cycles_per_hour to clarify whether “low‑load” here means “few, long cycles” vs. “many, short ones”.

Not a blocker, but clarifying this now will make future PID/learning tweaks easier.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 41577cc and 58b3181.

📒 Files selected for processing (1)
  • custom_components/sat/minimum_setpoint.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/boiler.py (1)
  • BoilerState (17-27)
custom_components/sat/const.py (2)
  • CycleClassification (188-194)
  • CycleKind (181-185)
custom_components/sat/cycles.py (5)
  • CycleStatistics (66-71)
  • Cycle (39-62)
  • cycles_last_hour (111-117)
  • duty_ratio_last_15m (120-133)
  • sample_count_4h (102-108)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
🪛 Pylint (4.0.3)
custom_components/sat/minimum_setpoint.py

[refactor] 21-21: Too many instance attributes (18/7)

(R0902)


[refactor] 101-102: Consider using 'minimum_setpoint = max(minimum_setpoint, allowed_minimum)' instead of unnecessary if block

(R1731)


[refactor] 235-235: Too many arguments (6/5)

(R0913)


[refactor] 235-235: Too many positional arguments (6/5)

(R0917)


[refactor] 235-235: Too many return statements (10/6)

(R0911)


[refactor] 235-235: Too many branches (13/12)

(R0912)

🪛 Ruff (0.14.5)
custom_components/sat/minimum_setpoint.py

213-213: Value being cast to int is already an integer

Remove unnecessary int call

(RUF046)

🔇 Additional comments (2)
custom_components/sat/minimum_setpoint.py (2)

203-234: Regime keying and initial minimum reuse look sound

Bucketing setpoints into coarse regime_band_width bands and seeding new regimes from the nearest existing bucket is a nice way to keep behavior smooth across adjacent bands while still allowing differentiated learning. Logic for parsing bucket indices and clamping the initial minimum is clear and robust to malformed keys.


383-406: Large‑jump damping and clamping are implemented correctly

The large‑jump damping logic around base_setpoint transitions is well‑contained: it no‑ops when there is no previous base, respects max_setpoint_jump_without_damping, damps toward the absolute minimum using large_jump_damping_factor, and finally clamps via _clamp_setpoint. This should effectively avoid dragging a low‑load minimum into a very different regime.

…rom_hass`, improve `cycles_last_hour` calculation logic.
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: 1

♻️ Duplicate comments (1)
custom_components/sat/climate.py (1)

211-252: Fix EVENT_HOMEASSISTANT_STOP listener to avoid unsafe hass.async_create_task usage.

The current STOP handler:

self.hass.bus.async_listen(
    EVENT_HOMEASSISTANT_STOP,
    lambda _: self.hass.async_create_task(self.async_will_remove_from_hass())
)

is what the pipeline flags as calling hass.async_create_task from a non-event-loop thread. It also relies on a lambda rather than a proper async listener.

A safer and more idiomatic pattern is to register an async handler that the event bus can schedule itself:

-        self.hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, lambda _: self.hass.async_create_task(self.async_will_remove_from_hass()))
+        self.hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
+
+    async def _handle_ha_stop(self, _event: Event) -> None:
+        """Handle Home Assistant stop event and run cleanup."""
+        await self.async_will_remove_from_hass()

This removes direct async_create_task usage from the listener, satisfies HA’s thread-safety checks, and ensures your cleanup is awaited on shutdown.

Verify the recommended pattern for registering async event listeners and avoiding unsafe `hass.async_create_task` calls in Home Assistant custom integrations (particularly for `EVENT_HOMEASSISTANT_STOP` handlers).
🧹 Nitpick comments (4)
custom_components/sat/cycles.py (2)

183-205: _current_time_hint choice of “now” is acceptable but slightly under-doc’d.

Using the newest timestamp from the existing deques as a synthetic “now” keeps pruning and stats deterministic without needing wall-clock time, but it means windows are effectively anchored to the last recorded cycle, not real time. That’s reasonable for per-cycle classification; consider adding a short comment explaining this design choice so future readers don’t assume it’s actual wall-clock monotonic() time.


347-386: Align _classify_cycle type hints with actual Optional usage.

The implementation correctly handles missing data (average_setpoint is None, max_flow_temperature is None) and returns INSUFFICIENT_DATA / UNCERTAIN appropriately, but the signature still types these as float:

def _classify_cycle(
    statistics: CycleStatistics,
    duration: float,
    max_flow_temperature: float,
    average_setpoint: float,
) -> CycleClassification:

Given you explicitly check for None, it would be more accurate to type them as Optional[float] and pass them as such from _build_cycle_state. This will quiet type-checkers and make the contract clearer to readers.

custom_components/sat/climate.py (2)

571-620: PWM enable/disable logic is much clearer; consider tiny doc tweaks.

The new pulse_width_modulation_enabled plus _is_pwm_forced, _should_enable_static_pwm, and _should_enable_dynamic_pwm helpers make the decision-making around PWM explicit and easier to reason about:

  • Require a valid _calculated_setpoint before even considering PWM.
  • Force PWM when setpoint management isn’t supported or explicitly forced.
  • For static minimum setpoint, use a simple hysteresis (BOILER_DEADBAND) around the coordinator’s minimum.
  • For dynamic minimum setpoint, consider both minimum_setpoint.value and recent last_cycle.classification via UNHEALTHY_CYCLES.

Behavior-wise this is sound. As a small polish, a brief comment on the intended hysteresis in _should_enable_static_pwm and on the “no history yet: keep current PWM state” strategy in _should_enable_dynamic_pwm would help future readers understand why PWM doesn’t flap at startup.


795-824: _async_control_setpoint matches PWM state model; first branch is currently unreachable.

The refactored _async_control_setpoint aligns well with the new PWM semantics:

  • Normal path: when PWM is disabled or PWMStatus.IDLE, use the filtered _calculated_setpoint.
  • PWM active: use minimum_setpoint_value when PWMStatus.ON, and MINIMUM_SETPOINT when PWM is effectively “off”.
  • Always delegates the final setpoint application to the coordinator.

Given async_control_heating_loop already early-returns if hvac_mode != HVACMode.HEAT, the top branch that sets _setpoint = MINIMUM_SETPOINT for non-HEAT modes is currently unreachable. Not harmful, but you could simplify by either removing that branch or documenting that _async_control_setpoint is intended to be reusable from other call sites in the future.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 58b3181 and b39bec0.

📒 Files selected for processing (2)
  • custom_components/sat/climate.py (22 hunks)
  • custom_components/sat/cycles.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
custom_components/sat/cycles.py (4)
custom_components/sat/const.py (2)
  • CycleKind (181-185)
  • CycleClassification (188-194)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/boiler.py (1)
  • BoilerState (17-27)
custom_components/sat/coordinator.py (5)
  • last_cycle (156-157)
  • flame_active (179-180)
  • hot_water_active (187-188)
  • setpoint (165-166)
  • return_temperature (199-200)
custom_components/sat/climate.py (4)
custom_components/sat/const.py (2)
  • RelativeModulationState (226-230)
  • PWMStatus (220-223)
custom_components/sat/util.py (2)
  • create_pid_controller (21-45)
  • create_dynamic_minimum_setpoint_controller (48-54)
custom_components/sat/pwm.py (6)
  • reset (65-74)
  • state (238-242)
  • enabled (230-231)
  • enable (82-136)
  • disable (138-141)
  • status (234-235)
custom_components/sat/pid.py (4)
  • reset (49-64)
  • update (66-83)
  • output (264-266)
  • last_updated (202-204)
🪛 GitHub Actions: Run PyTest Unit Tests
custom_components/sat/climate.py

[error] 237-237: Detected that custom integration 'sat' calls hass.async_create_task from a thread other than the event loop. This is unsafe and will cause Home Assistant to crash. For more information, see https://developers.home-assistant.io/docs/asyncio/thread-safety/#hassasync_create_task

🪛 Pylint (4.0.3)
custom_components/sat/cycles.py

[refactor] 39-39: Too many instance attributes (16/7)

(R0902)


[refactor] 264-264: Too many local variables (23/15)

(R0914)


[refactor] 347-347: Too many return statements (8/6)

(R0911)

🪛 Ruff (0.14.5)
custom_components/sat/cycles.py

80-80: Avoid specifying long messages outside the exception class

(TRY003)


83-83: Avoid specifying long messages outside the exception class

(TRY003)


86-86: Avoid specifying long messages outside the exception class

(TRY003)


122-122: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)


212-212: Avoid specifying long messages outside the exception class

(TRY003)


232-232: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/climate.py

180-180: CONF_MODE may be undefined, or defined from star imports

(F405)


180-180: MODE_SWITCH may be undefined, or defined from star imports

(F405)


180-180: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


239-239: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


600-600: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


613-613: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


617-617: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


819-819: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


820-820: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


944-944: DEADBAND may be undefined, or defined from star imports

(F405)


944-944: DEADBAND may be undefined, or defined from star imports

(F405)


1001-1001: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)

🔇 Additional comments (13)
custom_components/sat/cycles.py (2)

74-120: CycleHistory windows and cycles_last_hour normalization look correct.

Validation of the rolling windows and cycles_last_hour rate is solid: you validate positive window sizes, prune before computing, and correctly normalize the cycle count by self._cycles_window_seconds (so the value is now truly “cycles per hour” regardless of window size). This addresses the earlier concern about raw counts vs. rates and keeps the API semantics coherent.


264-345: Cycle aggregation and kind/fraction logic are consistent with boiler model.

The way you derive DHW vs. space-heating fractions, compute averages over non-None samples, and collect min/max temps is logically sound and robust to missing telemetry. The thresholds for CycleKind (> 0.8, < 0.2, etc.) are simple and easy to reason about, and the sample_count/minimum_samples_per_cycle guard avoids noisy micro-cycles polluting stats.

custom_components/sat/climate.py (11)

223-236: Startup lifecycle wiring for PID/heating loop and minimum setpoint looks coherent.

Conditionally registering _register_event_listeners, async_control_heating_loop, and async_control_pid(reset=True) via EVENT_HOMEASSISTANT_STARTED when HA isn’t yet running, and otherwise awaiting them immediately, gives you predictable startup behavior while keeping everything on the event loop. The subsequent calls to coordinator/areas/minimum_setpoint async_added_to_hass are ordered sensibly.


395-438: State exposure for dynamic minimum setpoint and PWM state is consistent.

Adding minimum_setpoint: self.minimum_setpoint.value and switching PWM-related attributes to self.pwm.status.name / self.pwm.state.duty_cycle keeps external state aligned with the new controllers and enum/state model. This should make it much easier to debug dynamic minimum setpoint behavior and PWM decisions from the UI.


520-530: Requested setpoint computation is straightforward and guarded.

requested_setpoint now cleanly returns MINIMUM_SETPOINT when the heating curve has no value and otherwise uses round(self.heating_curve.value + self.pid.output, 1). Combined with the new debounce in async_control_pid, this gives a clear separation between curve+PID math and later clamping/filtering.


634-641: minimum_setpoint_value nicely encapsulates dynamic vs. static behavior.

Routing the minimum setpoint through minimum_setpoint.value when _dynamic_minimum_setpoint is enabled, and otherwise falling back to self._coordinator.minimum_setpoint, centralizes this choice and keeps _async_control_setpoint and other callers simple.


669-693: Using schedule_control_pid for sensor changes is a good debouncing strategy.

Switching the inside/outside/humidity sensor handlers to call schedule_control_pid() (and schedule_control_heating_loop()) instead of driving control directly is a good way to coalesce rapid sensor updates and avoid thrashing the PID/heating loop.


705-750: Climate entity change handling with schedule_control_pid covers key cases.

The updated _async_climate_changed logic correctly:

  • Forces a PID reset when the entity state or target temperature changes.
  • Schedules a non-reset PID run when only the current temperature changes (and no linked sensor).
  • Keeps room setpoints in _rooms in sync when appropriate.
  • Always schedules the heating loop afterward.

This matches the new debounced PID model and should reduce unnecessary recalculations while still reacting promptly to meaningful changes.


751-760: Tracking additional temperature sensors via _async_temperature_change is consistent.

The handler updates HA state and then schedules both PID and heating loop via the debouncers, which is consistent with the other sensor handlers and should keep behavior predictable across different sensor sources.


921-950: PID control logic and stale-sensor handling are sound.

Within async_control_pid:

  • You bail out cleanly if current_outside_temperature is missing.
  • You reset PID/area PIDs when pid.last_updated exceeds _sensor_max_value_age.
  • You keep heating-curve updates in sync with the latest target/outside temps.
  • You distinguish reset vs. non-reset flows (pid.update_reset vs pid.update) and only autotune the heating curve when in the deadband.

Overall this is a solid control loop and matches the new requested/minimum setpoint pipeline.


952-1011: Heating loop guard conditions and PWM application are coherent.

The updated async_control_heating_loop:

  • Skips work when any required input (target_temperature, heating_curve.value, current_temperature, current_outside_temperature) is missing.
  • Exits early when not in HVACMode.HEAT.
  • Delegates boiler-specific logic to the coordinator.
  • Smooths _calculated_setpoint with a low-pass filter and clamps via clamp(..., MINIMUM_SETPOINT, self._coordinator.maximum_setpoint).
  • Drives PWM via pulse_width_modulation_enabled and then passes self.pwm.status into _async_control_setpoint.

These steps are consistent and keep boiler control within sane bounds.


1073-1078: Resetting PID via schedule_control_pid(True) on HVAC mode changes is appropriate.

Resetting the PID controller (and, via reset_control_state, PWM and _calculated_setpoint) whenever HVAC mode changes ensures you don’t carry stale integral or filtered setpoints across OFF/HEAT transitions.


1154-1165: Target temperature changes correctly trigger PID reset and forced heating loop.

On async_set_target_temperature, you:

  • Avoid redundant work when the target is unchanged.
  • Cascade updates to radiators and (optionally) the boiler thermostat.
  • Reset the PID via schedule_control_pid(True) and reset_control_state().
  • Force an immediate heating-loop run with schedule_control_heating_loop(force=True).

That sequence is consistent with the debounced control design and should make target changes feel responsive.

Comment thread custom_components/sat/climate.py Outdated
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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
custom_components/sat/climate.py (1)

968-968: Same HassJob coroutine issue: wrap async_control_heating_loop in a callback.

Line 968 has the same issue as line 909: passing a coroutine function (self.async_control_heating_loop) directly to HassJob will raise ValueError at runtime.

Apply the same fix:

-        self._control_heating_loop_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_heating_loop))
+        self._control_heating_loop_unsub = async_call_later(
+            self.hass, 10, 
+            HassJob(lambda: self.hass.async_create_background_task(self.async_control_heating_loop()))
+        )
♻️ Duplicate comments (3)
custom_components/sat/minimum_setpoint.py (2)

26-36: [Duplicate] Clarify inverted comments and use the defined low_load_maximum_duty_ratio_15m threshold.

As flagged in previous reviews, the comments for increase_step and decrease_step remain inverted relative to how they are applied in _maybe_tune_minimum (lines 304–321):

  • TOO_SHORT_UNDERHEAT (underheat) → decreases the minimum (line 311: regime_state.minimum_setpoint -= self._config.decrease_step)
  • TOO_SHORT_OVERSHOOT/SHORT_CYCLING_OVERSHOOT (overshoot/short-cycling) → increases the minimum (line 321: regime_state.minimum_setpoint += self._config.increase_step)

The config comments currently say the opposite. Update them to reflect actual behavior.

Additionally, low_load_maximum_duty_ratio_15m is defined but never used. Add a check in _is_tunable_regime (lines 334–348) to gate tuning when statistics.duty_ratio_last_15m exceeds this threshold.

Based on past review comments.


112-140: [Duplicate] Prevent repeated tuning on the same completed cycle.

As flagged in previous reviews, the update method increments regime_state.completed_cycles and calls _maybe_tune_minimum every time last_cycle is non-None (lines 134–137), but there is no guard to ensure each physical cycle is processed only once.

Since last_cycle in CycleHistory is the last completed cycle and remains constant until a new one finishes, your heating loop will call update(...) many times for the same Cycle, re-applying the same classification adjustment on each call.

Consequences:

  • For a single bad cycle (e.g., TOO_SHORT_UNDERHEAT), minimum_setpoint will be stepped up/down on every control iteration (e.g., every 30s) until another cycle completes.
  • Over a minute of iterations, a step size of 1.0 could move the learned minimum by many degrees based on a single cycle.

Fix: Track the last processed cycle identity (e.g., last_cycle.end or id(last_cycle)) in RegimeState or DynamicMinimumSetpoint, and skip _maybe_tune_minimum when the cycle has already been applied.

Based on past review comments.

Apply this fix to add per-regime cycle tracking:

 @dataclass(slots=True)
 class RegimeState:
     minimum_setpoint: float
     completed_cycles: int = 0
+    last_processed_cycle_end: Optional[float] = None
         # Mark a cycle as completed.
-        regime_state.completed_cycles += 1
-
         # Update the count of cycles and possibly adjust the learned minimum when a cycle has just completed.
-        self._maybe_tune_minimum(regime_state, boiler_state, cycles, last_cycle, base_setpoint=requested_setpoint)
+        if last_cycle.end != regime_state.last_processed_cycle_end:
+            regime_state.completed_cycles += 1
+            self._maybe_tune_minimum(regime_state, boiler_state, cycles, last_cycle, base_setpoint=requested_setpoint)
+            regime_state.last_processed_cycle_end = last_cycle.end

Also update storage load/save logic (lines 141–203) to persist/restore last_processed_cycle_end.

custom_components/sat/climate.py (1)

893-909: [Duplicate] Fix HassJob with coroutine function: wrap in callback that schedules the coroutine.

As flagged in previous reviews, line 909 passes self.async_control_pid (a coroutine function) to HassJob. When async_call_later calls it, it will return a coroutine object, which Home Assistant forbids passing to HassJob (raises ValueError).

Current Home Assistant best practice: Wrap the coroutine in a callback that uses hass.async_create_background_task.

Based on past review comments.

Apply this fix:

-        self._control_pid_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_pid))
+        def _schedule_control_pid_callback() -> None:
+            self.hass.async_create_background_task(self.async_control_pid())
+        
+        self._control_pid_unsub = async_call_later(self.hass, 10, HassJob(_schedule_control_pid_callback))

Or use a lambda:

-        self._control_pid_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_pid))
+        self._control_pid_unsub = async_call_later(
+            self.hass, 10, 
+            HassJob(lambda: self.hass.async_create_background_task(self.async_control_pid()))
+        )
🧹 Nitpick comments (1)
custom_components/sat/minimum_setpoint.py (1)

350-364: Remove unused _relax_minimum_when_uncertain method.

This method is defined but never called in the current code. If it's intended for future use, consider removing it for now to keep the codebase clean, or add a TODO comment explaining its purpose.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b39bec0 and 3b1dd2b.

📒 Files selected for processing (2)
  • custom_components/sat/climate.py (22 hunks)
  • custom_components/sat/minimum_setpoint.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/minimum_setpoint.py (6)
custom_components/sat/boiler.py (1)
  • BoilerState (17-27)
custom_components/sat/const.py (2)
  • CycleClassification (188-194)
  • CycleKind (181-185)
custom_components/sat/coordinator.py (6)
  • cycles (152-153)
  • minimum_setpoint (260-262)
  • last_cycle (156-157)
  • get (407-422)
  • setpoint (165-166)
  • hot_water_active (187-188)
custom_components/sat/cycles.py (7)
  • CycleStatistics (66-71)
  • Cycle (39-62)
  • last_cycle (152-153)
  • statistics (156-163)
  • cycles_last_hour (111-118)
  • duty_ratio_last_15m (121-134)
  • sample_count_4h (102-108)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/climate.py (2)
  • requested_setpoint (524-529)
  • setpoint (520-521)
🪛 GitHub Actions: Run PyTest Unit Tests
custom_components/sat/climate.py

[error] 237-237: Detected that custom integration 'sat' calls hass.async_create_task from a thread other than the event loop, which may cause Home Assistant to crash or data to corrupt. For more information, see https://developers.home-assistant.io/docs/asyncio_thread_safety/#hassasync_create_task

🪛 Pylint (4.0.3)
custom_components/sat/minimum_setpoint.py

[refactor] 21-21: Too many instance attributes (18/7)

(R0902)


[refactor] 101-102: Consider using 'minimum_setpoint = max(minimum_setpoint, allowed_minimum)' instead of unnecessary if block

(R1731)


[refactor] 236-236: Too many arguments (6/5)

(R0913)


[refactor] 236-236: Too many positional arguments (6/5)

(R0917)


[refactor] 236-236: Too many return statements (10/6)

(R0911)


[refactor] 236-236: Too many branches (13/12)

(R0912)

🪛 Ruff (0.14.5)
custom_components/sat/minimum_setpoint.py

214-214: Value being cast to int is already an integer

Remove unnecessary int call

(RUF046)

custom_components/sat/climate.py

180-180: CONF_MODE may be undefined, or defined from star imports

(F405)


180-180: MODE_SWITCH may be undefined, or defined from star imports

(F405)


180-180: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


239-239: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


600-600: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


613-613: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


617-617: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


819-819: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


820-820: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


944-944: DEADBAND may be undefined, or defined from star imports

(F405)


944-944: DEADBAND may be undefined, or defined from star imports

(F405)


1001-1001: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)

🔇 Additional comments (2)
custom_components/sat/climate.py (2)

572-621: Nice refactoring: PWM enable logic is now clearer with helper methods.

The extraction of _is_pwm_forced, _should_enable_static_pwm, and _should_enable_dynamic_pwm improves readability and maintainability. The logic flow is easier to follow, and the separation of concerns between static and dynamic minimum setpoint handling is clean.


659-693: Good change: debouncing PID control on sensor changes reduces redundant work.

Switching from direct _async_control_pid() calls to schedule_control_pid() in sensor change handlers (lines 669, 677, 690) avoids stacking multiple PID updates when sensors change rapidly. The added debug logs are also helpful for troubleshooting.

Comment thread custom_components/sat/climate.py Outdated
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: 8

♻️ Duplicate comments (4)
custom_components/sat/pwm.py (1)

48-57: Negative off_time issue with high maximum_cycles already flagged.

The threshold calculation issue where high maximum_cycles can produce negative off_time has been identified in a previous review. The suggested fix to clamp _on_time_maximum_threshold and use max(0, ...) for off_time should be applied.

custom_components/sat/minimum_setpoint.py (2)

28-35: Config comments are inverted relative to actual behavior.

The comments on increase_step and decrease_step are reversed compared to how they're applied in _maybe_tune_minimum:

  • TOO_SHORT_UNDERHEATminimum_setpoint -= decrease_step (Line 352)
  • TOO_SHORT_OVERSHOOT / SHORT_CYCLING_OVERSHOOTminimum_setpoint += increase_step (Line 362)

Additionally, low_load_maximum_duty_ratio_15m is defined but never used in tuning logic.


140-156: Tuning occurs on every on_cycle_end call without deduplication.

The on_cycle_end method increments completed_cycles and calls _maybe_tune_minimum every time it's invoked. If the same last_cycle object is passed multiple times (e.g., due to multiple event triggers before a new cycle completes), the same cycle will be tuned repeatedly.

custom_components/sat/climate.py (1)

253-253: STOP listener still doesn't properly await cleanup.

The lambda lambda _: self.async_will_remove_from_hass() returns a coroutine that is never awaited. Home Assistant's event bus won't schedule this coroutine, so cleanup won't run on shutdown.

Apply this diff:

-        self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda _: self.async_will_remove_from_hass())
+        async def _handle_ha_stop(_event):
+            await self.async_will_remove_from_hass()
+        
+        self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_ha_stop)
🧹 Nitpick comments (10)
custom_components/sat/const.py (1)

197-217: Consider inheriting from str for consistency with other enums.

BoilerStatus inherits only from Enum, while CycleKind, CycleClassification, PWMStatus, and RelativeModulationState all use the (str, Enum) pattern. This inconsistency may cause issues with serialization or comparison in Home Assistant state attributes.

-class BoilerStatus(Enum):
+class BoilerStatus(str, Enum):
custom_components/sat/binary_sensor.py (1)

200-223: Consider using the UNHEALTHY_CYCLES constant for maintainability.

The health check logic manually excludes GOOD, UNCERTAIN, and INSUFFICIENT_DATA, which is the inverse of the UNHEALTHY_CYCLES tuple defined in const.py. Using the constant would make this easier to maintain if classifications change.

-from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, CONF_WINDOW_SENSORS, BoilerStatus, CycleClassification
+from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, CONF_WINDOW_SENSORS, BoilerStatus, CycleClassification, UNHEALTHY_CYCLES

Then update the is_on property:

     @property
     def is_on(self) -> bool:
         """Return the state of the sensor."""
         if self._coordinator.last_cycle is None:
             return False
 
-        return self._coordinator.last_cycle.classification not in (CycleClassification.GOOD, CycleClassification.UNCERTAIN, CycleClassification.INSUFFICIENT_DATA)
+        return self._coordinator.last_cycle.classification in UNHEALTHY_CYCLES
custom_components/sat/pwm.py (1)

139-142: Redundant assignment in disable().

reset() already sets self._enabled = False at line 68, so the assignment at line 142 is redundant.

     def disable(self) -> None:
         """Disable the PWM control."""
         self.reset()
-        self._enabled = False
custom_components/sat/minimum_setpoint.py (1)

116-116: Unused boiler_state parameter.

The boiler_state parameter is accepted but never used in on_cycle_start. Consider removing it or documenting why it's reserved for future use.

-    def on_cycle_start(self, boiler_state: BoilerState, cycles: CycleStatistics, last_cycle: Optional[Cycle], requested_setpoint: Optional[float], outside_temperature: Optional[float]) -> None:
+    def on_cycle_start(self, cycles: CycleStatistics, last_cycle: Optional[Cycle], requested_setpoint: Optional[float], outside_temperature: Optional[float]) -> None:
custom_components/sat/coordinator.py (2)

331-344: Unused climate parameter in async_control_heating_loop.

The climate parameter is accepted but never used. Static analysis also flags this. If it's intended for future use, consider documenting this or removing it.

-    async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMState] = None, timestamp: float = None) -> None:
+    async def async_control_heating_loop(self, pwm_state: Optional[PWMState] = None, timestamp: float = None) -> None:

Note: If you keep the parameter, update the type hint to use explicit Optional[float] instead of implicit float = None per PEP 484:

timestamp: Optional[float] = None

119-120: Listener callbacks invoke properties that may trigger side effects.

The listeners call self.state and self.last_cycle which construct new objects on each invocation. While this works, it creates objects that are immediately discarded if the tracker/boiler only needs specific fields. Consider whether this pattern causes unnecessary allocations in high-frequency update scenarios.

custom_components/sat/boiler.py (1)

364-365: Consider using a bounded deque instead of manual slicing.

The modulation values list is manually sliced to keep the last 50 values. Using a deque with maxlen=50 would be more efficient and idiomatic.

+from collections import deque
...
-        self._modulation_values_when_flame_on: List[float] = []
+        self._modulation_values_when_flame_on: deque[float] = deque(maxlen=50)
...
-        self._modulation_values_when_flame_on.append(value)
-        if len(self._modulation_values_when_flame_on) > 50:
-            self._modulation_values_when_flame_on = self._modulation_values_when_flame_on[-50:]
+        self._modulation_values_when_flame_on.append(value)
custom_components/sat/climate.py (1)

237-251: Event listeners for cycle events invoke synchronous lambdas that call synchronous methods.

The on_cycle_start and on_cycle_end methods are synchronous, and the lambdas correctly invoke them synchronously. However, these listeners are not unsubscribed when the entity is removed, which could cause issues if the entity is removed and re-added.

Consider storing the unsubscribe callbacks and calling them in async_will_remove_from_hass:

self._cycle_started_unsub = self.hass.bus.async_listen(EVENT_SAT_CYCLE_STARTED, ...)
self._cycle_ended_unsub = self.hass.bus.async_listen(EVENT_SAT_CYCLE_ENDED, ...)
custom_components/sat/cycles.py (2)

246-246: Use explicit Optional type hint per PEP 484.

The timestamp parameter uses implicit Optional (timestamp: float = None), which is discouraged.

-    def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: float = None) -> None:
+    def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: Optional[float] = None) -> None:

310-391: Consider extracting aggregation logic to reduce method complexity.

The _build_cycle_state method has many local variables (flagged by Pylint). While the logic is correct, extracting the aggregation of setpoints/temperatures into a helper method would improve readability.

def _aggregate_samples(self, samples: List[CycleSample]) -> dict:
    """Aggregate numeric fields from samples."""
    def _avg(values: List[Optional[float]]) -> Optional[float]:
        filtered = [v for v in values if v is not None]
        return sum(filtered) / len(filtered) if filtered else None
    
    setpoints = [s.boiler_state.setpoint for s in samples]
    flow_temps = [s.boiler_state.flow_temperature for s in samples]
    # ... etc
    return {
        "average_setpoint": _avg(setpoints),
        # ... etc
    }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3b1dd2b and cdb66a4.

📒 Files selected for processing (10)
  • custom_components/sat/area.py (6 hunks)
  • custom_components/sat/binary_sensor.py (4 hunks)
  • custom_components/sat/boiler.py (1 hunks)
  • custom_components/sat/climate.py (23 hunks)
  • custom_components/sat/const.py (1 hunks)
  • custom_components/sat/coordinator.py (7 hunks)
  • custom_components/sat/cycles.py (1 hunks)
  • custom_components/sat/minimum_setpoint.py (1 hunks)
  • custom_components/sat/pwm.py (8 hunks)
  • custom_components/sat/simulator/__init__.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
custom_components/sat/simulator/__init__.py (2)
custom_components/sat/coordinator.py (1)
  • async_control_heating_loop (331-349)
custom_components/sat/pwm.py (1)
  • PWMState (18-24)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/boiler.py (1)
  • BoilerState (21-34)
custom_components/sat/const.py (2)
  • CycleClassification (188-194)
  • CycleKind (181-185)
custom_components/sat/cycles.py (4)
  • CycleStatistics (67-72)
  • Cycle (40-63)
  • duty_ratio_last_15m (125-138)
  • sample_count_4h (106-112)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/pwm.py (3)
custom_components/sat/const.py (1)
  • PWMStatus (220-223)
custom_components/sat/boiler.py (1)
  • BoilerState (21-34)
custom_components/sat/setpoint_adjuster.py (1)
  • reset (20-22)
custom_components/sat/boiler.py (2)
custom_components/sat/const.py (2)
  • BoilerStatus (197-217)
  • CycleClassification (188-194)
custom_components/sat/cycles.py (1)
  • Cycle (40-63)
custom_components/sat/climate.py (4)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/const.py (3)
  • RelativeModulationState (226-230)
  • BoilerStatus (197-217)
  • PWMStatus (220-223)
custom_components/sat/util.py (1)
  • create_dynamic_minimum_setpoint_controller (48-54)
custom_components/sat/pwm.py (4)
  • state (239-244)
  • enable (83-137)
  • disable (139-142)
  • status (235-236)
custom_components/sat/area.py (1)
custom_components/sat/pid.py (4)
  • update (66-83)
  • update_reset (85-99)
  • output (264-266)
  • reset (49-64)
custom_components/sat/binary_sensor.py (3)
custom_components/sat/const.py (2)
  • BoilerStatus (197-217)
  • CycleClassification (188-194)
custom_components/sat/coordinator.py (2)
  • device_status (133-135)
  • last_cycle (157-158)
custom_components/sat/cycles.py (1)
  • last_cycle (156-157)
🪛 Pylint (4.0.3)
custom_components/sat/minimum_setpoint.py

[refactor] 23-23: Too many instance attributes (19/7)

(R0902)


[refactor] 105-106: Consider using 'minimum_setpoint = max(minimum_setpoint, allowed_minimum)' instead of unnecessary if block

(R1731)


[refactor] 116-116: Too many arguments (6/5)

(R0913)


[refactor] 116-116: Too many positional arguments (6/5)

(R0917)


[refactor] 140-140: Too many arguments (6/5)

(R0913)


[refactor] 140-140: Too many positional arguments (6/5)

(R0917)


[refactor] 279-279: Too many arguments (6/5)

(R0913)


[refactor] 279-279: Too many positional arguments (6/5)

(R0917)


[refactor] 279-279: Too many return statements (10/6)

(R0911)


[refactor] 279-279: Too many branches (13/12)

(R0912)

custom_components/sat/pwm.py

[refactor] 36-36: Too many instance attributes (19/7)

(R0902)

custom_components/sat/boiler.py

[refactor] 21-21: Too many instance attributes (8/7)

(R0902)


[refactor] 37-37: Too many instance attributes (24/7)

(R0902)


[refactor] 38-38: Too many arguments (14/5)

(R0913)


[refactor] 38-38: Too many positional arguments (14/5)

(R0917)


[refactor] 152-152: Too many return statements (18/6)

(R0911)


[refactor] 152-152: Too many branches (17/12)

(R0912)


[refactor] 295-295: Too many return statements (7/6)

(R0911)


[refactor] 381-381: Too many return statements (7/6)

(R0911)

custom_components/sat/cycles.py

[refactor] 40-40: Too many instance attributes (16/7)

(R0902)


[refactor] 218-218: Too many instance attributes (8/7)

(R0902)


[refactor] 310-310: Too many local variables (23/15)

(R0914)


[refactor] 394-394: Too many return statements (8/6)

(R0911)

🪛 Ruff (0.14.5)
custom_components/sat/simulator/__init__.py

88-88: Unused method argument: timestamp

(ARG002)


88-88: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)


88-88: Unused method argument: climate

(ARG002)


88-88: Unused method argument: pwm_state

(ARG002)

custom_components/sat/minimum_setpoint.py

116-116: Unused method argument: boiler_state

(ARG002)


138-138: f-string without any placeholders

Remove extraneous f prefix

(F541)

custom_components/sat/coordinator.py

14-14: from .const import * used; unable to detect undefined names

(F403)


263-263: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


263-263: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


331-331: Unused method argument: climate

(ARG002)


331-331: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/climate.py

180-180: CONF_MODE may be undefined, or defined from star imports

(F405)


180-180: MODE_SWITCH may be undefined, or defined from star imports

(F405)


180-180: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


237-237: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports

(F405)


245-245: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


539-539: HEATING_MODE_ECO may be undefined, or defined from star imports

(F405)


613-613: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


627-627: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


631-631: BoilerStatus may be undefined, or defined from star imports

(F405)


635-635: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


683-683: f-string without any placeholders

Remove extraneous f prefix

(F541)


695-695: f-string without any placeholders

Remove extraneous f prefix

(F541)


709-709: f-string without any placeholders

Remove extraneous f prefix

(F541)


820-820: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


821-821: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


837-837: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


838-838: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


962-962: DEADBAND may be undefined, or defined from star imports

(F405)


962-962: DEADBAND may be undefined, or defined from star imports

(F405)


1018-1018: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)

custom_components/sat/cycles.py

81-81: Avoid specifying long messages outside the exception class

(TRY003)


84-84: Avoid specifying long messages outside the exception class

(TRY003)


87-87: Avoid specifying long messages outside the exception class

(TRY003)


126-126: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)


223-223: Avoid specifying long messages outside the exception class

(TRY003)


246-246: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

🔇 Additional comments (21)
custom_components/sat/const.py (3)

175-178: LGTM!

The new event constants for cycle lifecycle are well-named and follow the existing naming convention.


181-195: LGTM!

CycleKind and CycleClassification enums are well-structured with the (str, Enum) pattern, enabling clean serialization for Home Assistant state attributes.


226-238: LGTM!

RelativeModulationState follows the (str, Enum) pattern and UNHEALTHY_CYCLES correctly identifies the problematic cycle classifications for health detection.

custom_components/sat/simulator/__init__.py (2)

88-112: Unused parameters are acceptable for interface compliance.

The static analysis flags timestamp, climate, and pwm_state as unused (ARG002). This is expected since the simulator has its own internal state management and these parameters are only needed for API compatibility with the base class. No action needed.


88-88: Parameter order inconsistency is not causing practical issues.

While the method signatures differ between the coordinator (base class) parameter order (climate, pwm_state, timestamp) and simulator (subclass) order (timestamp, climate, pwm_state), all calls in the codebase use either keyword arguments or no arguments. No positional argument calls exist that would trigger the binding issue described in the review. The parameter order difference is a design inconsistency, but it does not result in the "incorrect argument binding" problem claimed.

Likely an incorrect or invalid review comment.

custom_components/sat/binary_sensor.py (2)

27-29: LGTM!

Good defensive check to ensure required keys exist before proceeding with entity setup.


190-192: LGTM!

The boiler health check correctly uses the new BoilerStatus enum.

custom_components/sat/area.py (2)

181-188: LGTM!

The output method correctly handles the case where no areas have valid heating curve values by falling back to MINIMUM_SETPOINT.


58-64: Remove HVACMode.OFF from state check or clarify supported entity types.

The check at line 61 includes HVACMode.OFF ("off") when filtering sensor states, which is contradictory given the constant name SENSOR_TEMPERATURE_ID and docstring say "sensor." Temperature sensors never have state "off"—only climate entities do.

Two options:

  1. If only temperature sensors should be supported: remove HVACMode.OFF from the check (it's unnecessary and misleading).
  2. If both temperature sensors AND climate entities are supported: rename the constant to something like TEMPERATURE_OVERRIDE_ENTITY_ID or add documentation clarifying which entity types are acceptable. Additionally, add a config schema constraint to enforce the supported domains.

Currently, the code accepts any entity_id without validation, creating ambiguity about intent.

custom_components/sat/pwm.py (2)

17-34: LGTM!

Well-designed immutable dataclasses using frozen=True and slots=True for efficiency. PWMState cleanly encapsulates the observable state, and PWMConfig cleanly encapsulates configuration.


238-244: LGTM!

The state property correctly wraps internal state into an immutable PWMState object, with proper handling of the optional last_duty_cycle_percentage.

custom_components/sat/coordinator.py (2)

102-104: LGTM: Clean initialization of cycle-centric components.

The new Boiler, CycleHistory, and CycleTracker composition replaces the old flame-centric tracking cleanly. The wiring is straightforward and the responsibility separation is clear.


152-158: LGTM: New cycles and last_cycle properties.

Clean property implementations that delegate to the underlying CycleHistory. The API surface aligns well with the cycle-centric architecture.

custom_components/sat/boiler.py (2)

152-227: LGTM: Comprehensive and well-structured status derivation.

The _derive_status method provides a clear decision tree for determining boiler status. The ordering of checks (OFF → overshoot cooling → anti-cycling → stalled ignition → etc.) follows logical precedence. While Pylint flags many return statements, this is acceptable given the nature of state machine logic.


289-293: Potential negative time delta not guarded correctly.

Line 290-291 checks if time_since_off < 0: return False, but this check is redundant since time_since_off < self._anti_cycling_min_off_seconds (Line 293) would also be False for negative values less than the positive threshold. However, this is fine as defensive coding.

custom_components/sat/climate.py (2)

584-638: LGTM: Well-structured PWM enable/disable logic.

The refactored PWM logic with _is_pwm_forced, _should_enable_static_pwm, and _should_enable_dynamic_pwm helpers improves readability and maintainability. The decision tree is clear and the conditions are logically sound.


1010-1018: Low-pass filter and clamping logic is correct.

The exponential moving average filter (alpha = 0.2) smooths the requested setpoint appropriately, and the subsequent clamping ensures bounds are respected.

custom_components/sat/cycles.py (4)

75-168: LGTM: CycleHistory implements robust rolling window statistics.

The CycleHistory class correctly manages rolling windows for cycle tracking with proper pruning, duty ratio calculation, and median computation. The cycles_last_hour property now correctly normalizes by window duration (Line 122).


254-284: LGTM: OFF→ON transition handling with demand duration tracking.

The cycle start detection correctly computes off_with_demand_duration by checking if demand was present during the OFF period. The logic properly resets tracking when demand isn't present.


393-437: Classification logic is sound with good edge case handling.

The _classify_cycle method handles the various scenarios correctly:

  • Returns INSUFFICIENT_DATA when duration or setpoint is missing
  • Returns UNCERTAIN for short burns without temperature data
  • Correctly identifies overshoot/underheat conditions
  • Short-cycling detection uses appropriate thresholds

The PWM-aware short threshold adjustment (Lines 413-414) is a nice touch.


331-338: Cycle kind thresholds could miss edge cases.

The thresholds for determining CycleKind use > 0.8 and < 0.2 with an intermediate check for > 0.1. Consider what happens when both fractions are exactly 0.1 — this falls through to UNKNOWN. This seems intentional but verify it matches the expected behavior.

Comment thread custom_components/sat/area.py Outdated
Comment thread custom_components/sat/boiler.py Outdated
Comment on lines +116 to +126
async def async_added_to_hass(self, hass: HomeAssistant, device_id: str) -> None:
"""Called when entity is added to Home Assistant, restore persisted flags."""
if self._store is None:
self._store = Store(hass, STORAGE_VERSION, f"sat.boiler.{device_id}")

if not flame_active:
self._handle_flame_inactive()
elif self._adjusting_to_lower_setpoint:
self._handle_adjusting_to_lower_setpoint(boiler_temperature, boiler_temperature_derivative, setpoint)
elif self._active:
self._handle_tracking(boiler_temperature, boiler_temperature_derivative, setpoint)
data = await self._store.async_load() or {}
stored_flag = data.get("modulation_reliable")
if stored_flag is not None:
self._modulation_reliable = bool(stored_flag)

self._last_setpoint = setpoint
self._last_boiler_temperature = boiler_temperature
async_track_time_interval(hass, self.async_save_options, timedelta(minutes=15))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unsubscribe handle for periodic save is not stored or cleaned up.

async_track_time_interval returns an unsubscribe callback that should be stored and called during cleanup. Currently, the periodic save will continue even if the boiler is removed.

Apply this diff:

+        self._save_unsub: Optional[Callable[[], None]] = None
...
-        async_track_time_interval(hass, self.async_save_options, timedelta(minutes=15))
+        self._save_unsub = async_track_time_interval(hass, self.async_save_options, timedelta(minutes=15))

Then add cleanup in async_save_options or a new async_will_remove_from_hass:

if self._save_unsub is not None:
    self._save_unsub()
    self._save_unsub = None

Comment thread custom_components/sat/boiler.py Outdated
Comment thread custom_components/sat/climate.py Outdated
Comment thread custom_components/sat/climate.py Outdated
Comment thread custom_components/sat/minimum_setpoint.py Outdated
Comment thread custom_components/sat/minimum_setpoint.py Outdated
Comment thread custom_components/sat/minimum_setpoint.py Outdated
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)
custom_components/sat/cycles.py (1)

124-138: Minor: Replace EN DASH with HYPHEN-MINUS in docstring.

The docstring uses an EN DASH () instead of a standard HYPHEN-MINUS (-) which can cause linting warnings (Ruff RUF002).

 @property
 def duty_ratio_last_15m(self) -> float:
-    """Duty ratio (0.0–1.0) over the duty window, derived from recorded cycles."""
+    """Duty ratio (0.0-1.0) over the duty window, derived from recorded cycles."""
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cdb66a4 and 2c2ba99.

📒 Files selected for processing (1)
  • custom_components/sat/cycles.py (1 hunks)
🧰 Additional context used
🪛 Pylint (4.0.3)
custom_components/sat/cycles.py

[refactor] 40-40: Too many instance attributes (16/7)

(R0902)


[refactor] 218-218: Too many instance attributes (8/7)

(R0902)


[refactor] 310-310: Too many local variables (23/15)

(R0914)


[refactor] 394-394: Too many return statements (8/6)

(R0911)

🪛 Ruff (0.14.5)
custom_components/sat/cycles.py

81-81: Avoid specifying long messages outside the exception class

(TRY003)


84-84: Avoid specifying long messages outside the exception class

(TRY003)


87-87: Avoid specifying long messages outside the exception class

(TRY003)


126-126: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)


223-223: Avoid specifying long messages outside the exception class

(TRY003)


246-246: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

🔇 Additional comments (7)
custom_components/sat/cycles.py (7)

1-31: LGTM!

Clean imports with proper TYPE_CHECKING guard to avoid circular dependencies. Constants are well-documented with clear thresholds for cycle classification logic.


39-64: LGTM!

Well-structured frozen dataclass representing cycle metrics. The 16 attributes are justified for a comprehensive DTO capturing all relevant cycle statistics.


393-437: LGTM!

The classification logic handles various cycle scenarios with clear decision boundaries. The multiple return statements create a readable decision tree that covers edge cases appropriately (e.g., falling back to UNCERTAIN when both overshoot and underheat conditions are ambiguous).


310-391: LGTM!

The cycle building logic correctly aggregates samples, handles None values gracefully in averages and min/max calculations, and uses sensible thresholds for kind classification. The local _avg helper is a clean pattern for filtered averaging.


254-308: LGTM!

The state machine correctly handles all flame transitions. The OFF→ON transition properly captures off-with-demand duration before starting a new cycle, and the ON→OFF transition finalizes and records the cycle. The demand detection logic (lines 262-268) appropriately excludes DHW periods.


75-216: LGTM!

CycleHistory is well-implemented with proper validation, efficient deque usage for rolling windows, and correct pruning semantics. The _current_time_hint pattern elegantly handles the absence of a system clock by deriving time from recorded data.


413-414: No issues found with duty_cycle access.

The type definition Optional[Tuple[int, int]] guarantees that when duty_cycle is not None (checked by the truthy condition), it always contains exactly 2 elements. Therefore, accessing pwm_state.duty_cycle[0] is safe and no additional length check is needed.

Likely an incorrect or invalid review comment.

Comment thread custom_components/sat/cycles.py Outdated
Comment thread custom_components/sat/cycles.py Outdated
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: 1

♻️ Duplicate comments (11)
custom_components/sat/area.py (1)

163-179: Potential None passed to PID.update where float is expected.

The guard at lines 168 and 177 only checks area.error is not None but not area.heating_curve.value. Since heating_curve.value can be None (as State.value is Optional[float]), and PID.update expects a float, this can cause a type error.

Apply this diff to add the guard:

         def update(self, entity_id: str) -> None:
             if (area := self.get(entity_id)) is None:
                 _LOGGER.warning(f"Could not update PID controller for entity {entity_id}. Areas: {self._areas}.")
                 return
 
-            if area.error is not None:
+            if area.error is not None and area.heating_curve.value is not None:
                 _LOGGER.info(f"Updating error to {area.error.value} from {area.id} (Reset: False)")
                 area.pid.update(area.error, area.heating_curve.value)
 
         def update_reset(self, entity_id: str) -> None:
             if (area := self.get(entity_id)) is None:
                 _LOGGER.warning(f"Could not update PID controller for entity {entity_id}")
                 return
 
-            if area.error is not None:
+            if area.error is not None and area.heating_curve.value is not None:
                 _LOGGER.info(f"Updating error to {area.error.value} from {area.id} (Reset: True)")
                 area.pid.update_reset(area.error, area.heating_curve.value)
custom_components/sat/cycles.py (2)

246-246: Fix implicit Optional type annotation.

The timestamp parameter defaults to None but is typed as float, violating PEP 484.

Apply this diff:

-    def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: float = None) -> None:
+    def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: Optional[float] = None) -> None:

297-298: Convert Cycle dataclass to dict before firing event.

Home Assistant's event bus requires JSON-serializable data. The frozen Cycle dataclass will fail serialization during event persistence.

Apply this fix:

+from dataclasses import asdict
+
 ...
-        cycle_state = self._build_cycle_state(timestamp)
-        self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle_state})
+        cycle_state = self._build_cycle_state(timestamp)
+        self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle_state)})
custom_components/sat/minimum_setpoint.py (4)

28-35: Fix inverted comments and use the unused duty ratio parameter.

The comments for increase_step and decrease_step are inverted relative to their actual usage in _maybe_tune_minimum. Also, low_load_maximum_duty_ratio_15m is defined but never checked.

Apply these fixes:

-    # How quickly the learned minimum moves when we detect a clear error
-    increase_step: float = 1.0  # when minimum is too low (underheat / too short)
-    decrease_step: float = 1.0  # when minimum is too high (overshoot / short-cycling)
+    # How quickly the learned minimum moves when we detect a clear error
+    increase_step: float = 1.0  # raise minimum when overshooting or short-cycling
+    decrease_step: float = 1.0  # lower minimum when underheating

And in _is_tunable_regime (after line 385), add:

     if statistics.last_hour_count < self._config.low_load_minimum_cycles_per_hour:
         return False
+    
+    if statistics.duty_ratio_last_15m > self._config.low_load_maximum_duty_ratio_15m:
+        return False

     return True

137-137: Fix malformed f-string.

The f-string uses C-style format specifiers without actual placeholders.

Apply this diff:

-            _LOGGER.debug(f"Updated regime %s minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint)
+            _LOGGER.debug("Updated regime minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint)

221-221: Fix return type annotation.

_regime_for always returns a RegimeState (creating one if missing), so the return type should not be Optional.

-    def _regime_for(self, cycles: CycleStatistics, requested_setpoint: float, outside_temperature: Optional[float]) -> Optional[RegimeState]:
+    def _regime_for(self, cycles: CycleStatistics, requested_setpoint: float, outside_temperature: Optional[float]) -> RegimeState:

257-275: Fix regime key parsing bug.

The code attempts to extract the setpoint band with split(":", 1)[1], but for keys like "10:cold:normal", this returns "cold:normal", causing int() to fail.

Apply this diff:

     def _initial_minimum_for_regime(self, regime_key: str, requested_setpoint: float) -> float:
         # If we already have regimes, reuse the nearest one (unchanged)
         if self._regimes:
             try:
-                target_bucket = int(regime_key.split(":", 1)[1])
+                target_bucket = int(regime_key.split(":", 1)[0])
             except (IndexError, ValueError):
                 target_bucket = 0

             def bucket_of(key: str) -> int:
                 try:
-                    return int(key.split(":", 1)[1])
+                    return int(key.split(":", 1)[0])
                 except (IndexError, ValueError):
                     return 0
custom_components/sat/climate.py (4)

683-683: Remove extraneous f-string prefix.

The f-string has no placeholders and uses %-style formatting instead.

-        _LOGGER.debug(f"Inside sensor changed (%.2f°C).", float(new_state.state))
+        _LOGGER.debug("Inside sensor changed (%.2f°C).", float(new_state.state))

695-695: Remove extraneous f-string prefix.

-        _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature)
+        _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)

709-709: Remove extraneous f-string prefix.

-        _LOGGER.debug(f"Humidity sensor changed (%.2f%%).", float(new_state.state))
+        _LOGGER.debug("Humidity sensor changed (%.2f%%).", float(new_state.state))

912-928: Fix async_call_later to use sync callback that schedules coroutine.

Home Assistant's async_call_later requires a sync callback. Passing an async function directly to HassJob will fail.

Apply this fix:

-        self._control_pid_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_pid))
+        def _schedule_pid():
+            self.hass.async_create_task(self.async_control_pid())
+        
+        self._control_pid_unsub = async_call_later(self.hass, 10, _schedule_pid)
🧹 Nitpick comments (2)
custom_components/sat/area.py (2)

89-93: Redundant multiplication by 1.0.

raw_weight = effective_delta * 1.0 is a no-op. If this is a placeholder for a configurable weight factor, consider adding a named constant or comment. Otherwise, simplify:

         delta = target_temperature - current_temperature
         effective_delta = max(delta - 0.2, 0.0)
-        raw_weight = effective_delta * 1.0
-
-        return round(max(0.0, min(raw_weight, 2.0)), 3)
+        return round(max(0.0, min(effective_delta, 2.0)), 3)

156-161: Consider using a dictionary for O(1) lookups.

The get() method performs a linear search. For typical home setups with few areas, this is fine. If the number of areas grows, consider using a dict[str, Area] keyed by entity_id for O(1) lookups.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2c2ba99 and b56dd48.

📒 Files selected for processing (4)
  • custom_components/sat/area.py (6 hunks)
  • custom_components/sat/climate.py (24 hunks)
  • custom_components/sat/cycles.py (1 hunks)
  • custom_components/sat/minimum_setpoint.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
custom_components/sat/area.py (3)
custom_components/sat/state.py (1)
  • State (10-12)
custom_components/sat/helpers.py (1)
  • float_value (60-69)
custom_components/sat/errors.py (2)
  • Error (37-40)
  • max (29-33)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/boiler.py (1)
  • BoilerState (21-34)
custom_components/sat/const.py (2)
  • CycleClassification (188-194)
  • CycleKind (181-185)
custom_components/sat/cycles.py (1)
  • Cycle (40-63)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/cycles.py (4)
custom_components/sat/const.py (2)
  • CycleKind (181-185)
  • CycleClassification (188-194)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/pwm.py (2)
  • PWMState (18-24)
  • enabled (231-232)
custom_components/sat/boiler.py (1)
  • BoilerState (21-34)
custom_components/sat/climate.py (4)
custom_components/sat/helpers.py (2)
  • convert_time_str_to_seconds (19-28)
  • clamp (90-95)
custom_components/sat/coordinator.py (5)
  • get (408-423)
  • minimum_setpoint (261-263)
  • state (138-150)
  • setpoint (166-167)
  • update (44-49)
custom_components/sat/minimum_setpoint.py (2)
  • reset (109-113)
  • value (89-107)
custom_components/sat/heating_curve.py (4)
  • reset (21-25)
  • value (91-92)
  • update (27-30)
  • autotune (37-63)
🪛 Pylint (4.0.3)
custom_components/sat/minimum_setpoint.py

[refactor] 23-23: Too many instance attributes (19/7)

(R0902)


[refactor] 104-105: Consider using 'minimum_setpoint = max(minimum_setpoint, allowed_minimum)' instead of unnecessary if block

(R1731)


[refactor] 115-115: Too many arguments (6/5)

(R0913)


[refactor] 115-115: Too many positional arguments (6/5)

(R0917)


[refactor] 139-139: Too many arguments (6/5)

(R0913)


[refactor] 139-139: Too many positional arguments (6/5)

(R0917)


[refactor] 276-276: Too many arguments (6/5)

(R0913)


[refactor] 276-276: Too many positional arguments (6/5)

(R0917)


[refactor] 276-276: Too many return statements (10/6)

(R0911)


[refactor] 276-276: Too many branches (13/12)

(R0912)

custom_components/sat/cycles.py

[refactor] 40-40: Too many instance attributes (16/7)

(R0902)


[refactor] 218-218: Too many instance attributes (8/7)

(R0902)


[refactor] 310-310: Too many local variables (23/15)

(R0914)


[refactor] 394-394: Too many return statements (8/6)

(R0911)

🪛 Ruff (0.14.5)
custom_components/sat/minimum_setpoint.py

115-115: Unused method argument: boiler_state

(ARG002)


137-137: f-string without any placeholders

Remove extraneous f prefix

(F541)

custom_components/sat/cycles.py

81-81: Avoid specifying long messages outside the exception class

(TRY003)


84-84: Avoid specifying long messages outside the exception class

(TRY003)


87-87: Avoid specifying long messages outside the exception class

(TRY003)


126-126: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)


223-223: Avoid specifying long messages outside the exception class

(TRY003)


246-246: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/climate.py

180-180: CONF_MODE may be undefined, or defined from star imports

(F405)


180-180: MODE_SWITCH may be undefined, or defined from star imports

(F405)


180-180: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


237-237: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports

(F405)


245-245: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


539-539: HEATING_MODE_ECO may be undefined, or defined from star imports

(F405)


613-613: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


627-627: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


631-631: BoilerStatus may be undefined, or defined from star imports

(F405)


635-635: BOILER_DEADBAND may be undefined, or defined from star imports

(F405)


683-683: f-string without any placeholders

Remove extraneous f prefix

(F541)


695-695: f-string without any placeholders

Remove extraneous f prefix

(F541)


709-709: f-string without any placeholders

Remove extraneous f prefix

(F541)


820-820: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


821-821: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


837-837: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


838-838: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


962-962: DEADBAND may be undefined, or defined from star imports

(F405)


962-962: DEADBAND may be undefined, or defined from star imports

(F405)


1018-1018: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)

🔇 Additional comments (23)
custom_components/sat/area.py (5)

1-20: LGTM!

Imports and module-level constants are appropriate. The logging setup follows standard patterns.


36-50: LGTM!

Properties properly handle None cases and use float_value for safe type conversion.


59-64: Questionable fallback logic and HVACMode check.

Two concerns:

  1. Line 61: Comparing sensor_state.state against HVACMode.OFF seems semantically incorrect. HVACMode.OFF is an HVAC mode enum value, not a typical sensor state. If the intent is to check for "off" string state, use the string literal directly.

  2. Line 64: Falling back to target_temperature when current_temperature is unavailable may mask issues. The error calculation (target - current) would incorrectly be 0, potentially causing incorrect PID behavior.

Consider this adjustment for line 61:

-            if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]:
+            if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, "off"]:

For line 64, verify if fallback to target temperature is intentional behavior when current temperature is unavailable.


98-104: LGTM!

Proper null guards before calling pid.update_integral, preventing potential type errors.


181-188: LGTM!

The output() method correctly filters out areas with None heating curve values before computing the maximum, providing a safe fallback to MINIMUM_SETPOINT.

custom_components/sat/cycles.py (5)

1-31: LGTM: Module setup and constants are well-defined.

The imports are appropriate, constants are clearly named with sensible defaults, and the module structure is clean.


33-73: LGTM: Dataclass design is appropriate.

The CycleSample, Cycle, and CycleStatistics dataclasses are well-structured with frozen=True and slots=True for immutability and memory efficiency. While Cycle has 16 attributes (triggering a Pylint warning), they are all logically related fields for representing a complete heating cycle, making this an appropriate data transfer object.


75-216: LGTM: CycleHistory implementation is robust.

The rolling window management is well-implemented:

  • Proper validation of window parameters
  • Correct normalization of cycles_last_hour to cycles per hour (line 122)
  • Efficient pruning of stale data
  • Clean separation of concerns between different rolling windows

The static analysis hints about exception messages (TRY003) are minor style suggestions that can be addressed later if desired.


218-309: LGTM: Cycle detection logic is well-structured.

The state machine for tracking flame transitions (OFF→ON, ON→ON, ON→OFF) is clear and correct:

  • Properly records off-with-demand duration before cycle starts
  • Accumulates samples during ON phase
  • Finalizes and records cycle on OFF transition
  • Fires appropriate events for cycle lifecycle

The cycle building and classification logic is comprehensive and handles edge cases appropriately.


310-434: LGTM: Cycle building and classification logic is comprehensive.

The _build_cycle_state method correctly:

  • Validates minimum sample requirements
  • Computes cycle kind based on DHW/heating fractions
  • Calculates accurate averages and extremes
  • Handles None values appropriately

The _classify_cycle method provides robust classification with appropriate handling of:

  • Insufficient data scenarios
  • PWM-aware short burn thresholds
  • Low-load short-cycling detection
  • Overshoot vs. underheat detection
custom_components/sat/minimum_setpoint.py (5)

1-21: LGTM: Module setup is clean.

Imports are appropriate and STORAGE_VERSION is properly defined for future migration handling.


67-114: LGTM: Initialization and value property are well-designed.

The regime management and value calculation logic is sound:

  • Properly initializes state
  • Returns active regime minimum with appropriate fallbacks
  • Includes safety guard against excessive drift from recent base setpoints

The Pylint suggestion at lines 104-105 (using max() instead of an if-block) is a minor style optimization that can be addressed later.


139-156: LGTM: on_cycle_end integrates well with event-driven architecture.

The method correctly:

  • Determines the active regime
  • Handles large setpoint jumps with damping
  • Increments cycle counter
  • Delegates to tuning logic

Unlike previous versions that called this from a polling loop, the current event-driven design (triggered by EVENT_SAT_CYCLE_ENDED) ensures this method is invoked once per cycle, eliminating the repeated-tuning concern from earlier reviews.


157-220: LGTM: Persistence logic is robust.

The storage implementation correctly:

  • Initializes Store with versioning support
  • Validates and clamps loaded data
  • Handles missing or invalid fields gracefully
  • Schedules periodic saves every 15 minutes

276-427: LGTM: Tuning logic is comprehensive and well-reasoned.

The _maybe_tune_minimum method correctly:

  • Respects warmup periods before tuning
  • Validates cycle suitability (heating-dominant, close to current minimum)
  • Applies appropriate adjustments based on classification:
    • Lowers minimum for underheat (setpoint too high)
    • Raises minimum for overshoot/short-cycling (setpoint too low)
    • Relaxes toward base for uncertain cycles
  • Includes safeguards against excessive drift

The helper methods (_is_tunable_regime, _relax_toward_base, _maybe_damp_on_large_jump) provide clean separation of concerns. The Pylint warnings about complexity are acceptable given the nuanced logic required for adaptive setpoint control.

custom_components/sat/climate.py (8)

1-202: LGTM: Climate entity initialization is well-structured.

The setup correctly:

  • Imports necessary dependencies including partial for deferred execution
  • Initializes the dynamic minimum setpoint controller
  • Creates PWM controller with heating curve dependency
  • Sets up debouncing infrastructure with unsub handles
  • Properly configures preset modes and supported features

237-251: LGTM: Cycle event listeners correctly integrate minimum setpoint controller.

The event-driven approach properly:

  • Listens for cycle start/end events
  • Passes all required context (boiler state, statistics, cycle, setpoint, temperature)
  • Uses sync lambdas calling sync methods (no coroutine issues)

262-382: LGTM: Event listener registration and state restoration are correct.

The lifecycle management properly:

  • Registers all necessary state change listeners
  • Handles missing or unavailable sensors gracefully
  • Restores previous state with sensible fallbacks
  • Tracks coordinator updates appropriately

584-639: LGTM: PWM enablement logic is well-structured and comprehensive.

The refactored decision logic cleanly separates concerns:

  • _is_pwm_forced: Checks for relay-only mode or explicit forcing
  • _should_enable_static_pwm: Handles legacy fixed minimum setpoint with hysteresis
  • _should_enable_dynamic_pwm: Uses cycle history and classification for intelligent enablement

The dynamic logic appropriately enables PWM for unhealthy cycles, stalled ignition, or when near the learned minimum, while maintaining the current state when no new information is available.


686-687: LGTM: Sensor change handlers correctly use debounced scheduling.

The handlers properly call schedule_control_pid() instead of directly invoking async methods, ensuring debounced PID updates to reduce computational overhead from rapid sensor changes.

Also applies to: 701-701, 712-713


813-890: LGTM: Control methods integrate well with new architecture.

The refactored methods correctly:

  • Use _last_requested_setpoint for setpoint decisions
  • Apply PWM-aware logic for normal vs. minimum setpoints
  • Reset both PWM and minimum setpoint controllers when needed
  • Handle relative modulation with manufacturer-specific adjustments

930-1042: LGTM: PID and heating loop control methods are well-integrated.

The refactored control flow correctly:

  • Implements debounced execution with proper unsub management
  • Validates required inputs before proceeding
  • Applies low-pass filtering to requested setpoint to reduce oscillation
  • Integrates PWM, minimum setpoint, and coordinator state appropriately
  • Handles reset scenarios by clearing _last_requested_setpoint
  • Uses autotuning when in deadband range

The separation between async_control_pid (error calculation) and async_control_heating_loop (setpoint application) is clean and logical.


1077-1184: LGTM: HVAC mode and temperature setpoint methods handle resets correctly.

The methods properly:

  • Reset PID controllers and minimum setpoint state on mode/temperature changes
  • Use schedule_control_pid(reset=True) for immediate reset execution
  • Cascade changes to controlled climates when appropriate
  • Maintain preset mode synchronization

Comment thread custom_components/sat/climate.py Outdated
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: 0

♻️ Duplicate comments (4)
custom_components/sat/minimum_setpoint.py (4)

28-34: Config comments are inverted relative to actual usage; low_load_maximum_duty_ratio_15m is unused.

The comments on increase_step and decrease_step describe the opposite of what the code does:

  • increase_step is applied for overshoot/short-cycling (to raise the minimum)
  • decrease_step is applied for underheat (to lower the minimum)

Additionally, low_load_maximum_duty_ratio_15m is defined but never used in _is_tunable_regime.


221-221: Return type annotation is incorrect.

The method always returns a RegimeState (creating one if needed), but is annotated as Optional[RegimeState].

-    def _regime_for(self, cycles: CycleStatistics, requested_setpoint: float, outside_temperature: Optional[float]) -> Optional[RegimeState]:
+    def _regime_for(self, cycles: CycleStatistics, requested_setpoint: float, outside_temperature: Optional[float]) -> RegimeState:

257-274: Bug: Incorrect index when extracting setpoint band from regime key.

Given the key format "{setpoint_band}:{temp_band}:{load_band}" (e.g., "10:cold:normal"), split(":", 1)[1] returns "cold:normal", not the setpoint band. This causes int() to always fail, making the nearest-bucket lookup ineffective.

         if self._regimes:
             try:
-                target_bucket = int(regime_key.split(":", 1)[1])
+                target_bucket = int(regime_key.split(":", 1)[0])
             except (IndexError, ValueError):
                 target_bucket = 0

             def bucket_of(key: str) -> int:
                 try:
-                    return int(key.split(":", 1)[1])
+                    return int(key.split(":", 1)[0])
                 except (IndexError, ValueError):
                     return 0

137-137: Malformed f-string: missing placeholders.

The f-string uses C-style %s and %.1f format specifiers instead of f-string placeholders. This will log a literal %s instead of the regime key.

-            _LOGGER.debug(f"Updated regime %s minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint)
+            _LOGGER.debug("Updated regime %s minimum_setpoint=%.1f after starvation.", self._active_regime_key, regime_state.minimum_setpoint)

Note: Using standard %-style formatting with _LOGGER.debug is preferred for lazy evaluation.

🧹 Nitpick comments (3)
custom_components/sat/minimum_setpoint.py (3)

101-107: Consider simplifying with max().

The conditional assignment can be simplified.

         # Additional guard: do not allow minimum to drift too far below recent bases.
         if self._last_requested_setpoint is not None:
             allowed_minimum = max(self._config.minimum_setpoint, self._last_requested_setpoint - self._config.max_deviation_from_recent_base)
-            if minimum_setpoint < allowed_minimum:
-                minimum_setpoint = allowed_minimum
+            minimum_setpoint = max(minimum_setpoint, allowed_minimum)

         return self._clamp_setpoint(minimum_setpoint)

165-167: Consider rejecting data on version mismatch or documenting migration strategy.

When version != STORAGE_VERSION, the code logs a debug message but continues loading the data anyway. If the storage format changes in a future version, this could lead to subtle bugs or incorrect state restoration.

Consider either:

  1. Clearing regimes and starting fresh on version mismatch
  2. Implementing explicit migration logic for version upgrades
         version = data.get("version")
         if version != STORAGE_VERSION:
             _LOGGER.debug("Unknown minimum setpoint storage version: %s", version)
+            return  # Don't load incompatible data

115-117: Consider using or removing unused boiler_state parameter.

The boiler_state parameter is unused in on_cycle_start. If this is intentional for API consistency with on_cycle_end, consider prefixing with underscore (_boiler_state) to signal intent.

-    def on_cycle_start(self, boiler_state: BoilerState, cycles: CycleStatistics, last_cycle: Optional[Cycle], requested_setpoint: Optional[float], outside_temperature: Optional[float]) -> None:
+    def on_cycle_start(self, _boiler_state: BoilerState, cycles: CycleStatistics, last_cycle: Optional[Cycle], requested_setpoint: Optional[float], outside_temperature: Optional[float]) -> None:
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b56dd48 and 2db4f5c.

📒 Files selected for processing (1)
  • custom_components/sat/minimum_setpoint.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/boiler.py (1)
  • BoilerState (21-34)
custom_components/sat/const.py (2)
  • CycleClassification (188-194)
  • CycleKind (181-185)
custom_components/sat/cycles.py (2)
  • Cycle (40-63)
  • reset (239-244)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
🪛 Pylint (4.0.3)
custom_components/sat/minimum_setpoint.py

[refactor] 23-23: Too many instance attributes (19/7)

(R0902)


[refactor] 104-105: Consider using 'minimum_setpoint = max(minimum_setpoint, allowed_minimum)' instead of unnecessary if block

(R1731)


[refactor] 115-115: Too many arguments (6/5)

(R0913)


[refactor] 115-115: Too many positional arguments (6/5)

(R0917)


[refactor] 139-139: Too many arguments (6/5)

(R0913)


[refactor] 139-139: Too many positional arguments (6/5)

(R0917)


[refactor] 276-276: Too many arguments (6/5)

(R0913)


[refactor] 276-276: Too many positional arguments (6/5)

(R0917)


[refactor] 276-276: Too many return statements (10/6)

(R0911)


[refactor] 276-276: Too many branches (13/12)

(R0912)

🪛 Ruff (0.14.5)
custom_components/sat/minimum_setpoint.py

115-115: Unused method argument: boiler_state

(ARG002)


137-137: f-string without any placeholders

Remove extraneous f prefix

(F541)

🔇 Additional comments (9)
custom_components/sat/minimum_setpoint.py (9)

1-20: LGTM!

Imports are well-organized with proper separation between standard library, Home Assistant, and local imports. Storage versioning is correctly initialized.


67-70: LGTM!

Simple, focused dataclass for regime state tracking. Appropriate use of slots=True for memory efficiency.


139-155: LGTM! Event-driven design addresses previous repeated-tuning concern.

The refactor to on_cycle_end (called once per cycle completion) properly addresses the previous concern about repeated tuning on the same cycle. The completed_cycles increment and tuning now happen exactly once per cycle.


201-219: LGTM!

Clean serialization with proper null-check on store.


236-255: LGTM!

Clear regime key construction with well-defined bands for setpoint, temperature, and load conditions.


276-368: Method is complex but logically sound.

The tuning logic correctly handles each CycleClassification:

  • GOOD/INSUFFICIENT_DATA: no change (stable)
  • TOO_SHORT_UNDERHEAT: decrease minimum (with modulation guard)
  • TOO_SHORT_OVERSHOOT/SHORT_CYCLING_OVERSHOOT: increase minimum
  • UNCERTAIN: relax toward base

While static analysis flags complexity (too many returns/branches), the early-return pattern is appropriate for this decision tree.


370-384: LGTM!

Tuning eligibility checks are appropriate. Note: The unused low_load_maximum_duty_ratio_15m config parameter was flagged earlier.


386-401: LGTM!

Proper exponential relaxation toward the base setpoint with appropriate factor validation.


403-425: LGTM!

Jump detection prevents aggressive minimum application when transitioning between regimes. Clean delegation to the clamp helper.

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: 0

♻️ Duplicate comments (4)
custom_components/sat/area.py (2)

92-92: Remove climate-specific enum from sensor state validation.

Line 92 checks for HVACMode.OFF when validating sensor states, but sensor_temperature_id references sensor entities (not climate entities). Sensors don't have HVAC modes. If you need to filter an "off" state from sensors, use the string literal "off" instead.

🔎 Proposed fix
-        if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]:
+        if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]:

203-203: Wrap update() call in event handler to match expected signature.

EVENT_HOMEASSISTANT_STARTED handlers receive an Event object, but update() expects Optional[datetime]. This signature mismatch will cause a runtime error.

🔎 Proposed fix
-            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update)
+            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update())
tests/test_pid.py (1)

85-105: Test validates buggy behavior: automatic_gain_value parameter is ignored.

The test passes automatic_gain_value=2.0 (line 90), but the PID implementation at custom_components/sat/pid.py line 51 ignores this parameter and hardcodes the value as 4 for underfloor heating. The test expects kp=20.0, which matches the buggy calculation: (2.0 * 40.0) / 4 = 20.0.

When the bug in pid.py is fixed to use the constructor parameter, this test will fail. The expected value should then be kp=40.0 (i.e., (2.0 * 40.0) / 2.0 = 40.0).

🔎 Expected fix (after pid.py is corrected)
     pid.update(Error("sensor.test", 0.05), 5.0, heating_curve=40.0)

-    assert pid.kp == 20.0
-    assert pid.ki == round(20.0 / 8400, 6)
-    assert pid.kd == round(0.07 * 8400 * 20.0, 6)
+    assert pid.kp == 40.0
+    assert pid.ki == round(40.0 / 8400, 6)
+    assert pid.kd == round(0.07 * 8400 * 40.0, 6)
custom_components/sat/pid.py (1)

26-52: Constructor parameter automatic_gain_value is stored but never used.

Line 32 stores the automatic_gain_value parameter in self._automatic_gains_value, but line 51 hardcodes the value as 4 or 3 based on the heating system, completely ignoring the stored parameter. This means users configuring automatic_gain_value will see no effect.

Either use the stored parameter or remove it from the constructor signature.

🔎 Option 1: Use the constructor parameter
         if self._heating_curve is None:
             return 0.0

-        automatic_gain_value = 4 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 3
+        # Use configured value, falling back to default based on heating system
+        automatic_gain_value = self._automatic_gains_value if self._automatic_gains_value > 0 else (4 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 3)
         return round((self._heating_curve_coefficient * self._heating_curve) / automatic_gain_value, 6)
🔎 Option 2: Remove the unused parameter
-    def __init__(self, heating_system: str, automatic_gain_value: float, heating_curve_coefficient: float, kp: float, ki: float, kd: float, automatic_gains: bool = False) -> None:
+    def __init__(self, heating_system: str, heating_curve_coefficient: float, kp: float, ki: float, kd: float, automatic_gains: bool = False) -> None:
         self._kp: float = kp
         self._ki: float = ki
         self._kd: float = kd
         self._heating_system: str = heating_system
         self._automatic_gains: bool = automatic_gains
-        self._automatic_gains_value: float = automatic_gain_value
         self._heating_curve_coefficient: float = heating_curve_coefficient

Note: This requires updating all call sites that pass automatic_gain_value.

🧹 Nitpick comments (2)
custom_components/sat/area.py (2)

275-275: Add type annotation for areas parameter.

The areas parameter lacks a type annotation while other parameters are annotated. Based on usage, it should be list[Area].

🔎 Proposed fix
-        def __init__(self, areas, percentile: float = 0.75, headroom: float = 5.0):
+        def __init__(self, areas: list[Area], percentile: float = 0.75, headroom: float = 5.0):

290-294: Consider catching more specific exceptions in PID output aggregation.

The broad Exception catch provides robustness but may hide unexpected bugs. Consider catching specific exceptions like (TypeError, ValueError, AttributeError) if you can anticipate the failure modes.

🔎 Proposed refinement
-                except Exception as exception:
+                except (TypeError, ValueError, AttributeError) as exception:
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c05ff71 and 66a6808.

📒 Files selected for processing (3)
  • custom_components/sat/area.py
  • custom_components/sat/pid.py
  • tests/test_pid.py
🧰 Additional context used
🧬 Code graph analysis (3)
custom_components/sat/pid.py (4)
custom_components/sat/state.py (1)
  • State (10-12)
custom_components/sat/errors.py (1)
  • Error (37-40)
custom_components/sat/helpers.py (1)
  • timestamp (12-14)
custom_components/sat/climate.py (1)
  • error (430-438)
custom_components/sat/area.py (4)
custom_components/sat/heating_curve.py (3)
  • update (27-30)
  • value (91-92)
  • reset (21-25)
custom_components/sat/helpers.py (4)
  • convert_time_str_to_seconds (40-49)
  • is_state_stale (32-37)
  • state_age_seconds (27-29)
  • event_timestamp (17-19)
custom_components/sat/pid.py (5)
  • PID (23-211)
  • available (38-40)
  • output (94-99)
  • update (138-148)
  • reset (101-115)
custom_components/sat/climate.py (3)
  • current_temperature (411-419)
  • target_temperature (406-408)
  • error (430-438)
tests/test_pid.py (1)
custom_components/sat/pid.py (11)
  • PID (23-211)
  • kp (43-52)
  • ki (55-60)
  • kd (63-68)
  • integral (76-78)
  • raw_derivative (89-91)
  • derivative (81-86)
  • output (94-99)
  • update (138-148)
  • proportional (71-73)
  • restore (117-136)
🪛 Pylint (4.0.4)
custom_components/sat/pid.py

[refactor] 23-23: Too many instance attributes (15/7)

(R0902)


[refactor] 26-26: Too many arguments (8/5)

(R0913)


[refactor] 26-26: Too many positional arguments (8/5)

(R0917)

custom_components/sat/area.py

[refactor] 37-37: Too many instance attributes (8/7)

(R0902)

tests/test_pid.py

[refactor] 19-19: Too few public methods (1/2)

(R0903)

🪛 Ruff (0.14.10)
custom_components/sat/pid.py

8-8: from .const import * used; unable to detect undefined names

(F403)


51-51: HEATING_SYSTEM_UNDERFLOOR may be undefined, or defined from star imports

(F405)


154-154: DEADBAND may be undefined, or defined from star imports

(F405)


158-158: DEADBAND may be undefined, or defined from star imports

(F405)


186-186: DEADBAND may be undefined, or defined from star imports

(F405)

custom_components/sat/area.py

292-292: Do not catch blind exception: Exception

(BLE001)

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: 0

♻️ Duplicate comments (1)
custom_components/sat/pid.py (1)

42-52: Constructor parameter automatic_gain_value is still being ignored.

Line 51 hardcodes automatic_gain_value based on heating system type instead of using self._automatic_gains_value (stored at line 32). This issue was flagged in previous reviews but remains unresolved.

When automatic_gains=True, the configured value passed to the constructor is completely ignored, making the parameter effectively useless.

🔎 Proposed fix
-        automatic_gain_value = 4 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 3
+        # Use configured value, falling back to default based on heating system
+        automatic_gain_value = self._automatic_gains_value if self._automatic_gains_value > 0 else (4 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 3)
         return round((self._heating_curve_coefficient * self._heating_curve) / automatic_gain_value, 6)

Alternatively, if the hardcoded logic is intentional, remove the unused constructor parameter and the stored attribute.

Based on learnings, this issue persists from previous reviews.

🧹 Nitpick comments (1)
custom_components/sat/pid.py (1)

150-175: Integral anti-windup logic is sound.

The implementation correctly prevents integral windup by:

  1. Resetting the time base when entering the deadband (line 154)
  2. Zeroing integral when error exceeds deadband (lines 158-160)
  3. Clamping accumulated integral to curve bounds (lines 171-172)

This ensures integral action only occurs when close to the target, avoiding overshoot.

Minor: Redundant unary plus operator

Line 171 uses float(+heating_curve_value) where the unary + is redundant:

-        self._integral = min(self._integral, float(+heating_curve_value))
+        self._integral = min(self._integral, float(heating_curve_value))
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 66a6808 and edb5a08.

📒 Files selected for processing (1)
  • custom_components/sat/pid.py
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/pid.py (2)
custom_components/sat/state.py (1)
  • State (10-12)
custom_components/sat/helpers.py (1)
  • timestamp (12-14)
🪛 Pylint (4.0.4)
custom_components/sat/pid.py

[refactor] 23-23: Too many instance attributes (15/7)

(R0902)


[refactor] 26-26: Too many arguments (8/5)

(R0913)


[refactor] 26-26: Too many positional arguments (8/5)

(R0917)

🪛 Ruff (0.14.10)
custom_components/sat/pid.py

8-8: from .const import * used; unable to detect undefined names

(F403)


51-51: HEATING_SYSTEM_UNDERFLOOR may be undefined, or defined from star imports

(F405)


154-154: DEADBAND may be undefined, or defined from star imports

(F405)


158-158: DEADBAND may be undefined, or defined from star imports

(F405)


187-187: DEADBAND may be undefined, or defined from star imports

(F405)

🔇 Additional comments (4)
custom_components/sat/pid.py (4)

101-116: LGTM!

The reset logic correctly initializes all state variables, timing anchors, and accumulators to appropriate starting values.


117-137: LGTM!

The restore logic correctly reconstructs PID state from saved attributes and resets timing anchors to the current moment, ensuring derivative calculations start fresh after restoration.


138-149: LGTM!

The update flow correctly stores the heating curve, updates derivative and integral components, and maintains error history for the next iteration.


177-230: Sophisticated derivative computation with appropriate filtering.

The implementation uses:

  • Exponential smoothing of the error signal (line 185)
  • Decay during deadband to prevent derivative kick (line 189)
  • Change detection to skip updates when error is stable (line 184)
  • Double cascaded low-pass filtering to reduce noise (lines 218, 221)
  • Clamping to prevent excessive derivative action (line 222)

The detailed debug logging aligns with the commit message noting "detailed debug logging to PID."

One edge case to verify: when time_elapsed <= 0 (line 205), the derivative update is correctly skipped, but this could occur if timestamp() returns identical values on consecutive calls or immediately after restore(). Verify this behavior is intentional and doesn't cause issues in practice, particularly after integration restarts.

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: 0

♻️ Duplicate comments (14)
custom_components/sat/boiler.py (3)

256-257: Potential TypeError: comparing None with float.

If state.max_modulation_level is None, the comparison max_modulation < BOILER_MODULATION_DELTA_THRESHOLD on line 256 will raise a TypeError. A past review comment indicated this was addressed in commit b239316, but the max_modulation is None check is still missing from the current code.

🔎 Proposed fix
     max_modulation = state.max_modulation_level
     current_modulation = state.relative_modulation_level
-    if current_modulation is None or max_modulation < BOILER_MODULATION_DELTA_THRESHOLD:
+    if current_modulation is None or max_modulation is None or max_modulation < BOILER_MODULATION_DELTA_THRESHOLD:
         return

267-271: Modulation reliability logic checks individual values instead of variation.

The current logic counts how many modulation readings are ≥ BOILER_MODULATION_DELTA_THRESHOLD, but reliable modulation means the values vary by at least 3.0 percentage points over the window. A boiler stuck at 50% modulation would be incorrectly marked reliable even though it's not varying. This could contribute to incorrect boiler status detection mentioned in the PR feedback.

🔎 Proposed fix to check modulation range instead
     window = self._modulation_values_when_flame_on[-BOILER_MODULATION_RELIABILITY_MIN_SAMPLES:]
-    above_threshold = sum(1 for value in window if value >= BOILER_MODULATION_DELTA_THRESHOLD)
-    required_samples = max(2, int(len(window) * 0.4))
-
-    self._modulation_reliable = above_threshold >= required_samples
+    max_value = max(window)
+    min_value = min(window)
+    
+    if max_value - min_value < BOILER_MODULATION_DELTA_THRESHOLD:
+        self._modulation_reliable = False
+    else:
+        self._modulation_reliable = True

118-123: Type annotation mismatch persists: properties return Optional[int] but store Optional[float].

The flame_on_since and flame_off_since properties are annotated to return Optional[int], but _last_flame_on_at and _last_flame_off_at store float values from timestamp() (line 156, which calls dt.utcnow().timestamp()). A past review comment indicated this was addressed in commit 554bdfc, but the type mismatch remains in the current code.

🔎 Proposed fix
     @property
-    def flame_on_since(self) -> Optional[int]:
+    def flame_on_since(self) -> Optional[float]:
         return self._last_flame_on_at

     @property
-    def flame_off_since(self) -> Optional[int]:
+    def flame_off_since(self) -> Optional[float]:
         return self._last_flame_off_at
custom_components/sat/minimum_setpoint.py (1)

368-370: Use async_create_task instead of create_task for Home Assistant compatibility.

The code calls self._hass.create_task(...) but Home Assistant's API uses async_create_task() for scheduling coroutines from synchronous code.

🔎 Proposed fix
         if self._hass is not None:
-            self._hass.create_task(self.async_save_regimes())
+            self._hass.async_create_task(self.async_save_regimes())
custom_components/sat/area.py (2)

90-97: Remove climate-specific HVACMode.OFF from sensor state validation.

Line 92 checks for HVACMode.OFF when validating sensor state, but sensor_temperature_id references sensor entities (e.g., sensor.room1_temp), which don't have HVAC modes. HVACMode.OFF is a climate-specific enum constant and shouldn't be used here.

🔎 Proposed fix
         # Check if there is an overridden sensor temperature
         if (sensor_temperature_id := climate_state.attributes.get(ATTR_SENSOR_TEMPERATURE_ID)) is not None:
             sensor_state = self._hass.states.get(sensor_temperature_id)
-            if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]:
+            if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
                 if is_state_stale(sensor_state, self._sensor_max_value_age):

If you need to filter an "off" state from sensors, use the string literal "off" instead.


202-206: Fix event handler signature mismatch.

Line 205 registers self.update directly as the EVENT_HOMEASSISTANT_STARTED handler, but event callbacks receive an Event object while update() expects datetime | None. This will cause a TypeError when the event fires.

🔎 Proposed fix
         if hass.state is CoreState.running:
             self.update()
         else:
-            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update)
+            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update())
tests/test_pid.py (1)

85-106: Test validates buggy PID behavior—constructor parameter automatic_gain_value is ignored.

The test passes automatic_gain_value=2.0 to the PID constructor (line 91) and expects kp == 20.0 (line 103). However, the PID implementation ignores the constructor parameter and uses hardcoded values instead:

automatic_gain_value = 4 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 3
return round((self._heating_curve_coefficient * self._heating_curve) / automatic_gain_value, 6)

Current behavior (using hardcoded 4 for underfloor):

  • kp = (2.0 * 40.0) / 4 = 20.0 ✓ (test passes)

Expected behavior (using constructor parameter 2.0):

  • kp = (2.0 * 40.0) / 2.0 = 40.0

When the PID implementation is fixed to use self._automatic_gains_value, this test will need updated expectations.

custom_components/sat/sensor.py (4)

88-91: Fix implicit Optional type annotation.

Line 88 has area_id: str = None which creates an implicit Optional prohibited by PEP 484. The parameter should be explicitly typed as Optional[str].

🔎 Proposed fix
-    def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None):
+    def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: Optional[str] = None):

123-147: Guard against None PID in native_value and extra_state_attributes.

While the available property correctly checks for None, Home Assistant may still access native_value and extra_state_attributes before checking availability, causing AttributeError when self._pid is None.

🔎 Proposed fix
     @property
     def native_value(self) -> float:
+        if self._pid is None:
+            return None
         return self._pid.output

     @property
     def extra_state_attributes(self) -> Optional[Mapping[str, Any]]:
+        if self._pid is None:
+            return None
         return {
             "current_kp": self._pid.kp,
             "current_ki": self._pid.ki,
             "current_kd": self._pid.kd,

             "proportional": self._pid.proportional,
             "integral": self._pid.integral,
             "derivative": self._pid.derivative,

             "error": self._pid.last_error,
             "output": self._pid.output,
         }

Also update the return type of native_value to Optional[float].


262-270: Align return type with nullable heating curve value.

The native_value property returns self._climate.heating_curve.value which can be None, but the return type is annotated as float.

🔎 Proposed fix
     @property
-    def native_value(self) -> float:
+    def native_value(self) -> Optional[float]:
         """Return the state of the device in native units."""
         return self._climate.heating_curve.value

304-312: Guard against None error in native_value.

Line 307 accesses self._climate.error.value without checking if error is None. Even though available checks this, Home Assistant may call native_value before checking availability.

🔎 Proposed fix
     @property
-    def native_value(self) -> float:
+    def native_value(self) -> Optional[float]:
         """Return the state of the device in native units."""
-        return self._climate.error.value
+        return self._climate.error.value if self._climate.error is not None else None

     @property
     def available(self):
         """Return availability of the sensor."""
         return self._climate.error is not None
custom_components/sat/climate.py (2)

877-892: Critical: HassJob cannot wrap async coroutines—will raise ValueError at runtime.

Line 892 passes self.async_control_heating_loop (an async method) directly to HassJob. Home Assistant's HassJob raises ValueError: Coroutine not allowed to be passed to HassJob when given an async callable. The correct pattern is demonstrated at line 885, which uses async_create_task.

This issue was flagged in multiple previous reviews and reportedly addressed, but the problematic code remains.

🔎 Proposed fix
-        self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
+        self._control_heating_loop_unsub = async_call_later(
+            self.hass,
+            5,
+            lambda _: self.hass.async_create_task(self.async_control_heating_loop())
+        )

778-783: Undefined attribute _requested_setpoint_up_ticks causes potential runtime error.

Line 781 assigns to self._requested_setpoint_up_ticks, but this attribute is not initialized in __init__. While the assignment will create the attribute, reading it elsewhere before this code path executes would fail.

🔎 Proposed fix

Add to __init__ around line 90:

 self._setpoint: Optional[float] = None
 self._requested_setpoint_down_ticks: int = 0
+self._requested_setpoint_up_ticks: int = 0
 self._rooms: Optional[dict[str, float]] = None
custom_components/sat/pid.py (1)

35-48: Constructor stores automatic_gain_value but never uses it.

The automatic_gain_value parameter is passed to the constructor and stored in self._automatic_gains_value (line 41), but line 64 hardcodes the gain value instead of using the stored parameter. This means the configured value is ignored when automatic_gains=True.

🔎 Proposed fix (use the stored parameter)
         if self._heating_curve is None:
             return 0.0

-        automatic_gain_value = 4 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 3
+        # Use configured value, falling back to default based on heating system
+        automatic_gain_value = self._automatic_gains_value if self._automatic_gains_value > 0 else (4 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 3)
         return round((self._heating_curve_coefficient * self._heating_curve) / automatic_gain_value, 6)

Alternatively, if the hardcoded logic is intentional, remove the unused automatic_gain_value parameter from the constructor signature and create_pid_controller in util.py.

🧹 Nitpick comments (1)
custom_components/sat/pid.py (1)

172-173: Using create_task for persistence is acceptable but consider error handling.

The fire-and-forget create_task for saving state is a reasonable pattern for non-critical persistence. However, if the save fails repeatedly, errors will be silently lost. Consider whether a warning log on save failure would be helpful for debugging.

🔎 Optional: Add error handling to save
     async def _async_save_state(self) -> None:
         if self._store is None:
             return

         data: dict[str, Any] = {
             STORAGE_KEY_INTEGRAL: self._integral,
             STORAGE_KEY_LAST_ERROR: self._last_error,
             STORAGE_KEY_PREVIOUS_ERROR: self._previous_error,
             STORAGE_KEY_FILTERED_ERROR: self._filtered_error,
             STORAGE_KEY_RAW_DERIVATIVE: self._raw_derivative,
             STORAGE_KEY_LAST_INTERVAL_UPDATED: self._last_interval_updated,
             STORAGE_KEY_LAST_DERIVATIVE_UPDATED: self._last_derivative_updated,
         }

-        await self._store.async_save(data)
-        _LOGGER.debug("Saved PID state to storage for entity=%s", self._entity_id)
+        try:
+            await self._store.async_save(data)
+            _LOGGER.debug("Saved PID state to storage for entity=%s", self._entity_id)
+        except Exception as exc:
+            _LOGGER.warning("Failed to save PID state for entity=%s: %s", self._entity_id, exc)
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9220065 and c423bdb.

📒 Files selected for processing (9)
  • custom_components/sat/area.py
  • custom_components/sat/boiler.py
  • custom_components/sat/climate.py
  • custom_components/sat/minimum_setpoint.py
  • custom_components/sat/pid.py
  • custom_components/sat/sensor.py
  • custom_components/sat/util.py
  • tests/test_area.py
  • tests/test_pid.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_area.py
🧰 Additional context used
🧬 Code graph analysis (4)
custom_components/sat/boiler.py (3)
custom_components/sat/helpers.py (1)
  • timestamp (12-14)
custom_components/sat/types.py (1)
  • BoilerStatus (15-41)
custom_components/sat/cycles.py (2)
  • Cycle (53-72)
  • duration (70-72)
custom_components/sat/climate.py (10)
custom_components/sat/area.py (11)
  • Areas (228-346)
  • get (249-255)
  • update (215-225)
  • items (261-263)
  • async_added_to_hass (196-207)
  • async_added_to_hass (265-268)
  • ids (257-259)
  • reset (342-346)
  • pids (245-247)
  • output (279-307)
  • overshoot_cap (310-340)
custom_components/sat/boiler.py (4)
  • BoilerControlIntent (37-39)
  • update (150-164)
  • async_added_to_hass (125-141)
  • status (99-103)
custom_components/sat/coordinator.py (18)
  • SatDataUpdateCoordinator (102-455)
  • get (459-474)
  • minimum_setpoint (308-310)
  • update (50-55)
  • async_added_to_hass (370-372)
  • device_id (131-133)
  • async_control_heating_loop (387-400)
  • device_capabilities (147-152)
  • cycles (174-176)
  • setpoint (190-192)
  • maximum_setpoint (313-316)
  • device_status (142-144)
  • hot_water_active (217-219)
  • minimum_setpoint_value (252-254)
  • flame_active (207-209)
  • async_set_control_setpoint (406-409)
  • device_state (155-171)
  • async_set_heater_state (402-404)
custom_components/sat/helpers.py (5)
  • is_state_stale (32-37)
  • state_age_seconds (27-29)
  • event_timestamp (17-19)
  • clamp (108-113)
  • timestamp (12-14)
custom_components/sat/manufacturers/geminox.py (1)
  • Geminox (4-7)
custom_components/sat/types.py (4)
  • RelativeModulationState (107-112)
  • PWMStatus (79-83)
  • PWMDecision (86-104)
  • enabled (103-104)
custom_components/sat/util.py (3)
  • create_pid_controller (20-41)
  • create_heating_curve_controller (55-62)
  • create_pwm_controller (65-75)
custom_components/sat/pid.py (4)
  • update (155-173)
  • async_added_to_hass (135-153)
  • reset (119-133)
  • output (112-117)
custom_components/sat/pwm.py (6)
  • update (84-151)
  • state (241-247)
  • reset (66-76)
  • enabled (233-234)
  • status (237-238)
  • disable (153-156)
custom_components/sat/simulator/__init__.py (6)
  • device_id (29-30)
  • async_control_heating_loop (85-111)
  • setpoint (49-50)
  • flame_active (61-62)
  • async_set_control_setpoint (77-79)
  • async_set_heater_state (72-75)
custom_components/sat/pid.py (4)
custom_components/sat/helpers.py (1)
  • timestamp (12-14)
custom_components/sat/area.py (6)
  • output (279-307)
  • async_added_to_hass (196-207)
  • async_added_to_hass (265-268)
  • get (249-255)
  • update (215-225)
  • error (145-153)
custom_components/sat/boiler.py (2)
  • async_added_to_hass (125-141)
  • update (150-164)
custom_components/sat/climate.py (2)
  • async_added_to_hass (199-241)
  • error (421-429)
tests/test_pid.py (2)
custom_components/sat/pid.py (11)
  • PID (32-268)
  • kp (56-65)
  • ki (68-73)
  • kd (76-81)
  • available (51-53)
  • integral (89-91)
  • raw_derivative (102-104)
  • derivative (94-99)
  • output (112-117)
  • update (155-173)
  • proportional (84-86)
custom_components/sat/helpers.py (1)
  • timestamp (12-14)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py

[refactor] 98-98: Too many instance attributes (8/7)

(R0902)


[refactor] 446-446: Too many local variables (16/15)

(R0914)


[refactor] 511-572: Unnecessary "elif" after "return", remove the leading "el" from "elif"

(R1705)


[refactor] 446-446: Too many return statements (12/6)

(R0911)


[refactor] 446-446: Too many branches (22/12)

(R0912)


[refactor] 446-446: Too many statements (58/50)

(R0915)


[refactor] 587-587: Too many local variables (18/15)

(R0914)

custom_components/sat/boiler.py

[refactor] 50-50: Too many instance attributes (11/7)

(R0902)


[refactor] 77-77: Too many instance attributes (12/7)

(R0902)


[refactor] 166-166: Too many return statements (18/6)

(R0911)


[refactor] 166-166: Too many branches (17/12)

(R0912)


[refactor] 276-276: Too many return statements (7/6)

(R0911)


[refactor] 390-390: Too many return statements (7/6)

(R0911)

custom_components/sat/climate.py

[refactor] 76-76: Too many statements (53/50)

(R0915)


[refactor] 291-291: Too many branches (16/12)

(R0912)


[refactor] 731-731: Too many branches (13/12)

(R0912)

custom_components/sat/pid.py

[refactor] 32-32: Too many instance attributes (18/7)

(R0902)


[refactor] 35-35: Too many arguments (9/5)

(R0913)


[refactor] 35-35: Too many positional arguments (9/5)

(R0917)

custom_components/sat/area.py

[refactor] 37-37: Too many instance attributes (8/7)

(R0902)

tests/test_pid.py

[refactor] 17-17: Too few public methods (1/2)

(R0903)

🪛 Ruff (0.14.10)
custom_components/sat/util.py

9-9: from .const import * used; unable to detect undefined names

(F403)


23-23: CONF_PROPORTIONAL may be undefined, or defined from star imports

(F405)


24-24: CONF_INTEGRAL may be undefined, or defined from star imports

(F405)


25-25: CONF_DERIVATIVE may be undefined, or defined from star imports

(F405)


27-27: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


28-28: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports

(F405)


29-29: CONF_AUTOMATIC_GAINS_VALUE may be undefined, or defined from star imports

(F405)


30-30: CONF_HEATING_CURVE_COEFFICIENT may be undefined, or defined from star imports

(F405)


49-49: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


50-50: CONF_MAXIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


68-68: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports

(F405)


69-69: CONF_DUTY_CYCLE may be undefined, or defined from star imports

(F405)

custom_components/sat/climate.py

39-39: from .const import * used; unable to detect undefined names

(F403)


80-80: CONF_THERMOSTAT may be undefined, or defined from star imports

(F405)


81-81: CONF_INSIDE_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


82-82: CONF_HUMIDITY_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


83-83: CONF_OUTSIDE_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


104-104: Consider [PRESET_NONE, *list(self._presets.keys())] instead of concatenation

Replace with [PRESET_NONE, *list(self._presets.keys())]

(RUF005)


112-112: CONF_NAME may be undefined, or defined from star imports

(F405)


113-113: CONF_NAME may be undefined, or defined from star imports

(F405)


115-115: CONF_RADIATORS may be undefined, or defined from star imports

(F405)


116-116: CONF_WINDOW_SENSORS may be undefined, or defined from star imports

(F405)


118-118: CONF_SIMULATION may be undefined, or defined from star imports

(F405)


119-119: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


120-120: CONF_OVERSHOOT_PROTECTION may be undefined, or defined from star imports

(F405)


121-121: CONF_PUSH_SETPOINT_TO_THERMOSTAT may be undefined, or defined from star imports

(F405)


124-124: CONF_HEATING_MODE may be undefined, or defined from star imports

(F405)


125-125: CONF_THERMAL_COMFORT may be undefined, or defined from star imports

(F405)


126-126: CONF_CLIMATE_VALVE_OFFSET may be undefined, or defined from star imports

(F405)


127-127: CONF_TARGET_TEMPERATURE_STEP may be undefined, or defined from star imports

(F405)


128-128: CONF_DYNAMIC_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


129-129: CONF_SYNC_CLIMATES_WITH_MODE may be undefined, or defined from star imports

(F405)


130-130: CONF_SYNC_CLIMATES_WITH_PRESET may be undefined, or defined from star imports

(F405)


131-131: CONF_MAXIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports

(F405)


132-132: CONF_SENSOR_MAX_VALUE_AGE may be undefined, or defined from star imports

(F405)


133-133: CONF_WINDOW_MINIMUM_OPEN_TIME may be undefined, or defined from star imports

(F405)


134-134: CONF_MODE may be undefined, or defined from star imports

(F405)


134-134: MODE_SWITCH may be undefined, or defined from star imports

(F405)


134-134: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


160-160: OPTIONS_DEFAULTS may be undefined, or defined from star imports

(F405)


225-225: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports

(F405)


233-233: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


243-243: Unused method argument: event

(ARG002)


353-353: DOMAIN may be undefined, or defined from star imports

(F405)


353-353: SERVICE_RESET_INTEGRAL may be undefined, or defined from star imports

(F405)


488-488: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


493-493: HEATING_MODE_ECO may be undefined, or defined from star imports

(F405)


506-506: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


506-506: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


593-593: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


597-597: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


610-610: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


616-616: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


620-620: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


631-631: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports

(F405)


640-640: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


737-737: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


738-738: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


793-793: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


797-797: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


797-797: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


866-866: DEADBAND may be undefined, or defined from star imports

(F405)


866-866: DEADBAND may be undefined, or defined from star imports

(F405)


928-928: COLD_SETPOINT may be undefined, or defined from star imports

(F405)

custom_components/sat/pid.py

9-9: from .const import * used; unable to detect undefined names

(F403)


64-64: HEATING_SYSTEM_UNDERFLOOR may be undefined, or defined from star imports

(F405)


179-179: DEADBAND may be undefined, or defined from star imports

(F405)


183-183: DEADBAND may be undefined, or defined from star imports

(F405)


211-211: DEADBAND may be undefined, or defined from star imports

(F405)

custom_components/sat/sensor.py

14-14: from .const import * used; unable to detect undefined names

(F403)


53-53: CONF_ROOMS may be undefined, or defined from star imports

(F405)


59-59: CONF_MINIMUM_CONSUMPTION may be undefined, or defined from star imports

(F405)


59-59: CONF_MAXIMUM_CONSUMPTION may be undefined, or defined from star imports

(F405)


67-67: CONF_NAME may be undefined, or defined from star imports

(F405)


84-84: CONF_NAME may be undefined, or defined from star imports

(F405)


88-88: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)


106-106: CONF_NAME may be undefined, or defined from star imports

(F405)


108-108: CONF_NAME may be undefined, or defined from star imports

(F405)


130-130: CONF_NAME may be undefined, or defined from star imports

(F405)


132-132: CONF_NAME may be undefined, or defined from star imports

(F405)


323-323: CONF_NAME may be undefined, or defined from star imports

(F405)


342-342: CONF_NAME may be undefined, or defined from star imports

(F405)


347-347: CycleClassification may be undefined, or defined from star imports

(F405)


353-353: CONF_NAME may be undefined, or defined from star imports

(F405)


359-359: CONF_NAME may be undefined, or defined from star imports

(F405)

custom_components/sat/area.py

290-290: Do not catch blind exception: Exception

(BLE001)

🔇 Additional comments (19)
custom_components/sat/boiler.py (1)

273-274: No changes needed — create_task is the correct API here.

The method _update_modulation_reliability is synchronous (not async), so using self._hass.create_task() is the correct Home Assistant pattern for scheduling tasks from sync contexts. The async_create_task() method is for async contexts only. The codebase correctly uses this pattern consistently in other sync methods (pid.py, minimum_setpoint.py).

custom_components/sat/util.py (1)

4-95: LGTM! Factory functions properly updated for new architecture.

The factory functions have been correctly refactored to support the new cycle-centric architecture. The changes include:

  • Added proper imports for new controller types (DynamicMinimumSetpoint, PWMConfig)
  • Updated PID factory to accept entity_id parameter for per-area instances
  • Created DynamicMinimumSetpoint factory with MinimumSetpointConfig
  • Simplified PWM factory to use PWMConfig dataclass

All previously flagged issues (TYPE_CHECKING import, CONF_MAXIMUM_SETPOINT default) have been addressed in prior commits.

custom_components/sat/climate.py (10)

56-59: Good addition of explicit threshold constants.

Extracting these magic numbers into named constants improves readability and maintainability of the setpoint control logic.


146-168: Clean static helper extraction.

The _ensure_list, _build_config_options, and _build_presets static methods improve code organization by extracting configuration logic from __init__.


170-180: Good defensive feature detection.

Using hasattr to check for TURN_ON/TURN_OFF features ensures backward compatibility with older Home Assistant versions.


182-197: Well-structured sensor state retrieval with staleness check.

The _get_entity_state_float helper centralizes entity state retrieval with proper staleness validation and invalid state handling.


224-241: Event listeners properly use async_on_remove for cleanup.

The cycle event listeners are correctly registered with async_on_remove, ensuring they are unsubscribed when the entity is removed.


551-566: Clean PWM decision tracking with change detection logging.

The refactored pulse_width_modulation_enabled property with decision tracking and change-based logging provides good observability without excessive log spam.


568-623: Well-structured PWM decision methods.

Breaking the PWM enable logic into _pwm_decision, _pwm_static_decision, and _pwm_dynamic_decision improves readability and makes the decision tree easier to understand and maintain.


731-797: Solid setpoint control logic with flame stability protection.

The near-target downward lock logic (lines 757-765) prevents premature flame-off by holding the setpoint when the boiler is actively heating near target. The persistent decrease tracking (lines 748-754, 773-775) adds hysteresis to avoid oscillation. Good logging throughout aids debugging.


894-930: Well-structured heating control loop with clean coordinator delegation.

The control loop properly:

  1. Guards against missing required values (lines 900-908)
  2. Updates PWM state before coordinator interaction (lines 915-918)
  3. Passes control context and intent to coordinator (lines 921-923)
  4. Applies computed controls (lines 926-928)

484-506: LGTM: requested_setpoint now returns a computed value, not a method reference.

The requested_setpoint property correctly accesses self.pid.output and self.areas.pids.output as properties (not method calls), which aligns with the PID class's output property definition. The clamping logic ensures the result stays within valid bounds.

custom_components/sat/pid.py (7)

14-20: Good extraction of derivative filtering constants.

Named constants for the derivative filtering pipeline make the algorithm more maintainable and the tuning parameters explicit.


22-29: Storage key constants improve maintainability.

Using named constants for storage keys prevents typos and makes the persistence interface self-documenting.


50-53: Reasonable availability check.

The available property correctly requires both a last error value and a heating curve to be set before the PID is considered usable.


111-117: Output computation is straightforward.

The output property correctly combines the heating curve with P, I, and D terms. Returning 0.0 when the heating curve is None is a safe fallback.


175-200: Solid integral term implementation with anti-windup.

The integral update correctly:

  1. Resets the time base when entering the deadband (lines 179-180)
  2. Disables accumulation outside the deadband (lines 183-185)
  3. Clamps the integral to prevent windup (lines 196-197)

202-241: Well-designed derivative filtering pipeline.

The two-stage low-pass filtering approach effectively reduces noise while maintaining responsiveness. Key features:

  • Error smoothing with DERIVATIVE_ERROR_ALPHA (line 209)
  • Decay within deadband prevents stale derivative influence (line 213)
  • Dual alpha filters (lines 229, 232) for progressive smoothing
  • Capping to ±DERIVATIVE_RAW_CAP prevents extreme values (line 233)

243-251: Good defensive parsing with _maybe_float.

The static helper safely converts stored values to floats, returning None on failure rather than raising exceptions during state restoration.

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: 1

♻️ Duplicate comments (4)
custom_components/sat/climate.py (3)

253-289: Critical: Missing state change listener for inside temperature sensor.

The current_temperature property (lines 402-410) reads from inside_sensor_entity_id, but there is no state change listener registered for this sensor in _register_event_listeners. State listeners exist for thermostat, climates, and window sensors, but not for the primary inside temperature sensor.

This means the climate entity won't update its state when the inside sensor changes, which directly correlates with the user's reported issue: "SAT climate 'current temperature' did not update correctly (only updated once on Home Assistant restart)."

The entity relies solely on periodic async_control_pid calls (every 30 seconds), which have multiple early-return paths that can skip async_write_ha_state().

🔎 Proposed fix

Add this listener registration in _register_event_listeners:

         self.async_on_remove(
             async_track_state_change_event(
                 self.hass, self.areas.ids(), self._async_climate_changed
             )
         )
+
+        self.async_on_remove(
+            async_track_state_change_event(
+                self.hass, self.inside_sensor_entity_id, self._async_inside_sensor_changed
+            )
+        )

         if len(self._window_sensors) > 0:

And add the handler method:

async def _async_inside_sensor_changed(self, event: Event[EventStateChangedData]) -> None:
    """Handle inside temperature sensor state changes."""
    new_state = event.data.get("new_state")
    if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
        return

    _LOGGER.debug("Inside sensor changed to %.2f°C", float(new_state.state))
    self.async_write_ha_state()

877-892: Critical: HassJob with async callable requires proper scheduling.

Line 892 passes the async method self.async_control_heating_loop to HassJob. When async_call_later invokes this, it will call the function and receive a coroutine object, but Home Assistant's scheduler won't automatically await it. The coroutine will be created but never executed.

Line 885 demonstrates the correct pattern using async_create_task.

🔎 Proposed fix
-        self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
+        def _schedule_heating_loop() -> None:
+            self.hass.async_create_task(self.async_control_heating_loop())
+
+        self._control_heating_loop_unsub = async_call_later(self.hass, 5, _schedule_heating_loop)

Alternatively, use a lambda:

-        self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
+        self._control_heating_loop_unsub = async_call_later(
+            self.hass, 5, lambda _: self.hass.async_create_task(self.async_control_heating_loop())
+        )

731-797: Minor: Uninitialized attribute _requested_setpoint_up_ticks.

Line 781 assigns to self._requested_setpoint_up_ticks, but this attribute is never initialized in __init__ (line 90 only initializes _requested_setpoint_down_ticks). While the assignment will create the attribute, it's inconsistent with the established pattern of initializing all instance variables.

🔎 Proposed fix

Add initialization in __init__ around line 90:

         self._setpoint: Optional[float] = None
         self._requested_setpoint_down_ticks: int = 0
+        self._requested_setpoint_up_ticks: int = 0
         self._rooms: Optional[dict[str, float]] = None
custom_components/sat/pid.py (1)

56-65: Major issue remains: Constructor parameter automatic_gain_value is still ignored.

Line 64 hardcodes automatic_gain_value as 4 or 3 based on heating system, completely ignoring the self._automatic_gains_value parameter stored from the constructor (line 41). This was flagged in previous reviews but remains unresolved.

Either use the stored parameter when automatic_gains=True, or remove the unused constructor parameter and stored attribute.

Based on learnings, this issue was previously flagged and marked unresolved.

🔎 Proposed fix (use stored parameter)
         if self._heating_curve is None:
             return 0.0

-        automatic_gain_value = 4 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 3
+        # Use configured value, falling back to heating-system default
+        automatic_gain_value = self._automatic_gains_value if self._automatic_gains_value > 0 else (4 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 3)
         return round((self._heating_curve_coefficient * self._heating_curve) / automatic_gain_value, 6)
🧹 Nitpick comments (1)
custom_components/sat/pid.py (1)

161-179: Use async_create_task instead of create_task.

Line 179 uses self._hass.create_task(self._async_save_state()) but Home Assistant's recommended pattern is to use async_create_task for background tasks. The deprecated create_task may be removed in future versions.

🔎 Proposed fix
         _LOGGER.debug(
             "PID update for %s (error=%.3f curve=%.3f proportional=%.3f integral=%.3f derivative=%.3f output=%.3f)",
             self._entity_id, error, heating_curve, self.proportional, self.integral, self.derivative, self.output
         )

         if self._hass is not None and self._store is not None:
-            self._hass.create_task(self._async_save_state())
+            self._hass.async_create_task(self._async_save_state())
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c423bdb and 3608883.

📒 Files selected for processing (3)
  • custom_components/sat/climate.py
  • custom_components/sat/pid.py
  • custom_components/sat/util.py
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/util.py (5)
custom_components/sat/helpers.py (1)
  • convert_time_str_to_seconds (40-49)
custom_components/sat/minimum_setpoint.py (2)
  • DynamicMinimumSetpoint (269-792)
  • MinimumSetpointConfig (92-94)
custom_components/sat/pid.py (4)
  • PID (32-273)
  • kp (56-65)
  • ki (68-73)
  • kd (76-81)
custom_components/sat/pwm.py (2)
  • PWM (38-247)
  • PWMConfig (30-35)
custom_components/sat/climate.py (1)
  • SatClimate (73-1062)
🪛 Pylint (4.0.4)
custom_components/sat/climate.py

[refactor] 76-76: Too many statements (53/50)

(R0915)


[refactor] 291-291: Too many branches (16/12)

(R0912)


[refactor] 731-731: Too many branches (13/12)

(R0912)

custom_components/sat/pid.py

[refactor] 32-32: Too many instance attributes (18/7)

(R0902)


[refactor] 35-35: Too many arguments (9/5)

(R0913)


[refactor] 35-35: Too many positional arguments (9/5)

(R0917)

🪛 Ruff (0.14.10)
custom_components/sat/util.py

9-9: from .const import * used; unable to detect undefined names

(F403)


23-23: CONF_PROPORTIONAL may be undefined, or defined from star imports

(F405)


24-24: CONF_INTEGRAL may be undefined, or defined from star imports

(F405)


25-25: CONF_DERIVATIVE may be undefined, or defined from star imports

(F405)


27-27: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


28-28: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports

(F405)


29-29: CONF_AUTOMATIC_GAINS_VALUE may be undefined, or defined from star imports

(F405)


30-30: CONF_HEATING_CURVE_COEFFICIENT may be undefined, or defined from star imports

(F405)


49-49: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


50-50: CONF_MAXIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


68-68: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports

(F405)


69-69: CONF_DUTY_CYCLE may be undefined, or defined from star imports

(F405)

custom_components/sat/climate.py

39-39: from .const import * used; unable to detect undefined names

(F403)


80-80: CONF_THERMOSTAT may be undefined, or defined from star imports

(F405)


81-81: CONF_INSIDE_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


82-82: CONF_HUMIDITY_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


83-83: CONF_OUTSIDE_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


104-104: Consider [PRESET_NONE, *list(self._presets.keys())] instead of concatenation

Replace with [PRESET_NONE, *list(self._presets.keys())]

(RUF005)


112-112: CONF_NAME may be undefined, or defined from star imports

(F405)


113-113: CONF_NAME may be undefined, or defined from star imports

(F405)


115-115: CONF_RADIATORS may be undefined, or defined from star imports

(F405)


116-116: CONF_WINDOW_SENSORS may be undefined, or defined from star imports

(F405)


118-118: CONF_SIMULATION may be undefined, or defined from star imports

(F405)


119-119: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


120-120: CONF_OVERSHOOT_PROTECTION may be undefined, or defined from star imports

(F405)


121-121: CONF_PUSH_SETPOINT_TO_THERMOSTAT may be undefined, or defined from star imports

(F405)


124-124: CONF_HEATING_MODE may be undefined, or defined from star imports

(F405)


125-125: CONF_THERMAL_COMFORT may be undefined, or defined from star imports

(F405)


126-126: CONF_CLIMATE_VALVE_OFFSET may be undefined, or defined from star imports

(F405)


127-127: CONF_TARGET_TEMPERATURE_STEP may be undefined, or defined from star imports

(F405)


128-128: CONF_DYNAMIC_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


129-129: CONF_SYNC_CLIMATES_WITH_MODE may be undefined, or defined from star imports

(F405)


130-130: CONF_SYNC_CLIMATES_WITH_PRESET may be undefined, or defined from star imports

(F405)


131-131: CONF_MAXIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports

(F405)


132-132: CONF_SENSOR_MAX_VALUE_AGE may be undefined, or defined from star imports

(F405)


133-133: CONF_WINDOW_MINIMUM_OPEN_TIME may be undefined, or defined from star imports

(F405)


134-134: CONF_MODE may be undefined, or defined from star imports

(F405)


134-134: MODE_SWITCH may be undefined, or defined from star imports

(F405)


134-134: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


160-160: OPTIONS_DEFAULTS may be undefined, or defined from star imports

(F405)


225-225: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports

(F405)


233-233: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


243-243: Unused method argument: event

(ARG002)


353-353: DOMAIN may be undefined, or defined from star imports

(F405)


353-353: SERVICE_RESET_INTEGRAL may be undefined, or defined from star imports

(F405)


488-488: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


493-493: HEATING_MODE_ECO may be undefined, or defined from star imports

(F405)


506-506: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


506-506: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


593-593: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


597-597: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


610-610: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


616-616: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


620-620: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


631-631: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports

(F405)


640-640: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


737-737: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


738-738: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


793-793: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


797-797: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


797-797: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


866-866: DEADBAND may be undefined, or defined from star imports

(F405)


866-866: DEADBAND may be undefined, or defined from star imports

(F405)


928-928: COLD_SETPOINT may be undefined, or defined from star imports

(F405)

custom_components/sat/pid.py

9-9: from .const import * used; unable to detect undefined names

(F403)


64-64: HEATING_SYSTEM_UNDERFLOOR may be undefined, or defined from star imports

(F405)


185-185: DEADBAND may be undefined, or defined from star imports

(F405)


189-189: DEADBAND may be undefined, or defined from star imports

(F405)


217-217: DEADBAND may be undefined, or defined from star imports

(F405)

🔇 Additional comments (4)
custom_components/sat/util.py (2)

20-41: LGTM! entity_id parameter properly added.

The addition of the entity_id parameter to create_pid_controller correctly forwards it to the PID constructor, enabling storage-backed state persistence for the PID controller. The parameter is appropriately optional with a default of None.


65-75: LGTM! PWM controller factory properly updated.

The refactored create_pwm_controller correctly accepts the heating_curve parameter and constructs a PWMConfig object before passing both to the PWM constructor. This aligns with the new cycle-centric architecture.

custom_components/sat/pid.py (1)

50-117: LGTM! PID properties properly expose controller state.

The new properties (available, kp, ki, kd, proportional, integral, derivative, raw_derivative, last_error, output) correctly handle None values and provide appropriate read-only access to the PID controller's internal state for observability.

custom_components/sat/climate.py (1)

552-623: LGTM! PWM decision logic is well-structured.

The refactored PWM enablement logic cleanly separates static and dynamic decision paths, uses descriptive enums for logging, and implements appropriate hysteresis bands to prevent oscillation. The separation of concerns between configuration checks, static minimum logic, and dynamic minimum logic improves maintainability.

Comment thread custom_components/sat/climate.py Outdated
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: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tests/test_config_flow.py (1)

5-13: Test may fail due to missing hass context.

The hass parameter is declared but never used. The SatFlowHandler and async_create_coordinator() likely require a Home Assistant instance to function properly. Consider passing hass to the flow handler or initializing it with flow_handler.hass = hass.

🔎 Proposed fix
 async def test_create_coordinator(hass):
     flow_handler = SatFlowHandler()
+    flow_handler.hass = hass
     flow_handler.data = {
         "name": "Test",
         "mode": SatMode.FAKE,
         "device": "test_device",
     }

     await flow_handler.async_create_coordinator()
custom_components/sat/mqtt/__init__.py (1)

34-46: Use self.hass consistently throughout the method.

The method mixes usage of the hass parameter (line 35: mqtt.async_wait_for_mqtt_client(hass)) with self.hass (line 39: mqtt.async_subscribe(self.hass, ...)). Since self.hass is already initialized in __init__ via the parent class before this lifecycle method is called, use self.hass in both places for consistency:

await mqtt.async_wait_for_mqtt_client(self.hass)

The hass parameter is required by the parent class signature but doesn't need to be used when self.hass is available.

♻️ Duplicate comments (11)
tests/test_climate.py (1)

218-222: Use public pwm.enabled property instead of private _enabled attribute.

Lines 218, 221, and 253 directly access climate.pwm._enabled. The PWM class exposes a public enabled property that should be used instead to respect encapsulation.

Also applies to: 253-253

custom_components/sat/pid.py (1)

63-64: Constructor parameter automatic_gain_value is still ignored (duplicate issue).

Line 63 hardcodes the automatic gain calculation using 4 or 3, completely ignoring self._automatic_gains_value which was passed to the constructor and stored at line 40. This issue was flagged in previous reviews but remains unresolved.

Based on learnings, this was flagged in previous reviews.

🔎 Proposed fix
         if self._heating_curve is None:
             return 0.0

-        automatic_gain_value = 4 if self._heating_system == HeatingSystem.UNDERFLOOR else 3
+        # Use configured value, falling back to default based on heating system
+        automatic_gain_value = self._automatic_gains_value if self._automatic_gains_value > 0 else (4 if self._heating_system == HeatingSystem.UNDERFLOOR else 3)
         return round((self._heating_curve_coefficient * self._heating_curve) / automatic_gain_value, 6)

Alternatively, if the hardcoded logic is intentional, remove the unused automatic_gain_value parameter from the constructor signature.

tests/test_pid.py (1)

97-118: Test validates buggy behavior (duplicate issue).

This test passes automatic_gain_value=2.0 but expects kp == 20.0, which only matches the current buggy implementation that ignores the parameter and uses hardcoded values (4 for underfloor, 3 for radiators). When the PID bug is fixed to honor self._automatic_gains_value, the expected kp should be 40.0 (since (2.0 * 40.0) / 2.0 = 40.0), and ki and kd must also be recalculated.

Based on learnings, this was flagged in previous reviews.

🔎 Proposed fix (after PID bug is resolved)
     pid.update(state, heating_curve=40.0)

-    assert pid.kp == 20.0
-    assert pid.ki == round(20.0 / 8400, 6)
-    assert pid.kd == round(0.07 * 8400 * 20.0, 6)
+    assert pid.kp == 40.0
+    assert pid.ki == round(40.0 / 8400, 6)
+    assert pid.kd == round(0.07 * 8400 * 40.0, 6)
custom_components/sat/simulator/__init__.py (1)

46-47: Fix return type annotation mismatch.

The method is annotated to return Optional[float] but returns True (a bool). Based on the base class interface in coordinator.py, this property should return bool.

🔎 Proposed fix
-    def supports_relative_modulation(self) -> Optional[float]:
+    def supports_relative_modulation(self) -> bool:
         return True
custom_components/sat/esphome/__init__.py (1)

1-1: Remove duplicate import identifier.

The annotations identifier is duplicated in the future import. While Python may tolerate this, it should be corrected.

🔎 Proposed fix
-from __future__ import annotations, annotations
+from __future__ import annotations
custom_components/sat/mqtt/__init__.py (1)

20-25: Past review concern about storage key migration remains unaddressed.

The storage key format change from the previous snake_case-based dynamic key to f"sat.mqtt.{self._device_id}" will cause data loss for existing users, as previously noted. No migration logic has been added.

custom_components/sat/coordinator.py (1)

117-117: Critical: Wrong debouncer callback method name.

Line 117 references self.async_update_listeners, which does not exist. The correct method is async_notify_listeners (defined at line 448). This will cause an AttributeError when the debouncer attempts to call the callback.

🔎 Proposed fix
-        self._hass_notify_debouncer = Debouncer(hass=self.hass, logger=_LOGGER, cooldown=0.2, immediate=False, function=self.async_update_listeners)
+        self._hass_notify_debouncer = Debouncer(hass=self.hass, logger=_LOGGER, cooldown=0.2, immediate=False, function=self.async_notify_listeners)

Based on past review comments, this issue was previously identified but remains unresolved.

custom_components/sat/sensor.py (1)

88-91: Use a stable identifier (entry_id) for SatRequestedSetpoint.unique_id.

Deriving unique_id from name_lower makes it change when the user renames the integration, breaking entity history and automations.

Prefer the config entry id (or another immutable key) instead, e.g.:

Proposed fix
-    def unique_id(self) -> str:
-        return f"{self._config.name_lower}-requested-setpoint"
+    def unique_id(self) -> str:
+        return f"{self._config.entry_id}-requested-setpoint"
custom_components/sat/minimum_setpoint.py (1)

368-371: Use async_create_task instead of create_task to schedule async_save_regimes.

HomeAssistant exposes async_create_task, not create_task. This call will fail at runtime and regimes will not be persisted.

Replace the scheduling with the supported API:

Proposed fix
-        if self._hass is not None:
-            self._hass.create_task(self.async_save_regimes())
+        if self._hass is not None:
+            self._hass.async_create_task(self.async_save_regimes())
custom_components/sat/area.py (1)

88-97: Remove HVACMode.OFF from sensor state validation in temperature_state.

This filter mixes climate modes into generic sensor validation:

if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]:
  • For real sensors, HVACMode.OFF is meaningless.
  • For climates used as sensors, the temperature remains valid even when HVAC is off.

Treat only unavailable/unknown as invalid, or (if required) compare against the literal "off" and only when the override entity is a climate.

Proposed change
-            if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]:
+            if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
custom_components/sat/climate.py (1)

855-871: Do not pass async coroutine directly to HassJob / async_call_later.

Here:

self._control_heating_loop_unsub = async_call_later(
    self.hass, 5, HassJob(self.async_control_heating_loop)
)

HassJob cannot wrap async coroutines this way and will raise ValueError: Coroutine not allowed to be passed to HassJob, preventing the control loop from running.

Schedule a regular callback that creates the task instead:

Proposed fix
-        self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
+        def _schedule_heating_loop(_time=None) -> None:
+            self.hass.async_create_task(self.async_control_heating_loop(_time))
+
+        self._control_heating_loop_unsub = async_call_later(self.hass, 5, _schedule_heating_loop)
#!/bin/bash
# Confirm other uses of HassJob do not wrap async coroutines directly.
rg -n "HassJob\(" custom_components/sat -n -C2
🧹 Nitpick comments (8)
custom_components/sat/entry_data.py (1)

9-9: Consider replacing wildcard import with explicit imports.

The from .const import * pattern obscures dependencies and triggers static analysis warnings (F403, F405). While functional, explicit imports would improve code clarity and tooling support.

custom_components/sat/fake/__init__.py (1)

66-68: Add return type annotation for consistency.

The relative_modulation_value property lacks a return type annotation, unlike other similar properties in this class.

🔎 Proposed fix
     @property
-    def relative_modulation_value(self):
+    def relative_modulation_value(self) -> Optional[float]:
         return self._relative_modulation_value
custom_components/sat/pid.py (1)

165-174: Verify background task completion on shutdown.

The code schedules _async_save_state() as a background task without awaiting it or tracking completion. If Home Assistant shuts down before the save completes, state may be lost. Consider using async_create_task with a task group or ensure shutdown sequences wait for pending saves.

custom_components/sat/heating_curve.py (1)

78-80: Consider explicit import for better clarity.

The enum usage is correct, but the static analysis warning indicates that star imports can obscure dependencies. While this is a false positive (since HeatingSystem is defined in const.py), an explicit import would improve code clarity and eliminate the warning.

🔎 Proposed refactor
-from .const import *
+from .const import (
+    HeatingSystem,
+    MINIMUM_SETPOINT,
+    # Add other needed imports
+)
custom_components/sat/serial/sensor.py (1)

109-127: Consider refactoring to reduce constructor parameters.

The constructor has 6 parameters, which exceeds the typical guideline of 5. While this refactor maintains the existing parameter count (previously had config_entry instead of config), consider whether info, key, and source could be consolidated into a single data structure to improve maintainability.

💡 Example refactor approach
@dataclass
class SensorConfig:
    info: SatSensorInfo
    key: str
    source: str

def __init__(
    self,
    coordinator: SatSerialCoordinator,
    config: SatConfig,
    sensor_config: SensorConfig
):
    ...

This would reduce the parameter count and group related configuration together.

custom_components/sat/serial/binary_sensor.py (1)

94-109: Consider consolidating constructor parameters.

The constructor accepts 6 parameters, slightly exceeding conventions. While all parameters are used, consider grouping info, key, and source into a single configuration object (e.g., SensorConfig) in a future refactor to improve maintainability.

custom_components/sat/__init__.py (2)

33-37: Use logging.exception for better error diagnostics.

Replace _LOGGER.error with _LOGGER.exception to automatically include the traceback, making Sentry initialization failures easier to diagnose.

🔎 Proposed fix
     try:
         if entry_data.config.error_monitoring_enabled:
             entry_data.sentry = initialize_sentry()
     except Exception as error:
-        _LOGGER.error("Error during Sentry initialization: %s", error)
+        _LOGGER.exception("Error during Sentry initialization")

59-65: Use logging.exception for better error diagnostics.

Replace _LOGGER.error with _LOGGER.exception to automatically include the traceback.

🔎 Proposed fix
     try:
         if entry_data.sentry is not None:
             entry_data.sentry.flush()
             entry_data.sentry.close()
             entry_data.sentry = None
     except Exception as error:
-        _LOGGER.error("Error during Sentry cleanup: %s", error)
+        _LOGGER.exception("Error during Sentry cleanup")
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3608883 and 28880d1.

📒 Files selected for processing (36)
  • custom_components/sat/__init__.py
  • custom_components/sat/area.py
  • custom_components/sat/binary_sensor.py
  • custom_components/sat/climate.py
  • custom_components/sat/config_flow.py
  • custom_components/sat/const.py
  • custom_components/sat/coordinator.py
  • custom_components/sat/entity.py
  • custom_components/sat/entry_data.py
  • custom_components/sat/errors.py
  • custom_components/sat/esphome/__init__.py
  • custom_components/sat/fake/__init__.py
  • custom_components/sat/heating_curve.py
  • custom_components/sat/helpers.py
  • custom_components/sat/minimum_setpoint.py
  • custom_components/sat/mqtt/__init__.py
  • custom_components/sat/mqtt/opentherm.py
  • custom_components/sat/number.py
  • custom_components/sat/pid.py
  • custom_components/sat/sensor.py
  • custom_components/sat/serial/__init__.py
  • custom_components/sat/serial/binary_sensor.py
  • custom_components/sat/serial/sensor.py
  • custom_components/sat/simulator/__init__.py
  • custom_components/sat/simulator/sensor.py
  • custom_components/sat/switch/__init__.py
  • custom_components/sat/temperature_state.py
  • custom_components/sat/util.py
  • tests/conftest.py
  • tests/const.py
  • tests/test_area.py
  • tests/test_climate.py
  • tests/test_config_flow.py
  • tests/test_overshoot_protection.py
  • tests/test_pid.py
  • tests/test_pwm.py
💤 Files with no reviewable changes (1)
  • custom_components/sat/errors.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_area.py
  • tests/const.py
🧰 Additional context used
🧬 Code graph analysis (23)
custom_components/sat/temperature_state.py (2)
custom_components/sat/area.py (2)
  • errors (245-248)
  • error (127-145)
custom_components/sat/climate.py (2)
  • error (380-399)
  • setpoint (454-456)
tests/test_config_flow.py (2)
custom_components/sat/entry_data.py (1)
  • SatMode (19-26)
custom_components/sat/config_flow.py (1)
  • SatFlowHandler (33-547)
custom_components/sat/entry_data.py (2)
custom_components/sat/helpers.py (2)
  • calculate_default_maximum_setpoint (65-67)
  • convert_time_str_to_seconds (40-49)
custom_components/sat/const.py (2)
  • HeatingMode (104-106)
  • HeatingSystem (97-101)
custom_components/sat/__init__.py (2)
custom_components/sat/coordinator.py (4)
  • SatDataUpdateCoordinatorFactory (69-100)
  • resolve (71-100)
  • async_setup (371-373)
  • get (464-474)
custom_components/sat/entry_data.py (3)
  • SatConfig (87-249)
  • SatEntryData (253-259)
  • error_monitoring_enabled (97-98)
custom_components/sat/binary_sensor.py (13)
custom_components/sat/entity.py (2)
  • SatClimateEntity (42-56)
  • async_added_to_hass (48-56)
custom_components/sat/entry_data.py (6)
  • SatConfig (87-249)
  • SatMode (19-26)
  • mode (116-117)
  • sensors (159-171)
  • window_sensors (155-156)
  • name_lower (127-128)
custom_components/sat/helpers.py (2)
  • seconds_since (22-24)
  • timestamp (12-14)
custom_components/sat/__init__.py (1)
  • async_setup_entry (20-45)
custom_components/sat/sensor.py (12)
  • async_setup_entry (23-66)
  • name (72-73)
  • name (126-130)
  • name (178-179)
  • name (219-220)
  • name (261-262)
  • name (293-294)
  • name (327-328)
  • name (354-355)
  • name (371-372)
  • async_added_to_hass (104-113)
  • async_added_to_hass (345-351)
custom_components/sat/serial/binary_sensor.py (2)
  • async_setup_entry (68-88)
  • name (112-114)
custom_components/sat/area.py (3)
  • get (255-261)
  • async_added_to_hass (202-213)
  • async_added_to_hass (271-274)
custom_components/sat/coordinator.py (6)
  • get (464-474)
  • supports_setpoint_management (324-330)
  • supports_relative_modulation_management (354-360)
  • setpoint (190-192)
  • relative_modulation_value (247-249)
  • async_added_to_hass (375-377)
custom_components/sat/serial/__init__.py (4)
  • get (152-154)
  • supports_setpoint_management (56-57)
  • setpoint (72-76)
  • relative_modulation_value (114-118)
custom_components/sat/simulator/__init__.py (3)
  • supports_setpoint_management (38-39)
  • setpoint (50-51)
  • relative_modulation_value (66-67)
custom_components/sat/pid.py (1)
  • async_added_to_hass (136-153)
custom_components/sat/minimum_setpoint.py (1)
  • async_added_to_hass (371-423)
custom_components/sat/boiler.py (1)
  • async_added_to_hass (125-141)
custom_components/sat/heating_curve.py (1)
custom_components/sat/const.py (1)
  • HeatingSystem (97-101)
custom_components/sat/simulator/__init__.py (4)
custom_components/sat/coordinator.py (6)
  • maximum_setpoint (313-321)
  • supports_relative_modulation (342-351)
  • setpoint (190-192)
  • member_id (202-204)
  • async_control_heating_loop (392-405)
  • async_notify_listeners (448-455)
custom_components/sat/entry_data.py (3)
  • SatConfig (87-249)
  • simulation (242-249)
  • limits (198-217)
custom_components/sat/helpers.py (1)
  • seconds_since (22-24)
custom_components/sat/types.py (1)
  • DeviceState (73-76)
custom_components/sat/entity.py (2)
custom_components/sat/entry_data.py (1)
  • SatConfig (87-249)
custom_components/sat/esphome/__init__.py (1)
  • async_added_to_hass (139-164)
tests/test_overshoot_protection.py (3)
custom_components/sat/const.py (1)
  • HeatingSystem (97-101)
custom_components/sat/overshoot_protection.py (6)
  • OvershootProtection (37-196)
  • _record_sample (143-148)
  • _sample_statistics (157-182)
  • _is_stable (185-196)
  • _calculate_overshoot_value (114-120)
  • _get_setpoint (131-136)
custom_components/sat/coordinator.py (1)
  • maximum_setpoint_value (257-259)
custom_components/sat/helpers.py (2)
custom_components/sat/state.py (1)
  • State (10-12)
custom_components/sat/const.py (1)
  • HeatingSystem (97-101)
custom_components/sat/pid.py (3)
custom_components/sat/helpers.py (3)
  • clamp (108-113)
  • timestamp (12-14)
  • float_value (78-87)
custom_components/sat/temperature_state.py (3)
  • TemperatureState (38-49)
  • error (47-49)
  • max (30-34)
custom_components/sat/const.py (1)
  • HeatingSystem (97-101)
custom_components/sat/sensor.py (3)
custom_components/sat/coordinator.py (6)
  • SatDataUpdateCoordinator (103-460)
  • get (464-474)
  • supports_relative_modulation_management (354-360)
  • relative_modulation_value (247-249)
  • last_cycle (179-181)
  • device_status (142-144)
custom_components/sat/entry_data.py (9)
  • SatConfig (87-249)
  • SatMode (19-26)
  • mode (116-117)
  • sensors (159-171)
  • rooms (143-144)
  • limits (198-217)
  • name (123-124)
  • name_lower (127-128)
  • pid (174-183)
custom_components/sat/types.py (2)
  • BoilerStatus (15-41)
  • CycleClassification (54-70)
custom_components/sat/esphome/__init__.py (5)
custom_components/sat/coordinator.py (3)
  • SatDataUpdateCoordinator (103-460)
  • get (464-474)
  • async_added_to_hass (375-377)
custom_components/sat/entry_data.py (2)
  • SatConfig (87-249)
  • device (93-94)
custom_components/sat/helpers.py (2)
  • float_value (78-87)
  • int_value (96-99)
custom_components/sat/types.py (1)
  • DeviceState (73-76)
custom_components/sat/mqtt/__init__.py (1)
  • async_added_to_hass (34-46)
custom_components/sat/const.py (1)
custom_components/sat/types.py (1)
  • CycleClassification (54-70)
custom_components/sat/climate.py (4)
custom_components/sat/area.py (9)
  • Areas (234-352)
  • get (255-261)
  • async_added_to_hass (202-213)
  • async_added_to_hass (271-274)
  • target_temperature (119-124)
  • items (267-269)
  • ids (263-265)
  • reset (348-352)
  • pids (251-253)
custom_components/sat/entry_data.py (9)
  • SatConfig (87-249)
  • SatMode (19-26)
  • presets (220-239)
  • name_lower (127-128)
  • pid (174-183)
  • pwm (186-195)
  • sensors (159-171)
  • limits (198-217)
  • is_mode (119-120)
custom_components/sat/helpers.py (6)
  • float_value (78-87)
  • is_state_stale (32-37)
  • state_age_seconds (27-29)
  • event_timestamp (17-19)
  • clamp (108-113)
  • timestamp (12-14)
custom_components/sat/types.py (5)
  • BoilerStatus (15-41)
  • RelativeModulationState (107-112)
  • PWMStatus (79-83)
  • PWMDecision (86-104)
  • enabled (103-104)
custom_components/sat/minimum_setpoint.py (5)
custom_components/sat/boiler.py (4)
  • BoilerControlIntent (37-39)
  • BoilerCapabilities (43-46)
  • flow_setpoint_error (69-70)
  • flow_return_delta (73-74)
custom_components/sat/coordinator.py (5)
  • ControlLoopSample (33-39)
  • cycles (174-176)
  • minimum_setpoint (308-310)
  • maximum_setpoint (313-321)
  • setpoint (190-192)
custom_components/sat/types.py (2)
  • CycleKind (44-51)
  • CycleClassification (54-70)
custom_components/sat/helpers.py (1)
  • clamp (108-113)
custom_components/sat/cycles.py (3)
  • CycleStatistics (76-80)
  • Cycle (53-72)
  • duration (70-72)
custom_components/sat/serial/__init__.py (2)
custom_components/sat/coordinator.py (1)
  • member_id (202-204)
custom_components/sat/entry_data.py (1)
  • device (93-94)
custom_components/sat/fake/__init__.py (3)
custom_components/sat/coordinator.py (5)
  • SatDataUpdateCoordinator (103-460)
  • member_id (202-204)
  • setpoint (190-192)
  • device_active (196-198)
  • relative_modulation_value (247-249)
custom_components/sat/entry_data.py (1)
  • SatConfig (87-249)
custom_components/sat/types.py (1)
  • DeviceState (73-76)
custom_components/sat/mqtt/__init__.py (2)
custom_components/sat/entry_data.py (3)
  • SatConfig (87-249)
  • device (93-94)
  • mqtt_topic (112-113)
custom_components/sat/coordinator.py (1)
  • async_added_to_hass (375-377)
custom_components/sat/switch/__init__.py (2)
custom_components/sat/coordinator.py (1)
  • member_id (202-204)
custom_components/sat/entry_data.py (1)
  • device (93-94)
custom_components/sat/serial/binary_sensor.py (2)
custom_components/sat/entry_data.py (3)
  • SatConfig (87-249)
  • get_entry_data (262-263)
  • name_lower (127-128)
custom_components/sat/serial/__init__.py (1)
  • SatSerialCoordinator (27-208)
custom_components/sat/config_flow.py (2)
custom_components/sat/entry_data.py (3)
  • SatConfig (87-249)
  • SatMode (19-26)
  • mode (116-117)
custom_components/sat/const.py (2)
  • HeatingSystem (97-101)
  • HeatingMode (104-106)
tests/test_pwm.py (5)
custom_components/sat/boiler.py (6)
  • BoilerState (50-74)
  • flame_on_since (118-119)
  • flame_off_since (122-123)
  • modulation_reliable (114-115)
  • update (150-164)
  • status (99-103)
custom_components/sat/const.py (1)
  • HeatingSystem (97-101)
custom_components/sat/heating_curve.py (3)
  • HeatingCurve (11-92)
  • update (27-30)
  • base_offset (78-80)
custom_components/sat/pwm.py (9)
  • PWM (38-247)
  • PWMConfig (30-35)
  • update (84-151)
  • enabled (233-234)
  • status (237-238)
  • state (241-247)
  • restore (78-82)
  • _calculate_duty_cycle (158-230)
  • disable (153-156)
custom_components/sat/types.py (2)
  • PWMStatus (79-83)
  • enabled (103-104)
🪛 Pylint (4.0.4)
custom_components/sat/entry_data.py

[refactor] 87-87: Too many public methods (22/20)

(R0904)

custom_components/sat/binary_sensor.py

[refactor] 59-59: Too few public methods (1/2)

(R0903)

custom_components/sat/pid.py

[refactor] 31-31: Too many instance attributes (17/7)

(R0902)


[refactor] 34-34: Too many arguments (8/5)

(R0913)


[refactor] 34-34: Too many positional arguments (8/5)

(R0917)

custom_components/sat/climate.py

[refactor] 71-71: Too many instance attributes (28/7)

(R0902)


[refactor] 250-250: Too many branches (16/12)

(R0912)


[refactor] 71-71: Too many public methods (29/20)

(R0904)


[refactor] 707-707: Too many branches (13/12)

(R0912)

custom_components/sat/minimum_setpoint.py

[refactor] 98-98: Too many instance attributes (8/7)

(R0902)


[refactor] 446-446: Too many local variables (16/15)

(R0914)


[refactor] 511-572: Unnecessary "elif" after "return", remove the leading "el" from "elif"

(R1705)


[refactor] 446-446: Too many return statements (12/6)

(R0911)


[refactor] 446-446: Too many branches (22/12)

(R0912)


[refactor] 446-446: Too many statements (58/50)

(R0915)


[refactor] 587-587: Too many local variables (18/15)

(R0914)

custom_components/sat/serial/__init__.py

[error] 33-33: Undefined variable 'DEFAULT_STATUS'

(E0602)

custom_components/sat/serial/sensor.py

[refactor] 110-110: Too many arguments (6/5)

(R0913)


[refactor] 110-110: Too many positional arguments (6/5)

(R0917)

custom_components/sat/area.py

[refactor] 37-37: Too many instance attributes (8/7)

(R0902)

custom_components/sat/serial/binary_sensor.py

[refactor] 94-94: Too many arguments (6/5)

(R0913)


[refactor] 94-94: Too many positional arguments (6/5)

(R0917)

custom_components/sat/coordinator.py

[refactor] 71-71: Too many return statements (7/6)

(R0911)


[refactor] 103-103: Too many instance attributes (14/7)

(R0902)


[refactor] 103-103: Too many public methods (49/20)

(R0904)


[refactor] 463-463: Too few public methods (1/2)

(R0903)

tests/test_pid.py

[refactor] 18-18: Too few public methods (1/2)

(R0903)

tests/test_pwm.py

[refactor] 83-83: Too many arguments (6/5)

(R0913)


[refactor] 83-83: Too many positional arguments (6/5)

(R0917)

🪛 Ruff (0.14.10)
tests/test_config_flow.py

5-5: Unused function argument: hass

(ARG001)

custom_components/sat/entry_data.py

9-9: from .const import * used; unable to detect undefined names

(F403)


19-19: StrEnum may be undefined, or defined from star imports

(F405)


70-70: HeatingMode may be undefined, or defined from star imports

(F405)


94-94: CONF_DEVICE may be undefined, or defined from star imports

(F405)


98-98: CONF_ERROR_MONITORING may be undefined, or defined from star imports

(F405)


101-101: HeatingSystem may be undefined, or defined from star imports

(F405)


102-102: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


103-103: HeatingSystem may be undefined, or defined from star imports

(F405)


105-105: HeatingSystem may be undefined, or defined from star imports

(F405)


105-105: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


105-105: HeatingSystem may be undefined, or defined from star imports

(F405)


109-109: CONF_MANUFACTURER may be undefined, or defined from star imports

(F405)


113-113: CONF_MQTT_TOPIC may be undefined, or defined from star imports

(F405)


117-117: CONF_MODE may be undefined, or defined from star imports

(F405)


124-124: CONF_NAME may be undefined, or defined from star imports

(F405)


132-132: CONF_OVERSHOOT_PROTECTION may be undefined, or defined from star imports

(F405)


136-136: CONF_PUSH_SETPOINT_TO_THERMOSTAT may be undefined, or defined from star imports

(F405)


140-140: CONF_RADIATORS may be undefined, or defined from star imports

(F405)


144-144: CONF_ROOMS may be undefined, or defined from star imports

(F405)


148-148: CONF_SIMULATION may be undefined, or defined from star imports

(F405)


152-152: CONF_THERMOSTAT may be undefined, or defined from star imports

(F405)


156-156: CONF_WINDOW_SENSORS may be undefined, or defined from star imports

(F405)


161-161: CONF_INSIDE_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


162-162: CONF_HUMIDITY_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


163-163: CONF_OUTSIDE_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


166-166: CONF_WINDOW_MINIMUM_OPEN_TIME may be undefined, or defined from star imports

(F405)


169-169: CONF_SENSOR_MAX_VALUE_AGE may be undefined, or defined from star imports

(F405)


176-176: CONF_INTEGRAL may be undefined, or defined from star imports

(F405)


177-177: CONF_DERIVATIVE may be undefined, or defined from star imports

(F405)


178-178: CONF_PROPORTIONAL may be undefined, or defined from star imports

(F405)


180-180: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports

(F405)


181-181: CONF_AUTOMATIC_GAINS_VALUE may be undefined, or defined from star imports

(F405)


182-182: CONF_HEATING_CURVE_COEFFICIENT may be undefined, or defined from star imports

(F405)


188-188: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports

(F405)


189-189: CONF_DUTY_CYCLE may be undefined, or defined from star imports

(F405)


191-191: CONF_DYNAMIC_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


192-192: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


193-193: CONF_MAXIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports

(F405)


194-194: CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR may be undefined, or defined from star imports

(F405)


199-199: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


199-199: HeatingSystem may be undefined, or defined from star imports

(F405)


200-200: CONF_MAXIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


204-204: CONF_MAXIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


209-209: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


209-209: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


212-212: CONF_MAXIMUM_CONSUMPTION may be undefined, or defined from star imports

(F405)


213-213: CONF_MINIMUM_CONSUMPTION may be undefined, or defined from star imports

(F405)


215-215: CONF_CLIMATE_VALVE_OFFSET may be undefined, or defined from star imports

(F405)


216-216: CONF_TARGET_TEMPERATURE_STEP may be undefined, or defined from star imports

(F405)


221-221: HeatingMode may be undefined, or defined from star imports

(F405)


221-221: CONF_HEATING_MODE may be undefined, or defined from star imports

(F405)


224-224: CONF_AWAY_TEMPERATURE may be undefined, or defined from star imports

(F405)


224-224: CONF_AWAY_TEMPERATURE may be undefined, or defined from star imports

(F405)


225-225: CONF_HOME_TEMPERATURE may be undefined, or defined from star imports

(F405)


225-225: CONF_HOME_TEMPERATURE may be undefined, or defined from star imports

(F405)


226-226: CONF_SLEEP_TEMPERATURE may be undefined, or defined from star imports

(F405)


226-226: CONF_SLEEP_TEMPERATURE may be undefined, or defined from star imports

(F405)


227-227: CONF_COMFORT_TEMPERATURE may be undefined, or defined from star imports

(F405)


227-227: CONF_COMFORT_TEMPERATURE may be undefined, or defined from star imports

(F405)


228-228: CONF_ACTIVITY_TEMPERATURE may be undefined, or defined from star imports

(F405)


228-228: CONF_ACTIVITY_TEMPERATURE may be undefined, or defined from star imports

(F405)


233-233: CONF_THERMAL_COMFORT may be undefined, or defined from star imports

(F405)


234-234: CONF_SYNC_CLIMATES_WITH_MODE may be undefined, or defined from star imports

(F405)


235-235: CONF_SYNC_CLIMATES_WITH_PRESET may be undefined, or defined from star imports

(F405)


238-238: CONF_ROOM_WEIGHTS may be undefined, or defined from star imports

(F405)


244-244: CONF_SIMULATED_HEATING may be undefined, or defined from star imports

(F405)


244-244: OPTIONS_DEFAULTS may be undefined, or defined from star imports

(F405)


244-244: CONF_SIMULATED_HEATING may be undefined, or defined from star imports

(F405)


245-245: CONF_SIMULATED_COOLING may be undefined, or defined from star imports

(F405)


245-245: OPTIONS_DEFAULTS may be undefined, or defined from star imports

(F405)


245-245: CONF_SIMULATED_COOLING may be undefined, or defined from star imports

(F405)


247-247: CONF_SIMULATED_WARMING_UP may be undefined, or defined from star imports

(F405)


247-247: OPTIONS_DEFAULTS may be undefined, or defined from star imports

(F405)


247-247: CONF_SIMULATED_WARMING_UP may be undefined, or defined from star imports

(F405)


263-263: DOMAIN may be undefined, or defined from star imports

(F405)

custom_components/sat/__init__.py

36-36: Do not catch blind exception: Exception

(BLE001)


37-37: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


64-64: Do not catch blind exception: Exception

(BLE001)


65-65: Use logging.exception instead of logging.error

Replace with exception

(TRY400)

custom_components/sat/binary_sensor.py

15-15: from .const import * used; unable to detect undefined names

(F403)


25-25: DOMAIN may be undefined, or defined from star imports

(F405)


220-220: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


239-239: CycleClassification may be undefined, or defined from star imports

(F405)


240-240: CycleClassification may be undefined, or defined from star imports

(F405)


241-241: CycleClassification may be undefined, or defined from star imports

(F405)

custom_components/sat/heating_curve.py

80-80: HeatingSystem may be undefined, or defined from star imports

(F405)

custom_components/sat/pid.py

10-10: from .const import * used; unable to detect undefined names

(F403)


63-63: HeatingSystem may be undefined, or defined from star imports

(F405)


174-174: SIGNAL_PID_UPDATED may be undefined, or defined from star imports

(F405)


182-182: DEADBAND may be undefined, or defined from star imports

(F405)


186-186: DEADBAND may be undefined, or defined from star imports

(F405)


215-215: DEADBAND may be undefined, or defined from star imports

(F405)

custom_components/sat/util.py

8-8: from .const import * used; unable to detect undefined names

(F403)


69-69: DOMAIN may be undefined, or defined from star imports

(F405)

custom_components/sat/sensor.py

14-14: from .const import * used; unable to detect undefined names

(F403)


25-25: DOMAIN may be undefined, or defined from star imports

(F405)


113-113: SIGNAL_PID_UPDATED may be undefined, or defined from star imports

(F405)


351-351: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


360-360: CycleClassification may be undefined, or defined from star imports

(F405)

custom_components/sat/climate.py

35-35: from .const import * used; unable to detect undefined names

(F403)


95-95: Consider [PRESET_NONE, *list(self._presets.keys())] instead of concatenation

Replace with [PRESET_NONE, *list(self._presets.keys())]

(RUF005)


185-185: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports

(F405)


193-193: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


203-203: Unused method argument: event

(ARG002)


242-242: DOMAIN may be undefined, or defined from star imports

(F405)


312-312: DOMAIN may be undefined, or defined from star imports

(F405)


312-312: SERVICE_RESET_INTEGRAL may be undefined, or defined from star imports

(F405)


462-462: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


467-467: HeatingMode may be undefined, or defined from star imports

(F405)


480-480: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


480-480: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


567-567: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


571-571: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


584-584: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


590-590: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


594-594: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


605-605: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports

(F405)


614-614: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


713-713: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


714-714: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


769-769: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


773-773: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


773-773: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


844-844: DEADBAND may be undefined, or defined from star imports

(F405)


844-844: DEADBAND may be undefined, or defined from star imports

(F405)


906-906: COLD_SETPOINT may be undefined, or defined from star imports

(F405)

custom_components/sat/serial/__init__.py

10-10: from pyotgw.vars import * used; unable to detect undefined names

(F403)


33-33: DEFAULT_STATUS may be undefined, or defined from star imports

(F405)

custom_components/sat/area.py

221-221: Unused method argument: time

(ARG002)


296-296: Do not catch blind exception: Exception

(BLE001)

tests/test_climate.py

74-74: Unused lambda argument: self

(ARG005)


75-75: Unused lambda argument: self

(ARG005)


76-76: Unused lambda argument: self

(ARG005)


85-85: Unused lambda argument: self

(ARG005)


86-86: Unused lambda argument: self

(ARG005)


87-87: Unused lambda argument: self

(ARG005)


119-119: Unused function argument: self

(ARG001)


148-148: Unused lambda argument: self

(ARG005)


160-160: Unused lambda argument: self

(ARG005)


175-175: Unused lambda argument: self

(ARG005)

custom_components/sat/config_flow.py

117-117: CONF_MODE may be undefined, or defined from star imports

(F405)


120-120: CONF_MODE may be undefined, or defined from star imports

(F405)


162-162: CONF_MODE may be undefined, or defined from star imports

(F405)


181-181: CONF_MODE may be undefined, or defined from star imports

(F405)


214-214: CONF_MODE may be undefined, or defined from star imports

(F405)


236-236: CONF_MODE may be undefined, or defined from star imports

(F405)


237-237: CONF_DEVICE may be undefined, or defined from star imports

(F405)


237-237: f-string without any placeholders

Remove extraneous f prefix

(F541)


237-237: CONF_NAME may be undefined, or defined from star imports

(F405)


277-277: CONF_MODE may be undefined, or defined from star imports

(F405)


310-310: HeatingSystem may be undefined, or defined from star imports

(F405)


311-311: HeatingSystem may be undefined, or defined from star imports

(F405)


312-312: HeatingSystem may be undefined, or defined from star imports

(F405)


350-350: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports

(F405)


353-353: CONF_MODE may be undefined, or defined from star imports

(F405)


439-439: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


442-442: CONF_MODE may be undefined, or defined from star imports

(F405)


458-458: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports

(F405)


463-463: CONF_MODE may be undefined, or defined from star imports

(F405)


523-523: OPTIONS_DEFAULTS may be undefined, or defined from star imports

(F405)


560-560: CONF_ROOMS may be undefined, or defined from star imports

(F405)


586-586: HeatingMode may be undefined, or defined from star imports

(F405)


587-587: HeatingMode may be undefined, or defined from star imports

(F405)


599-599: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports

(F405)


657-657: CONF_ROOM_WEIGHTS may be undefined, or defined from star imports

(F405)


659-659: CONF_ROOMS may be undefined, or defined from star imports

(F405)


675-675: CONF_ROOM_WEIGHTS may be undefined, or defined from star imports

(F405)


697-697: CONF_SYNC_CLIMATES_WITH_MODE may be undefined, or defined from star imports

(F405)


697-697: CONF_SYNC_CLIMATES_WITH_MODE may be undefined, or defined from star imports

(F405)


700-700: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


700-700: HeatingSystem may be undefined, or defined from star imports

(F405)


701-701: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports

(F405)


701-701: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports

(F405)


708-708: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


708-708: HeatingSystem may be undefined, or defined from star imports

(F405)


737-737: CONF_MODE may be undefined, or defined from star imports

(F405)

custom_components/sat/coordinator.py

15-15: from .const import * used; unable to detect undefined names

(F403)


100-100: Create your own exception

(TRY002)


100-100: Avoid specifying long messages outside the exception class

(TRY003)


106-106: DOMAIN may be undefined, or defined from star imports

(F405)


310-310: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)

Comment on lines +820 to +829
async def async_control_pid(self, _time: Optional[datetime] = None) -> None:
"""Control the PID controller."""
sensor_max_value_age = self._config.sensors.sensor_max_value_age_seconds

Parameters:
entity_id (str): The entity id of the sensor to track.
if sensor_max_value_age > 0:
state = self.hass.states.get(self._config.sensors.inside_sensor_entity_id)
if is_state_stale(state, sensor_max_value_age):
_LOGGER.debug("Resetting PID for %s due to stale sensor %s (age=%.1fs > %.1fs)", self.entity_id, self._config.sensors.inside_sensor_entity_id, state_age_seconds(state), sensor_max_value_age)
self.pid.reset()
return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard stale-sensor check against missing inside sensor state.

async_control_pid can call is_state_stale with state=None:

state = self.hass.states.get(self._config.sensors.inside_sensor_entity_id)
if is_state_stale(state, sensor_max_value_age):
    ...

If the inside sensor entity is removed or renamed, this will raise.

Add a None check before calling is_state_stale:

Proposed fix
-        if sensor_max_value_age > 0:
-            state = self.hass.states.get(self._config.sensors.inside_sensor_entity_id)
-            if is_state_stale(state, sensor_max_value_age):
+        if sensor_max_value_age > 0:
+            state = self.hass.states.get(self._config.sensors.inside_sensor_entity_id)
+            if state is not None and is_state_stale(state, sensor_max_value_age):
                 _LOGGER.debug(
                     "Resetting PID for %s due to stale sensor %s (age=%.1fs > %.1fs)",
                     self.entity_id,
                     self._config.sensors.inside_sensor_entity_id,
                     state_age_seconds(state),
                     sensor_max_value_age,
                 )
                 self.pid.reset()
                 return
🤖 Prompt for AI Agents
In custom_components/sat/climate.py around lines 820 to 829, the code calls
is_state_stale(state, ...) without checking if state is None which will raise if
the inside sensor entity is missing; update the logic to first check whether
state is None (e.g., if state is None: log a debug/warning about missing inside
sensor, reset the PID and return) and only call is_state_stale when state is not
None, ensuring the PID is reset/returned in both stale and missing-sensor cases.

Comment on lines +233 to 238
async def async_step_simulator(self, _user_input: Optional[dict[str, Any]] = None):
if _user_input is not None:
self.data.update(_user_input)
self.data[CONF_MODE] = MODE_SIMULATOR
self.data[CONF_DEVICE] = f"%s_%s".format(MODE_SIMULATOR, snake_case(_user_input.get(CONF_NAME)))
self.data[CONF_MODE] = SatMode.SIMULATOR
self.data[CONF_DEVICE] = f"%s_%s".format(SatMode.SIMULATOR, snake_case(_user_input.get(CONF_NAME)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix simulator device id formatting (CONF_DEVICE).

The current line:

self.data[CONF_DEVICE] = f"%s_%s".format(SatMode.SIMULATOR, snake_case(_user_input.get(CONF_NAME)))

does not interpolate anything—"%s_%s".format(...) with %-style placeholders just returns the literal "%s_%s".

Use a real f-string (or proper .format) so each simulator gets a unique, meaningful device id:

Proposed fix
-            self.data[CONF_MODE] = SatMode.SIMULATOR
-            self.data[CONF_DEVICE] = f"%s_%s".format(SatMode.SIMULATOR, snake_case(_user_input.get(CONF_NAME)))
+            self.data[CONF_MODE] = SatMode.SIMULATOR
+            self.data[CONF_DEVICE] = f"{SatMode.SIMULATOR}_{snake_case(_user_input.get(CONF_NAME))}"
🧰 Tools
🪛 Ruff (0.14.10)

236-236: CONF_MODE may be undefined, or defined from star imports

(F405)


237-237: CONF_DEVICE may be undefined, or defined from star imports

(F405)


237-237: f-string without any placeholders

Remove extraneous f prefix

(F541)


237-237: CONF_NAME may be undefined, or defined from star imports

(F405)

🤖 Prompt for AI Agents
In custom_components/sat/config_flow.py around lines 233 to 238, the CONF_DEVICE
assignment uses "%s_%s".format(...) which does not interpolate and yields a
literal string; replace it with a proper string interpolation such as
f"{SatMode.SIMULATOR}_{snake_case(_user_input.get(CONF_NAME))}" (or use
"{}_{}".format(...)) so each simulator gets a unique device id; ensure you call
snake_case on a non-None name (e.g., _user_input.get(CONF_NAME) or a safe
default) before composing the device id.

@@ -1,11 +1,13 @@
from __future__ import annotations, annotations
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove duplicate annotations import.

from __future__ import annotations, annotations imports annotations twice.

🔎 Proposed fix
-from __future__ import annotations, annotations
+from __future__ import annotations
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from __future__ import annotations, annotations
from __future__ import annotations
🤖 Prompt for AI Agents
In custom_components/sat/fake/__init__.py around line 1, the future import lists
`annotations` twice; remove the duplicate so the line reads a single `from
__future__ import annotations` to eliminate the redundant import and keep the
same behavior.

Comment on lines +64 to +69
return 30

@property
def native_max_value(self) -> float:
"""Return the maximum accepted temperature."""
return self._coordinator.maximum_hot_water_setpoint
return 60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if coordinator exposes hot water temperature range properties
rg -n "hot_water.*min|hot_water.*max|dhw.*min|dhw.*max" custom_components/sat/coordinator.py -A 2 -B 2

Repository: Alexwijn/SAT

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Also examine the full context of the number.py file around lines 64-69
cat -n custom_components/sat/number.py | sed -n '50,75p'

Repository: Alexwijn/SAT

Length of output: 971


🏁 Script executed:

#!/bin/bash
# Search for any references to temperature ranges in the entire codebase
rg -n "native_min_value|native_max_value" custom_components/sat/ -A 1 -B 1

Repository: Alexwijn/SAT

Length of output: 930


🏁 Script executed:

#!/bin/bash
# Check if there are multiple boiler model definitions or configurations
fd "model|config" custom_components/sat/ -type f | head -20

Repository: Alexwijn/SAT

Length of output: 226


🏁 Script executed:

#!/bin/bash
# Get full context of number.py to understand both temperature entities
wc -l custom_components/sat/number.py

Repository: Alexwijn/SAT

Length of output: 91


🏁 Script executed:

#!/bin/bash
# View more of the number.py file to see class names and both entities
head -130 custom_components/sat/number.py | tail -80

Repository: Alexwijn/SAT

Length of output: 2526


🏁 Script executed:

#!/bin/bash
# Search for any documentation about temperature ranges or boiler models
fd -e md -e rst -e txt . custom_components/sat/ 2>/dev/null | head -10

Repository: Alexwijn/SAT

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Check if there are any comments or docstrings mentioning temperature ranges
rg -n "temperature.*range|valid.*range|min.*max|boiler.*model" custom_components/sat/ -i -A 1

Repository: Alexwijn/SAT

Length of output: 15218


🏁 Script executed:

#!/bin/bash
# Check git log or comments about when temperature limits were changed
git log -p --all -S "native_min_value\|native_max_value" -- custom_components/sat/number.py 2>/dev/null | head -100

Repository: Alexwijn/SAT

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Search for boiler capabilities definitions and what ranges they support
rg -n "boiler_capabilities|BoilerCapabilities" custom_components/sat/ -A 3 -B 1

Repository: Alexwijn/SAT

Length of output: 8301


🏁 Script executed:

#!/bin/bash
# Check what values are used in coordinator for actual control
rg -n "hot_water.*setpoint|async_set_control_hot_water" custom_components/sat/coordinator.py -A 2 -B 2

Repository: Alexwijn/SAT

Length of output: 1090


🏁 Script executed:

#!/bin/bash
# Search for any validation or bounds checking related to setpoints
rg -n "clamp.*setpoint|setpoint.*30|setpoint.*60" custom_components/sat/ -i

Repository: Alexwijn/SAT

Length of output: 1387


🏁 Script executed:

#!/bin/bash
# Check the full coordinator.py file around minimum/maximum_hot_water_setpoint properties
grep -n "class.*Coordinator" custom_components/sat/coordinator.py | head -5

Repository: Alexwijn/SAT

Length of output: 211


🏁 Script executed:

#!/bin/bash
# See if these properties are implemented differently in subclasses
rg -n "minimum_hot_water_setpoint|maximum_hot_water_setpoint" custom_components/sat/ -B 2 -A 2

Repository: Alexwijn/SAT

Length of output: 3939


🏁 Script executed:

#!/bin/bash
# Check boiler.py to understand BoilerCapabilities and if hot water should be included
cat -n custom_components/sat/boiler.py | head -60

Repository: Alexwijn/SAT

Length of output: 2005


🏁 Script executed:

#!/bin/bash
# Check if hot water setpoint should use device_capabilities instead
grep -n "async_set_control_hot_water_setpoint\|supports_hot_water" custom_components/sat/number.py -B 5 -A 5

Repository: Alexwijn/SAT

Length of output: 1038


Use coordinator properties instead of hard-coding hot water temperature limits.

The entity hard-codes min/max values (30°C, 60°C) instead of using the coordinator's minimum_hot_water_setpoint and maximum_hot_water_setpoint properties. These coordinator properties can be overridden by device-specific data from OpenTherm, Serial, and ESPHome protocols, allowing different boiler models to report their actual supported ranges. By hard-coding, the entity ignores potentially different limits reported by the device.

Replace the hard-coded returns with self._coordinator.minimum_hot_water_setpoint and self._coordinator.maximum_hot_water_setpoint respectively.

🤖 Prompt for AI Agents
In custom_components/sat/number.py around lines 64 to 69, the entity currently
hard-codes native_min_value and native_max_value to 30 and 60; replace those
returns to use the coordinator-provided ranges instead. Change native_min_value
to return self._coordinator.minimum_hot_water_setpoint and native_max_value to
return self._coordinator.maximum_hot_water_setpoint so device-specific limits
supplied by OpenTherm/Serial/ESPHome are respected.

Comment on lines +243 to 251
if not self._coordinator.device_active or not self._coordinator.flame_active:
return 0.0

differential_gas_consumption = self._maximum_consumption - self._minimum_consumption
relative_modulation_value = self._coordinator.relative_modulation_value

return round(self._minimum_consumption + ((relative_modulation_value / 100) * differential_gas_consumption), 3)
modulation_fraction = relative_modulation_value / 100
return round(self._minimum_consumption + (modulation_fraction * differential_gas_consumption), 3)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Defensively handle relative_modulation_value being None in SatCurrentConsumptionSensor.native_value.

available checks relative_modulation_value is not None, but HA may still call native_value when available is false. In that case, this line will fail:

modulation_fraction = relative_modulation_value / 100

Add a simple None check:

Proposed fix
-        relative_modulation_value = self._coordinator.relative_modulation_value
-
-        modulation_fraction = relative_modulation_value / 100
+        relative_modulation_value = self._coordinator.relative_modulation_value
+        if relative_modulation_value is None:
+            return 0.0
+
+        modulation_fraction = relative_modulation_value / 100
🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 243 to 251, native_value assumes
self._coordinator.relative_modulation_value is not None and will raise if HA
calls it while unavailable; add a defensive None check for
relative_modulation_value at the start of the calculation (e.g., if
relative_modulation_value is None: return 0.0 or another sensible fallback) so
the method returns a valid float instead of raising, then proceed with the
existing modulation_fraction calculation and rounding when the value is present.

Comment on lines +29 to +32
def test_setpoint_uses_default_when_maximum_missing(hass):
coordinator = _build_coordinator(hass)
protection = OvershootProtection(coordinator, HeatingSystem.RADIATORS)
assert protection._setpoint == float(coordinator.maximum_setpoint_value)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find the _build_coordinator function in the test file
rg -n "_build_coordinator" tests/test_overshoot_protection.py -A 8

Repository: Alexwijn/SAT

Length of output: 4387


🏁 Script executed:

# Find the OVERSHOOT_PROTECTION_SETPOINT definition
rg -n "OVERSHOOT_PROTECTION_SETPOINT" custom_components/sat/ -A 3

Repository: Alexwijn/SAT

Length of output: 1091


🏁 Script executed:

# Find OvershootProtection.__init__ to see how _setpoint is assigned
rg -n "class OvershootProtection" custom_components/sat/ -A 30

Repository: Alexwijn/SAT

Length of output: 3651


🏁 Script executed:

# Check if there's a fake coordinator and what maximum_setpoint_value returns
fd "fake" custom_components/sat/ -type f | head -20

Repository: Alexwijn/SAT

Length of output: 226


🏁 Script executed:

# Find SatFakeCoordinator and its maximum_setpoint_value property/method
rg -n "class SatFakeCoordinator" custom_components/sat/ -A 50 | head -100

Repository: Alexwijn/SAT

Length of output: 3566


🏁 Script executed:

# Search for maximum_setpoint_value property definition in the fake coordinator or base coordinator
rg -n "maximum_setpoint_value" custom_components/sat/ -B 2 -A 2

Repository: Alexwijn/SAT

Length of output: 4173


🏁 Script executed:

# Search for the maximum_setpoint property in coordinator.py
rg -n "def maximum_setpoint" custom_components/sat/coordinator.py -B 2 -A 5

Repository: Alexwijn/SAT

Length of output: 688


🏁 Script executed:

# Also check the SatFakeCoordinator to see if it overrides maximum_setpoint
rg -n "maximum_setpoint" custom_components/sat/fake/__init__.py -B 2 -A 5

Repository: Alexwijn/SAT

Length of output: 1677


🏁 Script executed:

# Check what happens with the config - see if there's a default maximum_setpoint
rg -n "calculate_default_maximum_setpoint" custom_components/sat/ -B 2 -A 8

Repository: Alexwijn/SAT

Length of output: 6347


🏁 Script executed:

# Get the full maximum_setpoint property implementation
rg -n "def maximum_setpoint" custom_components/sat/coordinator.py -A 10

Repository: Alexwijn/SAT

Length of output: 941


🏁 Script executed:

# Check the test's DEFAULT_USER_DATA to understand the initial config
rg -n "DEFAULT_USER_DATA" tests/ -B 2 -A 5

Repository: Alexwijn/SAT

Length of output: 3677


🏁 Script executed:

# Get the full DEFAULT_USER_DATA definition
rg -n "DEFAULT_USER_DATA" tests/const.py -A 10

Repository: Alexwijn/SAT

Length of output: 611


🏁 Script executed:

# Check OPTIONS_DEFAULTS to see if CONF_MAXIMUM_SETPOINT is set there
rg -n "OPTIONS_DEFAULTS" tests/const.py -A 10

Repository: Alexwijn/SAT

Length of output: 546


🏁 Script executed:

# Find OPTIONS_DEFAULTS definition
rg -n "OPTIONS_DEFAULTS" custom_components/sat/ -B 2 -A 15

Repository: Alexwijn/SAT

Length of output: 14868


🏁 Script executed:

# Check if there are any other similar tests that show the correct pattern
rg -n "test_setpoint" tests/test_overshoot_protection.py -A 3

Repository: Alexwijn/SAT

Length of output: 568


Assert against the correct default setpoint value.

The test name suggests it should verify _setpoint equals the OVERSHOOT_PROTECTION_SETPOINT default (62 for RADIATORS), but the assertion compares against coordinator.maximum_setpoint_value. The coordinator never returns None for maximum_setpoint_value; it calculates a default of 55 for RADIATORS when not explicitly configured. The test passes coincidentally because OvershootProtection clamps to min(62, 55) = 55, matching the coordinator's calculated value. To properly test the default behavior, assert against OVERSHOOT_PROTECTION_SETPOINT[HeatingSystem.RADIATORS] instead.

🤖 Prompt for AI Agents
In tests/test_overshoot_protection.py around lines 29-32, the assertion compares
protection._setpoint to coordinator.maximum_setpoint_value but should assert the
default overshoot setpoint for radiators; change the assertion to compare
protection._setpoint to OVERSHOOT_PROTECTION_SETPOINT[HeatingSystem.RADIATORS]
(and add or adjust the import for OVERSHOOT_PROTECTION_SETPOINT if not already
imported).

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: 1

♻️ Duplicate comments (2)
custom_components/sat/pid.py (1)

61-65: Constructor parameter automatic_gain_value is still hardcoded and ignored.

Despite past review comments indicating this was addressed in commits 716b320 to 28880d1, line 64 still hardcodes automatic_gain_value as 4 or 3 based on heating system, completely ignoring self._automatic_gains_value from the constructor. This means the configured value is never used.

🔎 Proposed fix
         if self._heating_curve is None:
             return 0.0

-        automatic_gain_value = 4 if self._heating_system == HeatingSystem.UNDERFLOOR else 3
+        # Use configured value, falling back to default based on heating system if not set
+        if self._automatic_gains_value > 0:
+            automatic_gain_value = self._automatic_gains_value
+        else:
+            automatic_gain_value = 4 if self._heating_system == HeatingSystem.UNDERFLOOR else 3
         return round((self._heating_curve_coefficient * self._heating_curve) / automatic_gain_value, 6)

Based on learnings, this issue was previously flagged but remains unresolved despite being marked as addressed.

tests/test_pid.py (1)

73-92: Test validates buggy behavior and will fail when PID is fixed.

This test passes automatic_gain_value=2.0 (line 76) but expects kp == 20.0 (line 89), which is calculated using the hardcoded value of 4 instead of the configured 2.0. Once the bug in pid.py line 64 is fixed to use self._automatic_gains_value, the expected value should be:

kp = (heating_curve_coefficient * heating_curve) / automatic_gain_value
   = (2.0 * 40.0) / 2.0
   = 40.0
🔎 Proposed fix (after PID bug is resolved)
-    assert pid.kp == 20.0
-    assert pid.ki == round(20.0 / 8400, 6)
-    assert pid.kd == round(0.07 * 8400 * 20.0, 6)
+    assert pid.kp == 40.0
+    assert pid.ki == round(40.0 / 8400, 6)
+    assert pid.kd == round(0.07 * 8400 * 40.0, 6)

Based on learnings, this issue was previously flagged and remains dependent on fixing the PID implementation.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 28880d1 and 17f8466.

📒 Files selected for processing (2)
  • custom_components/sat/pid.py
  • tests/test_pid.py
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/pid.py (4)
custom_components/sat/helpers.py (3)
  • clamp (108-113)
  • float_value (78-87)
  • timestamp (12-14)
custom_components/sat/area.py (4)
  • temperature_state (83-102)
  • get (255-261)
  • update (221-231)
  • error (127-145)
custom_components/sat/temperature_state.py (3)
  • TemperatureState (38-49)
  • error (47-49)
  • max (30-34)
custom_components/sat/const.py (1)
  • HeatingSystem (97-101)
🪛 Pylint (4.0.4)
custom_components/sat/pid.py

[refactor] 32-32: Too many instance attributes (17/7)

(R0902)


[refactor] 35-35: Too many arguments (8/5)

(R0913)


[refactor] 35-35: Too many positional arguments (8/5)

(R0917)

🪛 Ruff (0.14.10)
custom_components/sat/pid.py

10-10: from .const import * used; unable to detect undefined names

(F403)


64-64: HeatingSystem may be undefined, or defined from star imports

(F405)


177-177: SIGNAL_PID_UPDATED may be undefined, or defined from star imports

(F405)


185-185: DEADBAND may be undefined, or defined from star imports

(F405)


189-189: DEADBAND may be undefined, or defined from star imports

(F405)


222-222: DEADBAND may be undefined, or defined from star imports

(F405)

🔇 Additional comments (4)
custom_components/sat/pid.py (3)

134-157: LGTM! Storage restoration is well-structured.

The storage restoration logic properly handles optional fields, uses safe float conversion, and scopes storage per entity and device.


179-216: LGTM! Integral logic is well-designed.

The integral implementation properly handles deadband transitions, prevents windup through clamping, and caps integration intervals to avoid over-accumulation from sensor gaps.


217-253: LGTM! Derivative filtering is robust.

The two-stage low-pass filtering and capping logic effectively reduce noise while preventing extreme derivative values. The deadband freeze and sensor timing checks are appropriate safeguards.

tests/test_pid.py (1)

1-205: Excellent test coverage for PID controller.

The test suite comprehensively covers initialization, manual/automatic gains, integral accumulation and clamping, derivative filtering and capping, deadband behavior, and timing considerations. The tests are well-structured and use appropriate assertions.

def last_updated(self) -> float:
"""Return the timestamp of the last update to the PID controller."""
return self._last_updated
self._hass.loop.call_soon_threadsafe(async_dispatcher_send, self._hass, SIGNAL_PID_UPDATED, self._entity_id)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: async_dispatcher_send cannot be used with call_soon_threadsafe.

Line 177 passes async_dispatcher_send (a coroutine function) directly to call_soon_threadsafe, which expects a regular callable. This will fail because calling async_dispatcher_send(...) returns a coroutine object that needs to be scheduled, not a value.

This may be related to the TypeError reported in PR comments: "TypeError: a coroutine was expected, got ".

🔎 Proposed fix
-            self._hass.loop.call_soon_threadsafe(async_dispatcher_send, self._hass, SIGNAL_PID_UPDATED, self._entity_id)
+            async_dispatcher_send(self._hass, SIGNAL_PID_UPDATED, self._entity_id)

Since update() is already called from an async context (the coordinator), you can call async_dispatcher_send directly without thread-safe wrapping. If update() is sometimes called from a sync context, use:

-            self._hass.loop.call_soon_threadsafe(async_dispatcher_send, self._hass, SIGNAL_PID_UPDATED, self._entity_id)
+            self._hass.async_create_task(async_dispatcher_send(self._hass, SIGNAL_PID_UPDATED, self._entity_id))

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Ruff (0.14.10)

177-177: SIGNAL_PID_UPDATED may be undefined, or defined from star imports

(F405)

🤖 Prompt for AI Agents
In custom_components/sat/pid.py around line 177, calling
self._hass.loop.call_soon_threadsafe(async_dispatcher_send, self._hass,
SIGNAL_PID_UPDATED, self._entity_id) is wrong because async_dispatcher_send
should be invoked directly (or scheduled as a coroutine), not passed into
call_soon_threadsafe; replace that line with a direct call to
async_dispatcher_send(self._hass, SIGNAL_PID_UPDATED, self._entity_id) when
update() runs in async context, and if update() can be called from sync contexts
instead, schedule it via Home Assistant's async job scheduler (e.g.,
self._hass.async_add_job / self._hass.add_job or call_soon_threadsafe with a
lambda that creates an async task) so the dispatcher is properly scheduled
rather than passed as a coroutine object.

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: 1

♻️ Duplicate comments (2)
custom_components/sat/pid.py (2)

35-65: Constructor parameter automatic_gain_value remains unused.

Line 64 still hardcodes the gain value instead of using self._automatic_gains_value passed to the constructor. This issue was flagged in previous reviews but remains unresolved.


177-177: Critical: async_dispatcher_send misuse with call_soon_threadsafe.

This issue was flagged in previous reviews and likely causes the TypeError mentioned in PR comments ("a coroutine was expected"). The call_soon_threadsafe pattern is incorrect for async_dispatcher_send.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 17f8466 and 6bf5ba7.

📒 Files selected for processing (2)
  • custom_components/sat/pid.py
  • tests/test_pid.py
✅ Files skipped from review due to trivial changes (1)
  • tests/test_pid.py
🧰 Additional context used
🪛 Pylint (4.0.4)
custom_components/sat/pid.py

[refactor] 32-32: Too many instance attributes (17/7)

(R0902)


[refactor] 35-35: Too many arguments (8/5)

(R0913)


[refactor] 35-35: Too many positional arguments (8/5)

(R0917)

🪛 Ruff (0.14.10)
custom_components/sat/pid.py

10-10: from .const import * used; unable to detect undefined names

(F403)


64-64: HeatingSystem may be undefined, or defined from star imports

(F405)


177-177: SIGNAL_PID_UPDATED may be undefined, or defined from star imports

(F405)


185-185: DEADBAND may be undefined, or defined from star imports

(F405)


189-189: DEADBAND may be undefined, or defined from star imports

(F405)


222-222: DEADBAND may be undefined, or defined from star imports

(F405)

🔇 Additional comments (4)
custom_components/sat/pid.py (4)

1-30: LGTM: Storage constants and imports are well-structured.

The storage-backed state management constants and imports properly support the cycle-centric architecture refactor. The derivative filtering constants and storage keys are clearly named and appropriate.


67-120: LGTM: Property implementations are correct.

The ki/kd automatic gain calculations, component properties (proportional/integral/derivative), and output computation all correctly implement PID logic with proper null checks and rounding.


122-156: LGTM: State management initialization is robust.

The reset() method properly initializes all state variables, and async_added_to_hass() correctly restores PID state from storage with appropriate defaults and nullable field handling.


179-268: LGTM: PID component update logic is well-implemented.

The integral update correctly handles deadband boundaries, time capping, and clamping to heating curve bounds. The derivative calculation uses appropriate low-pass filtering with magnitude capping. State persistence is straightforward and correct.

"""Return the previous error value used by the PID controller."""
return self._previous_error
if self._store is not None:
self._hass.create_task(self._async_save_state())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix: Use async_create_task instead of create_task.

Home Assistant's HomeAssistant object uses async_create_task, not create_task. This will raise an AttributeError.

🔎 Proposed fix
-            self._hass.create_task(self._async_save_state())
+            self._hass.async_create_task(self._async_save_state())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self._hass.create_task(self._async_save_state())
self._hass.async_create_task(self._async_save_state())
🤖 Prompt for AI Agents
In custom_components/sat/pid.py around line 175, replace the incorrect call to
self._hass.create_task(self._async_save_state()) with Home Assistant's supported
async task helper; use self._hass.async_create_task(self._async_save_state())
instead so the coroutine is scheduled correctly and no AttributeError is raised.

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: 1

♻️ Duplicate comments (2)
custom_components/sat/pid.py (2)

35-53: Constructor stores automatic_gain_value but the value is ignored.

The parameter automatic_gain_value is stored at line 41, but line 69 computes the gain using hardcoded values (4 or 3) instead of using self._automatic_gains_value. This was previously flagged and marked as resolved, but the issue persists in the current code.

Either use the stored value or remove the parameter from the constructor.

🔎 Proposed fix (use the stored parameter)
-        automatic_gain_value = 4 if self._heating_system == HeatingSystem.UNDERFLOOR else 3
+        automatic_gain_value = self._automatic_gains_value if self._automatic_gains_value > 0 else (4 if self._heating_system == HeatingSystem.UNDERFLOOR else 3)

173-176: Critical: Both task scheduling calls are incorrect and causing runtime errors.

These bugs match the TypeError reported in PR comments ("a coroutine was expected, got <bound method>"):

  1. Line 174: self._hass.create_task() does not exist on HomeAssistant — use async_create_task().

  2. Line 176: call_soon_threadsafe(async_dispatcher_send, ...) is incorrect. async_dispatcher_send returns a coroutine when called, but call_soon_threadsafe expects a regular callable. The coroutine is never scheduled.

Since update() is called from an async context (the coordinator), thread-safe wrapping is unnecessary.

🔎 Proposed fix
             if self._store is not None:
-                self._hass.create_task(self._async_save_state())
+                self._hass.async_create_task(self._async_save_state())

-            self._hass.loop.call_soon_threadsafe(async_dispatcher_send, self._hass, SIGNAL_PID_UPDATED, self._entity_id)
+            async_dispatcher_send(self._hass, SIGNAL_PID_UPDATED, self._entity_id)
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6bf5ba7 and 2a10d81.

📒 Files selected for processing (1)
  • custom_components/sat/pid.py
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/pid.py (5)
custom_components/sat/helpers.py (2)
  • clamp (108-113)
  • float_value (78-87)
custom_components/sat/area.py (6)
  • temperature_state (83-102)
  • async_added_to_hass (202-213)
  • async_added_to_hass (271-274)
  • get (255-261)
  • update (221-231)
  • error (127-145)
custom_components/sat/temperature_state.py (3)
  • TemperatureState (38-49)
  • error (47-49)
  • max (30-34)
custom_components/sat/climate.py (2)
  • async_added_to_hass (159-201)
  • error (380-399)
custom_components/sat/boiler.py (2)
  • async_added_to_hass (125-141)
  • update (150-164)
🪛 Pylint (4.0.4)
custom_components/sat/pid.py

[refactor] 32-32: Too many instance attributes (17/7)

(R0902)


[refactor] 35-35: Too many arguments (8/5)

(R0913)


[refactor] 35-35: Too many positional arguments (8/5)

(R0917)

🪛 Ruff (0.14.10)
custom_components/sat/pid.py

10-10: from .const import * used; unable to detect undefined names

(F403)


69-69: HeatingSystem may be undefined, or defined from star imports

(F405)


176-176: SIGNAL_PID_UPDATED may be undefined, or defined from star imports

(F405)


184-184: DEADBAND may be undefined, or defined from star imports

(F405)


188-188: DEADBAND may be undefined, or defined from star imports

(F405)


221-221: DEADBAND may be undefined, or defined from star imports

(F405)

🔇 Additional comments (6)
custom_components/sat/pid.py (6)

17-29: LGTM!

The constants are well-defined with clear naming conventions. The dual-stage low-pass filter coefficients (DERIVATIVE_ALPHA1/2), derivative cap, and storage keys are appropriately organized.


55-86: LGTM!

The gain properties correctly handle the automatic gains calculation with proper null checks. The available property appropriately guards state access.


88-125: LGTM!

The PID term properties are well-implemented with consistent null guards and appropriate rounding precision. The output calculation correctly combines all terms.


178-214: LGTM!

The integral update logic is well-designed with:

  • Anti-windup clamping to heating curve bounds
  • Proper deadband handling (only accumulates when error is small)
  • Time gap capping to prevent over-accumulation after sensor outages

216-252: LGTM!

The derivative update uses a derivative-on-measurement approach (negative temperature delta) to avoid derivative kick on setpoint changes. The dual-stage low-pass filter effectively reduces noise while the magnitude cap prevents outlier spikes.


254-267: LGTM!

The state persistence is clean and comprehensive, storing all necessary fields for proper restoration.

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: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
custom_components/sat/sensor.py (1)

237-255: Guard against None relative modulation value in calculation.

While available returns False when relative_modulation_value is None, Home Assistant may still call native_value when unavailable. Line 249 will raise TypeError when dividing None by 100.

🔎 Proposed fix
     @property
     def native_value(self) -> float:
         """Return the state of the device in native units."""
         if not self._coordinator.device_active or not self._coordinator.flame_active:
             return 0.0

         differential_gas_consumption = self._maximum_consumption - self._minimum_consumption
         relative_modulation_value = self._coordinator.relative_modulation_value
+        if relative_modulation_value is None:
+            return 0.0

         modulation_fraction = relative_modulation_value / 100
         return round(self._minimum_consumption + (modulation_fraction * differential_gas_consumption), 3)
🤖 Fix all issues with AI Agents
In @custom_components/sat/area.py:
- Around line 83-102: The sensor validation in temperature_state incorrectly
checks HVACMode.OFF (a climate enum) against sensor states; locate the
temperature_state method and the condition that compares sensor_state.state to
[STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF] (uses
ATTR_SENSOR_TEMPERATURE_ID and is_state_stale) and remove HVACMode.OFF or
replace it with the literal string "off" if you intend to filter sensors
reporting "off"; update the conditional to only use sensor-appropriate constants
(STATE_UNKNOWN, STATE_UNAVAILABLE, and optionally "off") and ensure logging and
stale checks remain unchanged.

In @custom_components/sat/binary_sensor.py:
- Around line 139-145: The is_on property can raise TypeError when calling int()
on None; update the is_on method (in binary_sensor.py) to first check
self._climate.relative_modulation_value and
self._coordinator.maximum_relative_modulation_value for None and only call int()
when both are not None; if either is None, return a safe boolean via
self.state_delayed(False) (or another explicit fallback) so the property never
attempts int(None) and always returns a bool.

In @custom_components/sat/climate.py:
- Around line 860-875: The code is passing an async coroutine method to HassJob
which raises ValueError; update the async_call_later call in
schedule_control_heating_loop so HassJob receives a synchronous callable that
schedules the coroutine instead. Replace
HassJob(self.async_control_heating_loop) with something that invokes
hass.async_create_task (e.g. HassJob(lambda _:
self.hass.async_create_task(self.async_control_heating_loop()))) so
async_control_heating_loop is scheduled correctly; keep the rest of the logic
and still cancel/clear _control_heating_loop_unsub as before.
- Around line 1047-1051: The _build_presets function builds conf_presets then
uses a dict comprehension that incorrectly checks "if key in conf_presets"
(always true) causing KeyError; change the comprehension to only include entries
whose mapped config key exists by replacing the condition with "if value in
config_options" (or use config_options.get(value) with a fallback) so it returns
a dict of preset_name: config_options[value] for available keys (conf_presets,
_build_presets, config_options,
PRESET_ACTIVITY/PRESET_AWAY/PRESET_HOME/PRESET_SLEEP/PRESET_COMFORT).

In @custom_components/sat/coordinator.py:
- Around line 143-149: The property maximum_setpoint_value is currently
annotated as returning Optional[float] but always forwards self.maximum_setpoint
(a float), causing a type mismatch; update the return type annotation of
maximum_setpoint_value from Optional[float] to float to match actual behavior
and keep type checkers happy, and ensure callers like device_capabilities (which
builds BoilerCapabilities with maximum_setpoint=maximum_setpoint_value) reflect
the non-optional float contract.

In @custom_components/sat/entry_data.py:
- Around line 171-181: The pid property builds a PidConfig but calls float(...)
and bool(...) directly on self.options.get(...) which will raise TypeError if
any option is missing; update the pid property to use safe conversions or
provide defaults to self.options.get(...) for each field (integral, derivative,
proportional, automatic_gains, automatic_gains_value, heating_curve_coefficient)
and coerce values only after defaulting (e.g. default numeric fields to 0.0 and
automatic_gains to False) so PidConfig(...) never receives None; ensure you
reference the PidConfig constructor and CONF_INTEGRAL / CONF_DERIVATIVE /
CONF_PROPORTIONAL / CONF_AUTOMATIC_GAINS / CONF_AUTOMATIC_GAINS_VALUE /
CONF_HEATING_CURVE_COEFFICIENT keys when making the changes.
- Around line 117-119: The property SatEntryData.mode currently calls
SatMode(self.data.get(CONF_MODE)) which will raise ValueError if CONF_MODE is
missing; update the mode property to first read value = self.data.get(CONF_MODE)
and if value is None return an appropriate fallback (e.g., None or a default
SatMode) or raise a clearer error, otherwise construct SatMode(value); reference
the mode property, SatMode, self.data, and CONF_MODE when making the change.
- Around line 217-219: The presets property calls
HeatingMode(self.options.get(CONF_HEATING_MODE)) which will raise ValueError if
CONF_HEATING_MODE is missing; change presets to first retrieve value =
self.options.get(CONF_HEATING_MODE) and only construct HeatingMode(value) when
value is not None (or wrap in try/except ValueError) and fall back to the same
default/None behavior used for SatMode so missing option does not raise.

In @custom_components/sat/mqtt/__init__.py:
- Around line 23-25: self._config.device is Optional[str] so calling
str(self._config.device) can produce "None"; update the initialization of
self._device_id (and the Store key f"sat.mqtt.{self._device_id}") to
validate/normalize the device value from SatConfig.device: if it's truthy and
matches expected format use it, otherwise generate a safe fallback (e.g.,
uuid.uuid4().hex) or raise a clear config error; modify the code that sets
self._device_id to use that validated/fallback value and add the necessary
import for uuid if you choose the generated-id approach.

In @custom_components/sat/pid.py:
- Around line 57-67: The kp property currently ignores
PidConfig.automatic_gains_value and hardcodes automatic_gain_value based on
HeatingSystem; update the logic in the kp getter to use
self._config.automatic_gains_value when automatic gains are enabled (falling
back to the heating-system-based default only if automatic_gains_value is None
or invalid), i.e., replace the hardcoded 4/3 selection with a value derived from
self._config.automatic_gains_value and then compute and return
round((self._config.heating_curve_coefficient * self._heating_curve.value) /
automatic_gain_value, 6) as before.
- Around line 139-142: Restoration uses inconsistent conversion: replace the
bare float() calls for self._integral and self._raw_derivative with the safe
conversion helper float_value (same used for self._last_error and
self._last_temperature) so corrupted or missing stored values don't raise
exceptions during state restoration; update the assignments that reference
STORAGE_KEY_INTEGRAL and STORAGE_KEY_RAW_DERIVATIVE to call float_value(...) and
supply the existing defaults (self._integral, self._raw_derivative) as before.
- Around line 154-156: The method set_heating_curve_value is dead because the
class uses self._heating_curve.value elsewhere; remove the method or change it
to update the real object (e.g., replace the body to set
self._heating_curve.value = heating_curve_value or call the actual heater curve
setter on the object) and ensure self._heating_curve exists (raise or initialize
if needed) instead of assigning to the unused self._heating_curve_value
attribute.
- Around line 176-179: The update() handler incorrectly uses
HomeAssistant.create_task and call_soon_threadsafe with async_dispatcher_send;
change self._hass.create_task(self._async_save_state()) to
self._hass.async_create_task(self._async_save_state()) and remove
call_soon_threadsafe: call async_dispatcher_send directly from the async context
as async_dispatcher_send(self._hass, SIGNAL_PID_UPDATED, self._entity_id) (i.e.,
dispatch directly in update()), ensuring the coroutine is scheduled/executed
properly.

In @custom_components/sat/serial/__init__.py:
- Around line 30-36: The constructor __init__ sets self._port =
str(self._config.device) which turns None into the string "None" and breaks
pyotgw; fix by checking self._config.device before coercion (e.g., if
self._config.device is not None assign its str() to self._port, otherwise set
self._port to None or an empty string) so the OpenThermGateway connection gets a
valid missing-port value; update the assignment near __init__ and ensure
downstream code using self._port can handle the chosen None/empty value.
🧹 Nitpick comments (6)
tests/test_pid.py (1)

32-48: Static analysis warning is a false positive.

The Pylint warning about too many arguments (6/5) for _pid_config is acceptable here. PID configuration naturally requires multiple parameters (proportional, integral, derivative, automatic gains settings, and heating curve coefficient), and consolidating them into a helper function improves test readability.

tests/test_climate.py (2)

74-76: Static analysis warnings about unused lambda arguments are false positives.

These lambdas are used with monkeypatch.setattr to replace properties. Properties always receive self as the first argument, even though these particular lambdas return constant values and don't need to use self. This is the correct pattern for mocking properties.

Also applies to: 85-87


220-224: Consider using a test helper or fixture to set PWM state.

Lines 220, 223, and 255 directly set the private _enabled attribute for test setup. While this may be necessary for testing hysteresis behavior, consider whether:

  1. A public setter method should be added to PWM for testing purposes
  2. A test fixture/helper should encapsulate this setup
  3. The test should use a different approach that doesn't require direct attribute access

The current approach works but may be fragile if the internal implementation changes.

Also applies to: 255-255

custom_components/sat/binary_sensor.py (1)

67-79: Wall-clock time is susceptible to clock adjustments.

timestamp() uses wall-clock time via dt.utcnow().timestamp(). System clock adjustments (NTP sync, manual changes) can cause the 60-second delay to behave unexpectedly. A backward clock jump prevents the condition from triggering; a forward jump causes premature alerts.

Consider using time.monotonic() for elapsed time measurement.

🔎 Proposed fix
+import time
+
 class SatSyncSensor:
     """Mixin to add delayed state change for binary sensors."""
 
     def __init__(self, delay: int = 60):
         """Initialize the mixin with a delay."""
         self._delay = delay
         self._last_mismatch: Optional[float] = None
 
     def state_delayed(self, condition: bool) -> bool:
         """Determine the delayed state based on a condition."""
         if not condition:
             self._last_mismatch = None
             return False
 
         if self._last_mismatch is None:
-            self._last_mismatch = timestamp()
+            self._last_mismatch = time.monotonic()
 
-        if seconds_since(self._last_mismatch) >= self._delay:
+        if (time.monotonic() - self._last_mismatch) >= self._delay:
             return True
 
         return False
custom_components/sat/sensor.py (1)

69-91: Consider using stable identifier for unique_id.

The unique_id uses self._config.name_lower which derives from the user-configurable CONF_NAME. If users rename the integration, entity tracking and automations will break.

Consider using a stable identifier like entry_id instead:

     @property
     def unique_id(self) -> str:
-        return f"{self._config.name_lower}-requested-setpoint"
+        return f"{self._coordinator.device_id}-requested-setpoint"

This pattern should be applied consistently across all sensor unique_id properties if the concern is valid. Based on learnings from past reviews, this was flagged but not yet addressed.

custom_components/sat/area.py (1)

307-311: Consider logging specific exception type instead of catching broad Exception.

Line 309 catches all exceptions when computing PID output. While the intent to log and continue is reasonable, catching Exception can mask unexpected errors.

🔎 Proposed refinement
             try:
                 value = area.pid.output
-            except Exception as exception:
+            except (TypeError, ValueError, AttributeError) as exception:
                 _LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception)
                 continue

This targets the expected failure modes while allowing truly unexpected exceptions to propagate.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2a10d81 and 5ba8a98.

📒 Files selected for processing (17)
  • custom_components/sat/area.py
  • custom_components/sat/binary_sensor.py
  • custom_components/sat/climate.py
  • custom_components/sat/coordinator.py
  • custom_components/sat/entry_data.py
  • custom_components/sat/esphome/__init__.py
  • custom_components/sat/heating_curve.py
  • custom_components/sat/helpers.py
  • custom_components/sat/mqtt/__init__.py
  • custom_components/sat/pid.py
  • custom_components/sat/sensor.py
  • custom_components/sat/serial/__init__.py
  • custom_components/sat/switch/__init__.py
  • custom_components/sat/util.py
  • tests/test_area.py
  • tests/test_climate.py
  • tests/test_pid.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_area.py
🧰 Additional context used
🧬 Code graph analysis (14)
custom_components/sat/binary_sensor.py (2)
custom_components/sat/entry_data.py (6)
  • SatConfig (89-246)
  • SatMode (19-26)
  • mode (118-119)
  • sensors (157-169)
  • name (125-126)
  • name_lower (129-130)
custom_components/sat/helpers.py (2)
  • seconds_since (22-24)
  • timestamp (12-14)
tests/test_climate.py (6)
custom_components/sat/entry_data.py (3)
  • SatConfig (89-246)
  • pid (172-181)
  • pwm (184-193)
custom_components/sat/const.py (2)
  • HeatingMode (104-106)
  • HeatingSystem (97-101)
custom_components/sat/heating_curve.py (5)
  • HeatingCurve (11-88)
  • update (23-26)
  • value (87-88)
  • calculate (69-71)
  • base_offset (74-76)
custom_components/sat/pid.py (2)
  • PID (34-270)
  • update (158-179)
custom_components/sat/types.py (1)
  • DeviceState (73-76)
custom_components/sat/minimum_setpoint.py (1)
  • value (286-287)
custom_components/sat/esphome/__init__.py (3)
custom_components/sat/coordinator.py (2)
  • SatDataUpdateCoordinator (103-457)
  • get (461-471)
custom_components/sat/entry_data.py (3)
  • SatConfig (89-246)
  • device (95-96)
  • simulation (240-246)
custom_components/sat/types.py (2)
  • DeviceState (73-76)
  • enabled (103-104)
custom_components/sat/heating_curve.py (3)
custom_components/sat/entry_data.py (1)
  • heating_system (103-107)
custom_components/sat/const.py (1)
  • HeatingSystem (97-101)
custom_components/sat/minimum_setpoint.py (1)
  • value (286-287)
custom_components/sat/coordinator.py (4)
custom_components/sat/boiler.py (8)
  • Boiler (77-439)
  • BoilerState (50-74)
  • BoilerCapabilities (43-46)
  • BoilerControlIntent (37-39)
  • status (99-103)
  • flame_on_since (118-119)
  • flame_off_since (122-123)
  • update (150-164)
custom_components/sat/cycles.py (6)
  • CycleTracker (251-503)
  • CycleHistory (96-248)
  • CycleStatistics (76-80)
  • Cycle (53-72)
  • statistics (181-199)
  • update (278-309)
custom_components/sat/entry_data.py (7)
  • SatConfig (89-246)
  • SatMode (19-26)
  • manufacturer (110-111)
  • pwm (184-193)
  • mode (118-119)
  • limits (196-215)
  • heating_system (103-107)
custom_components/sat/types.py (2)
  • BoilerStatus (15-41)
  • DeviceState (73-76)
custom_components/sat/mqtt/__init__.py (1)
custom_components/sat/entry_data.py (3)
  • SatConfig (89-246)
  • device (95-96)
  • mqtt_topic (114-115)
custom_components/sat/helpers.py (3)
custom_components/sat/state.py (1)
  • State (10-12)
custom_components/sat/const.py (1)
  • HeatingSystem (97-101)
custom_components/sat/entry_data.py (1)
  • heating_system (103-107)
custom_components/sat/serial/__init__.py (3)
custom_components/sat/coordinator.py (2)
  • SatDataUpdateCoordinator (103-457)
  • member_id (199-201)
custom_components/sat/entry_data.py (3)
  • SatConfig (89-246)
  • device (95-96)
  • simulation (240-246)
custom_components/sat/types.py (2)
  • DeviceState (73-76)
  • enabled (103-104)
custom_components/sat/switch/__init__.py (3)
custom_components/sat/coordinator.py (1)
  • SatDataUpdateCoordinator (103-457)
custom_components/sat/entry_data.py (3)
  • SatConfig (89-246)
  • device (95-96)
  • simulation (240-246)
custom_components/sat/types.py (2)
  • DeviceState (73-76)
  • enabled (103-104)
custom_components/sat/entry_data.py (2)
custom_components/sat/helpers.py (2)
  • calculate_default_maximum_setpoint (65-67)
  • convert_time_str_to_seconds (40-49)
custom_components/sat/const.py (2)
  • HeatingMode (104-106)
  • HeatingSystem (97-101)
custom_components/sat/climate.py (4)
custom_components/sat/entry_data.py (3)
  • presets (218-237)
  • pwm (184-193)
  • overshoot_protection (133-134)
custom_components/sat/helpers.py (2)
  • clamp (108-113)
  • ensure_list (126-134)
custom_components/sat/types.py (2)
  • PWMDecision (86-104)
  • enabled (103-104)
custom_components/sat/const.py (1)
  • HeatingMode (104-106)
custom_components/sat/pid.py (5)
custom_components/sat/entry_data.py (2)
  • PidConfig (40-46)
  • heating_system (103-107)
custom_components/sat/heating_curve.py (4)
  • HeatingCurve (11-88)
  • reset (17-21)
  • value (87-88)
  • update (23-26)
custom_components/sat/helpers.py (3)
  • float_value (78-87)
  • timestamp (12-14)
  • clamp_to_range (116-118)
custom_components/sat/temperature_state.py (3)
  • TemperatureState (38-49)
  • error (47-49)
  • max (30-34)
custom_components/sat/const.py (1)
  • HeatingSystem (97-101)
custom_components/sat/sensor.py (3)
custom_components/sat/entity.py (1)
  • SatEntity (20-39)
custom_components/sat/entry_data.py (4)
  • SatConfig (89-246)
  • SatMode (19-26)
  • mode (118-119)
  • name (125-126)
custom_components/sat/types.py (2)
  • BoilerStatus (15-41)
  • CycleClassification (54-70)
tests/test_pid.py (3)
custom_components/sat/heating_curve.py (3)
  • HeatingCurve (11-88)
  • value (87-88)
  • update (23-26)
custom_components/sat/pid.py (11)
  • PID (34-270)
  • proportional (86-91)
  • integral (94-96)
  • derivative (99-104)
  • available (53-55)
  • raw_derivative (107-109)
  • output (117-122)
  • update (158-179)
  • kp (58-67)
  • ki (70-75)
  • kd (78-83)
custom_components/sat/temperature_state.py (2)
  • TemperatureState (38-49)
  • error (47-49)
🪛 Pylint (4.0.4)
custom_components/sat/binary_sensor.py

[refactor] 59-59: Too few public methods (1/2)

(R0903)

custom_components/sat/coordinator.py

[refactor] 71-71: Too many return statements (7/6)

(R0911)


[refactor] 103-103: Too many instance attributes (12/7)

(R0902)


[refactor] 103-103: Too many public methods (49/20)

(R0904)


[refactor] 460-460: Too few public methods (1/2)

(R0903)

custom_components/sat/serial/__init__.py

[error] 33-33: Undefined variable 'DEFAULT_STATUS'

(E0602)

custom_components/sat/entry_data.py

[refactor] 89-89: Too many public methods (21/20)

(R0904)

custom_components/sat/climate.py

[refactor] 71-71: Too many instance attributes (28/7)

(R0902)


[refactor] 245-245: Too many branches (16/12)

(R0912)


[refactor] 71-71: Too many public methods (29/20)

(R0904)


[refactor] 716-716: Too many branches (13/12)

(R0912)

custom_components/sat/area.py

[refactor] 37-37: Too many instance attributes (8/7)

(R0902)


[refactor] 279-279: Too few public methods (1/2)

(R0903)

custom_components/sat/pid.py

[refactor] 34-34: Too many instance attributes (13/7)

(R0902)

tests/test_pid.py

[refactor] 32-32: Too many arguments (6/5)

(R0913)

🪛 Ruff (0.14.10)
custom_components/sat/binary_sensor.py

15-15: from .const import * used; unable to detect undefined names

(F403)


25-25: DOMAIN may be undefined, or defined from star imports

(F405)


220-220: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


239-239: CycleClassification may be undefined, or defined from star imports

(F405)


240-240: CycleClassification may be undefined, or defined from star imports

(F405)


241-241: CycleClassification may be undefined, or defined from star imports

(F405)

tests/test_climate.py

74-74: Unused lambda argument: self

(ARG005)


75-75: Unused lambda argument: self

(ARG005)


76-76: Unused lambda argument: self

(ARG005)


85-85: Unused lambda argument: self

(ARG005)


86-86: Unused lambda argument: self

(ARG005)


87-87: Unused lambda argument: self

(ARG005)


121-121: Unused function argument: self

(ARG001)


150-150: Unused lambda argument: self

(ARG005)


162-162: Unused lambda argument: self

(ARG005)


177-177: Unused lambda argument: self

(ARG005)

custom_components/sat/heating_curve.py

6-6: from .const import * used; unable to detect undefined names

(F403)


12-12: HeatingSystem may be undefined, or defined from star imports

(F405)


76-76: HeatingSystem may be undefined, or defined from star imports

(F405)

custom_components/sat/util.py

8-8: from .const import * used; unable to detect undefined names

(F403)


53-53: DOMAIN may be undefined, or defined from star imports

(F405)

custom_components/sat/coordinator.py

15-15: from .const import * used; unable to detect undefined names

(F403)


100-100: Create your own exception

(TRY002)


100-100: Avoid specifying long messages outside the exception class

(TRY003)


106-106: DOMAIN may be undefined, or defined from star imports

(F405)


307-307: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)

custom_components/sat/serial/__init__.py

10-10: from pyotgw.vars import * used; unable to detect undefined names

(F403)


33-33: DEFAULT_STATUS may be undefined, or defined from star imports

(F405)

custom_components/sat/entry_data.py

9-9: from .const import * used; unable to detect undefined names

(F403)


19-19: StrEnum may be undefined, or defined from star imports

(F405)


71-71: HeatingMode may be undefined, or defined from star imports

(F405)


96-96: CONF_DEVICE may be undefined, or defined from star imports

(F405)


100-100: CONF_ERROR_MONITORING may be undefined, or defined from star imports

(F405)


103-103: HeatingSystem may be undefined, or defined from star imports

(F405)


104-104: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


105-105: HeatingSystem may be undefined, or defined from star imports

(F405)


107-107: HeatingSystem may be undefined, or defined from star imports

(F405)


107-107: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


107-107: HeatingSystem may be undefined, or defined from star imports

(F405)


111-111: CONF_MANUFACTURER may be undefined, or defined from star imports

(F405)


115-115: CONF_MQTT_TOPIC may be undefined, or defined from star imports

(F405)


119-119: CONF_MODE may be undefined, or defined from star imports

(F405)


126-126: CONF_NAME may be undefined, or defined from star imports

(F405)


134-134: CONF_OVERSHOOT_PROTECTION may be undefined, or defined from star imports

(F405)


138-138: CONF_PUSH_SETPOINT_TO_THERMOSTAT may be undefined, or defined from star imports

(F405)


142-142: CONF_RADIATORS may be undefined, or defined from star imports

(F405)


146-146: CONF_ROOMS may be undefined, or defined from star imports

(F405)


150-150: CONF_THERMOSTAT may be undefined, or defined from star imports

(F405)


154-154: CONF_WINDOW_SENSORS may be undefined, or defined from star imports

(F405)


159-159: CONF_INSIDE_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


160-160: CONF_HUMIDITY_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


161-161: CONF_OUTSIDE_SENSOR_ENTITY_ID may be undefined, or defined from star imports

(F405)


164-164: CONF_WINDOW_MINIMUM_OPEN_TIME may be undefined, or defined from star imports

(F405)


167-167: CONF_SENSOR_MAX_VALUE_AGE may be undefined, or defined from star imports

(F405)


174-174: CONF_INTEGRAL may be undefined, or defined from star imports

(F405)


175-175: CONF_DERIVATIVE may be undefined, or defined from star imports

(F405)


176-176: CONF_PROPORTIONAL may be undefined, or defined from star imports

(F405)


178-178: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports

(F405)


179-179: CONF_AUTOMATIC_GAINS_VALUE may be undefined, or defined from star imports

(F405)


180-180: CONF_HEATING_CURVE_COEFFICIENT may be undefined, or defined from star imports

(F405)


186-186: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports

(F405)


187-187: CONF_DUTY_CYCLE may be undefined, or defined from star imports

(F405)


189-189: CONF_DYNAMIC_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


190-190: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports

(F405)


191-191: CONF_MAXIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports

(F405)


192-192: CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR may be undefined, or defined from star imports

(F405)


197-197: CONF_HEATING_SYSTEM may be undefined, or defined from star imports

(F405)


197-197: HeatingSystem may be undefined, or defined from star imports

(F405)


198-198: CONF_MAXIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


202-202: CONF_MAXIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


207-207: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


207-207: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


210-210: CONF_MAXIMUM_CONSUMPTION may be undefined, or defined from star imports

(F405)


211-211: CONF_MINIMUM_CONSUMPTION may be undefined, or defined from star imports

(F405)


213-213: CONF_CLIMATE_VALVE_OFFSET may be undefined, or defined from star imports

(F405)


214-214: CONF_TARGET_TEMPERATURE_STEP may be undefined, or defined from star imports

(F405)


219-219: HeatingMode may be undefined, or defined from star imports

(F405)


219-219: CONF_HEATING_MODE may be undefined, or defined from star imports

(F405)


222-222: CONF_AWAY_TEMPERATURE may be undefined, or defined from star imports

(F405)


222-222: CONF_AWAY_TEMPERATURE may be undefined, or defined from star imports

(F405)


223-223: CONF_HOME_TEMPERATURE may be undefined, or defined from star imports

(F405)


223-223: CONF_HOME_TEMPERATURE may be undefined, or defined from star imports

(F405)


224-224: CONF_SLEEP_TEMPERATURE may be undefined, or defined from star imports

(F405)


224-224: CONF_SLEEP_TEMPERATURE may be undefined, or defined from star imports

(F405)


225-225: CONF_COMFORT_TEMPERATURE may be undefined, or defined from star imports

(F405)


225-225: CONF_COMFORT_TEMPERATURE may be undefined, or defined from star imports

(F405)


226-226: CONF_ACTIVITY_TEMPERATURE may be undefined, or defined from star imports

(F405)


226-226: CONF_ACTIVITY_TEMPERATURE may be undefined, or defined from star imports

(F405)


231-231: CONF_THERMAL_COMFORT may be undefined, or defined from star imports

(F405)


232-232: CONF_SYNC_CLIMATES_WITH_MODE may be undefined, or defined from star imports

(F405)


233-233: CONF_SYNC_CLIMATES_WITH_PRESET may be undefined, or defined from star imports

(F405)


236-236: CONF_ROOM_WEIGHTS may be undefined, or defined from star imports

(F405)


242-242: CONF_SIMULATION may be undefined, or defined from star imports

(F405)


243-243: CONF_SIMULATED_HEATING may be undefined, or defined from star imports

(F405)


243-243: OPTIONS_DEFAULTS may be undefined, or defined from star imports

(F405)


243-243: CONF_SIMULATED_HEATING may be undefined, or defined from star imports

(F405)


244-244: CONF_SIMULATED_COOLING may be undefined, or defined from star imports

(F405)


244-244: OPTIONS_DEFAULTS may be undefined, or defined from star imports

(F405)


244-244: CONF_SIMULATED_COOLING may be undefined, or defined from star imports

(F405)


245-245: CONF_SIMULATED_WARMING_UP may be undefined, or defined from star imports

(F405)


245-245: OPTIONS_DEFAULTS may be undefined, or defined from star imports

(F405)


245-245: CONF_SIMULATED_WARMING_UP may be undefined, or defined from star imports

(F405)


260-260: DOMAIN may be undefined, or defined from star imports

(F405)

custom_components/sat/climate.py

35-35: from .const import * used; unable to detect undefined names

(F403)


95-95: Consider [PRESET_NONE, *list(self._presets.keys())] instead of concatenation

Replace with [PRESET_NONE, *list(self._presets.keys())]

(RUF005)


174-174: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports

(F405)


182-182: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


192-192: Unused method argument: event

(ARG002)


237-237: DOMAIN may be undefined, or defined from star imports

(F405)


307-307: DOMAIN may be undefined, or defined from star imports

(F405)


307-307: SERVICE_RESET_INTEGRAL may be undefined, or defined from star imports

(F405)


457-457: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


462-462: HeatingMode may be undefined, or defined from star imports

(F405)


475-475: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


475-475: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


562-562: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


566-566: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


579-579: UNHEALTHY_CYCLES may be undefined, or defined from star imports

(F405)


585-585: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


589-589: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


600-600: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports

(F405)


609-609: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


722-722: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


723-723: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


778-778: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


782-782: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


782-782: MINIMUM_SETPOINT may be undefined, or defined from star imports

(F405)


848-848: DEADBAND may be undefined, or defined from star imports

(F405)


848-848: DEADBAND may be undefined, or defined from star imports

(F405)


911-911: COLD_SETPOINT may be undefined, or defined from star imports

(F405)

custom_components/sat/area.py

309-309: Do not catch blind exception: Exception

(BLE001)

custom_components/sat/pid.py

10-10: from .const import * used; unable to detect undefined names

(F403)


37-37: HeatingSystem may be undefined, or defined from star imports

(F405)


66-66: HeatingSystem may be undefined, or defined from star imports

(F405)


179-179: SIGNAL_PID_UPDATED may be undefined, or defined from star imports

(F405)


187-187: DEADBAND may be undefined, or defined from star imports

(F405)


191-191: DEADBAND may be undefined, or defined from star imports

(F405)


224-224: DEADBAND may be undefined, or defined from star imports

(F405)

custom_components/sat/sensor.py

14-14: from .const import * used; unable to detect undefined names

(F403)


25-25: DOMAIN may be undefined, or defined from star imports

(F405)


113-113: SIGNAL_PID_UPDATED may be undefined, or defined from star imports

(F405)


351-351: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports

(F405)


360-360: CycleClassification may be undefined, or defined from star imports

(F405)

🔇 Additional comments (38)
custom_components/sat/helpers.py (1)

12-171: LGTM! Well-implemented utility functions.

The new timestamp, state management, and statistical utilities are well-designed with proper UTC semantics, None-safety, and error handling. The integration with the HeatingSystem enum (line 67) aligns correctly with the broader refactoring.

tests/test_pid.py (1)

105-123: Test correctly validates automatic gains calculation.

The test validates that automatic gains are computed from heating_curve_value when automatic_gains=True. The expected values (kp=20.0, ki=round(20.0/8400, 6), kd=round(0.07840020.0, 6)) align with the automatic gain formulas for UNDERFLOOR heating systems.

Note: The past review comment about automatic_gain_value being ignored may refer to a different code version or may have been addressed.

custom_components/sat/switch/__init__.py (1)

23-27: LGTM! Consistent refactoring to SatConfig architecture.

The changes properly migrate from individual config parameters to the unified SatConfig model. The updated type annotations (Optional[int] at line 53) and property access patterns (self._config.device, self._config.simulation.enabled) are consistent with the broader architectural refactoring across the codebase.

Also applies to: 53-57

custom_components/sat/heating_curve.py (3)

12-15: LGTM! Constructor now uses strongly-typed HeatingSystem enum.

The refactor from string-based heating system to HeatingSystem enum improves type safety and aligns with the centralized enum definition in const.py.


76-76: LGTM! Enum comparison is correct.

The comparison self._heating_system == HeatingSystem.UNDERFLOOR properly uses the enum instead of a string constant.


78-88: LGTM! Return types accurately reflect nullable properties.

The Optional[float] return types correctly indicate these properties can return None when no value has been computed yet.

custom_components/sat/mqtt/__init__.py (2)

34-46: LGTM! Lifecycle method signature updated correctly.

The async_added_to_hass now accepts hass: HomeAssistant parameter and properly awaits the parent method with the same parameter.


104-107: LGTM! Simulation check uses new config-driven approach.

The simulation gating correctly accesses self._config.simulation.enabled instead of the legacy self._simulation attribute.

custom_components/sat/entry_data.py (1)

249-256: LGTM! SatEntryData structure is well-designed.

The dataclass properly groups per-entry runtime state with the immutable config. The asyncio.Event factory is correctly using field(default_factory=asyncio.Event) to avoid shared state between instances.

custom_components/sat/serial/__init__.py (1)

174-208: LGTM! Simulation gating consistently applied across all control methods.

All async_set_control_* methods properly check self._config.simulation.enabled before issuing hardware commands, while still calling the parent method to update internal state.

custom_components/sat/binary_sensor.py (2)

23-36: LGTM! Robust climate readiness check with timeout.

The pattern of waiting for climate_ready event with a timeout and early returns on failure prevents race conditions during entity setup.


214-242: LGTM! Cycle health sensor with proper event-driven updates.

The sensor correctly listens for EVENT_SAT_CYCLE_ENDED to trigger state updates, and the is_on logic properly handles the None case for last_cycle. The classification check excludes GOOD, UNCERTAIN, and INSUFFICIENT_DATA as non-problem states.

custom_components/sat/util.py (2)

19-31: LGTM! Clean factory functions with SatConfig-driven construction.

The factory functions now use a centralized SatConfig for all configuration access, providing a cleaner API and consistent configuration handling.


44-58: LGTM! Entity lookup with proper null checks.

The function correctly validates registry entries and checks for None climate before appending to the result list.

custom_components/sat/pid.py (2)

181-217: LGTM! Integral update logic is well-implemented.

The integral term correctly:

  • Resets outside the deadband
  • Caps integration intervals to prevent over-accumulation after gaps
  • Clamps to heating curve bounds
  • Handles edge cases for forward timestamps

219-255: LGTM! Derivative update with dual low-pass filtering.

The derivative calculation properly applies two-stage exponential smoothing and caps the magnitude to prevent noise amplification. The deadband check and forward-time validation are appropriate.

custom_components/sat/coordinator.py (6)

32-39: LGTM! Clean dataclass for control loop sampling.

The ControlLoopSample dataclass is well-designed with appropriate fields for cycle tracking. Using frozen=True and slots=True is good practice for immutable data containers.


69-100: LGTM! Factory pattern refactored to use SatConfig.

The factory now cleanly resolves coordinator types based on config.mode enum values. The pattern is consistent and readable.


103-124: LGTM! Coordinator initialization with new architecture.

The initialization properly sets up the Boiler, CycleHistory, CycleTracker, and Debouncer instances. The debouncer function references (self.async_update_listeners and self.async_update_control) appear correct based on the method definitions below.


389-402: LGTM! Safe timestamp handling with event_timestamp helper.

The async_control_heating_loop now uses the event_timestamp() helper function which handles the None case safely, addressing the previous review concern about AttributeError when time is None.


444-457: LGTM! Debounced notification flow is well-structured.

The async_notify_listeners method properly coordinates control updates via debouncer and conditionally triggers listener updates based on dirty state. The async_set_updated_data method correctly updates data and triggers notifications with force=False.


432-442: Guard condition correctly ensures cycle samples are only created with both control values present.

The climate entity consistently sets both set_control_context() and set_control_intent() before calling the coordinator's async_control_heating_loop() method (lines 904-905 in climate.py), so the debouncer will always have both values available when triggered from that path. The guard condition protects against edge cases where async_notify_listeners() might be called from other sources (simulator, esphome implementations) without the control values being set. This defensive check is appropriate and the implementation is sound.

custom_components/sat/climate.py (8)

71-111: LGTM! Clean initialization with new controller architecture.

The __init__ method properly initializes all controllers (Areas, heating_curve, pwm, pid, minimum_setpoint) using the factory functions. The preset building and feature detection are well-structured.


128-145: LGTM! Helper method for safe entity state retrieval.

The _get_entity_state_float method properly handles None entity_id, missing state, stale state, and unavailable/unknown states before returning a float value.


173-190: LGTM! Cycle event listeners properly registered.

The EVENT_SAT_CYCLE_STARTED and EVENT_SAT_CYCLE_ENDED listeners are correctly wrapped with async_on_remove for cleanup. The lambda handlers appropriately pass event data to the minimum_setpoint controller.


190-200: LGTM! Shutdown handler signature fixed.

The async_will_remove_from_hass method now correctly accepts an optional Event parameter, addressing the previous review concern about the HOMEASSISTANT_STOP listener callback signature.


374-394: LGTM! Error property now returns proper TemperatureState.

The error property correctly handles None cases for inside sensor, state, target temperature, and current temperature before constructing the TemperatureState object.


537-592: LGTM! PWM decision logic is well-structured.

The _pwm_decision, _pwm_static_decision, and _pwm_dynamic_decision methods implement clear decision logic with appropriate logging. Each path returns a specific PWMDecision enum value.


877-913: LGTM! Heating loop control flow is well-structured.

The async_control_heating_loop method properly:

  • Clears the unsubscribe handle
  • Validates required inputs before proceeding
  • Updates PWM state based on device state
  • Passes control context to the coordinator
  • Applies setpoint and modulation controls

829-838: The is_state_stale() function already handles None state values gracefully. Its implementation explicitly checks for None and returns False early, so no guard is needed at line 835. The code is safe as written.

Likely an incorrect or invalid review comment.

custom_components/sat/sensor.py (4)

23-66: LGTM! Robust async setup with timeout handling.

The async_setup_entry function properly:

  • Checks for entry existence in hass.data
  • Waits for climate readiness with a 10-second timeout
  • Handles timeout gracefully by returning early
  • Creates appropriate sensors based on coordinator capabilities

93-172: LGTM! SatPidSensor with proper None handling.

The SatPidSensor class correctly:

  • Handles optional area_id parameter
  • Guards against None PID in available, native_value, and extra_state_attributes
  • Uses dispatcher signal for state updates
  • Generates unique IDs that include area context when applicable

344-366: LGTM! SatCycleSensor with event-driven updates.

The SatCycleSensor properly:

  • Registers for EVENT_SAT_CYCLE_ENDED events
  • Returns INSUFFICIENT_DATA when no cycle exists
  • Uses the cycle's classification for the native value

369-384: LGTM! SatBoilerSensor availability logic updated.

The available property now correctly treats INSUFFICIENT_DATA status as unavailable, which aligns with the user-reported issues about stale boiler status.

custom_components/sat/area.py (4)

37-63: LGTM! Area initialization with room weight clamping.

The Area class properly initializes heating curve and PID controllers, and clamps the room weight to a safe range [0.1, 3.0] with appropriate error handling for invalid values.


289-326: LGTM! PID output aggregation with percentile-based selection.

The _PIDs.output property implements a sophisticated aggregation strategy:

  • Filters areas that require heat and have available PIDs
  • Uses percentile-based selection for baseline
  • Applies headroom limiting
  • Handles empty outputs gracefully

328-359: LGTM! Overshoot cap calculation with proper error handling.

The overshoot_cap property correctly:

  • Uses error.error instead of negating the Error object directly (addressing past review)
  • Applies cooling slope calculation
  • Returns the strictest (minimum) cap
  • Ensures the cap never falls below COLD_SETPOINT

361-365: LGTM! PID reset with optional entity filtering.

The reset method correctly iterates through areas and resets PIDs either for all areas or for a specific entity when entity_id is provided.

Comment on lines +83 to +102
def temperature_state(self) -> Optional[State]:
"""Return the source state used to calculate the current temperature."""
if (self._hass is None) or (climate_state := self.climate_state) is None:
return None

return float_value(state.attributes.get("temperature"))
# Check if there is an overridden sensor temperature
if (sensor_temperature_id := climate_state.attributes.get(ATTR_SENSOR_TEMPERATURE_ID)) is not None:
sensor_state = self._hass.states.get(sensor_temperature_id)
if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]:
if is_state_stale(sensor_state, self._sensor_max_value_age):
_LOGGER.debug("Area sensor %s stale for %s (age=%.1fs > %.1fs)", sensor_temperature_id, self._entity_id, state_age_seconds(sensor_state), self._sensor_max_value_age)
return None

return sensor_state

if is_state_stale(climate_state, self._sensor_max_value_age):
_LOGGER.debug("Area climate %s stale for %s (age=%.1fs > %.1fs)", self._entity_id, self._entity_id, state_age_seconds(climate_state), self._sensor_max_value_age)
return None

return climate_state
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove HVACMode.OFF from sensor state validation.

Line 91 checks HVACMode.OFF alongside STATE_UNKNOWN and STATE_UNAVAILABLE for sensor temperature state validation. However, sensor_temperature_id references sensor entities (e.g., sensor.room1_temp), which don't use HVAC modes. HVACMode.OFF is a climate-specific constant.

🔎 Proposed fix
-            if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]:
+            if sensor_state and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]:

If filtering an "off" state from sensors is necessary, use the string "off" directly rather than the climate-specific enum.

This was flagged in a previous review but appears to remain unaddressed.

🤖 Prompt for AI Agents
In @custom_components/sat/area.py around lines 83 - 102, The sensor validation
in temperature_state incorrectly checks HVACMode.OFF (a climate enum) against
sensor states; locate the temperature_state method and the condition that
compares sensor_state.state to [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]
(uses ATTR_SENSOR_TEMPERATURE_ID and is_state_stale) and remove HVACMode.OFF or
replace it with the literal string "off" if you intend to filter sensors
reporting "off"; update the conditional to only use sensor-appropriate constants
(STATE_UNKNOWN, STATE_UNAVAILABLE, and optionally "off") and ensure logging and
stale checks remain unchanged.

Comment on lines 139 to +145
@property
def is_on(self):
def is_on(self) -> bool:
"""Return the state of the sensor."""
return self.state_delayed(int(self._climate.relative_modulation_value) != int(self._coordinator.maximum_relative_modulation_value))
climate_modulation = int(self._climate.relative_modulation_value)
maximum_modulation = int(self._coordinator.maximum_relative_modulation_value)

return self.state_delayed(climate_modulation != maximum_modulation)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential TypeError: int(None) will fail.

If self._climate.relative_modulation_value or self._coordinator.maximum_relative_modulation_value is None, the int() conversion will raise TypeError. The available property guards against this, but if called directly, is_on could fail.

🔎 Proposed defensive fix
     @property
     def is_on(self) -> bool:
         """Return the state of the sensor."""
+        if not self.available:
+            return False
         climate_modulation = int(self._climate.relative_modulation_value)
         maximum_modulation = int(self._coordinator.maximum_relative_modulation_value)
 
         return self.state_delayed(climate_modulation != maximum_modulation)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@property
def is_on(self):
def is_on(self) -> bool:
"""Return the state of the sensor."""
return self.state_delayed(int(self._climate.relative_modulation_value) != int(self._coordinator.maximum_relative_modulation_value))
climate_modulation = int(self._climate.relative_modulation_value)
maximum_modulation = int(self._coordinator.maximum_relative_modulation_value)
return self.state_delayed(climate_modulation != maximum_modulation)
@property
def is_on(self) -> bool:
"""Return the state of the sensor."""
if not self.available:
return False
climate_modulation = int(self._climate.relative_modulation_value)
maximum_modulation = int(self._coordinator.maximum_relative_modulation_value)
return self.state_delayed(climate_modulation != maximum_modulation)
🤖 Prompt for AI Agents
In @custom_components/sat/binary_sensor.py around lines 139 - 145, The is_on
property can raise TypeError when calling int() on None; update the is_on method
(in binary_sensor.py) to first check self._climate.relative_modulation_value and
self._coordinator.maximum_relative_modulation_value for None and only call int()
when both are not None; if either is None, return a safe boolean via
self.state_delayed(False) (or another explicit fallback) so the property never
attempts int(None) and always returns a bool.

Comment on lines +860 to +875
def schedule_control_heating_loop(self, _time: Optional[datetime] = None, force: bool = False) -> None:
"""Schedule a debounced execution of the heating control loop."""
# Force immediate execution
if force:
# Cancel previous scheduled run, if any
if self._control_heating_loop_unsub is not None:
self._control_heating_loop_unsub()
self._control_heating_loop_unsub = None

self.hass.async_create_task(self.async_control_heating_loop())
return
self.hass.async_create_task(self.async_control_heating_loop())
return

# If a run is already scheduled, do nothing.
if self._control_heating_loop_unsub is not None:
return

self._control_heating_loop_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_heating_loop))
self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: HassJob cannot accept async coroutines—will raise ValueError at runtime.

Line 875 passes self.async_control_heating_loop (an async method) directly to HassJob:

self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))

Home Assistant's HassJob raises ValueError: Coroutine not allowed to be passed to HassJob when given an async callable. This prevents the heating control loop from scheduling properly after the initial 5-second delay.

🔎 Proposed fix
-        self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
+        self._control_heating_loop_unsub = async_call_later(
+            self.hass,
+            5,
+            lambda _: self.hass.async_create_task(self.async_control_heating_loop())
+        )

This issue was flagged in previous reviews but appears to remain unresolved.

🤖 Prompt for AI Agents
In @custom_components/sat/climate.py around lines 860 - 875, The code is passing
an async coroutine method to HassJob which raises ValueError; update the
async_call_later call in schedule_control_heating_loop so HassJob receives a
synchronous callable that schedules the coroutine instead. Replace
HassJob(self.async_control_heating_loop) with something that invokes
hass.async_create_task (e.g. HassJob(lambda _:
self.hass.async_create_task(self.async_control_heating_loop()))) so
async_control_heating_loop is scheduled correctly; keep the rest of the logic
and still cancel/clear _control_heating_loop_unsub as before.

Comment on lines +1047 to +1051
@staticmethod
def _build_presets(config_options: Mapping[str, float]) -> dict[str, float]:
"""Build preset temperature mapping from config options."""
conf_presets = {p: f"{p}_temperature" for p in (PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)}
return {key: config_options[value] for key, value in conf_presets.items() if key in conf_presets}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

_build_presets always returns an empty dict due to logic error.

The condition if key in conf_presets is always True because key comes from iterating conf_presets.items(). The intended check was likely to verify the presence of value in config_options:

return {key: config_options[value] for key, value in conf_presets.items() if key in conf_presets}

This results in a KeyError when config_options doesn't contain the expected keys.

🔎 Proposed fix
     @staticmethod
     def _build_presets(config_options: Mapping[str, float]) -> dict[str, float]:
         """Build preset temperature mapping from config options."""
         conf_presets = {p: f"{p}_temperature" for p in (PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)}
-        return {key: config_options[value] for key, value in conf_presets.items() if key in conf_presets}
+        return {key: config_options[value] for key, value in conf_presets.items() if value in config_options}
🤖 Prompt for AI Agents
In @custom_components/sat/climate.py around lines 1047 - 1051, The
_build_presets function builds conf_presets then uses a dict comprehension that
incorrectly checks "if key in conf_presets" (always true) causing KeyError;
change the comprehension to only include entries whose mapped config key exists
by replacing the condition with "if value in config_options" (or use
config_options.get(value) with a fallback) so it returns a dict of preset_name:
config_options[value] for available keys (conf_presets, _build_presets,
config_options,
PRESET_ACTIVITY/PRESET_AWAY/PRESET_HOME/PRESET_SLEEP/PRESET_COMFORT).

Comment on lines +143 to +149
@property
def device_capabilities(self) -> BoilerCapabilities:
"""Describe setpoint capabilities for this device."""
return BoilerCapabilities(
minimum_setpoint=self.minimum_setpoint,
maximum_setpoint=self.maximum_setpoint_value,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "def minimum_setpoint|def maximum_setpoint_value" custom_components/sat/coordinator.py -A 5

Repository: Alexwijn/SAT

Length of output: 777


🏁 Script executed:

rg -n "def maximum_setpoint\b" custom_components/sat/coordinator.py -A 5

Repository: Alexwijn/SAT

Length of output: 404


🏁 Script executed:

rg -n "class BoilerCapabilities" custom_components/sat/ -A 10

Repository: Alexwijn/SAT

Length of output: 685


🏁 Script executed:

rg -n "def maximum_setpoint\b" custom_components/sat/coordinator.py -A 15

Repository: Alexwijn/SAT

Length of output: 886


Type annotation mismatch in maximum_setpoint_value property.

While device_capabilities is safe at runtime, maximum_setpoint_value is declared as returning Optional[float] (line 254) but always returns a float via self.maximum_setpoint. Update the return type hint to float instead of Optional[float] to match the actual behavior and prevent type checking issues.

🤖 Prompt for AI Agents
In @custom_components/sat/coordinator.py around lines 143 - 149, The property
maximum_setpoint_value is currently annotated as returning Optional[float] but
always forwards self.maximum_setpoint (a float), causing a type mismatch; update
the return type annotation of maximum_setpoint_value from Optional[float] to
float to match actual behavior and keep type checkers happy, and ensure callers
like device_capabilities (which builds BoilerCapabilities with
maximum_setpoint=maximum_setpoint_value) reflect the non-optional float
contract.

Comment on lines +57 to +67
@property
def kp(self) -> Optional[float]:
"""Return the value of kp based on the current configuration."""
if not self._config.automatic_gains:
return float(self._config.proportional)

self._last_boiler_temperature = boiler_temperature
self._last_heating_curve_value = heating_curve_value
if self._heating_curve.value is None:
return 0.0

def update_reset(self, error: Error, heating_curve_value: Optional[float]) -> None:
"""
Update the PID controller with resetting.
automatic_gain_value = 4 if self._heating_system == HeatingSystem.UNDERFLOOR else 3
return round((self._config.heating_curve_coefficient * self._heating_curve.value) / automatic_gain_value, 6)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Constructor parameter automatic_gains_value is still unused.

The PidConfig.automatic_gains_value is stored in config but line 66 hardcodes the gain value based on heating system instead of using self._config.automatic_gains_value. This was flagged in a previous review but remains unaddressed.

🔎 Proposed fix to use the configured value
         if self._heating_curve.value is None:
             return 0.0
 
-        automatic_gain_value = 4 if self._heating_system == HeatingSystem.UNDERFLOOR else 3
+        automatic_gain_value = self._config.automatic_gains_value if self._config.automatic_gains_value > 0 else (4 if self._heating_system == HeatingSystem.UNDERFLOOR else 3)
         return round((self._config.heating_curve_coefficient * self._heating_curve.value) / automatic_gain_value, 6)
🧰 Tools
🪛 Ruff (0.14.10)

66-66: HeatingSystem may be undefined, or defined from star imports

(F405)

🤖 Prompt for AI Agents
In @custom_components/sat/pid.py around lines 57 - 67, The kp property currently
ignores PidConfig.automatic_gains_value and hardcodes automatic_gain_value based
on HeatingSystem; update the logic in the kp getter to use
self._config.automatic_gains_value when automatic gains are enabled (falling
back to the heating-system-based default only if automatic_gains_value is None
or invalid), i.e., replace the hardcoded 4/3 selection with a value derived from
self._config.automatic_gains_value and then compute and return
round((self._config.heating_curve_coefficient * self._heating_curve.value) /
automatic_gain_value, 6) as before.

Comment on lines +139 to +142
self._last_error = float_value(data.get(STORAGE_KEY_LAST_ERROR))
self._integral = float(data.get(STORAGE_KEY_INTEGRAL, self._integral))
self._last_temperature = float_value(data.get(STORAGE_KEY_LAST_TEMPERATURE))
self._raw_derivative = float(data.get(STORAGE_KEY_RAW_DERIVATIVE, self._raw_derivative))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent error handling during state restoration.

Lines 140 and 142 use bare float() which will raise exceptions on corrupted data, while lines 139 and 141 correctly use float_value() for safe conversion.

🔎 Proposed fix
         self._last_error = float_value(data.get(STORAGE_KEY_LAST_ERROR))
-        self._integral = float(data.get(STORAGE_KEY_INTEGRAL, self._integral))
+        self._integral = float_value(data.get(STORAGE_KEY_INTEGRAL)) or self._integral
         self._last_temperature = float_value(data.get(STORAGE_KEY_LAST_TEMPERATURE))
-        self._raw_derivative = float(data.get(STORAGE_KEY_RAW_DERIVATIVE, self._raw_derivative))
+        self._raw_derivative = float_value(data.get(STORAGE_KEY_RAW_DERIVATIVE)) or self._raw_derivative
🤖 Prompt for AI Agents
In @custom_components/sat/pid.py around lines 139 - 142, Restoration uses
inconsistent conversion: replace the bare float() calls for self._integral and
self._raw_derivative with the safe conversion helper float_value (same used for
self._last_error and self._last_temperature) so corrupted or missing stored
values don't raise exceptions during state restoration; update the assignments
that reference STORAGE_KEY_INTEGRAL and STORAGE_KEY_RAW_DERIVATIVE to call
float_value(...) and supply the existing defaults (self._integral,
self._raw_derivative) as before.

Comment on lines +154 to +156
def set_heating_curve_value(self, heating_curve_value: float) -> None:
"""Set the heating curve value."""
self._heating_curve_value = heating_curve_value
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Dead code: set_heating_curve_value sets unused attribute.

This method sets self._heating_curve_value, but the class uses self._heating_curve.value everywhere else. This appears to be dead code or an incomplete refactor.

🔎 Proposed fix - remove dead code
-    def set_heating_curve_value(self, heating_curve_value: float) -> None:
-        """Set the heating curve value."""
-        self._heating_curve_value = heating_curve_value

Or if this should update the heating curve, use the proper interface:

     def set_heating_curve_value(self, heating_curve_value: float) -> None:
         """Set the heating curve value."""
-        self._heating_curve_value = heating_curve_value
+        self._heating_curve._last_heating_curve_value = heating_curve_value
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def set_heating_curve_value(self, heating_curve_value: float) -> None:
"""Set the heating curve value."""
self._heating_curve_value = heating_curve_value
🤖 Prompt for AI Agents
In @custom_components/sat/pid.py around lines 154 - 156, The method
set_heating_curve_value is dead because the class uses self._heating_curve.value
elsewhere; remove the method or change it to update the real object (e.g.,
replace the body to set self._heating_curve.value = heating_curve_value or call
the actual heater curve setter on the object) and ensure self._heating_curve
exists (raise or initialize if needed) instead of assigning to the unused
self._heating_curve_value attribute.

Comment on lines +176 to +179
if self._store is not None:
self._hass.create_task(self._async_save_state())

@property
def last_updated(self) -> float:
"""Return the timestamp of the last update to the PID controller."""
return self._last_updated
self._hass.loop.call_soon_threadsafe(async_dispatcher_send, self._hass, SIGNAL_PID_UPDATED, self._entity_id)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: self._hass.create_task should be async_create_task, and call_soon_threadsafe misuses async_dispatcher_send.

Two issues here:

  1. Line 177: Home Assistant's HomeAssistant object uses async_create_task, not create_task. This will raise AttributeError.

  2. Line 179: async_dispatcher_send is a coroutine function. Passing it to call_soon_threadsafe will not schedule the coroutine properly - it will just call the function and discard the returned coroutine object.

This may be related to the TypeError reported in PR comments: "a coroutine was expected, got ".

🔎 Proposed fix
             if self._store is not None:
-                self._hass.create_task(self._async_save_state())
+                self._hass.async_create_task(self._async_save_state())
 
-            self._hass.loop.call_soon_threadsafe(async_dispatcher_send, self._hass, SIGNAL_PID_UPDATED, self._entity_id)
+            async_dispatcher_send(self._hass, SIGNAL_PID_UPDATED, self._entity_id)

Since update() is called from async context (the coordinator), direct dispatch is appropriate.

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Ruff (0.14.10)

179-179: SIGNAL_PID_UPDATED may be undefined, or defined from star imports

(F405)

🤖 Prompt for AI Agents
In @custom_components/sat/pid.py around lines 176 - 179, The update() handler
incorrectly uses HomeAssistant.create_task and call_soon_threadsafe with
async_dispatcher_send; change self._hass.create_task(self._async_save_state())
to self._hass.async_create_task(self._async_save_state()) and remove
call_soon_threadsafe: call async_dispatcher_send directly from the async context
as async_dispatcher_send(self._hass, SIGNAL_PID_UPDATED, self._entity_id) (i.e.,
dispatch directly in update()), ensuring the coroutine is scheduled/executed
properly.

Comment on lines +30 to 36
def __init__(self, hass: HomeAssistant, config: SatConfig) -> None:
"""Initialize."""
super().__init__(hass, config_data, options)
super().__init__(hass, config)
self.async_set_updated_data(DEFAULT_STATUS)

self._port: str = port
self._port: str = str(self._config.device)
self._api: OpenThermGateway = OpenThermGateway()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Same issue: str(None) produces "None" string for port.

Similar to the MQTT coordinator, if self._config.device is None, this produces a port value of "None" which will cause pyotgw connection to fail with a confusing error.

🔎 Proposed fix
     def __init__(self, hass: HomeAssistant, config: SatConfig) -> None:
         """Initialize."""
         super().__init__(hass, config)
         self.async_set_updated_data(DEFAULT_STATUS)
 
-        self._port: str = str(self._config.device)
+        if self._config.device is None:
+            raise ConfigEntryNotReady("Serial device configuration is missing")
+        self._port: str = self._config.device
         self._api: OpenThermGateway = OpenThermGateway()
🧰 Tools
🪛 Pylint (4.0.4)

[error] 33-33: Undefined variable 'DEFAULT_STATUS'

(E0602)

🪛 Ruff (0.14.10)

33-33: DEFAULT_STATUS may be undefined, or defined from star imports

(F405)

🤖 Prompt for AI Agents
In @custom_components/sat/serial/__init__.py around lines 30 - 36, The
constructor __init__ sets self._port = str(self._config.device) which turns None
into the string "None" and breaks pyotgw; fix by checking self._config.device
before coercion (e.g., if self._config.device is not None assign its str() to
self._port, otherwise set self._port to None or an empty string) so the
OpenThermGateway connection gets a valid missing-port value; update the
assignment near __init__ and ensure downstream code using self._port can handle
the chosen None/empty value.

@Alexwijn Alexwijn closed this Jan 6, 2026
@Alexwijn Alexwijn deleted the feature/cycles branch January 6, 2026 14:02
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.

2 participants