Skip to content

Conversation

@Alexwijn
Copy link
Owner

@Alexwijn Alexwijn commented Nov 23, 2025

Summary by CodeRabbit

  • New Features

    • Per-area heating with PID and Areas UI/weights, cycle detection/history, richer Boiler model/status, dynamic regime-based minimum setpoint, per-room PID & requested-setpoint sensors, observable PWM config/state, cycle start/end events.
  • Bug Fixes

    • Improved control stability, modulation reliability tracking, tighter minimum-setpoint protection, safer handling of missing sensors/values, clearer availability/status semantics.
  • Refactor

    • Shifted to cycle-centric, event-driven control; reorganized lifecycle and control flows across climate, coordinator, boiler, PWM, and areas.
  • Removed

    • Legacy flame health, PWM service, setpoint adjuster, and removed climate tests.

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

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 23, 2025

Warning

Rate limit exceeded

@Alexwijn has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 17 minutes and 30 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 3759fb8 and 47394d4.

📒 Files selected for processing (3)
  • custom_components/sat/area.py (3 hunks)
  • custom_components/sat/climate.py (21 hunks)
  • custom_components/sat/minimum_setpoint.py (1 hunks)

Walkthrough

Replaces flame-centric components with cycle- and boiler-centric subsystems; adds CycleTracker/CycleHistory, Boiler, DynamicMinimumSetpoint, PWMConfig/PWMState, per-area Areas/PID aggregation and new sensors; removes flame module, SetpointAdjuster, PWM service and related tests; updates coordinator, climate, simulator, factories, constants and persistence keys.

Changes

Cohort / File(s) Summary
Integration lifecycle
custom_components/sat/__init__.py
Simplified async_setup_entry/unload/reload; added hass.data guard in unload; removed unused imports.
Constants & public surface
custom_components/sat/const.py
Removed FlameStatus; added CycleKind, CycleClassification, BoilerStatus, RelativeModulationState, PWM margins, events, CONF_ROOM_WEIGHTS; tightened MIN/MAX setpoint types; removed legacy option keys.
Cycle subsystem
custom_components/sat/cycles.py
New cycle models and tracker: CycleSample, Cycle, CycleStatistics, CycleHistory, CycleTracker — detect, classify, record cycles, emit events and expose rolling metrics.
Boiler state machine
custom_components/sat/boiler.py
New Boiler class with persistent Store, update(state,last_cycle,timestamp) API, rich status derivation, modulation reliability and flame-transition tracking.
Flame removal
custom_components/sat/flame.py
Deleted flame health-tracking module and all related public types and rolling-window logic.
Areas & PID
custom_components/sat/area.py, custom_components/sat/pid.py
Area/Areas now accept HeatingCurve, expose per-area room_weight, valve_position, Optional-returning properties and Areas._PIDs aggregation; PID simplified and exposes diagnostics/timing/derived metrics.
Dynamic minimum setpoint
custom_components/sat/minimum_setpoint.py
Added DynamicMinimumSetpoint, MinimumSetpointConfig, RegimeState, regime-aware tuning, persistence (STORAGE_VERSION) and lifecycle hooks.
PWM & modulation
custom_components/sat/pwm.py, custom_components/sat/relative_modulation.py
Introduced PWMConfig and PWMState; PWM.state returns PWMState; relative modulation enum moved to const; SetpointAdjuster removed; duty-cycle logic reworked.
Coordinator, Climate & Simulator
custom_components/sat/coordinator.py, custom_components/sat/climate.py, custom_components/sat/simulator/__init__.py
Coordinator composes Boiler/CycleTracker/CycleHistory, renames accessors (boiler→state, flame→cycles), adds last_cycle; async_control_heating_loop/timestamp signatures changed; SatClimate gains PID scheduling/lifecycle hooks.
Entities & sensors
custom_components/sat/sensor.py, custom_components/sat/binary_sensor.py
Flame sensors renamed to cycle equivalents; added SatPidSensor and SatRequestedSetpoint; sensors rely on cycle/boiler status and PID outputs; binary sensors switched to cycle-based health.
Factories, config & services
custom_components/sat/util.py, custom_components/sat/config_flow.py, custom_components/sat/services.py, custom_components/sat/mqtt/__init__.py
Factories now create DynamicMinimumSetpoint/PWMConfig; removed PID-version/sample-time options and PWM service; MQTT Store key standardized to sat.mqtt.{device_id}; options flow adds per-room areas step.
Type & helpers
custom_components/sat/heating_curve.py, custom_components/sat/helpers.py
HeatingCurve annotations switched to Optional[float]; added helpers filter_none, average, min_max.
Removals & tests
custom_components/sat/setpoint_adjuster.py, tests/test_climate.py
Removed SetpointAdjuster module and deleted tests/test_climate.py.
Other edits
various modules
Multiple signature, typing, logging and scheduling adjustments across coordinator, climate, areas, pwm, minimum_setpoint, sensors and simulator to align with cycle/boiler architecture.

Sequence Diagram(s)

sequenceDiagram
    participant Climate as SatClimate
    participant Coord as SatDataUpdateCoordinator
    participant Boiler as Boiler
    participant Cycles as CycleTracker
    participant MinSP as DynamicMinimumSetpoint
    participant PWM as PWM
    participant AreasPIDs as Areas._PIDs
    participant PID as PID

    Note over Climate: PID control & cycle integration
    Climate->>Coord: schedule_control_pid()
    Climate->>Coord: async_control_heating_loop(timestamp)
    Coord->>Boiler: update(state, last_cycle, timestamp)
    Coord->>Cycles: update(state, pwm_state?, timestamp)
    Cycles->>Cycles: detect & classify cycle
    Cycles-->>Coord: emit EVENT_SAT_CYCLE_STARTED / EVENT_SAT_CYCLE_ENDED
    Coord->>MinSP: on_cycle_start/on_cycle_end(..., requested_setpoint, outside_temp)
    AreasPIDs->>PID: update(entity_id)
    PID->>AreasPIDs: output()
    Climate->>PWM: enable/disable/update(requested_setpoint, boiler_state)
    Climate->>Coord: async_set_control_setpoint(requested_setpoint)
Loading
sequenceDiagram
    participant Climate
    participant MinSP
    participant Store as HA_Store

    Note over Climate,MinSP: Minimum-setpoint persistence lifecycle
    Climate->>MinSP: async_added_to_hass(hass, device_id)
    MinSP->>Store: load regimes
    Note over MinSP: runtime tuning on classified cycles
    MinSP->>Store: async_save_regimes() on changes or shutdown
    Climate->>MinSP: async_will_remove_from_hass()
    MinSP->>Store: async_save_regimes()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Areas needing focused review:

  • custom_components/sat/boiler.py — status derivation, modulation reliability, persistence format.
  • custom_components/sat/cycles.py — cycle detection/classification correctness, pruning and event payloads.
  • custom_components/sat/minimum_setpoint.py — regime bucketing, damping/relaxation logic, persistence/version handling.
  • Coordinator / climate / simulator signature changes — callers, schedulers and event wiring for reordered parameters and new types.
  • custom_components/sat/area.py, pid.py, pwm.py — PID semantics, Areas._PIDs aggregation, PWMState shape and consumers.

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.00% 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 PR title 'Refactor heating control to cycle-centric architecture' accurately describes the main architectural change: the codebase shifts from flame-based state tracking to a comprehensive cycle-tracking system with new data models (Cycle, CycleHistory, CycleTracker) and cycle-based events, affecting core components like boiler, coordinator, and climate control.

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

Copy link
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.

Copy link
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
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.

Copy link
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.

Copy link
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 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
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 on lines +354 to +379
def _update_modulation_reliability(self, state: BoilerState) -> None:
"""Detect boilers that always report relative_modulation_level as zero (or effectively constant) while the flame is on."""
if not state.flame_active:
return

return None
value = state.relative_modulation_level
if value is None:
return

def _handle_adjusting_to_lower_setpoint(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float) -> None:
"""Handle stabilization when adjusting to a lower setpoint."""
if boiler_temperature <= setpoint and boiler_temperature_derivative == 0:
return self._stop_adjusting_to_lower_setpoint("Setpoint stabilization complete.", boiler_temperature, setpoint)
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:]

return None
if not self._modulation_reliable:
return

def _stop_adjusting_to_lower_setpoint(self, reason: str, boiler_temperature: float, setpoint: float):
"""Stop the adjustment to a lower setpoint and log the reason."""
self._adjusting_to_lower_setpoint = False
if len(self._modulation_values_when_flame_on) < self._modulation_reliability_min_samples:
return

_LOGGER.debug(
f"Adjustment to lower setpoint stopped: {reason} "
f"(Setpoint: {setpoint}, Current: {boiler_temperature})."
)
values = self._modulation_values_when_flame_on
max_value = max(values)
min_value = min(values)

def _stop_warming_up(self, reason: str, boiler_temperature: float, setpoint: float):
"""Stop the warming-up phase and log the reason."""
self._warming_up = False
if max_value - min_value < 1e-3 and abs(max_value) < 1e-3:
# Modulation is effectively stuck at ~0 while the flame is on.
self._modulation_reliable = False
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Modulation reliability detection only marks as unreliable, never recovers.

Once _modulation_reliable is set to False (Line 379), there's no path to set it back to True. If the boiler reports stuck modulation temporarily (e.g., during startup) but later reports varying values, reliability won't be restored until Home Assistant restarts.

Consider adding recovery logic when variation is detected:

if max_value - min_value >= self._modulation_delta_threshold:
    if not self._modulation_reliable:
        self._modulation_reliable = True
        _LOGGER.info("Modulation reliability restored.")

Copy link
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 on lines 246 to 247
def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: float = None) -> None:
timestamp = timestamp or monotonic()
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Fix implicit Optional type annotation.

PEP 484 prohibits implicit Optional. The timestamp parameter defaults to None but is typed as float.

-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:
📝 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 update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: float = None) -> None:
timestamp = timestamp or monotonic()
def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: Optional[float] = None) -> None:
timestamp = timestamp or monotonic()
🧰 Tools
🪛 Ruff (0.14.5)

246-246: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

🤖 Prompt for AI Agents
In custom_components/sat/cycles.py around lines 246-247, the update method
currently types timestamp as float while defaulting it to None; change the
parameter annotation to Optional[float] (or float | None for Python 3.10+) so
the signature reads timestamp: Optional[float] = None (ensure Optional is
imported), leaving the existing timestamp = timestamp or monotonic() logic
unchanged.

Copy link
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

Copy link
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
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 (8)
custom_components/sat/cycles.py (2)

250-251: Fix implicit Optional type annotation for timestamp parameter.

The timestamp parameter defaults to None but is typed as float. PEP 484 prohibits implicit Optional.

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

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

The Cycle dataclass is passed directly to the event bus, but Home Assistant events require JSON-serializable data. This will cause issues with event logging and persistence.

+from dataclasses import asdict

 cycle_state = self._build_cycle_state(pwm_status, timestamp)
-self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle_state})
+if cycle_state is not None:
+    self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle_state)})
custom_components/sat/minimum_setpoint.py (3)

137-137: Fix malformed f-string missing placeholders.

The f-string uses % formatting syntax which won't work. Remove the f prefix or convert to proper f-string formatting.

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

29-31: Comments for increase_step and decrease_step are inverted.

The comments describe the opposite of what the code does:

  • increase_step (line 30) is used when minimum is too low (overshoot/short-cycling) — lines 333-334
  • decrease_step (line 31) is used when minimum is too high (underheat) — lines 323-324, 340-341
-    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)
+    increase_step: float = 1.0  # when minimum is too low (overshoot / short-cycling)
+    decrease_step: float = 1.0  # when minimum is too high (underheat)

221-234: Return type annotation is incorrect.

_regime_for always returns a RegimeState (creating one if it doesn't exist at lines 225-229), but the return type is annotated as Optional[RegimeState].

-    def _regime_for(self, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: float, outside_temperature: Optional[float]) -> Optional[RegimeState]:
+    def _regime_for(self, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: float, outside_temperature: Optional[float]) -> RegimeState:
custom_components/sat/boiler.py (2)

117-128: Store unsubscribe handle for periodic save cleanup.

The return value of async_track_time_interval should be stored and called during cleanup. Currently, the periodic save continues even if the boiler is removed.

+        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))

Add cleanup in async_save_options or create an async_will_remove_from_hass method:

async def async_will_remove_from_hass(self) -> None:
    if self._save_unsub is not None:
        self._save_unsub()
        self._save_unsub = None
    await self.async_save_options()

358-384: Modulation reliability detection only marks as unreliable, never recovers.

Once _modulation_reliable is set to False (line 383), there's no path to restore it to True. If the boiler temporarily reports stuck modulation during startup but later reports varying values, reliability won't be restored until restart.

Consider adding recovery logic when variation is detected:

     if len(self._modulation_values_when_flame_on) < self._modulation_reliability_min_samples:
         return

     values = self._modulation_values_when_flame_on
     max_value = max(values)
     min_value = min(values)

+    # Recovery: if we see meaningful variation, restore reliability
+    if max_value - min_value >= self._modulation_delta_threshold:
+        if not self._modulation_reliable:
+            self._modulation_reliable = True
+            _LOGGER.info("Modulation reliability restored after detecting variation.")
+        return
+
     if max_value - min_value < 1e-3 and abs(max_value) < 1e-3:
         # Modulation is effectively stuck at ~0 while the flame is on.
         self._modulation_reliable = False
custom_components/sat/climate.py (1)

717-717: Remove extraneous f-string prefix.

The f-string uses % formatting syntax with no {} placeholders.

-        _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature)
+        _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)
🧹 Nitpick comments (5)
custom_components/sat/cycles.py (1)

157-162: Consider making the staleness threshold configurable.

The hardcoded 3600-second (1 hour) staleness threshold for last_cycle may not suit all use cases. Consider extracting this as a class constant or constructor parameter for flexibility.

+LAST_CYCLE_STALENESS_SECONDS: float = 3600.0
+
 @property
 def last_cycle(self) -> Optional[Cycle]:
-    if self._last_cycle is None or (monotonic() - self._last_cycle.end) > 3600:
+    if self._last_cycle is None or (monotonic() - self._last_cycle.end) > LAST_CYCLE_STALENESS_SECONDS:
         return None
     return self._last_cycle
custom_components/sat/pid.py (1)

19-31: Consider using a configuration dataclass to reduce constructor parameters.

The constructor has 9 parameters, which is flagged by Pylint. While acceptable for a controller, extracting configuration into a dataclass would improve maintainability and align with the pattern used elsewhere (e.g., PWMConfig, MinimumSetpointConfig).

custom_components/sat/sensor.py (1)

63-66: Use explicit Optional or union syntax for type annotation.

PEP 484 prohibits implicit Optional. The area_id parameter should use explicit typing.

-    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):
custom_components/sat/coordinator.py (1)

332-346: Remove unused climate parameter or document its intended use.

The climate parameter is declared but never used in async_control_heating_loop. This was flagged by static analysis (ARG002).

If the parameter is for future use, add a comment; otherwise, remove it:

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

Also fix the implicit Optional for timestamp:

-    async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None:
+    async def async_control_heating_loop(self, pwm_status: PWMStatus | None = None, timestamp: float | None = None) -> None:
custom_components/sat/area.py (1)

213-216: Avoid catching blind Exception.

Catching broad Exception can mask programming errors. Consider catching specific exceptions or at minimum logging the exception type.

             try:
                 outputs.append(area.pid.output)
-            except Exception as exception:
+            except (TypeError, AttributeError) as exception:
                 _LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f43785e and 6129822.

📒 Files selected for processing (12)
  • custom_components/sat/area.py (3 hunks)
  • custom_components/sat/boiler.py (1 hunks)
  • custom_components/sat/climate.py (21 hunks)
  • custom_components/sat/config_flow.py (1 hunks)
  • custom_components/sat/const.py (2 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/pid.py (4 hunks)
  • custom_components/sat/sensor.py (5 hunks)
  • custom_components/sat/util.py (3 hunks)
  • tests/test_climate.py (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
custom_components/sat/pid.py (2)
custom_components/sat/errors.py (1)
  • Error (37-40)
custom_components/sat/helpers.py (1)
  • seconds_since (11-16)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/boiler.py (1)
  • BoilerState (21-35)
custom_components/sat/const.py (2)
  • CycleClassification (188-195)
  • CycleKind (181-185)
custom_components/sat/cycles.py (4)
  • CycleStatistics (69-74)
  • Cycle (42-65)
  • duty_ratio_last_15m (127-140)
  • sample_count_4h (108-114)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/cycles.py (3)
custom_components/sat/const.py (3)
  • CycleKind (181-185)
  • CycleClassification (188-195)
  • PWMStatus (221-224)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/boiler.py (1)
  • BoilerState (21-35)
custom_components/sat/coordinator.py (1)
custom_components/sat/cycles.py (6)
  • CycleTracker (223-432)
  • CycleHistory (77-220)
  • Cycle (42-65)
  • update (250-309)
  • last_cycle (158-162)
  • statistics (165-173)
custom_components/sat/boiler.py (2)
custom_components/sat/const.py (2)
  • BoilerStatus (198-218)
  • CycleClassification (188-195)
custom_components/sat/cycles.py (2)
  • Cycle (42-65)
  • last_cycle (158-162)
tests/test_climate.py (3)
custom_components/sat/climate.py (4)
  • async_control_pid (859-888)
  • async_control_heating_loop (908-963)
  • setpoint (526-527)
  • requested_setpoint (530-539)
custom_components/sat/coordinator.py (3)
  • async_control_heating_loop (332-350)
  • setpoint (167-168)
  • state (138-151)
custom_components/sat/pwm.py (1)
  • state (239-244)
🪛 GitHub Check: build (3.13)
tests/test_climate.py

[failure] 59-59:
test_scenario_1[domains0-data0-options0-config0]

assert (2400, 0) == (277, 922)

At index 0 diff: 2400 != 277

Full diff:
(

  • 277,
    
  • 922,
    
  • 2400,
    
  • 0,
    
    )

[failure] 103-103:
test_scenario_2[domains0-data0-options0-config0]

assert 58.0 == 10

  • where 58.0 = .setpoint

[failure] 156-156:
test_scenario_3[domains0-data0-options0-config0]

assert 7719193.0 == 33.5

  • where 7719193.0 = .requested_setpoint
🪛 Pylint (4.0.4)
custom_components/sat/pid.py

[refactor] 19-19: Too many arguments (9/5)

(R0913)


[refactor] 19-19: Too many positional arguments (9/5)

(R0917)

custom_components/sat/minimum_setpoint.py

[refactor] 24-24: Too many instance attributes (18/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] 267-267: Too many arguments (6/5)

(R0913)


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

(R0917)


[refactor] 267-267: Too many return statements (9/6)

(R0911)


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

(R0912)

custom_components/sat/cycles.py

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

(R0902)


[refactor] 311-311: Too many local variables (24/15)

(R0914)


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

(R0911)

custom_components/sat/boiler.py

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

(R0902)


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

(R0902)


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

(R0913)


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

(R0917)


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

(R0911)


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

(R0912)


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

(R0911)


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

(R0911)

custom_components/sat/climate.py

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

(R0911)

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

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

(F405)


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

(F405)


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

(F405)


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

(F405)

custom_components/sat/sensor.py

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

(F403)


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

(F405)


58-58: CONF_MINIMUM_CONSUMPTION may be undefined, or defined from star imports

(F405)


58-58: CONF_MAXIMUM_CONSUMPTION may be undefined, or defined from star imports

(F405)


63-63: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

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/util.py

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

(F405)


51-51: 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_AUTOMATIC_DUTY_CYCLE may be undefined, or defined from star imports

(F405)


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

(F405)

custom_components/sat/cycles.py

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)


250-250: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/coordinator.py

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

(F403)


106-106: PWMStatus may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


332-332: Unused method argument: climate

(ARG002)


332-332: PWMStatus may be undefined, or defined from star imports

(F405)


332-332: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/climate.py

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

(F403)


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

(F405)


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

(F405)


179-179: 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)


255-255: Unused method argument: event

(ARG002)


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

(F405)


470-470: Consider iterable unpacking instead of concatenation

Replace with iterable unpacking

(RUF005)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


646-646: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


650-650: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


717-717: f-string without any placeholders

Remove extraneous f prefix

(F541)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


809-809: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

custom_components/sat/area.py

215-215: Do not catch blind exception: Exception

(BLE001)

custom_components/sat/config_flow.py

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

(F405)

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

35-66: LGTM!

The CycleSample and Cycle dataclasses are well-structured with appropriate frozen and slots optimizations. The field organization with averages, extremes, and fractions is clear and maintainable.


77-141: LGTM!

The CycleHistory class has proper validation for window parameters, clean property implementations with pruning, and the cycles_last_hour normalization correctly extrapolates to cycles per hour regardless of window duration.


223-248: LGTM!

The CycleTracker initialization is clean with proper validation of minimum_samples_per_cycle. The started_since property and reset method provide a clear public API for managing tracking state.


394-432: LGTM!

The _classify_cycle method has clear classification logic with well-defined thresholds. The PWM status consideration for short cycles and the short-cycling context detection are thoughtfully implemented.

custom_components/sat/config_flow.py (1)

592-595: LGTM!

The simplified conditional properly shows PID gain controls when automatic gains are disabled. The removal of the PID version check aligns with the broader refactor removing version-based branching.

custom_components/sat/pid.py (7)

33-46: LGTM!

The new read-only properties last_error, previous_error, and last_updated provide clean access to internal state for diagnostics and external monitoring.


48-74: LGTM!

The dynamic computation of kp, ki, and kd when automatic gains is enabled is well-implemented. The fallback to configured values when automatic gains is disabled maintains backward compatibility.


76-120: LGTM!

The computed properties for proportional, integral, derivative, and output properly handle None cases and provide safe defaults. The integral_enabled and derivative_enabled properties encapsulate the deadband logic clearly.


139-152: LGTM!

The update method correctly sequences integral and derivative updates before updating state. The transition from storing error values before updating state ensures derivatives are computed correctly.


154-183: LGTM!

The update_integral method properly handles:

  • Reset timing when entering deadband
  • Early exit when integral is disabled
  • Guard against None ki
  • Integral windup prevention via clamping to heating curve bounds

185-219: LGTM!

The two-stage low-pass filtering for the derivative term is a solid approach to reduce noise sensitivity. The guard against None last_error prevents division issues on first update.


221-240: LGTM!

The restore method properly restores PID state from saved attributes and resets timing anchors to prevent stale time calculations after restoration.

custom_components/sat/util.py (2)

46-52: LGTM!

The create_dynamic_minimum_setpoint_controller function properly constructs the new DynamicMinimumSetpoint with MinimumSetpointConfig. The function signature clearly indicates the parameters used.


65-76: LGTM!

The create_pwm_controller function is updated to use the new PWMConfig dataclass, simplifying the PWM construction. The parameter rename to _config_data correctly indicates the parameter is unused.

tests/test_climate.py (4)

4-4: LGTM!

Using explicit module-qualified domain constants (template.DOMAIN, sensor.DOMAIN) is cleaner than local aliases and improves code clarity.


149-160: Verify actual test execution and pipeline output before concluding critical issue.

The code structure appears sound with proper initialization, guard conditions, and value clamping. The calculation logic for requested_setpoint depends on pid.output and areas.pids.output, both of which are properly bounded and rounded. The expected test value of 33.5°C is mathematically consistent with the heating curve value of 32.5°C plus typical PID adjustments. The claimed value of 7,719,193.0 cannot be reproduced through static code analysis of the current implementation. Recommend verifying whether this large value was actually observed in test execution or if it stems from misinterpretation of pipeline metrics.


50-60: Update test assertions to match current cycles-per-hour configuration.

The expected duty cycle values (277, 922) in this test correspond to a 3 cycles-per-hour configuration, but the test fixture uses the default of 4 cycles per hour (since CONF_CYCLES_PER_HOUR is not explicitly set in the test options). With the actual configuration, the calculated values should be approximately (208, 691) at 23.15% duty cycle.

Either update the assertions to reflect the current defaults, or explicitly set CONF_CYCLES_PER_HOUR: 3 in the test's options dict if 3 cycles per hour is the intended configuration.

Likely an incorrect or invalid review comment.


99-110: Test assertion failing due to PWM state handling or setpoint selection logic.

The setpoint is being set to 58.0 (the configured CONF_MINIMUM_SETPOINT via minimum_setpoint_value) when it should be 10 (the MINIMUM_SETPOINT constant). This occurs in _async_control_setpoint() when pwm.status == PWMStatus.ON triggers line 802 instead of the expected OFF state at line 805.

Given the test's duty_cycle expectation of (0, 2400) (0ms ON time), the PWM state should transition to OFF in pwm.enable(), which would correctly set setpoint to MINIMUM_SETPOINT (10). Verify that PWM state transitions are working correctly on the first enable() call, or that the setpoint selection logic properly distinguishes between PWM ON/OFF states.

custom_components/sat/const.py (1)

181-239: Well-structured cycle-centric enums and constants.

The new CycleKind, CycleClassification, BoilerStatus, and RelativeModulationState enums provide a comprehensive state model for the refactored architecture. The UNHEALTHY_CYCLES tuple is a clean way to group related classifications for conditional checks.

custom_components/sat/sensor.py (1)

313-327: Cycle sensor implementation looks correct.

The renamed SatCycleSensor properly handles the None case for last_cycle by returning INSUFFICIENT_DATA, and the unique_id update maintains consistency with the new naming scheme.

custom_components/sat/coordinator.py (2)

119-120: Lambda listeners capture self correctly for updates.

The listener lambdas properly delegate to the boiler and cycle tracker update methods, passing the current state and last cycle. This wiring integrates the new cycle-centric architecture cleanly.


328-331: CycleHistory is not currently persisted; clarify if this is intentional.

Unlike Boiler and MinimumSetpoint, CycleHistory does not use the Store pattern and has no async_save methods. The async_will_remove_from_hass method only persists boiler options. If cycle state (e.g., learned cycle data) should survive restarts, add similar persistence for CycleHistory; if cycle data is intentionally ephemeral, this is fine as-is.

custom_components/sat/minimum_setpoint.py (1)

115-156: Event-driven tuning addresses the repeated tuning concern.

The refactored on_cycle_start and on_cycle_end methods are now triggered by EVENT_SAT_CYCLE_STARTED and EVENT_SAT_CYCLE_ENDED events (see climate.py lines 237-251), ensuring tuning happens once per cycle event rather than on every control loop iteration. This addresses the previous review concern about destabilizing learned minimums.

custom_components/sat/boiler.py (1)

156-231: Comprehensive status derivation logic.

The _derive_status method implements a thorough state machine covering all relevant boiler states including overshoot cooling, anti-cycling, stalled ignition, and modulation direction. The logic is well-structured despite the many return paths.

custom_components/sat/climate.py (4)

253-263: HOMEASSISTANT_STOP listener properly handles async cleanup.

The updated implementation passes self.async_will_remove_from_hass directly to async_listen_once, which correctly handles async callables. The method signature now accepts Optional[Event] to support both direct calls and event-triggered invocations. This addresses previous review concerns.


584-654: PWM enable/disable logic is well-structured with clear conditions.

The refactored pulse_width_modulation_enabled property and helper methods (_is_pwm_forced, _should_enable_static_pwm, _should_enable_dynamic_pwm) provide clear separation of concerns. The hysteresis margins (PWM_ENABLE_MARGIN_CELSIUS and PWM_DISABLE_MARGIN_CELSIUS) help prevent flapping.


237-251: Cycle event handlers wire minimum setpoint updates correctly.

The EVENT_SAT_CYCLE_STARTED and EVENT_SAT_CYCLE_ENDED listeners correctly invoke minimum_setpoint.on_cycle_start and on_cycle_end with all required parameters. Since these methods are synchronous, using sync lambdas is appropriate.


906-906: No changes needed — HassJob with async callables in async_call_later is valid and supported.

The pattern HassJob(self.async_control_heating_loop) with async_call_later is correct. Home Assistant's HassJob explicitly supports wrapping async functions, and async_call_later is designed to accept HassJob instances with async callables. This is the recommended approach for scheduling delayed async work in Home Assistant. The code correctly stores the cancel callable in self._control_heating_loop_unsub for proper cleanup.

custom_components/sat/area.py (2)

141-150: PID update correctly guards against None values.

The update method properly checks for None error and heating curve values before calling pid.update, addressing the concern from past reviews about passing None to PID.update.


205-221: area.pid.output is correctly used as a property.

Verified that PID.output is decorated with @property in custom_components/sat/pid.py and returns a float. The code in lines 215 correctly accesses it without parentheses.

Comment on lines +98 to +115
@property
def native_value(self) -> float:
return self._pid.output

@property
def unique_id(self) -> str:
if self._area_id is None:
return f"{self._config_entry.data.get(CONF_NAME).lower()}-pid"

return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._area_id}-pid"

@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
return {
"proportional": self._pid.proportional,
"integral": self._pid.integral,
"derivative": self._pid.derivative,
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard against None PID access in native_value and extra_state_attributes.

When _pid is None, the available property correctly returns False, but Home Assistant may still access native_value and extra_state_attributes before checking availability. This can cause AttributeError.

Apply this diff:

     @property
     def native_value(self) -> float:
+        if self._pid is None:
+            return None
         return self._pid.output

     @property
     def extra_state_attributes(self) -> Mapping[str, Any] | None:
+        if self._pid is None:
+            return None
         return {
             "proportional": self._pid.proportional,
             "integral": self._pid.integral,
             "derivative": self._pid.derivative,
         }
📝 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 native_value(self) -> float:
return self._pid.output
@property
def unique_id(self) -> str:
if self._area_id is None:
return f"{self._config_entry.data.get(CONF_NAME).lower()}-pid"
return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._area_id}-pid"
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
return {
"proportional": self._pid.proportional,
"integral": self._pid.integral,
"derivative": self._pid.derivative,
}
@property
def native_value(self) -> float:
if self._pid is None:
return None
return self._pid.output
@property
def unique_id(self) -> str:
if self._area_id is None:
return f"{self._config_entry.data.get(CONF_NAME).lower()}-pid"
return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._area_id}-pid"
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
if self._pid is None:
return None
return {
"proportional": self._pid.proportional,
"integral": self._pid.integral,
"derivative": self._pid.derivative,
}
🧰 Tools
🪛 Ruff (0.14.7)

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

(F405)


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

(F405)

🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 98 to 115, native_value and
extra_state_attributes access self._pid without guarding for None which can
raise AttributeError; modify native_value to check if self._pid is None and
return None (or an appropriate default) when absent, and update
extra_state_attributes to return None or an empty mapping when self._pid is
None, otherwise return the proportional/integral/derivative values; ensure types
remain compatible with the property annotations.

Copy link
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)

908-925: Fix HassJob usage with async function.

HassJob does not accept coroutine functions directly. Home Assistant will raise "Coroutine not allowed to be passed to HassJob" at runtime when the callback fires.

Apply this diff to use the same pattern as line 917:

     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

         # 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, 10, 
+            lambda _now: self.hass.async_create_task(self.async_control_heating_loop())
+        )
♻️ Duplicate comments (4)
custom_components/sat/sensor.py (1)

123-140: Guard against None PID access in native_value and extra_state_attributes.

When _pid is None, the available property correctly returns False, but Home Assistant may still access native_value and extra_state_attributes before checking availability. This can cause AttributeError.

Apply this diff:

     @property
     def native_value(self) -> float:
+        if self._pid is None:
+            return None
         return self._pid.output

     @property
     def extra_state_attributes(self) -> Mapping[str, Any] | None:
+        if self._pid is None:
+            return None
         return {
             "proportional": self._pid.proportional,
             "integral": self._pid.integral,
             "derivative": self._pid.derivative,
         }
custom_components/sat/cycles.py (1)

255-255: Fix implicit Optional type annotation.

PEP 484 prohibits implicit Optional. The timestamp parameter defaults to None but is typed as float.

Apply this diff:

-    def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: float = None) -> None:
+    def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: Optional[float] = None) -> None:
custom_components/sat/climate.py (1)

733-733: Remove extraneous f-string prefix.

The f-string has no placeholders and uses % formatting instead. Remove the f prefix.

Apply this diff:

-        _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature)
+        _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)
custom_components/sat/area.py (1)

283-292: Critical: Walrus operator precedence bug.

The expression if area := self._get_area(entity_id) is None binds as area := (self._get_area(entity_id) is None), assigning True or False to area instead of the Area object. This causes area.error and area.heating_curve accesses at lines 288-289 to fail with AttributeError.

Apply this diff:

     def update(self, entity_id: str) -> None:
         """Update the PID controller for a specific area."""
-        if area := self._get_area(entity_id) is None:
+        if (area := self._get_area(entity_id)) is None:
             return

         if area.error is None or area.heating_curve.value is None:
             return

         _LOGGER.info("Updating PID for %s with error=%s", area.id, area.error.value)
         area.pid.update(area.error, area.heating_curve.value)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 86f3b70 and d512429.

📒 Files selected for processing (4)
  • custom_components/sat/area.py (3 hunks)
  • custom_components/sat/climate.py (21 hunks)
  • custom_components/sat/cycles.py (1 hunks)
  • custom_components/sat/sensor.py (7 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
custom_components/sat/cycles.py (4)
custom_components/sat/const.py (3)
  • CycleKind (181-185)
  • CycleClassification (188-195)
  • PWMStatus (221-224)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/boiler.py (1)
  • BoilerState (21-35)
custom_components/sat/coordinator.py (5)
  • last_cycle (158-159)
  • flame_active (181-182)
  • hot_water_active (189-190)
  • setpoint (167-168)
  • return_temperature (201-202)
custom_components/sat/climate.py (4)
custom_components/sat/area.py (19)
  • Areas (158-299)
  • errors (169-172)
  • get (179-184)
  • state (54-59)
  • async_control_heating_loop (134-144)
  • async_added_to_hass (114-126)
  • async_added_to_hass (194-197)
  • items (190-192)
  • ids (186-188)
  • current_temperature (70-81)
  • target_temperature (62-67)
  • error (84-92)
  • output (213-244)
  • pids (175-177)
  • overshoot_cap (247-277)
  • update (146-155)
  • update (283-292)
  • reset (279-281)
  • async_control_heating_loops (199-202)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/pid.py (10)
  • PID (16-240)
  • integral (82-84)
  • derivative (87-92)
  • proportional (77-79)
  • integral_enabled (108-110)
  • output (100-105)
  • update (139-152)
  • reset (122-137)
  • last_updated (44-46)
  • update_integral (154-183)
custom_components/sat/util.py (1)
  • create_dynamic_minimum_setpoint_controller (46-52)
🪛 Pylint (4.0.4)
custom_components/sat/cycles.py

[refactor] 42-42: Too many instance attributes (17/7)

(R0902)


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

(R0902)


[refactor] 318-318: Too many local variables (24/15)

(R0914)


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

(R0911)

custom_components/sat/climate.py

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

(R0911)

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

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)


255-255: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/area.py

227-227: Do not catch blind exception: Exception

(BLE001)

custom_components/sat/sensor.py

15-15: 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)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

custom_components/sat/climate.py

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

(F403)


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

(F405)


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

(F405)


179-179: 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)


255-255: Unused method argument: event

(ARG002)


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

(F405)


470-470: Consider iterable unpacking instead of concatenation

Replace with iterable unpacking

(RUF005)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


662-662: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


666-666: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


733-733: f-string without any placeholders

Remove extraneous f prefix

(F541)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


825-825: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

🔇 Additional comments (1)
custom_components/sat/area.py (1)

227-227: Broad exception handling is acceptable here.

The broad Exception catch at line 227 is acceptable defensive coding. The method logs the exception and continues processing other areas, preventing one failing area from breaking the entire aggregation.



class SatPidSensor(SatClimateEntity, SensorEntity):
def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None):
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix implicit Optional type annotation.

PEP 484 prohibits implicit Optional. The area_id parameter defaults to None but is typed as str.

Apply this diff:

-    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):
📝 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 __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):
🧰 Tools
🪛 Ruff (0.14.8)

88-88: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around line 88, the constructor parameter
area_id defaults to None but is annotated as str which creates an implicit
Optional; update the type annotation to explicitly allow None (e.g.,
Optional[str] or str | None depending on project typing standard) and add the
corresponding import from typing (Optional) if not already present so the
signature correctly reflects that area_id may be None.

…ce valve position handling, simplify PID updates, and optimize heating loop control.
Copy link
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

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/climate.py (2)

398-434: Avoid AttributeError when self.error is None in state attributes.

         return {
-            "error": self.error.value,
+            "error": self.error.value if self.error is not None else None,
             "integral": self.pid.integral,
             "derivative": self.pid.derivative,
             "proportional": self.pid.proportional,

890-907: HassJob does not accept coroutine functions directly — this will raise ValueError at runtime. Replace with a sync callback that schedules the coroutine:

-        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 _now: self.hass.async_create_task(self.async_control_heating_loop(_now))),
+        )

Home Assistant's event helpers (async_call_later, etc.) require HassJob to wrap a non-coroutine callable. Passing a coroutine function or coroutine object directly will fail with "Coroutine not allowed to be passed to HassJob". Use a lambda or @callback-decorated function to schedule the coroutine via hass.async_create_task().

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

355-369: Use low_load_maximum_duty_ratio_15m in _is_tunable_regime (currently unused).

         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

29-32: Fix inverted step comments (they contradict the actual tuning directions).

Given _maybe_tune_minimum increases on overshoot and decreases on underheat, these comments should reflect that.

-    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)
+    increase_step: float = 1.0  # when minimum is too low (overshoot / short-cycling)
+    decrease_step: float = 1.0  # when minimum is too high (underheat / too short)

115-138: Fix malformed log formatting + unused arg in on_cycle_start.

  • Line 137 mixes f"" with % placeholders.
  • boiler_state is unused (Ruff ARG002); either prefix _boiler_state or use it.
-    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:
@@
-            _LOGGER.debug(f"Updated regime minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint)
+            _LOGGER.debug("Updated regime minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint)

222-236: Return type of _regime_for should not be Optional.

It always returns a RegimeState.

-    def _regime_for(... ) -> Optional[RegimeState]:
+    def _regime_for(... ) -> RegimeState:
custom_components/sat/area.py (2)

240-268: Call PID.output() (method) instead of taking the method object.

Same underlying issue as previously flagged: PID.output is a method (per custom_components/sat/pid.py snippet).

-                    value = area.pid.output
+                    value = area.pid.output()

Also: catching bare Exception (Line 251) is a bit broad; if you keep it, consider narrowing to expected errors.


307-316: Fix walrus precedence bug in _PIDs.update (area becomes bool).

-            if area := self._get_area(entity_id) is None:
+            if (area := self._get_area(entity_id)) is None:
                 return
custom_components/sat/sensor.py (1)

87-140: Make area_id explicitly optional and guard _pid dereferences.

-class SatPidSensor(SatClimateEntity, SensorEntity):
-    def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None):
+class SatPidSensor(SatClimateEntity, SensorEntity):
+    def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str | None = None):
@@
     def native_value(self) -> float:
-        return self._pid.output
+        return self._pid.output if self._pid is not None else None
@@
     def extra_state_attributes(self) -> Mapping[str, Any] | None:
-        return {
+        if self._pid is None:
+            return None
+        return {
             "proportional": self._pid.proportional,
             "integral": self._pid.integral,
             "derivative": self._pid.derivative,
         }
custom_components/sat/climate.py (1)

708-721: Remove extraneous f prefix from logger format string.

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

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d512429 and dff8ab6.

📒 Files selected for processing (4)
  • custom_components/sat/area.py (3 hunks)
  • custom_components/sat/climate.py (21 hunks)
  • custom_components/sat/minimum_setpoint.py (1 hunks)
  • custom_components/sat/sensor.py (7 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/area.py (6)
custom_components/sat/state.py (1)
  • State (10-12)
custom_components/sat/errors.py (2)
  • Errors (5-33)
  • Error (37-40)
custom_components/sat/heating_curve.py (4)
  • HeatingCurve (11-92)
  • value (91-92)
  • update (27-30)
  • reset (21-25)
custom_components/sat/helpers.py (1)
  • float_value (60-69)
custom_components/sat/pid.py (5)
  • PID (16-240)
  • update (139-152)
  • output (100-105)
  • available (118-120)
  • reset (122-137)
custom_components/sat/coordinator.py (3)
  • state (138-151)
  • get (409-424)
  • update (43-48)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py

[refactor] 24-24: Too many instance attributes (18/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] 268-268: Too many arguments (6/5)

(R0913)


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

(R0917)


[refactor] 268-268: Too many return statements (9/6)

(R0911)


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

(R0912)

custom_components/sat/climate.py

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

(R0911)

🪛 Ruff (0.14.8)
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/area.py

251-251: Do not catch blind exception: Exception

(BLE001)

custom_components/sat/climate.py

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

(F403)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


254-254: Unused method argument: event

(ARG002)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


643-643: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


647-647: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


714-714: f-string without any placeholders

Remove extraneous f prefix

(F541)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


806-806: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

custom_components/sat/sensor.py

15-15: 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)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

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

236-250: Verify coordinator accessors used in cycle listeners are values (not methods).

These pass self._coordinator.state, self._coordinator.cycles, self._coordinator.last_cycle. If any of those are methods (vs @property), you’ll be passing callables into DynamicMinimumSetpoint and breaking classification logic. Please double-check the coordinator API shape on your branch.

Comment on lines 141 to 153
async def async_added_to_hass(self, hass: HomeAssistant) -> None:
"""Run when the area is added to Home Assistant."""
self._hass = hass

async def async_control_heating_loop(self, _time=None) -> None:
if hass.state is CoreState.running:
self.update()
else:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update)

# Periodic update as a fallback when we do not have a dedicated sensor listener
self._time_interval = async_track_time_interval(
self._hass, self.update, timedelta(seconds=60)
)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t register sync update() directly as an HA event callback.

EVENT_HOMEASSISTANT_STARTED handlers receive an Event; update() expects datetime | None. Wrap it.

-            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update)
+            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update())
🤖 Prompt for AI Agents
custom_components/sat/area.py around lines 141-153: the code registers
self.update directly as the EVENT_HOMEASSISTANT_STARTED handler but that event
callback receives an Event while update() expects datetime | None, so create a
small wrapper that accepts the event and calls self.update(None) (or forwards
event.time_fired if desired) and register that wrapper instead; ensure the
wrapper has the correct signature (event) and is used in
hass.bus.async_listen_once.

Comment on lines 511 to 537
def requested_setpoint(self) -> float:
"""Get the requested setpoint based on the heating curve and PID output."""
"""Get the requested setpoint based on the heating curve and PIDs."""
if self.heating_curve.value is None:
return MINIMUM_SETPOINT

return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 1)
setpoint = self.pid.output
if self._heating_mode == HEATING_MODE_ECO:
return setpoint

# Secondary rooms: heating and overshoot information.
secondary_heating = self.areas.pids.output
overshoot_cap = self.areas.pids.overshoot_cap

if secondary_heating is not None:
if secondary_heating >= setpoint:
# Other rooms need more heat: allow some boost.
setpoint = min(secondary_heating, setpoint + 3.0)
else:
# Other rooms are satisfied sooner: allow some reduction.
setpoint = max(secondary_heating, setpoint - 2.0)

# Apply the overshoot cap so overshooting rooms can cool down.
if overshoot_cap is not None:
setpoint = min(setpoint, overshoot_cap)

return setpoint

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Call PID.output() (method) when computing requested_setpoint.

If PID.output is the method shown in custom_components/sat/pid.py, this currently assigns a function object into arithmetic/returns.

-        setpoint = self.pid.output
+        setpoint = self.pid.output()
🧰 Tools
🪛 Ruff (0.14.8)

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

(F405)


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

(F405)

🤖 Prompt for AI Agents
In custom_components/sat/climate.py around lines 511-537, the code is using
PID.output (the method) as if it were a numeric attribute; call the method
instead. Replace uses of self.pid.output and self.areas.pids.output with
self.pid.output() and self.areas.pids.output() respectively, and keep
overshoot_cap as-is; also ensure you handle None return values from those method
calls before doing arithmetic (e.g., assign to local vars and check for is not
None).

Comment on lines 255 to 264
@property
def available(self):
"""Return availability of the sensor."""
return self._climate.extra_state_attributes.get("heating_curve") is not None
return self._climate.heating_curve.value is not None

@property
def native_value(self) -> float:
"""Return the state of the device in native units.
In this case, the state represents the current heating curve value.
"""
return self._climate.extra_state_attributes.get("heating_curve")
"""Return the state of the device in native units."""
return self._climate.heating_curve.value

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Align SatHeatingCurveSensor.native_value with optional curve values.

self._climate.heating_curve.value can be None, so the property type should allow None (or return a fallback).

-    def native_value(self) -> float:
+    def native_value(self) -> float | None:
         """Return the state of the device in native units."""
         return self._climate.heating_curve.value
🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 255 to 264, native_value
currently types and returns float but self._climate.heating_curve.value can be
None; change native_value to return Optional[float] and simply return
self._climate.heating_curve.value (which may be None) so the type matches the
possible None value, and add the appropriate Optional import from typing (or
return a defined fallback numeric value if you prefer a non-None contract).
Ensure signatures and any callers/annotations are updated to accept
Optional[float].

Comment on lines 297 to 301
@property
def native_value(self) -> float:
"""Return the state of the device in native units.
In this case, the state represents the current error value.
"""
return self._climate.extra_state_attributes.get("error")
"""Return the state of the device in native units."""
return self._climate.error.value

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Guard SatErrorValueSensor.native_value when climate error is None.

-    def native_value(self) -> float:
+    def native_value(self) -> float | None:
         """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
🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 297-301, native_value currently
returns self._climate.error.value without checking if self._climate.error is
None which will raise an AttributeError; change the implementation to first
check if self._climate.error is None and return None in that case, otherwise
return the error value. Also update the return type hint to Optional[float]
(import typing.Optional) to reflect that None may be returned.

Copy link
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: 3

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

266-352: Confirm on_cycle_end() runs once per completed cycle; otherwise add “already processed cycle” guard.

This code will tune every time on_cycle_end() is invoked. If upstream can call it multiple times with the same last_cycle (e.g., “last completed cycle” stays constant for a while), you’ll repeat the same adjustment.


28-35: Fix inverted increase_step / decrease_step comments + use low_load_maximum_duty_ratio_15m.

The comments don’t match _maybe_tune_minimum() (overshoot increases, underheat decreases). Also low_load_maximum_duty_ratio_15m is currently unused, so tuning can run outside “genuine low load”.

@@
-    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)
+    increase_step: float = 1.0  # when minimum is too low (overshoot / short-cycling)
+    decrease_step: float = 1.0  # when minimum is too high (underheat / too short)
@@
     def _is_tunable_regime(self, boiler_state: BoilerState, statistics: CycleStatistics) -> bool:
@@
         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

114-136: Remove the extraneous f prefix in the starvation log (it’s not formatting anything).

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

138-156: async_save_regimes() coroutine is dropped; regimes may never persist.

on_cycle_end() is sync but calls an async function without awaiting/scheduling it.

One minimal fix: store hass in async_added_to_hass() and schedule the task.

 class DynamicMinimumSetpoint:
     def __init__(self, config: MinimumSetpointConfig) -> None:
@@
         self._last_requested_setpoint: Optional[float] = None
+        self._hass: HomeAssistant | None = None
@@
     def on_cycle_end(self, boiler_state: BoilerState, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: Optional[float], outside_temperature: Optional[float]) -> None:
@@
-        self.async_save_regimes()
+        if self._hass is not None:
+            self._hass.async_create_task(self.async_save_regimes())
         self._last_requested_setpoint = requested_setpoint
@@
     async def async_added_to_hass(self, hass: HomeAssistant, device_id: str) -> None:
+        self._hass = hass
         if self._store is None:
             self._store = Store(hass, STORAGE_VERSION, f"sat.minimum_setpoint.{device_id}")

220-234: _regime_for(...) return type should not be Optional.

It always returns a RegimeState (creating one if missing).

-    def _regime_for(self, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: float, outside_temperature: Optional[float]) -> Optional[RegimeState]:
+    def _regime_for(self, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: float, outside_temperature: Optional[float]) -> RegimeState:
custom_components/sat/sensor.py (3)

87-141: Fix implicit Optional + guard _pid is None in native_value / extra_state_attributes.

Right now native_value and extra_state_attributes can throw if _pid is None, and area_id: str = None is an implicit Optional.

-class SatPidSensor(SatClimateEntity, SensorEntity):
-    def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None):
+class SatPidSensor(SatClimateEntity, SensorEntity):
+    def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str | None = None):
@@
     @property
-    def native_value(self) -> float:
-        return self._pid.output
+    def native_value(self) -> float | None:
+        if self._pid is None:
+            return None
+        return self._pid.output
@@
     @property
     def extra_state_attributes(self) -> Mapping[str, Any] | None:
+        if self._pid is None:
+            return None
         return {
             "proportional": self._pid.proportional,
             "integral": self._pid.integral,
             "derivative": self._pid.derivative,
         }

255-264: Align SatHeatingCurveSensor.native_value with optional curve values (and guard heating_curve itself).

     @property
     def available(self):
         """Return availability of the sensor."""
-        return self._climate.heating_curve.value is not None
+        return self._climate.heating_curve is not None and self._climate.heating_curve.value is not None
@@
     @property
-    def native_value(self) -> float:
+    def native_value(self) -> float | None:
         """Return the state of the device in native units."""
-        return self._climate.heating_curve.value
+        return self._climate.heating_curve.value if self._climate.heating_curve is not None else None

297-306: Guard SatErrorValueSensor.native_value when error is None (HA may access value even if unavailable).

     @property
-    def native_value(self) -> float:
+    def native_value(self) -> float | None:
         """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
🧹 Nitpick comments (1)
custom_components/sat/minimum_setpoint.py (1)

235-255: Avoid hard-coded thresholds in _make_regime_key(); use config for consistency.

Right now load banding uses 4.5 / 0.4, but tunability uses low_load_minimum_cycles_per_hour (and should use low_load_maximum_duty_ratio_15m too). Using the same config thresholds will make regime bucketing and tuning decisions align.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dff8ab6 and b4ad6e1.

📒 Files selected for processing (2)
  • custom_components/sat/minimum_setpoint.py (1 hunks)
  • custom_components/sat/sensor.py (7 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/boiler.py (1)
  • BoilerState (21-35)
custom_components/sat/const.py (2)
  • CycleClassification (188-195)
  • CycleKind (181-185)
custom_components/sat/cycles.py (4)
  • CycleStatistics (72-77)
  • Cycle (42-68)
  • duty_ratio_last_15m (130-143)
  • sample_count_4h (111-117)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/sensor.py (7)
custom_components/sat/climate.py (2)
  • SatClimate (79-1101)
  • name (389-391)
custom_components/sat/coordinator.py (2)
  • get (409-424)
  • last_cycle (158-159)
custom_components/sat/entity.py (1)
  • SatEntity (19-38)
custom_components/sat/pid.py (1)
  • available (118-120)
custom_components/sat/minimum_setpoint.py (1)
  • value (88-106)
custom_components/sat/cycles.py (1)
  • last_cycle (161-165)
custom_components/sat/const.py (2)
  • CycleClassification (188-195)
  • BoilerStatus (198-218)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py

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

(R0902)


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

(R1731)


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

(R0913)


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

(R0917)


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

(R0913)


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

(R0917)


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

(R0913)


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

(R0917)


[refactor] 266-266: Too many return statements (9/6)

(R0911)


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

(R0912)

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

114-114: Unused method argument: boiler_state

(ARG002)


136-136: f-string without any placeholders

Remove extraneous f prefix

(F541)

custom_components/sat/sensor.py

15-15: 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)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

Comment on lines +411 to +412
def _clamp_setpoint(self, value: float) -> float:
return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Lower clamp bound ignores MinimumSetpointConfig.minimum_setpoint.

_clamp_setpoint() clamps low to COLD_SETPOINT, but the config explicitly provides minimum_setpoint. If these differ, you can violate config bounds (or config becomes misleading).

     def _clamp_setpoint(self, value: float) -> float:
-        return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint)
+        return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint)
📝 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 _clamp_setpoint(self, value: float) -> float:
return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint)
def _clamp_setpoint(self, value: float) -> float:
return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint)
🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around lines 411-412,
_clamp_setpoint currently uses the hardcoded COLD_SETPOINT as the lower bound
which ignores MinimumSetpointConfig.minimum_setpoint; change the clamp call to
use self._config.minimum_setpoint as the lower bound (e.g., clamp(value,
self._config.minimum_setpoint, self._config.maximum_setpoint)) and ensure you
validate/normalize that minimum_setpoint <= maximum_setpoint before clamping to
avoid inverted bounds.

Comment on lines 43 to +55
_async_add_entities([
SatFlameSensor(coordinator, _config_entry),
SatCycleSensor(coordinator, _config_entry),
SatBoilerSensor(coordinator, _config_entry),
SatManufacturerSensor(coordinator, _config_entry),
SatPidSensor(coordinator, _config_entry, climate),
SatErrorValueSensor(coordinator, _config_entry, climate),
SatRequestedSetpoint(coordinator, _config_entry, climate),
SatHeatingCurveSensor(coordinator, _config_entry, climate),
])

for entity_id in _config_entry.data.get(CONF_ROOMS) or []:
_async_add_entities([SatPidSensor(coordinator, _config_entry, climate, entity_id)])

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor: rename loop variable entity_idarea_id to avoid confusion with HA entity_ids.

This is just readability (these look like room/area identifiers, not HA entity ids).

🧰 Tools
🪛 Ruff (0.14.8)

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

(F405)

🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 43 to 55, the loop uses the
variable name `entity_id` which is misleading because it refers to room/area
identifiers, not Home Assistant entity_ids; rename the loop variable to
`area_id` and update its usage inside the loop to pass `area_id` into
SatPidSensor to improve readability and avoid confusion.

Comment on lines +63 to +85
class SatRequestedSetpoint(SatClimateEntity, SensorEntity):

@property
def name(self) -> str:
return f"Requested Setpoint {self._config_entry.data.get(CONF_NAME)}"

@property
def device_class(self) -> SensorDeviceClass:
return SensorDeviceClass.TEMPERATURE

@property
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
return UnitOfTemperature.CELSIUS

@property
def native_value(self) -> float:
return self._climate.requested_setpoint

@property
def unique_id(self) -> str:
return f"{self._config_entry.data.get(CONF_NAME).lower()}-requested-setpoint"

Copy link
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:

# First, let's explore the repository structure
find . -type f -name "*.py" | grep -E "(sensor|climate|sat)" | head -20

Repository: Alexwijn/SAT

Length of output: 958


🏁 Script executed:

# Let's look at the full sensor.py file to understand context
wc -l custom_components/sat/sensor.py

Repository: Alexwijn/SAT

Length of output: 91


🏁 Script executed:

# Get the parent class definition
find . -type f -name "*.py" -path "*/sat/*" | xargs grep -l "SatClimateEntity"

Repository: Alexwijn/SAT

Length of output: 164


🏁 Script executed:

# Read the entity.py file to understand SatClimateEntity
cat custom_components/sat/entity.py

Repository: Alexwijn/SAT

Length of output: 1445


🏁 Script executed:

# Read the relevant portion of sensor.py around lines 63-85
sed -n '50,100p' custom_components/sat/sensor.py

Repository: Alexwijn/SAT

Length of output: 1810


🏁 Script executed:

# Search for requested_setpoint definition across the codebase
rg "requested_setpoint" --type py -A 3 -B 3

Repository: Alexwijn/SAT

Length of output: 28635


🏁 Script executed:

# Let's find the requested_setpoint property implementation in climate.py
sed -n '/def requested_setpoint/,/^    def /p' custom_components/sat/climate.py | head -15

Repository: Alexwijn/SAT

Length of output: 629


🏁 Script executed:

# Also check what MINIMUM_SETPOINT is
rg "MINIMUM_SETPOINT\s*=" --type py -A 1 -B 1

Repository: Alexwijn/SAT

Length of output: 681


🏁 Script executed:

# Check the heating_curve.value property to understand if it can be None
rg "class.*HeatingCurve|def value.*:" custom_components/sat/ --type py -A 3 -B 1 | head -50

Repository: Alexwijn/SAT

Length of output: 1520


🏁 Script executed:

# Let's see the full requested_setpoint implementation
sed -n '/def requested_setpoint/,/^    @property\|^    def [a-z]/p' custom_components/sat/climate.py | head -30

Repository: Alexwijn/SAT

Length of output: 1119


🏁 Script executed:

# Check how other sensors handle unique_id in this file
rg "def unique_id" custom_components/sat/sensor.py -A 2 -B 2

Repository: Alexwijn/SAT

Length of output: 1511


🏁 Script executed:

# Search for CONF_NAME usage to understand if it's meant to be stable
rg "CONF_NAME" custom_components/sat/ --type py -B 2 -A 2

Repository: Alexwijn/SAT

Length of output: 20179


🏁 Script executed:

# Let's check if there's a config_entry.entry_id that could be used instead
rg "config_entry.entry_id" custom_components/sat/ --type py

Repository: Alexwijn/SAT

Length of output: 1403


🏁 Script executed:

# Check what the device identifiers use
rg "identifiers=\{" custom_components/sat/ --type py -A 1 -B 1

Repository: Alexwijn/SAT

Length of output: 285


Fix unique_id to use stable identifier instead of user-configurable CONF_NAME.

The unique_id derived from CONF_NAME can churn when users rename the config, breaking entity tracking and automations in Home Assistant. Use self._config_entry.entry_id instead, e.g., f"{self._config_entry.entry_id}-requested-setpoint".

Note: native_value type annotation is correct as-is—requested_setpoint always returns a float (has fallback to MINIMUM_SETPOINT), never None.

🧰 Tools
🪛 Ruff (0.14.8)

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)

🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 63 to 85, the unique_id
currently uses the user-editable CONF_NAME which can change and break entity
tracking; replace it to use the config entry's stable entry_id (e.g.
f"{self._config_entry.entry_id}-requested-setpoint") so the entity ID stays
stable across renames, leaving other properties (native_value typing) unchanged.

…ance PID logic, optimize valve and overshoot calculations, and improve heating arbitration.
Copy link
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/area.py (1)

155-167: Potential issue: Event handler signature mismatch.

Line 162 registers self.update directly as the EVENT_HOMEASSISTANT_STARTED handler, but:

  • update signature (line 187): def update(self, _now: datetime | None = None)
  • Event handlers receive an Event object, not datetime | None

While the parameter is ignored (prefixed with _), passing an Event where datetime | None is expected violates type contracts and could cause issues if the method is later refactored to use the parameter.

Apply this fix:

         if hass.state is CoreState.running:
-            self.update()
+            self.update(None)
         else:
-            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update)
+            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update(None))
🧹 Nitpick comments (1)
custom_components/sat/climate.py (1)

711-711: Optional: Remove unnecessary f-string prefix.

The f-string has no interpolation placeholders, so the f prefix is unnecessary.

Apply this diff:

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

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b4ad6e1 and 706ed29.

📒 Files selected for processing (3)
  • custom_components/sat/area.py (3 hunks)
  • custom_components/sat/climate.py (21 hunks)
  • custom_components/sat/const.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
custom_components/sat/area.py (5)
custom_components/sat/errors.py (3)
  • Errors (5-33)
  • Error (37-40)
  • max (29-33)
custom_components/sat/heating_curve.py (4)
  • HeatingCurve (11-92)
  • update (27-30)
  • value (91-92)
  • reset (21-25)
custom_components/sat/helpers.py (1)
  • float_value (60-69)
custom_components/sat/pid.py (6)
  • PID (16-240)
  • output (100-105)
  • available (118-120)
  • update (139-152)
  • update_integral (154-183)
  • reset (122-137)
custom_components/sat/coordinator.py (3)
  • state (138-151)
  • get (409-424)
  • update (43-48)
custom_components/sat/climate.py (6)
custom_components/sat/area.py (17)
  • Areas (199-338)
  • errors (210-213)
  • get (220-226)
  • state (64-69)
  • async_control_heating_loop (175-185)
  • async_added_to_hass (155-167)
  • async_added_to_hass (236-239)
  • items (232-234)
  • ids (228-230)
  • error (111-119)
  • pids (216-218)
  • heating_aggregate (255-283)
  • overshoot_cap (286-316)
  • update (187-196)
  • update (322-331)
  • reset (318-320)
  • async_control_heating_loops (241-244)
custom_components/sat/const.py (2)
  • PWMStatus (221-224)
  • RelativeModulationState (227-231)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/manufacturers/geminox.py (1)
  • Geminox (4-7)
custom_components/sat/summer_simmer.py (2)
  • SummerSimmer (5-61)
  • index (7-36)
custom_components/sat/minimum_setpoint.py (3)
  • async_added_to_hass (157-198)
  • value (88-106)
  • reset (108-112)
🪛 Pylint (4.0.4)
custom_components/sat/climate.py

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

(R0911)

🪛 Ruff (0.14.8)
custom_components/sat/area.py

266-266: Do not catch blind exception: Exception

(BLE001)

custom_components/sat/climate.py

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

(F403)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


254-254: Unused method argument: event

(ARG002)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


640-640: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


644-644: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports

(F405)


711-711: f-string without any placeholders

Remove extraneous f prefix

(F541)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


803-803: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

🔇 Additional comments (9)
custom_components/sat/const.py (2)

24-29: LGTM! PWM hysteresis margins and float precision look good.

The PWM enable/disable margins (0.5°C and 1.5°C) establish a proper hysteresis band to prevent oscillation when operating near the dynamic minimum setpoint. The explicit float precision for MINIMUM/MAXIMUM_SETPOINT ensures consistent type handling.


175-239: LGTM! Cycle-centric enums and events are well-structured.

The new events (EVENT_SAT_CYCLE_STARTED, EVENT_SAT_CYCLE_ENDED) and enums (CycleKind, CycleClassification, BoilerStatus, RelativeModulationState) provide a comprehensive type-safe API for the cycle-centric architecture. The UNHEALTHY_CYCLES tuple properly references the problematic classification values.

custom_components/sat/climate.py (4)

209-262: LGTM! Lifecycle wiring looks correct.

The lifecycle methods properly register event listeners and coordinate startup/shutdown:

  • Conditional registration based on CoreState.running prevents race conditions
  • Event listeners for cycle start/end properly pass parameters to minimum_setpoint
  • async_will_remove_from_hass accepts Optional[Event] parameter, making it compatible with async_listen_once
  • Cleanup properly saves state and removes coordinators

511-533: LGTM! Multi-zone heating arbitration logic is well-designed.

The requested_setpoint property implements a sophisticated multi-zone control strategy:

  1. Base setpoint from main PID controller
  2. ECO mode returns base setpoint directly
  3. COMFORT mode considers secondary rooms via Areas.pids.heating_aggregate
  4. Applies scaled headroom (2°C + 0.7°C per additional area) to balance responsiveness
  5. Enforces overshoot cap when rooms are above target

This percentile-based aggregation with headroom is a solid approach for multi-zone systems.


578-648: Excellent PWM enablement logic with proper hysteresis.

The refactored PWM logic is much clearer:

  • Lines 604-606: Correctly handles forced PWM for relay-only systems
  • Lines 608-613: Static PWM uses BOILER_DEADBAND hysteresis to prevent flapping
  • Lines 615-648: Dynamic PWM considers cycle health, boiler stall detection, and margin-based hysteresis
  • Lines 640-648: Hysteresis band (PWM_ENABLE_MARGIN_CELSIUS vs PWM_DISABLE_MARGIN_CELSIUS) prevents rapid state changes

The separation into helper methods improves testability and readability.


855-960: LGTM! Control loops properly orchestrate PID, PWM, and multi-zone heating.

The control flow is well-structured:

async_control_pid (855-886):

  • Guards against missing outside temperature and error
  • Resets PID on stale sensor data
  • Updates heating curve and performs autotuning in deadband
  • Properly updates PID with error and curve value

schedule_control_heating_loop (887-903):

  • Debounces with 10-second delay
  • Force flag allows immediate execution
  • Correctly uses async_create_task (line 896)

async_control_heating_loop (905-960):

  • Validates all required values upfront
  • Smooths requested setpoint with low-pass filter (α=0.4)
  • Orchestrates PWM, Areas, coordinator, and setpoint control
  • Clamps setpoint to valid range
custom_components/sat/area.py (3)

44-153: LGTM! Area properties are well-designed with proper guards.

The Area class properties handle edge cases gracefully:

Lines 94-108 (valve_position):

  • Properly checks for attribute presence (line 99-100)
  • Uses defensive float_value() helper with try/except
  • Logs invalid values for debugging

Lines 111-119 (error):

  • Returns Optional[Error] for consistent error handling
  • Guards against None temperatures

Lines 141-153 (requires_heat):

  • Prefers physical valve signal when available (≥10% open)
  • Falls back to PID output vs COLD_SETPOINT comparison
  • Clean two-tier arbitration logic

254-283: LGTM! Percentile-based heating aggregation is robust.

The heating_aggregate property implements a sophisticated multi-zone arbitration:

  1. Lines 260-271: Collects PID outputs only from areas calling for heat
  2. Lines 273-274: Returns early with output=None when no areas need heat
  3. Lines 276-278: Computes percentile baseline (default 75th) to avoid outliers dominating
  4. Lines 280-281: Applies headroom above baseline, capped at maximum demand
  5. Line 283: Returns aggregate with area count for further decision logic

The try/except (line 264-268) catches errors during PID computation, preventing one bad area from breaking the entire system.

Note on static analysis: The generic Exception catch (line 266) is appropriate here for defensive programming across potentially diverse PID implementations.


318-338: LGTM! PID helper methods have proper guards.

The reset and update methods correctly handle edge cases:

Lines 318-320 (reset):

  • Simple iteration to reset all area PIDs

Lines 322-331 (update):

  • Line 324: Walrus operator properly parenthesized (area := ...)
  • Lines 327-328: Guards against None error or heating curve value before calling pid.update
  • Line 331: Calls with correct parameters

Lines 333-338 (_get_area):

  • Simple helper for entity lookup

- Remove SetpointAdjuster
- Add areas options flow step with per-room weight slider and translations
- Introduce AreasSnapshot and per-room demand weighting; aggregate secondary PID output directly
- Improve coordinator update debouncing (separate control updates vs HA listener notifications)
- Enhance PID debug logging with output values
- Add helper utilities: filter_none, average, min_max
- Persist minimum setpoint value and expand regime key using areas snapshot; tune overshoot/underheat adjustments
- Refactor cycle sampling to track min/max setpoint and simplify kind determination; use max setpoint for classification
- Minor cleanup fixes (timestamp defaulting, PWM debug log)
Copy link
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/climate.py (1)

400-435: Potential AttributeError if self.error is None.

Line 403 accesses self.error.value without checking if error is None. The error property (lines 456-464) returns None when temperatures are unavailable. This would cause an AttributeError during state serialization.

🔎 Apply this diff to handle None safely:
     def extra_state_attributes(self):
         """Return device state attributes."""
+        error = self.error
         return {
-            "error": self.error.value,
+            "error": error.value if error is not None else None,
             "integral": self.pid.integral,
custom_components/sat/coordinator.py (1)

326-332: Boiler.async_added_to_hass is never called - modulation reliability won't persist.

The coordinator creates self._boiler = Boiler() at line 104, but never invokes self._boiler.async_added_to_hass(hass, device_id). This means:

  1. The storage for modulation reliability is never initialized
  2. The periodic save interval is never registered
  3. async_save_options at line 332 will early-return because _store is None
🔎 Suggested fix in `async_added_to_hass`:
     async def async_added_to_hass(self) -> None:
         """Perform setup when the integration is added to Home Assistant."""
+        await self._boiler.async_added_to_hass(self.hass, self.device_id)
         await self.async_set_control_max_setpoint(self.maximum_setpoint)
♻️ Duplicate comments (10)
custom_components/sat/pwm.py (1)

48-51: Potential negative off_time when maximum_cycles is high remains unaddressed.

The threshold derivation at lines 50-51 allows _on_time_maximum_threshold to be smaller than _on_time_lower_threshold (180s) when maximum_cycles exceeds ~20. This causes negative off_time at line 177 (self._on_time_maximum_threshold - self._on_time_lower_threshold).

🔎 Apply this diff to enforce sane thresholds:
         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_maximum_threshold: float = max(
+            self._on_time_upper_threshold * 2,
+            self._on_time_lower_threshold + 60  # Ensure at least 60s off-time possible
+        )
custom_components/sat/minimum_setpoint.py (4)

165-170: on_cycle_end increments completed_cycles on every call without guarding against re-processing the same cycle.

The method increments regime_state.completed_cycles and calls _maybe_tune_minimum each time it's invoked. Since the event listener in climate.py fires on_cycle_end for each EVENT_SAT_CYCLE_ENDED, but the same last_cycle object remains in CycleHistory until a new cycle completes, multiple invocations for the same physical cycle could occur if the event fires more than once.

Consider tracking the last processed cycle to prevent duplicate processing:

🔎 Suggested guard pattern:
+        # Only process each cycle once
+        if last_cycle.end == getattr(self, '_last_processed_cycle_end', None):
+            return
+        self._last_processed_cycle_end = last_cycle.end
+
         # Mark a cycle as completed.
         regime_state.completed_cycles += 1

170-170: async_save_regimes() coroutine is dropped (regimes won't persist).

Line 170 calls self.async_save_regimes() without await, dropping the coroutine. The _store is set in async_added_to_hass, but there's no hass reference stored to schedule the task.

🔎 Apply this pattern to fix persistence:
 class DynamicMinimumSetpoint:
     def __init__(self, config: MinimumSetpointConfig) -> None:
         self._config = config
         self._store: Optional[Store] = None
+        self._hass: Optional[HomeAssistant] = None
         ...

     async def async_added_to_hass(self, hass: HomeAssistant, device_id: str) -> None:
+        self._hass = hass
         if self._store is None:
             self._store = Store(hass, STORAGE_VERSION, f"sat.minimum_setpoint.{device_id}")
         ...

     def on_cycle_end(...) -> None:
         ...
-        self.async_save_regimes()
+        if self._hass is not None:
+            self._hass.async_create_task(self.async_save_regimes())

476-477: Lower clamp bound ignores MinimumSetpointConfig.minimum_setpoint.

_clamp_setpoint() uses COLD_SETPOINT as the lower bound, but the config explicitly provides minimum_setpoint. If these differ, config bounds can be violated.

🔎 Suggested fix:
     def _clamp_setpoint(self, value: float) -> float:
-        return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint)
+        return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint)

32-34: Comments for increase_step and decrease_step are inverted.

Based on the implementation in _maybe_tune_minimum:

  • TOO_SHORT_UNDERHEAT/LONG_UNDERHEATdecreases minimum_setpoint (lines 392-394)
  • TOO_SHORT_OVERSHOOT/SHORT_CYCLING_OVERSHOOTincreases minimum_setpoint (lines 403-405)

The comments should reflect:

  • increase_step: applied when minimum is too low (overshoot/short-cycling)
  • decrease_step: applied when minimum is too high (underheat)
custom_components/sat/boiler.py (2)

117-127: Unsubscribe handle for periodic save not stored - will continue after removal.

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.

🔎 Suggested fix:
+        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))

And in cleanup:

async def async_will_remove_from_hass(self) -> None:
    if self._save_unsub is not None:
        self._save_unsub()
        self._save_unsub = None
    await self.async_save_options()

369-381: Modulation reliability detection only marks as unreliable, never recovers.

Once _modulation_reliable is set to False (line 381), there's no path to restore it to True. If the boiler reports stuck modulation temporarily (e.g., during startup) but later reports varying values, reliability won't be restored until Home Assistant restarts.

🔎 Suggested recovery logic:
+        # Check for recovery: if we see sufficient variation, restore reliability
+        if not self._modulation_reliable:
+            if len(self._modulation_values_when_flame_on) >= self._modulation_reliability_min_samples:
+                values = self._modulation_values_when_flame_on
+                if max(values) - min(values) >= self._modulation_delta_threshold:
+                    self._modulation_reliable = True
+                    _LOGGER.info("Modulation reliability restored after detecting variation.")
+            return
+
         if not self._modulation_reliable:
             return
custom_components/sat/cycles.py (1)

253-253: Fix implicit Optional type annotation.

PEP 484 prohibits implicit Optional. The timestamp parameter defaults to None but is typed as float.

🔎 Suggested fix:
-    def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: float = None) -> None:
+    def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: Optional[float] = None) -> None:
custom_components/sat/area.py (2)

188-191: Event callback signature mismatch - update() expects datetime | None, event provides Event.

Line 191 registers self.update directly as the EVENT_HOMEASSISTANT_STARTED handler. However, event callbacks receive an Event object, while update() expects datetime | None (line 216). This will cause a type mismatch at runtime.

🔎 Suggested fix:
-            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update)
+            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update())

366-370: Critical: Type error - negating Error object instead of its value.

Line 367 attempts degrees_over = -error, but Error is a dataclass with entity_id: str and value: float. It doesn't define __neg__, so this will raise TypeError: bad operand type for unary -: 'Error'.

🔎 Suggested fix:
                 # Degrees above target (positive number)
-                degrees_over = -error
+                degrees_over = -error.value
🧹 Nitpick comments (6)
custom_components/sat/config_flow.py (1)

662-688: Unused room_labels dictionary built but not displayed to user.

The room_labels dict (lines 663-667) maps entity IDs to friendly names like "Living Room (climate.living_room)", but the form schema uses raw entity_id as the field key. The user will see raw entity IDs in the UI instead of friendly names.

To show friendly labels, you'd need to use a different approach since vol.Required(entity_id) uses the key as the label. Consider using SelectSelector with options, or adding a description_placeholders parameter.

🔎 One possible approach using description placeholders:
         return self.async_show_form(
             step_id="areas",
             data_schema=vol.Schema(schema_fields),
+            description_placeholders=room_labels,
         )

Then update the translation string to reference the placeholders, or consider restructuring to use labeled selectors.

custom_components/sat/climate.py (1)

702-702: Remove extraneous f prefix from log statement.

This f-string has no {} placeholders but uses % formatting. The f prefix is unnecessary.

-        _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature)
+        _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)
custom_components/sat/const.py (1)

200-221: Consider using (str, Enum) for BoilerStatus for serialization consistency.

Other enums in this file (CycleKind, CycleClassification, PWMStatus, RelativeModulationState) inherit from (str, Enum), which enables direct JSON serialization. BoilerStatus only inherits from Enum, which may cause issues if the value is serialized to state attributes or logs.

🔎 Apply this diff for consistency:
-class BoilerStatus(Enum):
+class BoilerStatus(str, Enum):
     OFF = "off"
     IDLE = "idle"
custom_components/sat/pwm.py (1)

139-142: Redundant _enabled = False assignment.

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

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

261-261: Remove unnecessary int() cast.

round(requested_setpoint / self._config.regime_band_width) already returns an integer when called without a second argument.

🔎 Suggested fix:
-        setpoint_band = int(round(requested_setpoint / self._config.regime_band_width))
+        setpoint_band = round(requested_setpoint / self._config.regime_band_width)
custom_components/sat/area.py (1)

329-333: Avoid catching blind Exception.

Catching bare Exception can mask unexpected errors. Consider catching specific exceptions or at least logging the full traceback for debugging.

🔎 Suggested improvement:
                 try:
                     value = area.pid.output
-                except Exception as exception:
-                    _LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception)
+                except (TypeError, ValueError, AttributeError) as exception:
+                    _LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception, exc_info=True)
                     continue
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 706ed29 and b3c174a.

📒 Files selected for processing (12)
  • custom_components/sat/area.py (3 hunks)
  • custom_components/sat/boiler.py (1 hunks)
  • custom_components/sat/climate.py (22 hunks)
  • custom_components/sat/config_flow.py (3 hunks)
  • custom_components/sat/const.py (4 hunks)
  • custom_components/sat/coordinator.py (7 hunks)
  • custom_components/sat/cycles.py (1 hunks)
  • custom_components/sat/helpers.py (2 hunks)
  • custom_components/sat/minimum_setpoint.py (1 hunks)
  • custom_components/sat/pwm.py (8 hunks)
  • custom_components/sat/setpoint_adjuster.py (0 hunks)
  • custom_components/sat/translations/en.json (1 hunks)
💤 Files with no reviewable changes (1)
  • custom_components/sat/setpoint_adjuster.py
🧰 Additional context used
🧬 Code graph analysis (6)
custom_components/sat/helpers.py (2)
custom_components/sat/minimum_setpoint.py (1)
  • value (93-97)
custom_components/sat/errors.py (1)
  • max (29-33)
custom_components/sat/minimum_setpoint.py (5)
custom_components/sat/const.py (2)
  • CycleClassification (190-197)
  • CycleKind (183-187)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/area.py (5)
  • AreasSnapshot (38-42)
  • async_added_to_hass (184-196)
  • async_added_to_hass (301-304)
  • get (285-291)
  • state (84-89)
custom_components/sat/cycles.py (5)
  • CycleStatistics (72-77)
  • Cycle (42-68)
  • last_cycle (161-165)
  • duty_ratio_last_15m (130-143)
  • sample_count_4h (111-117)
custom_components/sat/climate.py (3)
  • requested_setpoint (512-533)
  • async_added_to_hass (209-253)
  • setpoint (508-509)
custom_components/sat/cycles.py (3)
custom_components/sat/const.py (3)
  • CycleKind (183-187)
  • CycleClassification (190-197)
  • PWMStatus (223-226)
custom_components/sat/helpers.py (3)
  • clamp (90-95)
  • min_max (112-118)
  • average (103-109)
custom_components/sat/boiler.py (2)
  • BoilerState (21-35)
  • update (136-152)
custom_components/sat/coordinator.py (7)
custom_components/sat/cycles.py (7)
  • CycleTracker (226-435)
  • CycleHistory (80-223)
  • CycleStatistics (72-77)
  • Cycle (42-68)
  • statistics (168-176)
  • last_cycle (161-165)
  • update (253-312)
custom_components/sat/manufacturer.py (4)
  • Manufacturer (28-39)
  • ManufacturerFactory (42-63)
  • member_id (33-34)
  • resolve_by_member_id (52-58)
custom_components/sat/manufacturers/geminox.py (1)
  • Geminox (4-7)
custom_components/sat/manufacturers/ideal.py (1)
  • Ideal (4-7)
custom_components/sat/manufacturers/intergas.py (1)
  • Intergas (4-7)
custom_components/sat/manufacturers/nefit.py (1)
  • Nefit (4-7)
custom_components/sat/const.py (1)
  • PWMStatus (223-226)
custom_components/sat/boiler.py (2)
custom_components/sat/const.py (2)
  • BoilerStatus (200-220)
  • CycleClassification (190-197)
custom_components/sat/cycles.py (2)
  • Cycle (42-68)
  • update (253-312)
custom_components/sat/area.py (5)
custom_components/sat/state.py (1)
  • State (10-12)
custom_components/sat/errors.py (3)
  • Errors (5-33)
  • Error (37-40)
  • max (29-33)
custom_components/sat/heating_curve.py (4)
  • HeatingCurve (11-92)
  • update (27-30)
  • value (91-92)
  • reset (21-25)
custom_components/sat/helpers.py (1)
  • float_value (60-69)
custom_components/sat/pid.py (5)
  • PID (16-240)
  • available (118-120)
  • output (100-105)
  • update (139-152)
  • reset (122-137)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py

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

(R0902)


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

(R0913)


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

(R0917)


[refactor] 145-145: Too many arguments (7/5)

(R0913)


[refactor] 145-145: Too many positional arguments (7/5)

(R0917)


[refactor] 260-260: Too many branches (18/12)

(R0912)


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

(R0913)


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

(R0917)


[refactor] 332-332: Too many return statements (9/6)

(R0911)

custom_components/sat/pwm.py

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

(R0902)

custom_components/sat/cycles.py

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

(R0902)


[refactor] 314-314: Too many local variables (25/15)

(R0914)


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

(R0911)

custom_components/sat/boiler.py

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

(R0902)


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

(R0902)


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

(R0913)


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

(R0917)


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

(R0911)


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

(R0912)


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

(R0911)


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

(R0911)

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

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

Remove unnecessary int call

(RUF046)

custom_components/sat/config_flow.py

558-558: CONF_ROOMS may be undefined, or defined from star imports

(F405)


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

(F405)


658-658: CONF_ROOM_WEIGHTS may be undefined, or defined from star imports

(F405)


660-660: CONF_ROOMS may be undefined, or defined from star imports

(F405)


676-676: CONF_ROOM_WEIGHTS may be undefined, or defined from star imports

(F405)

custom_components/sat/cycles.py

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)


253-253: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/coordinator.py

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

(F403)


109-109: PWMStatus may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


334-334: Unused method argument: climate

(ARG002)


334-334: PWMStatus may be undefined, or defined from star imports

(F405)


334-334: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/climate.py

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

(F403)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


255-255: Unused method argument: event

(ARG002)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


702-702: f-string without any placeholders

Remove extraneous f prefix

(F541)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


794-794: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


794-794: 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)


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

(F405)

custom_components/sat/area.py

331-331: Do not catch blind exception: Exception

(BLE001)

🔇 Additional comments (19)
custom_components/sat/helpers.py (1)

96-118: LGTM! Well-designed utility functions.

The new helper functions (filter_none, average, min_max) are clean, handle edge cases correctly (returning None or (None, None) for empty/all-None inputs), and follow good functional programming patterns. They align well with the PR's goal of supporting per-area and PID/cycle logic.

custom_components/sat/translations/en.json (1)

217-224: LGTM! Areas translations are properly integrated.

The new translation entries for the Areas configuration flow are well-written and provide clear user guidance. The description accurately explains the purpose of heating weights for multi-room control.

custom_components/sat/config_flow.py (1)

556-561: LGTM! Dynamic menu construction is correct.

The menu options are properly built based on whether rooms are configured, ensuring the Areas option only appears when relevant. The ordering (general → presets → areas → system_configuration → advanced) is logical.

custom_components/sat/climate.py (4)

253-263: LGTM! Lifecycle cleanup properly addressed from previous review.

The EVENT_HOMEASSISTANT_STOP listener now correctly passes self.async_will_remove_from_hass directly (an async callable), and async_will_remove_from_hass properly accepts an optional event parameter. This addresses the previous review concern about the lambda not awaiting the coroutine.


194-196: LGTM! Areas controller properly integrated.

The Areas controller is correctly initialized with configuration data and heating curve, and its lifecycle is properly managed through async_added_to_hass (line 233) and cleanup in async_will_remove_from_hass (lines 260-261). The PID state is appropriately reset alongside the main PID controller.


894-894: No action required—HassJob usage is correct.

Line 894 correctly passes HassJob(self.async_control_heating_loop) to async_call_later. Home Assistant's async_call_later officially supports HassJob[Awaitable[None] | None], and this pattern is consistent with Home Assistant's own codebase and best practices.


511-533: No action required — pid.output and related properties are correctly accessed without parentheses.

The code at lines 517, 524, and 525 is correct. Both self.pid.output and self.areas.pids.output are implemented as @property decorators in their respective classes (PID and Areas._PIDs), so they must be accessed without parentheses. Adding parentheses would incorrectly attempt to call a float value as a function.

custom_components/sat/const.py (3)

24-25: LGTM! PWM hysteresis margins are well-designed.

The 1.0°C hysteresis band between enable (0.5°C) and disable (1.5°C) margins will effectively prevent PWM state flapping near the minimum setpoint threshold.


177-179: LGTM! New cycle events properly defined.

The EVENT_SAT_CYCLE_STARTED and EVENT_SAT_CYCLE_ENDED events follow Home Assistant naming conventions and enable the new cycle-centric architecture to communicate state transitions.


236-241: LGTM! UNHEALTHY_CYCLES tuple cleanly groups problematic classifications.

This tuple provides a single source of truth for cycle states that should trigger PWM intervention, making the logic in climate.py more maintainable.

custom_components/sat/pwm.py (1)

230-243: LGTM!

The state property correctly encapsulates the PWM state into a frozen dataclass, with proper handling of None for last_duty_cycle_percentage.

custom_components/sat/coordinator.py (2)

391-420: Well-structured debounced update pattern.

The dual-debounce approach separating control-plane updates (boiler/cycle tracking) from UI listener notifications is a good design. Both debounces properly track cancellation handles and use async_call_later.


155-161: LGTM!

The cycles and last_cycle properties correctly delegate to the CycleHistory component, providing clean access to cycle statistics.

custom_components/sat/boiler.py (2)

154-229: Comprehensive boiler status derivation state machine.

The _derive_status method implements a well-structured state machine with appropriate priority ordering:

  1. Power/availability checks first
  2. Short-cycling detection from last cycle
  3. Flame-off scenarios (overshoot cooling, anti-cycling, stalled ignition, etc.)
  4. Flame-on scenarios (DHW, temperature-based phases, modulation direction)

The fallback to CENTRAL_HEATING when specific conditions aren't met is a safe default.


231-295: Well-factored helper predicates for state evaluation.

The helper methods (_demand_present, _is_pump_starting, _is_post_cycle_settling, _is_in_overshoot_cooling, _is_anti_cycling) are clean, single-purpose predicates with appropriate None guards. This makes the _derive_status logic much more readable.

custom_components/sat/cycles.py (2)

397-435: Well-designed cycle classification logic.

The _classify_cycle method handles edge cases appropriately:

  • Insufficient data returns INSUFFICIENT_DATA
  • Missing flow temperature returns UNCERTAIN
  • PWM OFF status adjusts short threshold to avoid false positives
  • Short-cycling detection uses both frequency and duty ratio context

119-127: LGTM!

The cycles_last_hour property correctly normalizes the cycle count by the window duration, extrapolating to a per-hour rate regardless of the configured window size.

custom_components/sat/area.py (2)

319-348: LGTM - PID output aggregation logic.

The output property correctly uses area.pid.output which is a @property (not a method) per pid.py lines 99-104. The percentile-based baseline with headroom capping is a reasonable approach for multi-zone arbitration.


249-283: Well-implemented areas snapshot computation.

The snapshot property correctly:

  • Filters to active areas requiring heat
  • Handles None demand weights gracefully
  • Computes mean and sum with proper rounding
  • Returns a frozen dataclass for immutability

Comment on lines +387 to +396
def update(self, entity_id: str) -> None:
"""Update the PID controller for a specific area."""
if (area := self._areas.get(entity_id)) is None:
return

if area.error is None or area.heating_curve.value is None:
return

_LOGGER.info("Updating PID for %s with error=%s", area.id, area.error.value)
area.pid.update(area.error, area.heating_curve.value)
Copy link
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._areas.get(entity_id) will fail - list has no get() method.

self._areas is a list[Area] (passed from Areas._areas), but line 389 calls self._areas.get(entity_id) which will raise AttributeError: 'list' object has no attribute 'get'.

🔎 Suggested fix - add a helper method:
+        def _get_area(self, entity_id: str) -> Optional[Area]:
+            for area in self._areas:
+                if area.id == entity_id:
+                    return area
+            return None
+
         def update(self, entity_id: str) -> None:
             """Update the PID controller for a specific area."""
-            if (area := self._areas.get(entity_id)) is None:
+            if (area := self._get_area(entity_id)) is None:
                 return
📝 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 update(self, entity_id: str) -> None:
"""Update the PID controller for a specific area."""
if (area := self._areas.get(entity_id)) is None:
return
if area.error is None or area.heating_curve.value is None:
return
_LOGGER.info("Updating PID for %s with error=%s", area.id, area.error.value)
area.pid.update(area.error, area.heating_curve.value)
def _get_area(self, entity_id: str) -> Optional[Area]:
for area in self._areas:
if area.id == entity_id:
return area
return None
def update(self, entity_id: str) -> None:
"""Update the PID controller for a specific area."""
if (area := self._get_area(entity_id)) is None:
return
if area.error is None or area.heating_curve.value is None:
return
_LOGGER.info("Updating PID for %s with error=%s", area.id, area.error.value)
area.pid.update(area.error, area.heating_curve.value)
🤖 Prompt for AI Agents
In custom_components/sat/area.py around lines 387 to 396, the code calls
self._areas.get(entity_id) but self._areas is a list, causing AttributeError;
replace the invalid call with a proper lookup: either add/use a helper on the
owning Areas class that returns an Area by id (e.g., Areas.get_by_id(entity_id))
or perform a list search (find the first area with matching id and return None
if not found), then keep the subsequent error/null checks and PID update as-is;
ensure you update any callers if you add a new helper method.

await self._boiler.async_save_options()

async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMState] = None, _time=None) -> None:
async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix implicit Optional type annotation and remove unused parameter.

PEP 484 prohibits implicit Optional. The timestamp parameter defaults to None but is typed as float. Additionally, climate is never used.

🔎 Suggested fix:
-    async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None:
+    async def async_control_heating_loop(self, pwm_status: Optional[PWMStatus] = None, timestamp: Optional[float] = None) -> None:

Note: If climate is needed for future use, consider prefixing with _ to indicate intentionally unused.

📝 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
async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None:
async def async_control_heating_loop(self, pwm_status: Optional[PWMStatus] = None, timestamp: Optional[float] = None) -> None:
🧰 Tools
🪛 Ruff (0.14.8)

334-334: Unused method argument: climate

(ARG002)


334-334: PWMStatus may be undefined, or defined from star imports

(F405)


334-334: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

🤖 Prompt for AI Agents
In custom_components/sat/coordinator.py around line 334, the method signature
uses implicit Optional for parameters and types are inconsistent: `timestamp`
defaults to None but is typed as float, and `climate` is unused. Fix by making
the annotation explicit and consistent (e.g., `timestamp: Optional[float] =
None`), and either remove the unused `climate` parameter or rename it to
`_climate` to indicate intentional non‑use; update any call sites if you remove
the parameter.

Comment on lines +301 to +302
cycle_state = self._build_cycle_state(pwm_status, timestamp)
self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle_state})
Copy link
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:

#!/bin/bash
# Search for the repository structure
find . -name "cycles.py" -type f 2>/dev/null | head -5

Repository: Alexwijn/SAT

Length of output: 89


🏁 Script executed:

#!/bin/bash
# Find and read cycles.py around lines 301-302
if [ -f "custom_components/sat/cycles.py" ]; then
  echo "=== Lines 295-310 of cycles.py ==="
  sed -n '295,310p' "custom_components/sat/cycles.py"
  echo ""
  echo "=== Searching for asdict and serialization patterns ==="
  rg -n "asdict|to_dict|EVENT_SAT_CYCLE_ENDED" "custom_components/sat/cycles.py" -A 3 -B 3
else
  echo "File not found at expected location"
  find . -name "cycles.py" 2>/dev/null
fi

Repository: Alexwijn/SAT

Length of output: 1393


🏁 Script executed:

#!/bin/bash
# Search for Cycle dataclass definition
rg -n "class Cycle|@dataclass" "custom_components/sat/cycles.py" -A 10 | head -30

Repository: Alexwijn/SAT

Length of output: 887


🏁 Script executed:

#!/bin/bash
# Check the full Cycle class definition and find imports
rg -n "class Cycle:|CycleKind|CycleClassification|BoilerState" "custom_components/sat/cycles.py" -B 5 -A 20 | head -80

Repository: Alexwijn/SAT

Length of output: 3068


🏁 Script executed:

#!/bin/bash
# Check imports to see where CycleKind comes from
head -30 "custom_components/sat/cycles.py"

Repository: Alexwijn/SAT

Length of output: 1046


🏁 Script executed:

#!/bin/bash
# Check const.py for enum definitions
rg -n "class CycleKind|class CycleClassification|class BoilerState|class Cycle" "custom_components/sat/const.py" -A 5

Repository: Alexwijn/SAT

Length of output: 479


🏁 Script executed:

#!/bin/bash
# Check if there are any existing serialization patterns or to_dict methods
rg -n "asdict|\.to_dict|_serialize|json.dumps|EVENT_SAT_CYCLE_STARTED" "custom_components/sat/cycles.py" -B 2 -A 2

Repository: Alexwijn/SAT

Length of output: 452


🏁 Script executed:

#!/bin/bash
# Check the const file for BoilerState to see if it's an enum
rg -n "class BoilerState|class PWMStatus" "custom_components/sat/const.py" -A 5

Repository: Alexwijn/SAT

Length of output: 157


🏁 Script executed:

#!/bin/bash
# Check boiler.py for BoilerState definition
rg -n "class BoilerState" "custom_components/sat/boiler.py" -A 10

Repository: Alexwijn/SAT

Length of output: 382


🏁 Script executed:

#!/bin/bash
# Check if there are any custom serialization patterns in the codebase
rg -n "import asdict|from dataclasses import|JSONEncoder" "custom_components/sat/" -r 'found' 2>/dev/null | head -20

Repository: Alexwijn/SAT

Length of output: 416


🏁 Script executed:

#!/bin/bash
# Final verification - check if asdict is imported or used anywhere
rg -n "asdict" "custom_components/sat/"

Repository: Alexwijn/SAT

Length of output: 38


Convert Cycle dataclass to dict before firing event.

Line 302 passes cycle_state (a Cycle dataclass) directly to the event bus. Home Assistant requires all event data to be JSON-serializable. Use dataclasses.asdict() to convert the dataclass:

from dataclasses import asdict

cycle_state = self._build_cycle_state(pwm_status, timestamp)
self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle_state)})
🤖 Prompt for AI Agents
In custom_components/sat/cycles.py around lines 301 to 302, the code fires an
event with cycle_state which is a Cycle dataclass and not JSON-serializable;
convert the dataclass to a dict before firing the event by importing
dataclasses.asdict and passing asdict(cycle_state) in the event payload so the
event data is JSON-serializable by Home Assistant.

self._active_regime_key = regime_key
return regime_state

def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str:
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Type annotation mismatch: outside_temperature should be Optional[float].

The parameter is typed as float but line 263 checks if outside_temperature is None, indicating it can be None.

🔎 Suggested fix:
-    def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str:
+    def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: Optional[float], areas_snapshot: Optional["AreasSnapshot"]) -> str:
📝 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 _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str:
def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: Optional[float], areas_snapshot: Optional["AreasSnapshot"]) -> str:
🧰 Tools
🪛 Pylint (4.0.4)

[refactor] 260-260: Too many branches (18/12)

(R0912)

🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around line 260, the parameter
outside_temperature is annotated as float but is treated as possibly None
(checked with `if outside_temperature is None`), so update the type annotation
to Optional[float] (and import Optional where needed) to match runtime behavior
and static typing; ensure any callers/overrides still accept Optional[float] and
run type checks.

Comment on lines 307 to 317
def regime_distance(key: str) -> tuple[int, int]:
parts_a = key.split(":")
parts_b = new_regime_key.split(":")

setpoint_a = int(parts_a[0])
setpoint_b = int(parts_b[0])

outside_a = int(parts_a[1]) if len(parts_a) > 1 else 0
outside_b = int(parts_b[1]) if len(parts_b) > 1 else 0

return abs(setpoint_a - setpoint_b), abs(outside_a - outside_b)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

regime_distance will raise ValueError parsing temperature band as int.

The regime key format is "{setpoint_band}:{temperature_band}:{load_band}:..." (e.g., "10:cold:normal:..."). Lines 314-315 attempt int(parts_a[1]) which will fail on strings like "cold", causing the min() call to raise an exception.

🔎 Suggested fix - use only setpoint band for distance:
             def regime_distance(key: str) -> tuple[int, int]:
                 parts_a = key.split(":")
                 parts_b = new_regime_key.split(":")

                 setpoint_a = int(parts_a[0])
                 setpoint_b = int(parts_b[0])

-                outside_a = int(parts_a[1]) if len(parts_a) > 1 else 0
-                outside_b = int(parts_b[1]) if len(parts_b) > 1 else 0
+                # Temperature band is a string, not parseable as int
+                # Use string equality check instead
+                temp_match = 0 if (len(parts_a) > 1 and len(parts_b) > 1 and parts_a[1] == parts_b[1]) else 1

-                return abs(setpoint_a - setpoint_b), abs(outside_a - outside_b)
+                return abs(setpoint_a - setpoint_b), temp_match
🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around lines 307 to 317,
regime_distance currently tries to cast parts[1] to int which will raise
ValueError for keys where the second segment is a string (e.g., "cold"); change
the function to compute distance using only the first segment (setpoint band) —
parse parts[0] and parts_b[0] to int and return their absolute difference (and
drop or ignore parsing of parts[1] altogether), so min() won't encounter parsing
exceptions.

Copy link
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 (3)
custom_components/sat/minimum_setpoint.py (3)

484-485: Clamp ignores configured minimum—uses hardcoded constant instead.

_clamp_setpoint() clamps to COLD_SETPOINT but the config explicitly provides minimum_setpoint. If these values differ, the configured bound is silently violated and the config becomes misleading.

🔎 Proposed fix
     def _clamp_setpoint(self, value: float) -> float:
-        return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint)
+        return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint)

Based on learnings, this issue was flagged in a previous review and remains unresolved.


170-170: Critical: Coroutine dropped without await—regimes won't persist.

Line 170 calls self.async_save_regimes() but doesn't await or schedule it. Since on_cycle_end is synchronous, the coroutine is immediately discarded and regimes never get saved to storage.

Store hass as an instance variable in async_added_to_hass, then schedule the save task:

🔎 Proposed fix
 class DynamicMinimumSetpoint:
     def __init__(self, config: MinimumSetpointConfig) -> None:
         self._config = config
         self._store: Optional[Store] = None
+        self._hass: Optional[HomeAssistant] = None
         self._value: Optional[float] = None
         # ...

     async def async_added_to_hass(self, hass: HomeAssistant, device_id: str) -> None:
+        self._hass = hass
         if self._store is None:
             self._store = Store(hass, STORAGE_VERSION, f"sat.minimum_setpoint.{device_id}")
         # ...

     def on_cycle_end(
             self,
             boiler_state: BoilerState,
             cycles: CycleStatistics,
             last_cycle: Cycle,
             requested_setpoint: Optional[float],
             outside_temperature: Optional[float],
             areas_snapshot: Optional[AreasSnapshot]
     ) -> None:
         # ... existing logic ...
 
-        self.async_save_regimes()
+        if self._hass is not None:
+            self._hass.async_create_task(self.async_save_regimes())
 
         self._value = regime_state.minimum_setpoint
         self._last_requested_setpoint = requested_setpoint

Based on learnings, this critical issue was flagged in a previous review and remains unresolved.


32-39: Comments remain inverted, and parameter still unused.

Despite being marked as addressed in past reviews, two issues persist:

  1. Inverted parameter descriptions: The comments contradict the implementation in _maybe_tune_minimum:

    • Lines 400-402: TOO_SHORT_UNDERHEAT / LONG_UNDERHEAT decrease the minimum (use -=)
    • Lines 411-413: TOO_SHORT_OVERSHOOT / SHORT_CYCLING_OVERSHOOT increase the minimum (use +=)

    The comments should state:

    • increase_step: applied when minimum is too low (overshoot/short-cycling)
    • decrease_step: applied when minimum is too high (underheat)
  2. Unused parameter: low_load_maximum_duty_ratio_15m is defined but never checked in _is_tunable_regime (lines 426-440). Per the past review suggestion, add a duty ratio check to ensure tuning only runs during genuine low-load conditions.

🔎 Proposed fixes

Fix 1: Correct the comments

     # 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)
+    increase_step: float = 1.0  # when minimum is too low (overshoot / short-cycling)
+    decrease_step: float = 1.0  # when minimum is too high (underheat / too short)

Fix 2: Use the low_load_maximum_duty_ratio_15m parameter

     def _is_tunable_regime(self, boiler_state: BoilerState, statistics: CycleStatistics) -> bool:
         """Decide whether the current conditions are suitable for minimum tuning."""
         if boiler_state.hot_water_active:
             return False
 
         if boiler_state.is_inactive:
             return False
 
         if statistics.sample_count_4h < self._config.minimum_on_samples_for_tuning:
             return False
 
         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

Based on learnings, this issue was flagged in previous reviews but remains unaddressed.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b3c174a and f386e3a.

📒 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 (5)
custom_components/sat/boiler.py (1)
  • BoilerState (21-35)
custom_components/sat/const.py (2)
  • CycleClassification (190-197)
  • CycleKind (183-187)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/area.py (1)
  • AreasSnapshot (38-42)
custom_components/sat/cycles.py (4)
  • CycleStatistics (72-77)
  • Cycle (42-68)
  • duty_ratio_last_15m (130-143)
  • sample_count_4h (111-117)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py

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

(R0902)


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

(R0913)


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

(R0917)


[refactor] 145-145: Too many arguments (7/5)

(R0913)


[refactor] 145-145: Too many positional arguments (7/5)

(R0917)


[refactor] 260-260: Too many branches (18/12)

(R0912)


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

(R0913)


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

(R0917)


[refactor] 340-340: Too many return statements (9/6)

(R0911)

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

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

Remove unnecessary int call

(RUF046)

Comment on lines +260 to +261
def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str:
setpoint_band = int(round(requested_setpoint / self._config.regime_band_width))
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix type annotation and remove unnecessary cast.

Two minor issues:

  1. Line 260: outside_temperature is typed as float but line 263 checks if outside_temperature is None. The type should be Optional[float].

  2. Line 261: The int() call is unnecessary since round() already returns an int in Python 3.

🔎 Proposed fixes
-    def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str:
-        setpoint_band = int(round(requested_setpoint / self._config.regime_band_width))
+    def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: Optional[float], areas_snapshot: Optional["AreasSnapshot"]) -> str:
+        setpoint_band = round(requested_setpoint / self._config.regime_band_width)

Based on learnings, the type annotation issue was flagged in a previous review.

🧰 Tools
🪛 Pylint (4.0.4)

[refactor] 260-260: Too many branches (18/12)

(R0912)

🪛 Ruff (0.14.8)

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

Remove unnecessary int call

(RUF046)

🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around lines 260 to 261, the
outside_temperature parameter should be Optional[float] because code checks for
None, and the int() wrapper around round() is redundant in Python 3; update the
signature to use Optional[float] and remove the int() call so setpoint_band =
round(requested_setpoint / self._config.regime_band_width).

Copy link
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 (5)
custom_components/sat/minimum_setpoint.py (5)

32-38: Config fields increase_step, decrease_step, and low_load_maximum_duty_ratio_15m are inconsistently used or unused.

  1. increase_step is only used in on_cycle_start (line 135) for starvation recovery, but _maybe_tune_minimum uses hardcoded clamp(1.0 + 0.3 * magnitude, 1.0, 2.5) instead of these step values.

  2. decrease_step appears completely unused.

  3. low_load_maximum_duty_ratio_15m is defined but never checked in _is_tunable_regime.

Consider either using these config values consistently or removing them to avoid confusion.


248-249: Fix type annotation and remove unnecessary int() cast.

  1. outside_temperature is typed as float but line 251 checks if outside_temperature is None. Should be Optional[float].

  2. round() already returns an int in Python 3, so the int() wrapper is redundant (per static analysis hint RUF046).

🔎 Proposed fix
-    def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str:
-        setpoint_band = int(round(requested_setpoint / self._config.regime_band_width))
+    def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: Optional[float], areas_snapshot: Optional["AreasSnapshot"]) -> str:
+        setpoint_band = round(requested_setpoint / self._config.regime_band_width)

388-401: Magnitude calculations are inverted—adjustments will always be 1.0.

The formulas measure the wrong direction:

  • Line 389 (UNDERHEAT): In underheat cycles, max_flow_temperature < max_setpoint, so max_flow - (setpoint + margin) is always negative → max(0.0, negative) = 0.0

  • Line 400 (OVERSHOOT): In overshoot cycles, max_flow_temperature > max_setpoint, so (setpoint - margin) - max_flow is always negative → max(0.0, negative) = 0.0

Both magnitude values are always zero, making adjustments always clamp(1.0 + 0.0, 1.0, 2.5) = 1.0.

🔎 Proposed fix - swap the formulas
         if classification in (CycleClassification.TOO_SHORT_UNDERHEAT, CycleClassification.LONG_UNDERHEAT):
-            magnitude = max(0.0, last_cycle.max_flow_temperature - (last_cycle.max_setpoint + OVERSHOOT_MARGIN_CELSIUS))
+            magnitude = max(0.0, (last_cycle.max_setpoint - UNDERSHOOT_MARGIN_CELSIUS) - last_cycle.max_flow_temperature)
             regime_state.minimum_setpoint -= clamp(1.0 + 0.3 * magnitude, 1.0, 2.5)
 
         # ...
         elif classification in (CycleClassification.TOO_SHORT_OVERSHOOT, CycleClassification.SHORT_CYCLING_OVERSHOOT):
-            magnitude = max(0.0, (last_cycle.max_setpoint - UNDERSHOOT_MARGIN_CELSIUS) - last_cycle.max_flow_temperature)
+            magnitude = max(0.0, last_cycle.max_flow_temperature - (last_cycle.max_setpoint + OVERSHOOT_MARGIN_CELSIUS))
             regime_state.minimum_setpoint += clamp(1.0 + 0.3 * magnitude, 1.0, 2.5)

451-452: Lower clamp bound ignores MinimumSetpointConfig.minimum_setpoint.

_clamp_setpoint() uses the hardcoded COLD_SETPOINT constant as the lower bound, but the config explicitly provides minimum_setpoint. This makes the config field misleading and could allow values outside configured bounds.

🔎 Proposed fix
     def _clamp_setpoint(self, value: float) -> float:
-        return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint)
+        return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint)

77-88: Missing _hass attribute initialization in __init__.

self._hass is assigned in async_added_to_hass (line 171) but never declared in __init__. If on_cycle_end is called before async_added_to_hass, line 163 will raise AttributeError.

🔎 Proposed fix
     def __init__(self, config: MinimumSetpointConfig) -> None:
 
         self._config = config
         self._store: Optional[Store] = None
         self._value: Optional[float] = None
+        self._hass: Optional[HomeAssistant] = None
 
         # Learned per-regime minimum setpoints.
         self._regimes: Dict[str, RegimeState] = {}
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6e9855d and 26325b8.

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

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

(R0902)


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

(R0913)


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

(R0917)


[refactor] 141-141: Too many arguments (7/5)

(R0913)


[refactor] 141-141: Too many positional arguments (7/5)

(R0917)


[refactor] 248-248: Too many branches (18/12)

(R0912)


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

(R0913)


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

(R0917)


[refactor] 328-328: Too many return statements (9/6)

(R0911)

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

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

Remove unnecessary int call

(RUF046)

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

102-139: LGTM on starvation detection logic.

The on_cycle_start method correctly calculates starvation thresholds and updates the regime minimum when the boiler has been off too long with demand. The guard conditions are appropriate.


170-212: Robust storage loading with good error handling.

The async_added_to_hass method handles storage loading defensively—checking version, validating dict structure, and using try/except for type conversions. This prevents crashes from corrupted or legacy storage data.


293-326: Regime distance calculation now correctly uses temperature band ordering.

The regime_distance function properly uses the temperature_band_order dictionary to convert temperature bands to integers for comparison, fixing the previously reported ValueError issue.

Comment on lines +163 to +164
if self._hass is not None:
self._hass.create_task(self.async_save_regimes())
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use async_create_task instead of create_task for Home Assistant.

Home Assistant's HomeAssistant object uses async_create_task() for scheduling coroutines from sync 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())
         else:
             _LOGGER.debug("Cannot save minimum setpoint regimes: hass not set")
🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around lines 163 to 164, the code
calls self._hass.create_task(...) from sync context; replace this with
self._hass.async_create_task(self.async_save_regimes()) to use Home Assistant's
supported API for scheduling coroutines from sync code, keeping the existing
self._hass is not None guard intact.

Copy link
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 (2)
custom_components/sat/area.py (2)

191-191: Event callback signature mismatch persists.

EVENT_HOMEASSISTANT_STARTED handlers receive an Event object, but update() expects datetime | None. While this won't crash (Python won't enforce the type), it's semantically incorrect.

🔎 Suggested fix
-            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update)
+            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update())

387-396: Critical: self._areas.get(entity_id) will fail - list has no get() method.

self._areas is a list[Area] (passed from Areas._areas), but line 389 calls .get(entity_id) which will raise AttributeError: 'list' object has no attribute 'get' at runtime.

🔎 Suggested fix - use a helper method or inline search
+        def _get_area(self, entity_id: str) -> Optional[Area]:
+            for area in self._areas:
+                if area.id == entity_id:
+                    return area
+            return None
+
         def update(self, entity_id: str) -> None:
             """Update the PID controller for a specific area."""
-            if (area := self._areas.get(entity_id)) is None:
+            if (area := self._get_area(entity_id)) is None:
                 return

Alternatively, reuse Areas.get() by passing the Areas instance to _PIDs:

-        def __init__(self, areas, percentile: float = 0.75, headroom: float = 5.0):
-            self._areas = areas
+        def __init__(self, areas_container: "Areas", percentile: float = 0.75, headroom: float = 5.0):
+            self._areas_container = areas_container
+            self._areas = areas_container._areas

Then use self._areas_container.get(entity_id).

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

48-48: Unused parameter _config_data.

The _config_data parameter is declared but never used in the constructor. Consider removing it if not needed, or document why it's kept for future use.


124-128: Redundant exception handling around float_value.

The float_value helper already handles TypeError and ValueError internally and returns None on failure. The try/except block here is unnecessary.

🔎 Suggested simplification
-        try:
-            return float_value(raw_value)
-        except (TypeError, ValueError):
-            _LOGGER.debug("Invalid valve position for %s: %r", self.id, raw_value)
-            return None
+        result = float_value(raw_value)
+        if result is None:
+            _LOGGER.debug("Invalid valve position for %s: %r", self.id, raw_value)
+        return result

329-333: Avoid catching blind Exception.

The broad except Exception catch can mask unexpected errors. Since pid.output is a property that performs arithmetic operations, consider catching more specific exceptions or removing the try/except if the property is expected to always succeed when pid.available is True.

🔎 Suggested fix
-            try:
-                value = area.pid.output
-            except Exception as exception:
-                _LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception)
-                continue
+            value = area.pid.output

If defensive handling is needed, catch specific arithmetic exceptions:

-            except Exception as exception:
+            except (TypeError, ValueError, ZeroDivisionError) as exception:

314-317: Consider adding type annotation for areas parameter.

Adding a type hint improves code clarity and IDE support.

-        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):
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 26325b8 and 09e62e0.

📒 Files selected for processing (1)
  • custom_components/sat/area.py (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/area.py (5)
custom_components/sat/state.py (1)
  • State (10-12)
custom_components/sat/errors.py (3)
  • Errors (5-33)
  • Error (37-40)
  • max (29-33)
custom_components/sat/helpers.py (1)
  • float_value (60-69)
custom_components/sat/pid.py (6)
  • PID (16-240)
  • available (118-120)
  • output (100-105)
  • update (139-152)
  • update_integral (154-183)
  • reset (122-137)
custom_components/sat/climate.py (3)
  • target_temperature (446-448)
  • current_temperature (438-443)
  • error (456-464)
🪛 Ruff (0.14.8)
custom_components/sat/area.py

331-331: Do not catch blind exception: Exception

(BLE001)

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

37-42: Clean dataclass design.

Good use of frozen=True for immutability and slots=True for memory efficiency.


216-225: LGTM!

Proper null guards before PID update, and informative debug logging.


249-283: LGTM!

Clean snapshot computation with proper handling of empty/missing data cases.


350-381: LGTM!

The overshoot cap calculation correctly uses error.value and implements sensible clamping logic.


170-182: LGTM!

Good layered approach: valve position takes precedence when available, with PID-based fallback properly guarded by pid.available.

Copy link
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 (1)
custom_components/sat/climate.py (1)

702-702: Remove extraneous f-string prefix.

This f-string has no {} placeholders but uses % formatting. The f prefix is incorrect here.

🔎 Proposed fix
-        _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature)
+        _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 09e62e0 and 3759fb8.

📒 Files selected for processing (1)
  • custom_components/sat/climate.py (22 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/climate.py (4)
custom_components/sat/area.py (6)
  • Areas (228-396)
  • get (285-291)
  • async_added_to_hass (184-196)
  • async_added_to_hass (301-304)
  • items (297-299)
  • ids (293-295)
custom_components/sat/const.py (3)
  • PWMStatus (223-226)
  • RelativeModulationState (229-233)
  • BoilerStatus (200-220)
custom_components/sat/helpers.py (2)
  • clamp (90-95)
  • float_value (60-69)
custom_components/sat/manufacturers/geminox.py (1)
  • Geminox (4-7)
🪛 Ruff (0.14.8)
custom_components/sat/climate.py

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

(F403)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


255-255: Unused method argument: event

(ARG002)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


702-702: f-string without any placeholders

Remove extraneous f prefix

(F541)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


794-794: COLD_SETPOINT may be undefined, or defined from star imports

(F405)


794-794: 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)


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

(F405)

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

30-47: Imports look appropriate for the refactor.

The new imports for Areas, Error, helper functions, and event tracking align with the cycle-centric architecture changes.


273-286: Good use of async_track_state_report_event for sensor updates.

Using state report events instead of state change events is appropriate for sensors that report the same value periodically, ensuring the control loop receives consistent updates.


568-572: Good defensive null checks added.

The added None checks for current_temperature and target_temperature prevent potential TypeErrors during float conversion.


604-639: Well-structured PWM decision logic with hysteresis.

The helper methods (_is_pwm_forced, _should_enable_static_pwm, _should_enable_dynamic_pwm) improve readability. The hysteresis band between PWM_ENABLE_MARGIN_CELSIUS and PWM_DISABLE_MARGIN_CELSIUS prevents oscillation.


720-730: Room temperature tracking logic is correct.

The conditional update properly handles both new entities and temperature changes when in PRESET_HOME mode.


846-876: PID control logic is well-structured.

Good defensive programming with null checks, sensor staleness detection, and proper separation of heating curve updates from PID updates. The autotune logic only activating within the deadband is sensible.


916-924: Setpoint smoothing logic is appropriate.

Bypassing the low-pass filter when PWM is enabled ensures precise setpoint control during PWM cycles. The clamp to [MINIMUM_SETPOINT, maximum_setpoint] prevents out-of-range values.


456-464: Error property handles null cases correctly.

Returning None when temperatures are unavailable prevents downstream arithmetic errors.


838-844: Control state reset consolidates cleanup logic.

Resetting PID controllers, minimum setpoint, and the last requested setpoint in one place ensures consistent state after mode/target changes.


255-263: Cleanup logic correctly handles both direct calls and event callbacks.

The optional event parameter allows this method to be used both as a direct call (e.g., entity removal) and as an event handler for EVENT_HOMEASSISTANT_STOP. The unused argument is intentional.


346-346: Good use of float_value for safe state restoration.

Using the float_value helper prevents crashes from invalid stored state values.


517-528: Verify pid.output and areas.pids.output are properties, not methods.

A past review flagged that PID.output might be a method requiring (). If these are methods rather than properties, the code assigns function objects instead of numeric values, breaking arithmetic operations.


236-251: Verify on_cycle_start and on_cycle_end are synchronous methods.

The lambda callbacks at lines 236-251 call minimum_setpoint.on_cycle_start() and on_cycle_end() synchronously. If these methods are async, the coroutines won't be awaited and will silently fail. Check the method signatures to confirm they are sync methods; if async, they must be awaited or wrapped properly for event handlers.


894-894: HassJob with async functions is supported — no changes needed.

HassJob correctly handles coroutine functions by detecting them via inspect.iscoroutinefunction() and only rejects actual coroutine objects. The pattern at line 894 wraps an async function (not a coroutine object) in HassJob, which is the correct usage and fully compatible with async_call_later.


126-126: Verify _sensors dictionary usage.

The _sensors dictionary is initialized but doesn't appear to be used elsewhere in the visible code. Confirm this is intentional or remove if dead code.

Comment on lines 402 to +403
return {
"error": self.max_error.value,
"error_pid": self.pid.last_error,
"error_source": self.max_error.entity_id,

"error": self.error.value,
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Potential AttributeError when error is None.

The error property can return None (lines 461-462), but this code unconditionally accesses .value on it, which will raise AttributeError: 'NoneType' object has no attribute 'value'.

🔎 Proposed fix
     def extra_state_attributes(self):
         """Return device state attributes."""
+        error_value = self.error.value if self.error is not None else None
         return {
-            "error": self.error.value,
+            "error": error_value,
             "integral": self.pid.integral,
🤖 Prompt for AI Agents
custom_components/sat/climate.py lines 402-403: the code unconditionally
accesses self.error.value which will raise AttributeError when self.error is
None; guard the access by returning a safe default when error is None (for
example return None or an empty string) — implement this by checking if
self.error is truthy before reading .value or use getattr(self.error, "value",
None) so the dict returns a safe value instead of raising.

Comment on lines +776 to +780
elif not self.pulse_width_modulation_enabled or self.pwm.status == PWMStatus.IDLE:
# Normal cycle without PWM
self._setpoint = self._calculated_setpoint
self._setpoint = self._last_requested_setpoint
_LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.")
_LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._calculated_setpoint)
_LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._last_requested_setpoint)
Copy link
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 None formatting error in setpoint logging.

If _last_requested_setpoint is None (e.g., before the first heating loop runs), line 780 will fail with TypeError when formatting %.1f.

🔎 Proposed fix
         elif not self.pulse_width_modulation_enabled or self.pwm.status == PWMStatus.IDLE:
             # Normal cycle without PWM
             self._setpoint = self._last_requested_setpoint
             _LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.")
-            _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._last_requested_setpoint)
+            if self._last_requested_setpoint is not None:
+                _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._last_requested_setpoint)

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