Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@Alexwijn
Copy link
Owner

@Alexwijn Alexwijn commented Nov 23, 2025

Summary by CodeRabbit

  • New Features

    • Per-room weight UI and per-room PID sensors; requested‑setpoint sensor; cycle detection/history with cycle started/ended events; dynamic regime-aware minimum setpoint; richer boiler status/modulation reporting; observable PWM config/state; Areas translations.
  • Bug Fixes

    • Safer handling of missing/invalid sensors/values; improved control stability; stricter minimum‑setpoint enforcement; clearer availability and status reporting.
  • Refactor

    • Shift to cycle‑centric, event‑driven control and lifecycle; simplified PID, PWM and boiler interfaces; new helpers and utilities.
  • Removed

    • Legacy flame‑health component, 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

Walkthrough

Replaces flame-centric logic with cycle- and boiler-centric subsystems; adds DynamicMinimumSetpoint with persisted regimes and per-area room weights; refactors PID/PWM/Areas/coordinator/climate APIs and sensors; adds cycle tracking, removes flame/setpoint_adjuster/PWM service and related tests; updates storage keys and many imports/signatures.

Changes

Cohort / File(s) Summary
Core lifecycle
custom_components/sat/__init__.py
Simplified setup/unload/reload, added hass.data guards, unload returns platform-unload result, preserved Sentry cleanup.
Constants & types
custom_components/sat/const.py
Replaced flame model with cycle/boiler enums (CycleKind, CycleClassification, BoilerStatus, RelativeModulationState), added PWM margins/events, CONF_ROOM_WEIGHTS, Percentiles dataclass, adjusted MIN/MAX setpoints and removed legacy keys.
Cycle tracking
custom_components/sat/cycles.py
New cycle modelling: Cycle, CycleMetrics, CycleStatistics, CycleHistory, CycleTracker — builds/classifies cycles, emits start/end events, exposes rolling metrics.
Boiler state machine
custom_components/sat/boiler.py
New Boiler class with rich status derivation, flame-transition tracking, modulation reliability, Home Assistant Store persistence, and update(state,last_cycle,timestamp) API.
Coordinator & simulator
custom_components/sat/coordinator.py, custom_components/sat/simulator/__init__.py
Coordinator now composes Boiler/CycleTracker/History, exposes last_cycle, debounced notifications; async_control_heating_loop signature changed to accept ControlLoopSample and timestamp; simulator signature updated accordingly.
Areas & per-area PID
custom_components/sat/area.py
Area/Areas accept HeatingCurve, per-area room_weight, valve_position(), update(), async lifecycle hooks; Areas._PIDs aggregates PID outputs, overshoot cap and reset/update helpers.
PID controller
custom_components/sat/pid.py
PID.init simplified (removed legacy params); exposes diagnostics/properties (proportional/integral/derivative/output/availability); simplified update/restore.
Dynamic minimum setpoint
custom_components/sat/minimum_setpoint.py
Added DynamicMinimumSetpoint with MinimumSetpointConfig and RegimeState, regime bucketing/tuning, persistence and async lifecycle hooks; replaces previous fixed minimum controller.
PWM & modulation
custom_components/sat/pwm.py, custom_components/sat/relative_modulation.py
Introduced PWMConfig and PWMState; PWM.state returns PWMState (includes ended_on_phase, last_duty_cycle_percentage); relative modulation enum moved to const; constructor/signature changes.
Climate integration
custom_components/sat/climate.py
SatClimate integrates dynamic minimum setpoint, new PID/PWM flows, async lifecycle methods (async_will_remove_from_hass), control methods (async_control_pid, async_control_heating_loop), reworked event/state-report wiring and attributes.
Sensors & binary sensors
custom_components/sat/sensor.py, custom_components/sat/binary_sensor.py
Flame sensors → cycle sensors; added SatPidSensor and SatRequestedSetpoint; sensors now read cycle/boiler/PID attributes; unique_id/name/availability adjusted.
Utilities & helpers
custom_components/sat/util.py, custom_components/sat/heating_curve.py, custom_components/sat/helpers.py
Factory functions produce DynamicMinimumSetpoint and PWMConfig; HeatingCurve typing refined; new helpers added (filter_none, average, min_max, percentile_interpolated).
Config flow & translations
custom_components/sat/config_flow.py, custom_components/sat/translations/en.json
Options flow adds per-room areas step and translations; removed legacy version selectors; UI exposes per-room weight controls.
Services & MQTT
custom_components/sat/services.py, custom_components/sat/mqtt/__init__.py
Removed PWM service and registration/schema; standardized MQTT Store key to sat.mqtt.{device_id}; pruned related imports.
Removed modules & tests
custom_components/sat/flame.py, custom_components/sat/setpoint_adjuster.py, tests/test_climate.py
Deleted flame health module, setpoint adjuster, and climate tests.
Misc / imports & typing
custom_components/sat/esphome/__init__.py, custom_components/sat/serial/*, custom_components/sat/mqtt/*, custom_components/sat/overshoot_protection.py, custom_components/sat/serial/sensor.py
Many import/source changes moving DeviceState and enums to const, fire-and-forget notification scheduling (hass.async_create_task), typing adjustments (Optional annotations), and small API signature tweaks—review call sites for updated types/params.

Sequence Diagram(s)

sequenceDiagram
    participant SatClimate as SatClimate
    participant Coordinator as SatDataUpdateCoordinator
    participant Boiler as Boiler
    participant Cycles as CycleTracker
    participant Areas as Areas._PIDs
    participant PID as PID
    participant PWM as PWM
    Note over SatClimate,Coordinator: Control tick / setpoint application
    SatClimate->>Coordinator: async_control_heating_loop(ControlLoopSample, timestamp)
    Coordinator->>Boiler: device_state.update(state, last_cycle, timestamp)
    Coordinator->>Cycles: cycle_tracker.update(control_sample)
    Cycles-->>Coordinator: emit EVENT_SAT_CYCLE_STARTED/ENDED
    Coordinator->>SatClimate: notify listeners (debounced)
    SatClimate->>Areas: async_control_heating_loop(timestamp)
    Areas->>PID: update(entity_id)
    PID-->>Areas: per-area output
    Areas->>SatClimate: aggregated output (Areas._PIDs.output)
    SatClimate->>PWM: enable/disable/update(requested_setpoint, boiler_state)
    SatClimate->>Coordinator: async_set_control_setpoint(new_setpoint)
Loading
sequenceDiagram
    participant SatClimate
    participant MinSP as DynamicMinimumSetpoint
    participant HAStore as HomeAssistant Store
    Note over MinSP,HAStore: Regime persistence lifecycle
    SatClimate->>MinSP: async_added_to_hass(hass, device_id)
    MinSP->>HAStore: restore regimes (load)
    Note right of MinSP: on_cycle_start/on_cycle_end adjust regimes
    MinSP->>HAStore: async_save_regimes() (periodic or shutdown)
    SatClimate->>MinSP: async_will_remove_from_hass()
    MinSP->>HAStore: async_save_regimes()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.24% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Refactor heating control to cycle-centric architecture' accurately and clearly describes the main change: a refactoring of the heating control system to use a cycle-centric architecture.
✨ Finishing touches
  • 📝 Generate docstrings

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

❤️ Share

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

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

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.

Corrected the short_threshold_seconds assignment for PWM IDLE and ON states to align with expected behavior. This ensures proper handling of overshoot and underheat conditions based on the duty cycle and target thresholds.
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)

396-431: Potential AttributeError when error is None.

The error property (lines 452-460) returns None when target_temperature or current_temperature is unavailable. Line 399 unconditionally accesses .value on it, which will crash.

🔎 Proposed fix
     @property
     def extra_state_attributes(self):
         """Return device state attributes."""
+        error_obj = self.error
         return {
-            "error": self.error.value,
+            "error": error_obj.value if error_obj is not None else None,
             "integral": self.pid.integral,
♻️ Duplicate comments (9)
custom_components/sat/cycles.py (2)

140-140: Minor: Replace EN DASH with standard hyphen in docstring.

The docstring uses an EN DASH () instead of a standard ASCII hyphen-minus (-). While this doesn't affect functionality, the standard hyphen improves tooling compatibility.

🔎 Proposed fix
-"""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."""

290-293: Critical: Convert Cycle dataclass to JSON-serializable dict before firing event.

Home Assistant's event bus requires event data to be JSON-serializable for persistence and logging. The Cycle dataclass contains nested dataclasses (CycleTailMetrics, Percentiles) and enums (CycleKind, CycleClassification) which are not directly JSON-serializable.

🔎 Proposed fix
+from dataclasses import asdict
+
 # ...

 cycle = self._build_cycle_state(pwm_state=pwm_state, end_time=timestamp)
 if cycle is not None:
-    self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle})
+    self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle)})
     self._history.record_cycle(cycle)

Note: asdict() recursively converts nested dataclasses. Enums will be converted to their string values automatically.

custom_components/sat/pwm.py (1)

49-59: Critical: High maximum_cycles can produce negative off_time.

The threshold calculations can produce invalid duty times when maximum_cycles is large:

_on_time_upper_threshold = 3600 / max(1, maximum_cycles)  # Line 51
_on_time_maximum_threshold = _on_time_upper_threshold * 2   # Line 52

At Line 178, the special low-duty case computes:

off_time = self._on_time_maximum_threshold - self._on_time_lower_threshold

Example failure: If maximum_cycles = 50:

  • _on_time_upper_threshold = 72 seconds
  • _on_time_maximum_threshold = 144 seconds
  • off_time = 144 - 180 = -36 seconds

This negative duration will break state transitions and timing logic in update.

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

And at Line 178:

 on_time = self._on_time_lower_threshold
-off_time = self._on_time_maximum_threshold - self._on_time_lower_threshold
+off_time = max(0, self._on_time_maximum_threshold - self._on_time_lower_threshold)

Alternatively, constrain maximum_cycles at configuration time to ensure these invariants hold.

custom_components/sat/coordinator.py (1)

335-335: Fix parameter issues in async_control_heating_loop signature.

Two issues with the method signature:

  1. Unused parameter: climate is never referenced in the method body
  2. Implicit Optional: timestamp: float = None violates PEP 484
🔎 Proposed fix
-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: Optional[float] = None) -> None:

If climate is needed for future use, prefix it with _climate to indicate it's intentionally unused.

custom_components/sat/sensor.py (3)

87-87: Fix implicit Optional type annotation.

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

🔎 Proposed fix
-def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None):
+def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: Optional[str] = None):

122-139: Guard against None PID access in native_value and extra_state_attributes.

Home Assistant may call native_value and extra_state_attributes before checking available. When _pid is None, accessing its attributes will raise AttributeError.

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

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

Also update the return type of native_value:

-def native_value(self) -> float:
+def native_value(self) -> float | None:

297-304: Guard SatErrorValueSensor when climate error is None.

Line 299 accesses self._climate.error.value without checking if error is None, which will raise AttributeError.

🔎 Proposed fix
 @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

 @property
 def available(self):
     """Return availability of the sensor."""
     return self._climate.error is not None
custom_components/sat/climate.py (2)

774-778: Potential TypeError when formatting None setpoint in debug log.

If hvac_mode == HVACMode.HEAT but _last_requested_setpoint is None (e.g., before the first heating loop completes), this branch is entered because pulse_width_modulation_enabled returns False. Line 778 will then fail with TypeError: must be real number, not NoneType 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)

698-698: Remove extraneous f-string prefix.

The f-string has no {} placeholders but uses % formatting. This is confusing and flagged by static analysis.

🔎 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)
🧹 Nitpick comments (3)
custom_components/sat/heating_curve.py (1)

82-91: Import Optional explicitly to resolve F405 warnings.

The type annotations use Optional[float] but Optional is not explicitly imported. While it may be available through the star import (from .const import *), explicit imports improve clarity and avoid linter warnings.

🔎 Add explicit import
+from typing import Optional
+
 from .const import *
custom_components/sat/pid.py (1)

18-18: Consider using a config dataclass to reduce parameter count.

The __init__ method accepts 9 parameters, which exceeds typical guidelines (5 parameters). While acceptable for a controller class, consider grouping related parameters into a PIDConfig dataclass to improve maintainability and reduce cognitive load.

💡 Example refactor
@dataclass
class PIDConfig:
    heating_system: str
    automatic_gain_value: float
    heating_curve_coefficient: float
    derivative_time_weight: float
    kp: float
    ki: float
    kd: float
    automatic_gains: bool = False

def __init__(self, config: PIDConfig) -> None:
    self._kp = config.kp
    # ...
custom_components/sat/climate.py (1)

249-259: Shutdown handler correctly accepts event parameter for callback compatibility.

The event: Optional[Event] = None parameter is intentionally unused but required for the async_listen_once callback signature. The static analysis warning (ARG002) can be silenced with _event naming convention if desired, but this is a minor style preference.

🔎 Optional: Silence unused argument warning
-    async def async_will_remove_from_hass(self, event: Optional[Event] = None):
+    async def async_will_remove_from_hass(self, _event: Optional[Event] = None):
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d013373 and 6e0ad56.

📒 Files selected for processing (10)
  • custom_components/sat/climate.py
  • custom_components/sat/config_flow.py
  • custom_components/sat/coordinator.py
  • custom_components/sat/cycles.py
  • custom_components/sat/heating_curve.py
  • custom_components/sat/pid.py
  • custom_components/sat/pwm.py
  • custom_components/sat/sensor.py
  • custom_components/sat/serial/sensor.py
  • custom_components/sat/util.py
🧰 Additional context used
🧬 Code graph analysis (7)
custom_components/sat/pid.py (4)
custom_components/sat/area.py (2)
  • output (320-348)
  • error (131-139)
custom_components/sat/sensor.py (7)
  • available (119-120)
  • available (159-161)
  • available (200-202)
  • available (255-257)
  • available (302-304)
  • available (323-324)
  • available (358-359)
custom_components/sat/climate.py (1)
  • error (452-460)
custom_components/sat/errors.py (1)
  • Error (37-40)
custom_components/sat/sensor.py (5)
custom_components/sat/climate.py (2)
  • SatClimate (78-1087)
  • name (386-388)
custom_components/sat/coordinator.py (2)
  • get (425-440)
  • last_cycle (161-162)
custom_components/sat/entity.py (2)
  • SatClimateEntity (41-45)
  • SatEntity (19-38)
custom_components/sat/cycles.py (1)
  • last_cycle (168-176)
custom_components/sat/const.py (2)
  • CycleClassification (195-202)
  • BoilerStatus (205-225)
custom_components/sat/config_flow.py (1)
custom_components/sat/area.py (1)
  • get (285-291)
custom_components/sat/pwm.py (1)
custom_components/sat/const.py (1)
  • PWMStatus (228-231)
custom_components/sat/cycles.py (2)
custom_components/sat/const.py (5)
  • CycleClassification (195-202)
  • CycleKind (188-192)
  • PWMStatus (228-231)
  • Percentiles (251-287)
  • make_from_cycle_samples (256-287)
custom_components/sat/helpers.py (2)
  • clamp (90-95)
  • min_max (112-118)
custom_components/sat/coordinator.py (3)
custom_components/sat/cycles.py (7)
  • CycleTracker (239-511)
  • CycleHistory (94-236)
  • CycleStatistics (85-91)
  • Cycle (63-81)
  • statistics (179-187)
  • last_cycle (168-176)
  • update (265-298)
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/pwm.py (3)
  • PWMState (18-25)
  • status (236-237)
  • state (240-246)
custom_components/sat/heating_curve.py (1)
custom_components/sat/minimum_setpoint.py (1)
  • value (82-86)
🪛 Pylint (4.0.4)
custom_components/sat/pid.py

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

(R0913)


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

(R0917)

custom_components/sat/pwm.py

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

(R0902)

custom_components/sat/cycles.py

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

(R0902)


[refactor] 63-63: Too many instance attributes (12/7)

(R0902)


[refactor] 321-321: Too many local variables (20/15)

(R0914)


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

(R0913)


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

(R0917)


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

(R0911)

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

33-33: Optional may be undefined, or defined from star imports

(F405)


38-38: Optional may be undefined, or defined from star imports

(F405)


48-48: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


60-60: Optional may be undefined, or defined from star imports

(F405)


68-68: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


130-130: Optional may be undefined, or defined from star imports

(F405)


131-131: Optional may be undefined, or defined from star imports

(F405)


132-132: Optional may be undefined, or defined from star imports

(F405)


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

(F405)

custom_components/sat/sensor.py

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

(F403)


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)


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

(F405)


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

(F405)


87-87: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)


90-90: Optional 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)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

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

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

(F403)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


251-251: Unused method argument: event

(ARG002)


251-251: Optional may be undefined, or defined from star imports

(F405)


261-261: Optional may be undefined, or defined from star imports

(F405)


434-434: Optional may be undefined, or defined from star imports

(F405)


442-442: Optional may be undefined, or defined from star imports

(F405)


447-447: Optional may be undefined, or defined from star imports

(F405)


452-452: Optional may be undefined, or defined from star imports

(F405)


463-463: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


698-698: f-string without any placeholders

Remove extraneous f prefix

(F541)


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

(F405)


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

(F405)


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)


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

(F405)


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

(F405)


844-844: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


876-876: Optional may be undefined, or defined from star imports

(F405)


894-894: Optional may be undefined, or defined from star imports

(F405)


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

(F405)

custom_components/sat/cycles.py

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)

custom_components/sat/util.py

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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

custom_components/sat/coordinator.py

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

(F403)


23-23: TYPE_CHECKING may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


112-112: Callable may be undefined, or defined from star imports

(F405)


113-113: Callable may be undefined, or defined from star imports

(F405)


161-161: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


335-335: Unused method argument: climate

(ARG002)


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

(F405)


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

(F405)


335-335: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/heating_curve.py

82-82: Optional may be undefined, or defined from star imports

(F405)


86-86: Optional may be undefined, or defined from star imports

(F405)


90-90: Optional may be undefined, or defined from star imports

(F405)

🔇 Additional comments (15)
custom_components/sat/config_flow.py (1)

657-689: LGTM! Well-structured per-area configuration flow.

The implementation correctly:

  • Retrieves and persists room weights using entity IDs as stable keys
  • Provides friendly labels by looking up state names
  • Normalizes values to float on save
  • Uses appropriate slider controls (0.1-3.0 range)
custom_components/sat/util.py (1)

45-75: LGTM! Factory functions properly updated for new config types.

The signatures correctly reflect the new DynamicMinimumSetpoint, MinimumSetpointConfig, and PWMConfig types introduced by the cycle-centric refactor.

custom_components/sat/pid.py (1)

196-198: Good None-safety guard in update_derivative.

The early return when self._last_error is None prevents TypeError when computing the derivative. This is essential since _last_error is Optional[float].

custom_components/sat/coordinator.py (1)

398-421: Well-designed debounced update coalescing.

The implementation correctly separates internal control-plane updates (_control_update_cancel) from Home Assistant entity updates (_hass_notify_cancel) with appropriate debounce intervals. The dirty-flag pattern (data.is_dirty()) prevents unnecessary work.

custom_components/sat/cycles.py (1)

1-511: Well-architected cycle tracking system.

This is a comprehensive implementation of flame-cycle monitoring with:

  • Clear separation of concerns (CycleHistory for storage, CycleTracker for detection)
  • Robust windowing and pruning logic for rolling metrics
  • Detailed classification logic with multiple fault modes
  • Proper percentile-based tail metrics

The event-driven architecture integrates well with Home Assistant's ecosystem.

custom_components/sat/climate.py (10)

29-46: Import changes look appropriate for the refactor.

The new imports for EVENT_HOMEASSISTANT_STOP, EventStateReportedData, async_track_state_report_event, and the factory function changes align with the cycle-centric architecture shift.


122-126: Initialization changes for cycle-centric tracking.

The alpha smoothing factor increase from 0.2 to 0.4 will make the system more responsive to setpoint changes. The new _last_requested_setpoint field properly tracks the filtered/clamped setpoint for PWM and control decisions.


574-635: Well-structured PWM enablement logic with proper defensive checks.

The refactored pulse_width_modulation_enabled property correctly:

  • Guards against None setpoint (lines 578-579)
  • Separates concerns into _is_pwm_forced, _should_enable_static_pwm, and _should_enable_dynamic_pwm
  • Implements hysteresis (lines 627-635) to prevent flapping

844-874: PID control method has proper defensive checks.

The method correctly guards against None values for outside temperature (line 847), error computation (line 851), and heating curve (line 867) before proceeding with updates. The sensor staleness check at lines 856-857 is a good reliability improvement.


894-949: Heating control loop is well-structured with proper guards.

The method correctly:

  • Guards against missing required values (lines 900-908)
  • Applies low-pass filtering for setpoint smoothing (line 919)
  • Clamps setpoint within bounds (line 922)
  • Conditionally enables/disables PWM (lines 925-928)
  • Updates integral only when error and curve are available (lines 931-932)

256-258: Iteration pattern correctly uses items() for Area objects.

Line 256 uses self.areas.items() to iterate over Area objects (needed to call async_will_remove_from_hass), while other locations correctly use self.areas.ids() for entity ID strings. This differentiation is appropriate.


706-728: Climate change handler correctly manages area state updates.

The handler properly guards against missing state (line 710), unchanged temperatures (lines 719-720), and conditionally updates room presets while resetting the corresponding area PID (line 726).


836-842: Reset method comprehensively clears control state.

The method properly resets all relevant state: PID controllers, area PIDs, minimum setpoint, and the new _last_requested_setpoint field. This ensures clean state transitions when mode or target changes.


507-529: The pid.output, areas.pids.output, and areas.pids.overshoot_cap are all Python @property decorated methods, not callable methods. The current code correctly accesses them without parentheses. No changes are needed.

Likely an incorrect or invalid review comment.


889-892: No issue: HassJob correctly accepts async functions.

Line 892 properly uses HassJob(self.async_control_heating_loop). Home Assistant's HassJob is explicitly designed to accept async coroutine functions, which it will await in the event loop. This code will not raise a runtime error.

"""Sensor platform for SAT."""
import logging
from typing import Optional, List
from typing import List
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: Optional removed from imports but still used in the code.

Line 20 references Optional[str] but Optional is no longer imported, causing a NameError at module load time.

🔎 Proposed fixes

Option 1 (recommended): Re-add Optional to the import statement.

-from typing import List
+from typing import List, Optional

Option 2: Add from __future__ import annotations at the top of the file.

Add this as the first line of the file:

+from __future__ import annotations
 """Sensor platform for SAT."""

Option 3: Use Python 3.10+ union syntax (requires from __future__ import annotations).

First add the future import, then update line 20:

-    def __init__(self, device_class: Optional[str], unit: Optional[str], friendly_name_format: str, status_sources: List[str]):
+    def __init__(self, device_class: str | None, unit: str | None, friendly_name_format: str, status_sources: List[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
from typing import List
from typing import List, Optional
🤖 Prompt for AI Agents
In custom_components/sat/serial/sensor.py (around line 3; problematic use at
line 20), the type hint Optional[str] is referenced but Optional was removed
from the imports causing a NameError; fix by restoring Optional in the typing
import (i.e., include Optional in the from typing import ... list), or
alternatively add from __future__ import annotations at the top of the file and
update the annotation to use modern union syntax (str | None) if you prefer;
implement one of these fixes so the annotation resolves at import time.

Remove unused relaxation logic for uncertain states in `minimum_setpoint.py`, and adjust `short_threshold_seconds` calculation in `cycles.py` to enforce a minimum ON time constraint. These changes improve code clarity and correctness.
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/cycles.py (2)

292-292: Critical: Cycle dataclass must be serialized before firing event.

The event is fired with a Cycle dataclass instance that is not JSON-serializable. Past review comments indicated this was addressed, but the current code still passes the raw dataclass. Home Assistant's event bus requires JSON-serializable data for persistence and consumption by other integrations.

The Cycle dataclass contains nested dataclasses (CycleTailMetrics, Percentiles) and enums (CycleKind, CycleClassification) that cannot be directly serialized to JSON.

🔎 Proposed fix

Import asdict at the top of the file:

 from dataclasses import dataclass
+from dataclasses import asdict

Then convert the Cycle to a dict before firing the event:

             cycle = self._build_cycle_state(pwm_state=pwm_state, end_time=timestamp)
             if cycle is not None:
-                self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle})
+                self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle)})
                 self._history.record_cycle(cycle)

140-140: EN DASH character in docstring should be replaced with hyphen.

The docstring contains an EN DASH (–) which should be a standard ASCII hyphen-minus (-) for better tooling compatibility. This was flagged in previous reviews but remains unaddressed.

🔎 Proposed fix
-    """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."""
custom_components/sat/minimum_setpoint.py (2)

168-171: Use async_create_task instead of create_task.

Based on learnings, this issue was flagged in a previous review and remains unresolved. Home Assistant's HomeAssistant object uses async_create_task() for scheduling coroutines from synchronous code.

🔎 Proposed fix
         if self._hass is not None:
-            self._hass.create_task(self.async_save_regimes())
+            self._hass.async_create_task(self.async_save_regimes())
         else:
             _LOGGER.debug("Cannot save minimum setpoint regimes: hass not set")

31-39: Clarify inverted comments and use the unused parameter.

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

  1. Comments are inverted: The comments at lines 32-33 contradict the implementation in _maybe_tune_minimum:

    • Line 364: TOO_SHORT_UNDERHEAT decreases minimum using decrease_step
    • Line 374: TOO_SHORT_OVERSHOOT increases minimum using increase_step

    The comments should state: increase_step applies when minimum is too low (overshoot/short-cycling), and decrease_step applies when minimum is too high (underheat).

  2. Unused parameter: low_load_maximum_duty_ratio_15m is defined but never used. Add a check in _is_tunable_regime to ensure tuning only runs during genuine low-load conditions.

🔎 Proposed fix
     # 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)
     decrease_small_step: float = 0.5  # when minimum might be too high (good)

Add duty ratio check in _is_tunable_regime:

     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
🧹 Nitpick comments (2)
custom_components/sat/cycles.py (1)

98-105: Consider extracting validation error messages.

Ruff flags inline exception messages as a style issue (TRY003). For these simple validation checks, inline messages are clear and readable. However, if validation logic becomes more complex or messages are reused, consider extracting them as constants.

This is an optional style improvement with minimal benefit in the current context.

custom_components/sat/minimum_setpoint.py (1)

437-437: Remove redundant int() cast.

The // operator already returns an integer, making the outer int() call unnecessary.

🔎 Proposed fix
-        setpoint_band = int((requested_setpoint + (band_width / 2.0)) // band_width)
+        setpoint_band = (requested_setpoint + (band_width / 2.0)) // band_width
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6e0ad56 and be97964.

📒 Files selected for processing (2)
  • custom_components/sat/cycles.py
  • custom_components/sat/minimum_setpoint.py
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/minimum_setpoint.py (5)
custom_components/sat/const.py (2)
  • CycleClassification (195-202)
  • CycleKind (188-192)
custom_components/sat/coordinator.py (4)
  • cycles (157-158)
  • minimum_setpoint (265-267)
  • get (425-440)
  • setpoint (170-171)
custom_components/sat/area.py (2)
  • AreasSnapshot (38-42)
  • get (285-291)
custom_components/sat/cycles.py (5)
  • CycleStatistics (85-91)
  • Cycle (63-81)
  • statistics (179-187)
  • sample_count_4h (121-126)
  • median_on_duration_seconds_4h (155-165)
custom_components/sat/climate.py (2)
  • requested_setpoint (508-529)
  • setpoint (504-505)
🪛 Pylint (4.0.4)
custom_components/sat/cycles.py

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

(R0902)


[refactor] 63-63: Too many instance attributes (12/7)

(R0902)


[refactor] 321-321: Too many local variables (20/15)

(R0914)


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

(R0913)


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

(R0917)


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

(R0911)

custom_components/sat/minimum_setpoint.py

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

(R0902)


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

(R0902)


[refactor] 297-300: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)


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

(R0913)


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

(R0917)


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

(R0911)


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

(R0912)


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

(R0912)


[refactor] 511-511: Too many branches (15/12)

(R0912)

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

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)

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

321-383: Complex but well-structured method.

The _build_cycle_state method has 20 local variables and substantial logic, which Pylint flags. However, the method is cohesive and performs a single well-defined task: constructing a complete Cycle object from samples. Breaking it into smaller methods would likely reduce readability given the tight coupling of the variables.

The current implementation is clear and maintainable.

@tbrasser
Copy link

tbrasser commented Dec 29, 2025

I'm testing this, very nice! Just wanted to let you know my sat climate "current temperature" doesn't seem to update correctly (only once when I restart ha).

Could it be due to the amount of changes that some older configuration is messing things up? (I can't seem to get it to behave even after deleting/reinstalling/reconfiguring/etc).

Also the boiler status remains unavailable since a few commits/week(s) ago. And the cycle status on INSUFFICIENT_DATA (also since +- december 20th)

just hoping to provide useful info/feedback, I'm expecting there to be dragons :)

(latest versions of home assistant + esphome opentherm, trv's in all rooms except main one, 25kW remeha combi)

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

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between be97964 and 38666a6.

📒 Files selected for processing (1)
  • custom_components/sat/util.py
🧰 Additional context used
🪛 Ruff (0.14.10)
custom_components/sat/util.py

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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)

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

12-14: LGTM! Import changes align with the cycle-centric refactor.

The updated imports correctly reflect the new DynamicMinimumSetpoint and PWMConfig classes introduced in this PR.


64-75: Remove unused _config_data parameter from function signature.

The _config_data parameter is not used in the function body and should be removed from the signature at line 64. The config_options parameter already provides all required configuration values; the config_data argument is redundant.

Comment on lines 45 to 51
def create_dynamic_minimum_setpoint_controller(_config_data, config_options) -> DynamicMinimumSetpoint:
"""Create and return a Dynamic Minimum Setpoint controller instance with the given configuration options."""
# Return a new Minimum Setpoint controller instance with the given configuration options
return MinimumSetpoint(configured_minimum_setpoint=minimum_setpoint, adjustment_factor=adjustment_factor)
return DynamicMinimumSetpoint(config=MinimumSetpointConfig(
minimum_setpoint=config_options.get(CONF_MINIMUM_SETPOINT),
maximum_setpoint=config_options.get(CONF_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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check all usages of create_dynamic_minimum_setpoint_controller
rg -n 'create_dynamic_minimum_setpoint_controller' -A 3 -B 3

Repository: Alexwijn/SAT

Length of output: 1954


🏁 Script executed:

# Check CONF_MINIMUM_SETPOINT and CONF_MAXIMUM_SETPOINT usage patterns
rg -n 'CONF_MINIMUM_SETPOINT|CONF_MAXIMUM_SETPOINT' --type=py -C 2

Repository: Alexwijn/SAT

Length of output: 7890


🏁 Script executed:

# Find MinimumSetpointConfig and DynamicMinimumSetpoint definitions
fd -e py | xargs rg -l 'class MinimumSetpointConfig|class DynamicMinimumSetpoint'

Repository: Alexwijn/SAT

Length of output: 97


🏁 Script executed:

# Look for config validation patterns in the codebase
rg -n 'config\.get|config\[' custom_components/sat --type=py -A 1 | head -50

Repository: Alexwijn/SAT

Length of output: 38


🏁 Script executed:

# Read the MinimumSetpointConfig class definition
cat custom_components/sat/minimum_setpoint.py

Repository: Alexwijn/SAT

Length of output: 22349


🏁 Script executed:

# Check how other controller functions handle _config_data parameter
rg -n 'def create_' custom_components/sat/util.py -A 2

Repository: Alexwijn/SAT

Length of output: 1104


🏁 Script executed:

# Look for where config_options is prepared or validated before calling create_dynamic_minimum_setpoint_controller
rg -n 'config_options|config_entry.data' custom_components/sat/climate.py -B 5 -A 5 | head -80

Repository: Alexwijn/SAT

Length of output: 4787


🏁 Script executed:

# Check if CONF_MAXIMUM_SETPOINT is always populated when config_entry is created
rg -n 'config_entry.options\[CONF_MAXIMUM_SETPOINT\]|CONF_MAXIMUM_SETPOINT.*=' custom_components/sat/config_flow.py -B 2 -A 2

Repository: Alexwijn/SAT

Length of output: 298


🏁 Script executed:

# Look for where CONF_MAXIMUM_SETPOINT is guaranteed to be set in config
rg -n 'CONF_MAXIMUM_SETPOINT' custom_components/sat/const.py

Repository: Alexwijn/SAT

Length of output: 101


🏁 Script executed:

# Check if there's validation or defaults applied before create_dynamic_minimum_setpoint_controller is called
rg -n 'create_dynamic_minimum_setpoint_controller' custom_components/sat/climate.py -B 10 -A 5

Repository: Alexwijn/SAT

Length of output: 1680


🏁 Script executed:

# Check if CONF_MAXIMUM_SETPOINT is in OPTIONS_DEFAULTS
rg -n 'OPTIONS_DEFAULTS\s*=' custom_components/sat/const.py -A 30

Repository: Alexwijn/SAT

Length of output: 1049


🏁 Script executed:

# Check if there's any migration or setup code that ensures CONF_MAXIMUM_SETPOINT exists
rg -n 'CONF_MAXIMUM_SETPOINT' custom_components/sat/config_flow.py | head -20

Repository: Alexwijn/SAT

Length of output: 263


🏁 Script executed:

# Check how the options schema is structured and if CONF_MAXIMUM_SETPOINT is always required
rg -n 'vol.Required|vol.Optional' custom_components/sat/config_flow.py | grep -A 2 -B 2 'CONF_MAXIMUM_SETPOINT'

Repository: Alexwijn/SAT

Length of output: 607


Add default value for CONF_MAXIMUM_SETPOINT in config_options.

The function passes config_options.get(CONF_MAXIMUM_SETPOINT) directly to MinimumSetpointConfig without a default. Since CONF_MAXIMUM_SETPOINT is not in OPTIONS_DEFAULTS, it will return None if missing from config_entry.options, causing a type error when MinimumSetpointConfig receives None for a float parameter.

Use config_options.get(CONF_MAXIMUM_SETPOINT, 50) or add CONF_MAXIMUM_SETPOINT to OPTIONS_DEFAULTS to match the pattern used elsewhere (e.g., CONF_MINIMUM_SETPOINT: 10).

The unused _config_data parameter is consistent with the existing codebase pattern (e.g., create_pwm_controller).

🧰 Tools
🪛 Ruff (0.14.10)

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

(F405)


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

(F405)

🤖 Prompt for AI Agents
In custom_components/sat/util.py around lines 45 to 51, the call to
config_options.get(CONF_MAXIMUM_SETPOINT) can return None (CONF_MAXIMUM_SETPOINT
is not in OPTIONS_DEFAULTS) and will cause a type error when passed to
MinimumSetpointConfig; update the call to provide a sensible default (e.g.,
config_options.get(CONF_MAXIMUM_SETPOINT, 50)) or alternatively add
CONF_MAXIMUM_SETPOINT: 50 to OPTIONS_DEFAULTS so the value is never None; leave
the unused _config_data parameter as-is to match other factory functions.

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

332-332: Fix implicit Optional type annotation and unused climate parameter.

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

This was flagged in a previous review. Apply the suggested fix:

🔎 Suggested fix
-    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: Optional[float] = None) -> None:
custom_components/sat/cycles.py (2)

136-150: Minor: Docstring contains EN DASH character.

The docstring uses (EN DASH) instead of - (HYPHEN-MINUS). This was flagged in a previous review.

🔎 Proposed fix
-    """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."""

284-294: Add asdict import and use it to serialize the Cycle dataclass for the event bus.

The Cycle dataclass is passed directly to the event bus at line 290 without JSON serialization. While Home Assistant's event bus accepts Python objects, proper serialization is required for event logging, webhooks, and API exposure. Import asdict from dataclasses and convert the cycle object:

-from dataclasses import dataclass
+from dataclasses import asdict, dataclass

             cycle = self._build_cycle_state(pwm_state=pwm_state, end_time=timestamp)
             if cycle is not None:
-                self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle})
+                self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle)})
                 self._history.record_cycle(cycle)
🧹 Nitpick comments (1)
custom_components/sat/coordinator.py (1)

6-14: Consider explicit imports for Optional and TYPE_CHECKING.

The code relies on Optional and TYPE_CHECKING being available via the star import from .const, which is fragile. If these are removed from const.py, this file will break. Consider importing them explicitly from typing.

🔎 Suggested fix
-from typing import Mapping, Any
+from typing import Mapping, Any, Optional, TYPE_CHECKING
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 38666a6 and 7404715.

📒 Files selected for processing (2)
  • custom_components/sat/coordinator.py
  • custom_components/sat/cycles.py
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/coordinator.py (4)
custom_components/sat/boiler.py (5)
  • BoilerState (21-35)
  • Boiler (38-415)
  • status (99-103)
  • modulation_reliable (114-115)
  • update (136-152)
custom_components/sat/cycles.py (7)
  • CycleTracker (237-495)
  • CycleHistory (92-234)
  • CycleStatistics (83-89)
  • Cycle (61-79)
  • statistics (177-185)
  • last_cycle (166-174)
  • update (263-296)
custom_components/sat/manufacturer.py (3)
  • Manufacturer (28-39)
  • ManufacturerFactory (42-63)
  • member_id (33-34)
custom_components/sat/pwm.py (3)
  • PWMState (18-25)
  • status (236-237)
  • state (240-246)
🪛 Pylint (4.0.4)
custom_components/sat/cycles.py

[refactor] 61-61: Too many instance attributes (12/7)

(R0902)


[refactor] 319-319: Too many local variables (20/15)

(R0914)


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

(R0913)


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

(R0917)


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

(R0911)

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

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

(F403)


23-23: TYPE_CHECKING may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


332-332: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/cycles.py

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)

🔇 Additional comments (13)
custom_components/sat/coordinator.py (5)

102-110: LGTM!

The initialization of Boiler, CycleHistory, CycleTracker, and the debouncer configuration is well-structured. The 200ms debounce cooldown is reasonable for avoiding update spam.


137-151: LGTM!

The state property correctly constructs a BoilerState with all required fields sourced from coordinator properties and the internal _boiler instance.


153-159: LGTM!

Clean delegation to CycleHistory for cycle statistics and last cycle access.


380-384: LGTM!

The control update correctly delegates to Boiler.update() and CycleTracker.update(). Both methods handle timestamp internally via monotonic() when not provided.


386-395: LGTM!

The dirty-checking optimization prevents unnecessary listener notifications while ensuring control updates are always processed. The debouncer pattern is correctly implemented.

custom_components/sat/cycles.py (8)

1-37: LGTM!

Clean imports with explicit type hints and well-documented constants. The threshold values have clear inline comments explaining their purpose.


39-90: LGTM!

Well-designed frozen dataclasses with slots for memory efficiency. The attribute count in Cycle is appropriate for a comprehensive data container representing cycle state.


92-116: LGTM!

Good defensive validation of window parameters. The use of deque for rolling windows is appropriate for efficient append/popleft operations.


126-134: LGTM!

Correctly normalizes the cycle count to a per-hour rate by dividing by the window duration, addressing the previous review feedback.


165-174: LGTM!

Good staleness check that invalidates cycles older than 6 hours, preventing the use of outdated data.


298-317: LGTM!

The demand detection logic correctly identifies scenarios where the boiler was off but demand was present (active heating with setpoint > flow temperature). Clearing the timestamp when no demand prevents stale data accumulation.


319-380: LGTM with optional refactor consideration.

The method is well-structured despite its complexity. Each local variable serves a clear purpose in building the comprehensive Cycle object. The logical flow from sample analysis → metric extraction → classification → construction is easy to follow.


427-438: LGTM!

Clear threshold-based classification logic with mutually exclusive conditions covering all cycle kinds.

Comment on lines 261 to +264
@property
def minimum_setpoint(self) -> float:
"""Return the minimum setpoint temperature before the device starts to overshoot."""
return float(self._config_data.get(CONF_MINIMUM_SETPOINT))
return max(float(self._config_data.get(CONF_MINIMUM_SETPOINT)), MINIMUM_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

Potential TypeError if CONF_MINIMUM_SETPOINT is missing.

self._config_data.get(CONF_MINIMUM_SETPOINT) returns None if the key is absent, and float(None) raises a TypeError. Consider providing a default value.

🔎 Suggested fix
-        return max(float(self._config_data.get(CONF_MINIMUM_SETPOINT)), MINIMUM_SETPOINT)
+        return max(float(self._config_data.get(CONF_MINIMUM_SETPOINT, MINIMUM_SETPOINT)), MINIMUM_SETPOINT)
🧰 Tools
🪛 Ruff (0.14.10)

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)

🤖 Prompt for AI Agents
In custom_components/sat/coordinator.py around lines 261 to 264, the code calls
float(self._config_data.get(CONF_MINIMUM_SETPOINT)) which will raise TypeError
if the key is missing (get returns None); guard against None by supplying a safe
default or checking for None before casting — e.g. obtain the raw value via
self._config_data.get(CONF_MINIMUM_SETPOINT, <safe_default>) or if raw is None
set raw = <safe_default>, then cast float(raw) and return max(that_float,
MINIMUM_SETPOINT); ensure the default is a numeric value or already validated
configuration value.

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 (6)
custom_components/sat/boiler.py (2)

117-127: Store and clean up the periodic save unsubscribe handle.

The async_track_time_interval call on Line 127 returns an unsubscribe callback that is not stored. Without storing and later calling this handle, the periodic save will continue indefinitely even if the boiler entity is removed, leading to resource leaks.

Store the handle and add cleanup logic as previously suggested.

Based on past review comments, this issue was flagged but not yet resolved.


356-381: Modulation reliability never recovers once marked unreliable.

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 or transient conditions) but later reports varying values, reliability won't be restored until Home Assistant restarts.

Consider the recovery logic previously suggested to detect when modulation variance returns.

Based on past review comments, this issue was flagged but not yet resolved.

custom_components/sat/cycles.py (2)

138-138: Replace EN DASH with standard hyphen for tooling compatibility.

The docstring on Line 138 uses an EN DASH () instead of a standard ASCII HYPHEN-MINUS (-). While this doesn't affect functionality, using the standard hyphen improves compatibility with tooling.

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

Based on past review comments, this minor issue was previously flagged.


446-446: Guard against TypeError when accessing duty_cycle[0].

Line 446 accesses pwm_state.duty_cycle[0] without first checking if pwm_state.duty_cycle itself is not None. According to the PWMState dataclass, duty_cycle is Optional[Tuple[int, int]], so this will raise a TypeError if duty_cycle is None.

-        if pwm_state.status == PWMStatus.ON and pwm_state.duty_cycle[0] is not None:
+        if pwm_state.status == PWMStatus.ON and pwm_state.duty_cycle is not None and pwm_state.duty_cycle[0] is not None:
             short_threshold_seconds = min(pwm_state.duty_cycle[0] * 0.9, TARGET_MIN_ON_TIME_SECONDS)

Based on past review comments, this issue was previously flagged.

custom_components/sat/minimum_setpoint.py (2)

168-169: Use async_create_task instead of create_task for Home Assistant.

Line 169 calls self._hass.create_task(...) from synchronous context. Home Assistant's HomeAssistant object uses async_create_task() for scheduling coroutines from sync code.

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

Based on past review comments, this issue was flagged but not yet resolved.


31-39: Clarify inverted comments and use the unused parameter.

Two issues flagged in previous reviews remain:

  1. Lines 32-33: The comments for increase_step and decrease_step are inverted relative to the implementation:

    • Line 368: TOO_SHORT_UNDERHEAT decreases the minimum using -= 1.0
    • Line 374: TOO_SHORT_OVERSHOOT increases the minimum using += 1.0

    The comments should state: increase_step is applied when the minimum is too low (overshoot/short-cycling), and decrease_step is applied when the minimum is too high (underheat/too short).

  2. Line 38: low_load_maximum_duty_ratio_15m is defined but never used. Consider adding a check in _is_tunable_regime (around Line 390-404):

    if statistics.duty_ratio_last_15m > self._config.low_load_maximum_duty_ratio_15m:
        return False

Based on learnings, these issues were flagged in previous reviews and should be addressed.

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

286-286: Remove redundant int() cast.

Line 286 wraps round() in int(), but round() already returns an int in Python 3 when called with a single argument, making the cast redundant.

-        index = int(round((len(values) - 1) * percentile))
+        index = round((len(values) - 1) * percentile)
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7404715 and 47cf6d3.

📒 Files selected for processing (4)
  • custom_components/sat/boiler.py
  • custom_components/sat/const.py
  • custom_components/sat/cycles.py
  • custom_components/sat/minimum_setpoint.py
🧰 Additional context used
🧬 Code graph analysis (4)
custom_components/sat/cycles.py (3)
custom_components/sat/const.py (5)
  • CycleClassification (195-204)
  • CycleKind (188-192)
  • PWMStatus (230-233)
  • Percentiles (253-289)
  • make_from_cycle_samples (258-289)
custom_components/sat/helpers.py (2)
  • clamp (90-95)
  • min_max (112-118)
custom_components/sat/boiler.py (2)
  • BoilerState (21-35)
  • status (99-103)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/const.py (2)
  • CycleClassification (195-204)
  • CycleKind (188-192)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/area.py (1)
  • AreasSnapshot (38-42)
custom_components/sat/cycles.py (5)
  • CycleStatistics (83-89)
  • Cycle (61-79)
  • statistics (177-185)
  • sample_count_4h (119-124)
  • median_on_duration_seconds_4h (153-163)
custom_components/sat/const.py (1)
custom_components/sat/cycles.py (1)
  • CycleSample (40-43)
custom_components/sat/boiler.py (2)
custom_components/sat/const.py (2)
  • BoilerStatus (207-227)
  • CycleClassification (195-204)
custom_components/sat/cycles.py (3)
  • Cycle (61-79)
  • update (263-296)
  • last_cycle (166-174)
🪛 Pylint (4.0.4)
custom_components/sat/cycles.py

[refactor] 61-61: Too many instance attributes (12/7)

(R0902)


[refactor] 319-319: Too many local variables (19/15)

(R0914)


[refactor] 438-438: Too many local variables (17/15)

(R0914)


[refactor] 438-438: Too many return statements (13/6)

(R0911)


[refactor] 438-438: Too many branches (15/12)

(R0912)

custom_components/sat/minimum_setpoint.py

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

(R0902)


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

(R0902)


[refactor] 297-300: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)


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

(R0913)


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

(R0917)


[refactor] 342-386: Unnecessary "elif" after "return", remove the leading "el" from "elif"

(R1705)


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

(R0911)


[refactor] 302-302: Too many branches (15/12)

(R0912)


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

(R0912)


[refactor] 517-517: Too many branches (15/12)

(R0912)

custom_components/sat/const.py

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

(R0913)


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

(R0917)

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.10)
custom_components/sat/cycles.py

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)

custom_components/sat/const.py

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

Remove unnecessary int call

(RUF046)


cycle = self._build_cycle_state(pwm_state=pwm_state, end_time=timestamp)
if cycle is not None:
self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle})
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:

# Check for serialization methods and asdict usage in the SAT component
rg -n "asdict|to_dict|\.dict\(\)|model_dump|json" custom_components/sat/ --type py -C 2

Repository: Alexwijn/SAT

Length of output: 787


🏁 Script executed:

# Find the Cycle dataclass definition to inspect it
rg -n "class Cycle|@dataclass" custom_components/sat/ --type py -A 10

Repository: Alexwijn/SAT

Length of output: 11571


🏁 Script executed:

# Check how EVENT_SAT_CYCLE_ENDED is used in other parts of the codebase
rg -n "EVENT_SAT_CYCLE_ENDED|bus\.fire" custom_components/sat/ --type py -B 2 -A 2

Repository: Alexwijn/SAT

Length of output: 1929


🏁 Script executed:

# Check for any custom JSON encoding or serialization in the codebase
rg -n "JSONEncoder|json.dumps|fire.*asdict" custom_components/sat/ --type py

Repository: Alexwijn/SAT

Length of output: 38


🏁 Script executed:

# Check if there are any tests for the event firing
fd -e py test | head -20

Repository: Alexwijn/SAT

Length of output: 145


🏁 Script executed:

# Look for how the event listener processes the cycle data
rg -n "on_cycle_end|EVENT_SAT_CYCLE_ENDED" custom_components/sat/ --type py -A 5

Repository: Alexwijn/SAT

Length of output: 2415


🏁 Script executed:

# Check what fields are inside the Cycle dataclass more completely
sed -n '60,82p' custom_components/sat/cycles.py

Repository: Alexwijn/SAT

Length of output: 562


Convert Cycle dataclass to dict before firing event for Home Assistant compatibility.

Line 290 passes the Cycle dataclass directly to the event bus. While this works for in-process event listeners (like the one at climate.py:245), Home Assistant requires JSON-serializable event data for database persistence and logging. The current implementation will fail if event recording or storage is enabled.

Convert to dict using dataclasses.asdict():

from dataclasses import asdict

cycle = self._build_cycle_state(pwm_state=pwm_state, end_time=timestamp)
if cycle is not None:
    self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle)})

Update the listener in climate.py to extract the dict and reconstruct the Cycle object if type hints require it, or accept dict data directly since the listener only uses attribute access.

🤖 Prompt for AI Agents
In custom_components/sat/cycles.py around line 290, the code fires a Cycle
dataclass directly to the Home Assistant event bus which requires
JSON-serializable data; import dataclasses.asdict and fire the event with
{"cycle": asdict(cycle)} instead of the dataclass instance, and ensure the local
listener in climate.py is updated to accept a dict or reconstruct the Cycle from
that dict before using attributes.

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 (12)
custom_components/sat/cycles.py (3)

311-314: Verify event payload is JSON-serializable.

Line 313 fires an event with a Cycle dataclass instance. Home Assistant's event bus requires JSON-serializable data for persistence. According to previous reviews, this should be converted using dataclasses.asdict():

from dataclasses import asdict

cycle = self._build_cycle_state(pwm_state=pwm_state, end_time=timestamp)
if cycle is not None:
    self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle)})

Previous reviews indicate this was addressed, but if the current code still passes the dataclass directly, it should be fixed. Please verify the actual implementation.


286-286: Fix implicit Optional type annotation.

Line 286 uses timestamp: float = None, violating PEP 484:

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

Wait, I see the annotation already says Optional[float] in line 286 based on the fix in f599b6a. Let me recheck... The current code shows timestamp without Optional. This was marked as addressed but is still present.


461-468: Guard against None when accessing duty_cycle[0].

Line 465 accesses pwm_state.duty_cycle[0] without verifying that pwm_state.duty_cycle is not None. According to the PWMState definition, duty_cycle is Optional[Tuple[int, int]]:

         if pwm_state.status == PWMStatus.IDLE:
             return 0.0

-        if pwm_state.status == PWMStatus.ON and pwm_state.duty_cycle[0] is not None:
+        if pwm_state.status == PWMStatus.ON and pwm_state.duty_cycle is not None and pwm_state.duty_cycle[0] is not None:
             return float(min(pwm_state.duty_cycle[0] * 0.9, TARGET_MIN_ON_TIME_SECONDS))

This was flagged in a previous review.

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

112-112: Verify the task signature matches async_notify_listeners.

Same issue as in esphome/__init__.py: Line 112 passes self.async_notify_listeners as a method reference. If async_notify_listeners expects parameters with no defaults, this will cause a runtime error. Based on the coordinator pattern, this should likely be:

-        self.hass.async_create_task(self.async_notify_listeners)
+        self.hass.async_create_task(self.async_notify_listeners())
custom_components/sat/minimum_setpoint.py (1)

146-147: Use async_create_task instead of create_task.

Line 147 calls self._hass.create_task(...), but Home Assistant's HomeAssistant object uses async_create_task() for scheduling coroutines:

         if self._hass is not None:
-            self._hass.create_task(self.async_save_regimes())
+            self._hass.async_create_task(self.async_save_regimes())

This was flagged in a previous review comment.

custom_components/sat/coordinator.py (2)

261-264: Guard against missing configuration key.

Line 264 calls float(self._config_data.get(CONF_MINIMUM_SETPOINT)), which will raise TypeError if the key is absent (since get() returns None). Provide a default value:

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

This was flagged in a previous review.


332-332: Fix implicit Optional type annotation and clarify unused parameter.

Line 332 has timestamp: float = None, which violates PEP 484. The climate parameter is also unused:

-    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, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMState] = None, timestamp: Optional[float] = None) -> None:

If climate is intentionally unused (for interface compatibility), consider prefixing with _climate.

This was flagged in a previous review.

custom_components/sat/area.py (2)

175-187: Signature mismatch in event handler registration.

Line 182 registers self.update directly as the EVENT_HOMEASSISTANT_STARTED handler. However, event handlers receive an Event object, while update() expects datetime | None. Wrap it in a lambda:

         if hass.state is CoreState.running:
             self.update()
         else:
-            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update)
+            hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update())

This was flagged in a previous review.


344-353: Critical: list has no get() method.

Line 346 calls self._areas.get(entity_id), but self._areas is a list[Area] (not a dict). This will raise AttributeError at runtime. Use the helper method defined in the parent Areas class:

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

Or better, change _PIDs.__init__ to accept the parent Areas instance and call its get() method:

if (area := self._parent.get(entity_id)) is None:
    return

This was flagged in a previous review as addressed in commit 8b042fb, but appears to still be present in the code.

custom_components/sat/climate.py (3)

698-698: Remove extraneous f-string prefix.

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

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

398-399: Guard against AttributeError when self.error is None.

The error property (line 452) can return None when temperature values are unavailable. Line 399 unconditionally accesses self.error.value, which will raise AttributeError when error is None.

This is a previously flagged issue that remains unresolved.

🔎 Proposed fix
     def extra_state_attributes(self):
         """Return device state attributes."""
         return {
-            "error": self.error.value,
+            "error": self.error.value if self.error is not None else None,
             "integral": self.pid.integral,

774-778: Potential None in setpoint assignment and logging.

Lines 776 and 778 use self._last_requested_setpoint which can be None:

  • Line 770 explicitly sets it to None when not in HEAT mode
  • Line 914 shows it starts as None before first heating loop

While line 778 would fail with TypeError if _last_requested_setpoint is None, the real issue is line 776 setting self._setpoint = None, which may cause problems downstream.

The code at line 792 guards against this with self._setpoint if self._setpoint > COLD_SETPOINT else MINIMUM_SETPOINT, but this still compares None > COLD_SETPOINT which will raise TypeError.

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

Also guard line 792:

         # Apply the setpoint using the coordinator
-        await self._coordinator.async_set_control_setpoint(self._setpoint if self._setpoint > COLD_SETPOINT else MINIMUM_SETPOINT)
+        await self._coordinator.async_set_control_setpoint(
+            self._setpoint if self._setpoint is not None and self._setpoint > COLD_SETPOINT else MINIMUM_SETPOINT
+        )
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 47cf6d3 and bac3393.

📒 Files selected for processing (9)
  • custom_components/sat/area.py
  • custom_components/sat/climate.py
  • custom_components/sat/const.py
  • custom_components/sat/coordinator.py
  • custom_components/sat/cycles.py
  • custom_components/sat/esphome/__init__.py
  • custom_components/sat/helpers.py
  • custom_components/sat/minimum_setpoint.py
  • custom_components/sat/simulator/__init__.py
🧰 Additional context used
🧬 Code graph analysis (5)
custom_components/sat/minimum_setpoint.py (3)
custom_components/sat/const.py (2)
  • CycleClassification (195-204)
  • CycleKind (188-192)
custom_components/sat/helpers.py (1)
  • clamp (90-95)
custom_components/sat/cycles.py (6)
  • CycleStatistics (76-84)
  • Cycle (54-72)
  • statistics (188-198)
  • duty_ratio_last_15m (130-143)
  • sample_count_4h (112-117)
  • delta_flow_minus_return_p50_4h (159-165)
custom_components/sat/esphome/__init__.py (1)
custom_components/sat/coordinator.py (1)
  • async_notify_listeners (387-394)
custom_components/sat/helpers.py (2)
custom_components/sat/minimum_setpoint.py (1)
  • value (78-82)
custom_components/sat/errors.py (1)
  • max (29-33)
custom_components/sat/coordinator.py (4)
custom_components/sat/boiler.py (5)
  • BoilerState (21-35)
  • Boiler (38-415)
  • status (99-103)
  • modulation_reliable (114-115)
  • update (136-152)
custom_components/sat/cycles.py (7)
  • CycleTracker (260-540)
  • CycleHistory (87-257)
  • CycleStatistics (76-84)
  • Cycle (54-72)
  • statistics (188-198)
  • last_cycle (177-185)
  • update (286-319)
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/pwm.py (3)
  • PWMState (18-25)
  • status (236-237)
  • state (240-246)
custom_components/sat/simulator/__init__.py (2)
custom_components/sat/coordinator.py (1)
  • async_notify_listeners (387-394)
custom_components/sat/pwm.py (1)
  • PWMState (18-25)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py

[refactor] 25-25: Too many instance attributes (12/7)

(R0902)


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

(R0902)


[refactor] 280-283: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)


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

(R0913)


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

(R0917)


[refactor] 339-377: Unnecessary "elif" after "return", remove the leading "el" from "elif"

(R1705)


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

(R0911)


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

(R0912)


[refactor] 392-392: Too many local variables (20/15)

(R0914)

custom_components/sat/cycles.py

[refactor] 54-54: Too many instance attributes (12/7)

(R0902)


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

(R0902)


[refactor] 342-342: Too many local variables (19/15)

(R0914)


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

(R0911)

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

286-286: Do not catch blind exception: Exception

(BLE001)

custom_components/sat/coordinator.py

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

(F403)


23-23: TYPE_CHECKING may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


332-332: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/cycles.py

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)

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)


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

(F405)


251-251: Unused method argument: event

(ARG002)


251-251: Optional may be undefined, or defined from star imports

(F405)


261-261: Optional may be undefined, or defined from star imports

(F405)


434-434: Optional may be undefined, or defined from star imports

(F405)


442-442: Optional may be undefined, or defined from star imports

(F405)


447-447: Optional may be undefined, or defined from star imports

(F405)


452-452: Optional may be undefined, or defined from star imports

(F405)


463-463: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


698-698: f-string without any placeholders

Remove extraneous f prefix

(F541)


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

(F405)


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

(F405)


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)


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

(F405)


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

(F405)


844-844: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


876-876: Optional may be undefined, or defined from star imports

(F405)


894-894: Optional may be undefined, or defined from star imports

(F405)


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

(F405)

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)

🔇 Additional comments (10)
custom_components/sat/esphome/__init__.py (1)

164-166: Verify the task signature matches async_notify_listeners.

Line 166 passes self.async_notify_listeners (method reference) to async_create_task. According to the coordinator snippet, async_notify_listeners has a signature def async_notify_listeners(self, force: bool = True). When passing a method reference without parentheses, the task will call it without arguments, which should work since force has a default value. However, if the intent was to pass force=True explicitly or control the force parameter, you would need to wrap it in a lambda or call it directly.

If the current behavior (using the default force=True) is intended, this is fine. Otherwise, consider:

self.hass.async_create_task(self.async_notify_listeners())
custom_components/sat/helpers.py (1)

98-137: LGTM! Well-structured helper functions.

The new statistical helper functions are clean, correct, and well-documented:

  • filter_none: properly filters out None values
  • average: safely computes mean with empty-list handling
  • min_max: returns both extremes in one pass
  • percentile_interpolated: implements linear interpolation for percentiles

The implementations handle edge cases appropriately (empty lists, single values, etc.) and align with their usage in cycle statistics and regime tuning throughout the PR.

custom_components/sat/minimum_setpoint.py (1)

1-569: LGTM! Comprehensive regime-based minimum setpoint implementation.

The refactored DynamicMinimumSetpoint system is well-architected:

  • Regime tracking: Properly buckets operating conditions (setpoint band, outside temperature, delta metrics) with hysteresis to prevent regime flapping
  • Per-regime learning: Each regime maintains its own minimum_setpoint, stable_cycles, and completed_cycles counters
  • Persistence: Implements async storage with version tracking
  • Tuning logic: Classification-based adjustments are sensible (decrease for underheat, increase for overshoot)
  • Seeding strategy: New regimes intelligently seed from trusted nearby regimes using distance metrics
  • Relaxation: Properly relaxes toward anchors when conditions aren't tunable

The cycle classification integration and tail-metric usage align well with the broader cycle-centric refactor. Previous review issues appear to be addressed in the commits referenced.

custom_components/sat/const.py (1)

1-255: LGTM! Clean transition to cycle-centric architecture.

The constant and type additions support the broader refactor effectively:

  • New enums: CycleKind, CycleClassification, BoilerStatus, RelativeModulationState provide clear, type-safe state representation
  • Percentiles dataclass: Simple, frozen structure for p50/p90 metrics used throughout cycle analysis
  • UNHEALTHY_CYCLES: Convenient grouping for classification checks
  • Configuration keys: CONF_ROOM_WEIGHTS enables per-area demand weighting
  • PWM margins: PWM_ENABLE_MARGIN_CELSIUS and PWM_DISABLE_MARGIN_CELSIUS provide clear hysteresis thresholds

Removing FlameStatus and shifting to cycle-based enums is consistent with the PR objectives.

custom_components/sat/coordinator.py (1)

96-395: LGTM! Clean integration of cycle-centric state management.

The coordinator refactor successfully integrates the new boiler/cycle subsystems:

  • State tracking: _boiler, _cycles, and _cycle_tracker replace flame-centric logic
  • Debouncing: Proper use of Debouncer for both listener updates and control updates prevents update storms
  • Renamed properties: state() and cycles() are clearer than the previous names
  • Async lifecycle: async_will_remove_from_hass correctly delegates to boiler cleanup
  • Timestamp handling: Line 335 properly defaults to monotonic() when timestamp is not provided

The integration with CycleTracker.update() and Boiler.update() at lines 381-384 is clean and correct.

custom_components/sat/cycles.py (1)

1-540: LGTM! Comprehensive cycle tracking implementation.

The new cycle tracking system is well-designed and robust:

  • State management: CycleTracker properly detects flame ON/OFF transitions and manages cycle lifecycle
  • Statistics: CycleHistory maintains rolling windows with correct pruning logic
  • Tail metrics: Percentile-based tail analysis provides robust cycle characterization
  • Classification: _classify_cycle implements sensible rules for detecting overshoot, underheat, and short-cycling
  • Event integration: Fires EVENT_SAT_CYCLE_STARTED and EVENT_SAT_CYCLE_ENDED at appropriate times

The implementation correctly handles edge cases (empty samples, insufficient data) and integrates cleanly with the boiler state and PWM subsystems.

custom_components/sat/area.py (1)

36-353: LGTM! Well-structured per-area demand management.

The refactored Area and Areas classes provide clean per-room control:

  • Room weighting: room_weight configuration enables user-defined per-room influence on demand calculations
  • Demand calculation: demand_weight properly scales base weight by room configuration
  • Heat arbitration: requires_heat intelligently prefers valve position when available, falling back to PID output
  • Valve position: Robust parsing with error logging for invalid TRV data
  • Async lifecycle: Proper setup/teardown with periodic updates and event-driven initialization
  • PID aggregation: The _PIDs.output method implements a sensible percentile-based approach with headroom
  • Overshoot protection: overshoot_cap computes cooling-driven caps based on per-room overshoot severity

The integration with HeatingCurve and the broader heating control loop is clean and correct.

custom_components/sat/climate.py (3)

575-636: Well-structured PWM enablement logic.

The refactored pulse_width_modulation_enabled property with dedicated helper methods (_is_pwm_forced, _should_enable_static_pwm, _should_enable_dynamic_pwm) significantly improves code clarity and maintainability.

The explicit None check for _last_requested_setpoint (line 578) prevents issues during initialization, and the separation between forced, static, and dynamic PWM scenarios makes the logic easy to follow and test.


508-529: No issues found. The PID.output attribute is defined as a @property in the PID class, making it a computed property that should be accessed without parentheses. Lines 513, 520, and 521 correctly access self.pid.output and self.areas.pids.output without parentheses, consistent with the entire codebase.


726-726: The reset() method signature at custom_components/sat/area.py:338 accepts entity_id as an optional parameter: def reset(self, entity_id: Optional[str] = None) -> None:. The implementation correctly handles entity-specific resets by filtering areas based on the provided ID. Line 726's call with new_state.entity_id is valid and properly supported by the custom API.

Comment on lines +280 to 282
async_track_state_report_event(
self.hass, [self.inside_sensor_entity_id], self._async_inside_sensor_reported
)
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, check the file structure and context around the flagged lines
head -n 320 custom_components/sat/climate.py | tail -n 70

Repository: Alexwijn/SAT

Length of output: 2588


🏁 Script executed:

# Search for EventStateReportedData and EventStateChangedData usage/definitions
rg -n 'EventStateReportedData|EventStateChangedData' --type=py -B2 -A2

Repository: Alexwijn/SAT

Length of output: 5572


🏁 Script executed:

# Find usages of async_track_state_report_event in the codebase
rg -n 'async_track_state_report_event' --type=py -B2 -A2

Repository: Alexwijn/SAT

Length of output: 937


🏁 Script executed:

# Check imports at the top of the file to understand which core modules are being used
head -n 50 custom_components/sat/climate.py

Repository: Alexwijn/SAT

Length of output: 2063


🏁 Script executed:

# Search for async_track_state_change_event to see if it's still used elsewhere
rg -n 'async_track_state_change_event' --type=py -B1 -A1

Repository: Alexwijn/SAT

Length of output: 3201


🏁 Script executed:

# Check if there are any comments or commit history explaining the choice of report_event for inside sensor
rg -n 'inside_sensor' custom_components/sat/climate.py -B3 -A3 | head -100

Repository: Alexwijn/SAT

Length of output: 1733


🏁 Script executed:

# Check the handler implementation details to understand what operations are performed
sed -n '674,681p' custom_components/sat/climate.py

Repository: Alexwijn/SAT

Length of output: 465


🏁 Script executed:

# Check if there's any documentation or notes about the change between versions/commits
git log --oneline --all -20 2>/dev/null | head -20 || echo "Git history not available"

Repository: Alexwijn/SAT

Length of output: 201


Consider adding documentation for the inside sensor's use of async_track_state_report_event.

The data structures (EventStateReportedData and EventStateChangedData) are compatible—both support event.data.get("new_state")—and the choice to use async_track_state_report_event only for the inside sensor appears intentional (proper type annotations confirm this). However, this creates an asymmetry: the inside sensor receives all state reports (even when the value hasn't changed), while other sensors (outside, thermostat, humidity, climate, window) only receive state change events.

This difference may be deliberate to ensure more responsive temperature updates, but it lacks explanation. Add a comment clarifying why the inside sensor specifically needs state report events rather than state change events.

🤖 Prompt for AI Agents
In custom_components/sat/climate.py around lines 280-282, add an inline comment
above the async_track_state_report_event call explaining why the inside sensor
uses state report events instead of state change events (e.g., to receive
periodic identical-value reports for more responsive/accurate indoor temperature
updates, debouncing, or sensor hardware that only reports on a schedule), and
note that other sensors intentionally use state change events; keep the comment
concise and mention any assumptions about EventStateReportedData vs
EventStateChangedData compatibility for future maintainers.

return

self._control_heating_loop_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_heating_loop))
self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: HassJob cannot accept async methods directly.

Line 892 passes self.async_control_heating_loop (an async method) to HassJob for use with async_call_later. Home Assistant forbids passing coroutines to HassJob and will raise ValueError at runtime.

This is a previously flagged critical issue. You must wrap the coroutine creation in a callback.

🔎 Proposed fix
-        self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
+        def _schedule_callback():
+            self.hass.async_create_task(self.async_control_heating_loop())
+        
+        self._control_heating_loop_unsub = async_call_later(self.hass, 5, _schedule_callback)

Or use a lambda:

-        self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
+        self._control_heating_loop_unsub = async_call_later(
+            self.hass, 5, 
+            lambda _: self.hass.async_create_task(self.async_control_heating_loop())
+        )
🤖 Prompt for AI Agents
In custom_components/sat/climate.py around line 892, you're passing the async
method self.async_control_heating_loop directly into HassJob which will raise
ValueError; wrap the coroutine creation in a callback so HassJob receives a
regular callable that schedules the coroutine on the event loop (e.g. create a
small lambda or def that calls
self.hass.async_create_task(self.async_control_heating_loop()) and pass that
callable into HassJob) and then use async_call_later as before.

await super().async_set_control_max_setpoint(value)

async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMStatus] = None, _time=None) -> None:
async def async_control_heating_loop(self, timestamp: float = None, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMState] = 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.

Line 88 uses timestamp: float = None, which violates PEP 484. The type should explicitly allow None:

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

The unused parameters (timestamp, climate, pwm_state) appear to be for interface compatibility with other coordinator implementations. If they're intentionally unused, consider prefixing with _ to indicate this (e.g., _timestamp, _climate, _pwm_state).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def async_control_heating_loop(self, timestamp: float = None, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMState] = None) -> None:
async def async_control_heating_loop(self, timestamp: Optional[float] = None, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMState] = None) -> None:
🧰 Tools
🪛 Ruff (0.14.10)

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)

🤖 Prompt for AI Agents
In custom_components/sat/simulator/__init__.py around line 88, the function
parameter annotation uses `timestamp: float = None` which is invalid under PEP
484; change the annotations to explicitly allow None (e.g., `timestamp:
Optional[float] = None`) and likewise mark `climate` and `pwm_state` as
`Optional[...]` types; ensure `Optional` is imported from `typing`. Also, if
those parameters are intentionally unused for interface compatibility, prefix
them with an underscore (e.g., `_timestamp`, `_climate`, `_pwm_state`) to
indicate unused parameters.

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

142-142: EN DASH character in docstring (duplicate).

Line 142 uses an EN DASH () instead of the standard ASCII hyphen-minus (-). This was flagged in previous reviews but remains unfixed.


358-358: Critical: Convert Cycle dataclass to dict before firing event.

Line 358 passes the Cycle dataclass directly to the Home Assistant event bus. Home Assistant requires event data to be JSON-serializable for persistence and logging. The Cycle dataclass contains enums (CycleKind, CycleClassification) and nested dataclasses (CycleTailMetrics, Percentiles) which are not JSON-serializable by default.

Although past reviews marked this as addressed (commits dab0b90-6129822), the fix is not present in the current code and must be re-applied.

🔎 Proposed fix

Add the import at the top of the file:

+from dataclasses import asdict
 from dataclasses import dataclass

Then convert the cycle to a dict before firing:

         cycle = self._build_cycle_state(pwm_state=pwm_state, end_time=timestamp)
         if cycle is not None:
-            self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle})
+            self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle)})
             self._history.record_cycle(cycle)

Note: If the event listener in climate.py expects a Cycle object, update it to reconstruct from the dict or work with dict data directly.

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

168-228: Extension and stall detection logic is sound.

The logic correctly:

  1. Detects temperature stalls over a 120s window
  2. Extends the ON phase in 30s increments when error > 2°C, up to 180s total
  3. Prevents extension if stalled or past the commit point

Minor cleanup opportunity at line 186:

-commit_seconds = max(float(HEATER_STARTUP_TIMEFRAME), 30.0)
+commit_seconds = float(HEATER_STARTUP_TIMEFRAME)

If HEATER_STARTUP_TIMEFRAME is 60, the max(..., 30.0) is redundant.


100-230: Consider extracting helper methods to reduce complexity.

The enable() method is complex with many branches, statements, and local variables (static analysis flags 68 statements, 16 branches, 18 local variables). While the logic is generally sound, extracting helpers would improve maintainability:

  • _should_start_on_phase() for lines 133-166
  • _should_extend_on_phase() for lines 183-216
  • _check_temperature_stall() for lines 189-202

This would make the state machine clearer and easier to test in isolation.

Based on learnings, static analysis hints about complexity.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2d8a61d and fe9e35f.

📒 Files selected for processing (2)
  • custom_components/sat/cycles.py
  • custom_components/sat/pwm.py
🧰 Additional context used
🧬 Code graph analysis (2)
custom_components/sat/pwm.py (6)
custom_components/sat/const.py (1)
  • PWMStatus (230-233)
custom_components/sat/heating_curve.py (4)
  • HeatingCurve (10-91)
  • value (90-91)
  • reset (20-24)
  • base_offset (77-79)
custom_components/sat/boiler.py (1)
  • BoilerState (21-35)
custom_components/sat/pid.py (2)
  • restore (220-239)
  • reset (121-136)
custom_components/sat/coordinator.py (5)
  • state (138-151)
  • get (403-418)
  • setpoint (167-168)
  • flame_active (181-182)
  • boiler_temperature (197-198)
custom_components/sat/state.py (1)
  • State (10-12)
custom_components/sat/cycles.py (9)
custom_components/sat/const.py (4)
  • CycleClassification (195-204)
  • CycleKind (188-192)
  • PWMStatus (230-233)
  • Percentiles (245-247)
custom_components/sat/helpers.py (3)
  • clamp (90-95)
  • min_max (112-118)
  • percentile_interpolated (121-137)
custom_components/sat/pwm.py (2)
  • PWMState (27-35)
  • status (328-329)
custom_components/sat/boiler.py (3)
  • BoilerState (21-35)
  • update (136-152)
  • status (99-103)
custom_components/sat/esphome/__init__.py (4)
  • setpoint (90-91)
  • return_temperature (114-115)
  • flame_active (82-83)
  • hot_water_active (86-87)
custom_components/sat/simulator/__init__.py (2)
  • setpoint (52-53)
  • flame_active (64-65)
custom_components/sat/climate.py (1)
  • setpoint (504-505)
custom_components/sat/errors.py (1)
  • max (29-33)
custom_components/sat/relative_modulation.py (1)
  • update (18-20)
🪛 Pylint (4.0.4)
custom_components/sat/pwm.py

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

(R0902)


[refactor] 100-100: Too many local variables (18/15)

(R0914)


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

(R0912)


[refactor] 100-100: Too many statements (68/50)

(R0915)


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

(R0911)

custom_components/sat/cycles.py

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

(R0902)


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

(R0902)


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

(R0902)


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

(R0914)


[refactor] 522-522: Too many return statements (11/6)

(R0911)

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

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)

🔇 Additional comments (8)
custom_components/sat/pwm.py (8)

16-44: LGTM! Well-structured configuration and state classes.

The new extension and stall detection constants are sensible, and the frozen dataclasses provide a clean, immutable API for configuration and state management.


74-92: LGTM! Comprehensive state reset.

All new extension and stall detection state variables are properly initialized.


100-125: LGTM! Good defensive checks and cycle reset logic.

The early return for missing values prevents invalid calculations, and the hourly cycle counter reset aligns with the maximum_cycles constraint.


126-166: LGTM! Robust ON phase startup logic.

The cycle limit check prevents runaway cycles, and the initialization of extension tracking state is well-structured.


232-235: LGTM! Clean disable implementation.

The disable method properly resets state and disables the PWM.


237-264: LGTM! Duty cycle calculation uses effective temperature.

Using _effective_on_temperature (line 240) provides a smoothed temperature value during ON cycles, which should improve stability. The fallback to flow_temperature is appropriate.


323-339: LGTM! State property correctly exposes PWMState.

The property correctly constructs and returns the PWMState with all required fields, including the percentage conversion for last_duty_cycle_percentage.


100-339: Verify sensor integration with new PWMState API.

Given the PR comments report that the "current temperature" sensor updates only once at restart and cycle status shows INSUFFICIENT_DATA, verify that sensors correctly consume the new PWMState API and handle the new fields (ended_on_phase, last_duty_cycle_percentage).

#!/bin/bash
# Search for sensor implementations that consume PWM state
rg -n -C5 --type=py 'pwm.*\.state|PWMState'

# Search for "current temperature" sensor definition
rg -n -C5 --type=py 'current.temperature|CurrentTemperature'

# Search for cycle status sensor
rg -n -C5 --type=py 'cycle.*status|INSUFFICIENT_DATA'

Comment on lines 56 to 66
# Timing thresholds for duty cycle management
self._on_time_lower_threshold: float = 180
self._on_time_upper_threshold: float = 3600 / max(1, self._cycles.maximum_count)
self._on_time_max_threshold: float = self._on_time_upper_threshold * 2
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

# Duty cycle percentage thresholds
self._duty_cycle_lower_threshold: float = self._on_time_lower_threshold / self._on_time_upper_threshold
self._duty_cycle_upper_threshold: float = 1 - self._duty_cycle_lower_threshold
self._min_duty_cycle_percentage: float = self._duty_cycle_lower_threshold / 2
self._max_duty_cycle_percentage: float = 1 - self._min_duty_cycle_percentage

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: Past review issue remains unresolved – high maximum_cycles can produce negative OFF times.

The thresholds are still derived solely from maximum_cycles without safeguards:

self._on_time_upper_threshold = 3600 / max(1, self._config.maximum_cycles)
self._on_time_maximum_threshold = self._on_time_upper_threshold * 2

When maximum_cycles is large (e.g., 50), _on_time_maximum_threshold (144s) becomes smaller than _on_time_lower_threshold (180s). This causes negative off_time at line 270:

off_time = self._on_time_maximum_threshold - self._on_time_lower_threshold  # e.g., 144 - 180 = -36

Negative durations will break state transition logic in enable().

🔎 Proposed fix to enforce minimum thresholds
 # Timing thresholds for duty cycle management
 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
+)

Additionally, add validation in _calculate_duty_cycle at line 270:

 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
+    off_time = max(0, self._on_time_maximum_threshold - self._on_time_lower_threshold)

Or better: validate maximum_cycles at configuration time to ensure 3600 / maximum_cycles >= 120 (so max threshold ≥ 240s > 180s lower threshold).

🤖 Prompt for AI Agents
In custom_components/sat/pwm.py around lines 56 to 66, the on-time thresholds
computed from maximum_cycles can make _on_time_maximum_threshold smaller than
_on_time_lower_threshold (e.g. max_cycles=50), causing negative off_time later;
ensure thresholds never produce negative durations by (1) validating/clamping
self._config.maximum_cycles at config load so 3600/maximum_cycles stays above a
safe minimum (e.g. >= 120s), and (2) when computing
_on_time_upper_threshold/_on_time_maximum_threshold, enforce min/max constraints
or post-checks so _on_time_maximum_threshold >= _on_time_lower_threshold (and if
not, set _on_time_maximum_threshold = _on_time_lower_threshold +
some_safe_margin); also update _calculate_duty_cycle to guard against negative
off_time by computing off_time = max(0, _on_time_maximum_threshold -
_on_time_lower_threshold) and handling zero off_time paths appropriately.

Comment on lines 94 to +98
def restore(self, state: State) -> None:
"""Restore the PWM controller from a saved state."""
if enabled := state.attributes.get("pulse_width_modulation_enabled"):
self._enabled = bool(enabled)
_LOGGER.debug("Restored Pulse Width Modulation state: %s", enabled)
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 examine the PWM class structure and state variables
cat -n custom_components/sat/pwm.py | head -150

Repository: Alexwijn/SAT

Length of output: 6944


🏁 Script executed:

# Check if there's a PWMState class and what attributes it exposes
rg -n 'class PWMState|@property' custom_components/sat/pwm.py | head -30

Repository: Alexwijn/SAT

Length of output: 128


🏁 Script executed:

# Look for state persistence/storage mechanisms - where state is saved
rg -n 'def restore|def get_initial_state|self._attr|@property' custom_components/sat/pwm.py | head -40

Repository: Alexwijn/SAT

Length of output: 157


🏁 Script executed:

# Check what attributes are being set in PWMState or similar
rg -n 'attributes' custom_components/sat/pwm.py

Repository: Alexwijn/SAT

Length of output: 136


🏁 Script executed:

# Search for where PWMState is used and state attributes are set
rg -n 'PWMState|attributes.*=' custom_components/sat/pwm.py

Repository: Alexwijn/SAT

Length of output: 103


🏁 Script executed:

# Look for entity attributes or state exposure
rg -n 'extra_state_attributes|@property|def.*state' custom_components/sat/ -A 2 | head -50

Repository: Alexwijn/SAT

Length of output: 3752


🏁 Script executed:

# Find where the PWM state is being stored/exposed to Home Assistant
rg -n 'pulse_width_modulation_enabled|PWMState' custom_components/sat/ --type=py

Repository: Alexwijn/SAT

Length of output: 2418


🏁 Script executed:

# Check all entity attributes being exposed in the climate entity
grep -n 'extra_state_attributes\|".*":' custom_components/sat/climate.py | grep -A 20 'extra_state_attributes'

Repository: Alexwijn/SAT

Length of output: 1426


🏁 Script executed:

# Get full context of what attributes are exposed
sed -n '420,450p' custom_components/sat/climate.py

Repository: Alexwijn/SAT

Length of output: 1353


🏁 Script executed:

# Check the complete PWMState property definition
sed -n '331,345p' custom_components/sat/pwm.py

Repository: Alexwijn/SAT

Length of output: 429


🏁 Script executed:

# Let's see what else is in extra_state_attributes to see the full picture
sed -n '396,435p' custom_components/sat/climate.py

Repository: Alexwijn/SAT

Length of output: 1897


🏁 Script executed:

# Check if PWMState has all the attributes or just these 5
sed -n '26,36p' custom_components/sat/pwm.py

Repository: Alexwijn/SAT

Length of output: 340


🏁 Script executed:

# Look for any documentation or comments about what should be restored
rg -n 'restore|persist|save.*state' custom_components/sat/pwm.py custom_components/sat/climate.py

Repository: Alexwijn/SAT

Length of output: 763


🏁 Script executed:

# Check what attributes are actually available on the restored state object
rg -n 'async_added_to_hass|old_state' custom_components/sat/climate.py | head -20

Repository: Alexwijn/SAT

Length of output: 1380


🏁 Script executed:

# Verify the full restore method and understand context
sed -n '322,335p' custom_components/sat/climate.py

Repository: Alexwijn/SAT

Length of output: 736


🏁 Script executed:

# Check if there are any other state storage mechanisms or if attributes are persisted differently
rg -n 'STATE_ATTRIBUTES|extra_restored_last_state|last_state' custom_components/sat/climate.py

Repository: Alexwijn/SAT

Length of output: 113


🏁 Script executed:

# Verify if _duty_cycle is supposed to be restored from pulse_width_modulation_duty_cycle
sed -n '74,99p' custom_components/sat/pwm.py

Repository: Alexwijn/SAT

Length of output: 1190


🏁 Script executed:

# Check what all attributes are exposed to verify what should be restored
sed -n '425,433p' custom_components/sat/climate.py

Repository: Alexwijn/SAT

Length of output: 442


_duty_cycle is persisted but not restored.

The restore method is incomplete: pulse_width_modulation_duty_cycle is exposed in entity attributes and persisted, but the restore() method does not restore _duty_cycle from this attribute.

However, the specific variables mentioned in the original review (ended_on_phase, last_duty_cycle_percentage) are not exposed in entity attributes and therefore were never persisted in the first place, making them unavailable for restoration.

Consider restoring the duty cycle:

Suggested fix
def restore(self, state: State) -> None:
    """Restore the PWM controller from a saved state."""
    if enabled := state.attributes.get("pulse_width_modulation_enabled"):
        self._enabled = bool(enabled)
        _LOGGER.debug("Restored Pulse Width Modulation state: %s", enabled)
    
    if duty_cycle := state.attributes.get("pulse_width_modulation_duty_cycle"):
        self._duty_cycle = duty_cycle
🤖 Prompt for AI Agents
In custom_components/sat/pwm.py around lines 94-98, the restore() method
restores only the enabled flag but does not restore the persisted
pulse_width_modulation_duty_cycle attribute; update restore() to read
state.attributes.get("pulse_width_modulation_duty_cycle"), cast/validate it
(e.g. float or int within expected range) and assign it to self._duty_cycle, and
add a debug log for the restored duty cycle; do not attempt to restore variables
that are not persisted (ended_on_phase, last_duty_cycle_percentage).

…eater state management. Update PWM control logic to utilize these properties, improving accuracy of duty cycle and temperature regulation.
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/pwm.py (2)

56-66: Critical: Unresolved issue – high maximum_cycles can produce negative OFF times.

The thresholds are still derived solely from maximum_cycles without safeguards:

self._on_time_upper_threshold = 3600 / max(1, self._config.maximum_cycles)
self._on_time_maximum_threshold = self._on_time_upper_threshold * 2

When maximum_cycles is large (e.g., 50), _on_time_maximum_threshold (144s) becomes smaller than _on_time_lower_threshold (180s). This causes negative off_time at line 271:

off_time = self._on_time_maximum_threshold - self._on_time_lower_threshold  # e.g., 144 - 180 = -36

Negative durations will break state transition logic in enable().

🔎 Proposed fix to enforce minimum thresholds
 # Timing thresholds for duty cycle management
 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
+)

Additionally, add validation in _calculate_duty_cycle at line 271:

 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
+    off_time = max(0, self._on_time_maximum_threshold - self._on_time_lower_threshold)

Or validate maximum_cycles at configuration time to ensure 3600 / maximum_cycles >= 120.


94-98: _duty_cycle is persisted but still not restored.

While debug logging was added (line 98), the core issue remains: pulse_width_modulation_duty_cycle is exposed in entity attributes and persisted, but the restore() method does not restore _duty_cycle from this attribute. This means PWM won't fully recover its state after a Home Assistant restart.

🔎 Suggested fix
 def restore(self, state: State) -> None:
     """Restore the PWM controller from a saved state."""
     if enabled := state.attributes.get("pulse_width_modulation_enabled"):
         self._enabled = bool(enabled)
         _LOGGER.debug("Restored Pulse Width Modulation state: %s", enabled)
+    
+    if duty_cycle := state.attributes.get("pulse_width_modulation_duty_cycle"):
+        self._duty_cycle = duty_cycle
+        _LOGGER.debug("Restored Pulse Width Modulation duty cycle: %s", duty_cycle)
custom_components/sat/boiler.py (2)

128-145: Unsubscribe handle for periodic save is still not stored or cleaned up.

async_track_time_interval returns an unsubscribe callback that should be stored and called during cleanup (line 138). Currently, the periodic save will continue even if the boiler is removed, causing a resource leak.

🔎 Proposed 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))

Then add cleanup method:

async def async_will_remove_from_hass(self) -> None:
    """Called when entity will be removed."""
    if self._save_unsub is not None:
        self._save_unsub()
        self._save_unsub = None

367-426: Modulation reliability detection still only marks as unreliable, never recovers.

Once _modulation_reliable is set to False (line 392), 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.

This could contribute to the user's report of boiler status issues if status derivation depends on modulation reliability.

🔎 Proposed fix to add recovery logic
         values = self._modulation_values_when_flame_on
         max_value = max(values)
         min_value = min(values)

         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
+        elif max_value - min_value >= self._modulation_delta_threshold:
+            # Modulation shows variation, restore reliability
+            if not self._modulation_reliable:
+                self._modulation_reliable = True
+                _LOGGER.info("Modulation reliability restored (variation detected)")
🧹 Nitpick comments (3)
custom_components/sat/const.py (1)

8-9: Consider removing the empty TYPE_CHECKING block.

The TYPE_CHECKING block contains only a pass statement and serves no purpose. Either add the intended type-only imports or remove the block entirely.

🔎 Proposed cleanup
-if TYPE_CHECKING:
-    pass
custom_components/sat/pwm.py (1)

16-23: Consider making extension parameters configurable.

The hardcoded extension constants (max 180s extension, 2°C threshold) significantly affect heating behavior but aren't user-configurable. Different heating systems (heat pumps vs. radiators) may benefit from different values.

Consider adding these to PWMConfig or documenting why these specific values are optimal across all heating system types.

custom_components/sat/boiler.py (1)

41-82: Consider using a configuration dataclass.

The Boiler class has 14 constructor parameters (similar to PWMConfig pattern used in pwm.py). While all parameters have defaults and the current approach works, a BoilerConfig dataclass would improve consistency with the PWM implementation and make parameter passing clearer.

💡 Example pattern from PWMConfig
@dataclass(frozen=True, slots=True, kw_only=True)
class BoilerConfig:
    preheat_delta: float = 6.0
    setpoint_band: float = 1.5
    # ... other parameters

Then constructor becomes:

def __init__(self, config: BoilerConfig) -> None:
    self._config = config
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8ce1b0f and 08e9366.

📒 Files selected for processing (3)
  • custom_components/sat/boiler.py
  • custom_components/sat/const.py
  • custom_components/sat/pwm.py
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/pwm.py (3)
custom_components/sat/const.py (1)
  • PWMStatus (231-234)
custom_components/sat/heating_curve.py (4)
  • HeatingCurve (10-91)
  • value (90-91)
  • reset (20-24)
  • base_offset (77-79)
custom_components/sat/boiler.py (2)
  • BoilerState (21-38)
  • flame_on_since (121-122)
🪛 Pylint (4.0.4)
custom_components/sat/pwm.py

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

(R0902)


[refactor] 100-100: Too many local variables (18/15)

(R0914)


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

(R0912)


[refactor] 100-100: Too many statements (69/50)

(R0915)


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

(R0911)

custom_components/sat/boiler.py

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

(R0902)


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

(R0902)


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

(R0913)


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

(R0917)


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

(R0911)


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

(R0912)


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

(R0911)


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

(R0911)

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

189-205: LGTM! Well-structured cycle classification enums.

The CycleKind and CycleClassification enums provide comprehensive coverage of cycle states and health conditions, supporting the new cycle-centric architecture effectively.


208-259: LGTM! Comprehensive boiler status model.

The expanded BoilerStatus enum provides detailed state tracking (including INSUFFICIENT_DATA) which should help diagnose the user-reported issues with boiler status and cycle tracking. The UNHEALTHY_CYCLES tuple correctly groups problematic cycle classifications for easy filtering.


245-248: The Percentiles dataclass is complete as designed. No make_from_cycle_samples method exists in the codebase, and this is not an incomplete implementation. The class serves as a simple data holder for optional percentile values (p50, p90), which is appropriate for its purpose.

Likely an incorrect or invalid review comment.

custom_components/sat/pwm.py (3)

238-322: LGTM with caveat on line 271.

The duty cycle calculation logic handles various ranges appropriately, using smoothed effective temperature for stability. The multiple return statements are justified by the distinct duty cycle ranges.

The critical issue at line 271 (negative off_time) was already flagged in a previous comment.


333-340: LGTM! State property correctly exposes PWM state.

The property properly packages internal state into the PWMState dataclass, with appropriate rounding for the duty cycle percentage.


100-231: The extension logic doesn't directly interfere with cycle duration calculation or classification.

The ON-phase extension updates only the internal tracking variable _active_on_time_seconds, leaving the reported duty_cycle tuple unchanged with proposed values. More importantly, cycle duration is calculated from actual flame ON/OFF state transitions (end_time - start_time), not from PWM durations, so extensions have no direct effect. The _classify_cycle method uses the proposed duty cycle for threshold computation and actual flame-based cycle duration for classification logic, keeping them independent of any ON-phase extensions.

However, the reported "INSUFFICIENT_DATA" status since December 20th likely indicates a different issue: INSUFFICIENT_DATA is returned when last_cycle is None (no cycle completed), not from cycles being classified with that status (which only happens if duration <= 0.0). Verify whether the extension logic or another component is preventing cycle completion.

custom_components/sat/boiler.py (3)

165-240: LGTM! Complex but well-structured status derivation.

The _derive_status method handles 18 different boiler states with clear conditional logic. While static analysis flags the complexity (18 return statements, 17 branches), each return represents a distinct boiler state and the structure is appropriate for this domain.

The logic properly handles edge cases (None checks, activity flags) and should provide the detailed diagnostics needed to address user-reported issues.


308-341: LGTM! Stall detection with graceful degradation.

The _is_stalled_ignition logic correctly detects prolonged OFF periods with demand present, using a dynamic threshold based on the last cycle duration (with fallback to minimum). The try-except block (lines 334-339) properly handles edge cases with missing or invalid cycle data.


147-163: The issue is not with update() itself but with how the Home Assistant UpdateCoordinator schedules listener notifications.

The boiler.update() method correctly sequences state updates and status derivation. However, Home Assistant changed the UpdateCoordinator API to require explicit listener notifications via async_update_listeners() and proper handling of _unschedule_refresh(). Without these changes, entity state updates won't propagate to Home Assistant after initialization, even though update() continues to execute. Ensure the coordinator implementation uses async_update_listeners() to push updates to listeners and overrides _unschedule_refresh() to detect when no listeners are present.

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

142-142: Minor: Replace EN DASH with standard hyphen in docstring.

The docstring uses an EN DASH () instead of ASCII hyphen-minus, flagged by Ruff. While this doesn't affect functionality, using standard ASCII improves tooling compatibility.

🔎 Proposed fix
-    """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."""

358-358: Critical: Convert Cycle dataclass to dict before firing event.

Home Assistant's event bus requires JSON-serializable data for persistence and logging. The Cycle dataclass contains enum fields (CycleKind, CycleClassification) and nested CycleTailMetrics that are not directly JSON-serializable. Although in-process listeners may work, event recording, database persistence, or external integrations will fail.

Past reviews flagged this issue and marked it as addressed in commits dab0b90 to 6129822, but the current code at line 358 still passes the dataclass directly.

🔎 Proposed fix
+from dataclasses import asdict
+
 # ... in the update method:
 cycle = self._build_cycle_state(pwm_state=pwm_state, end_time=timestamp)
 if cycle is not None:
-    self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle})
+    self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle)})
     self._history.record_cycle(cycle)
Note on enum serialization

dataclasses.asdict() converts enum members to their enum objects by default. For full JSON compatibility, you may need a custom serializer that converts enums to their string values:

def cycle_to_dict(cycle: Cycle) -> dict:
    data = asdict(cycle)
    data["kind"] = cycle.kind.value
    data["classification"] = cycle.classification.value
    return data
🧹 Nitpick comments (1)
custom_components/sat/pwm.py (1)

80-84: Minor: restore() does not restore the persisted duty cycle.

The pulse_width_modulation_duty_cycle attribute is exposed in entity attributes (persisted to Home Assistant state), but the restore() method only restores the enabled flag. The _duty_cycle field is not restored, meaning PWM state is incomplete after a restart.

While this may not cause immediate failure (duty cycle is recalculated on next enable()), restoring the full state provides better continuity.

🔎 Suggested enhancement
 def restore(self, state: State) -> None:
     """Restore the PWM controller from a saved state."""
     if enabled := state.attributes.get("pulse_width_modulation_enabled"):
         self._enabled = bool(enabled)
         _LOGGER.debug("Restored Pulse Width Modulation state: %s", enabled)
+    
+    if duty_cycle := state.attributes.get("pulse_width_modulation_duty_cycle"):
+        try:
+            # duty_cycle is exposed as a tuple or list of two integers
+            if isinstance(duty_cycle, (list, tuple)) and len(duty_cycle) == 2:
+                self._duty_cycle = (int(duty_cycle[0]), int(duty_cycle[1]))
+                _LOGGER.debug("Restored duty cycle: %s", self._duty_cycle)
+        except (ValueError, TypeError) as e:
+            _LOGGER.warning("Failed to restore duty cycle: %s", e)
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 08e9366 and b9956a9.

📒 Files selected for processing (2)
  • custom_components/sat/cycles.py
  • custom_components/sat/pwm.py
🧰 Additional context used
🧬 Code graph analysis (2)
custom_components/sat/pwm.py (3)
custom_components/sat/const.py (1)
  • PWMStatus (231-234)
custom_components/sat/heating_curve.py (4)
  • HeatingCurve (10-91)
  • value (90-91)
  • reset (20-24)
  • base_offset (77-79)
custom_components/sat/boiler.py (2)
  • BoilerState (21-38)
  • flame_on_since (121-122)
custom_components/sat/cycles.py (4)
custom_components/sat/const.py (4)
  • CycleClassification (196-205)
  • CycleKind (189-193)
  • PWMStatus (231-234)
  • Percentiles (246-248)
custom_components/sat/helpers.py (3)
  • clamp (90-95)
  • min_max (112-118)
  • percentile_interpolated (121-137)
custom_components/sat/pwm.py (2)
  • PWMState (18-26)
  • status (255-256)
custom_components/sat/boiler.py (2)
  • BoilerState (21-38)
  • status (102-106)
🪛 Pylint (4.0.4)
custom_components/sat/pwm.py

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

(R0902)


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

(R0911)

custom_components/sat/cycles.py

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

(R0902)


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

(R0902)


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

(R0902)


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

(R0914)


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

(R0911)

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

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)

🔇 Additional comments (2)
custom_components/sat/pwm.py (1)

86-157: Verify the assumption about PWM state machine affecting temperature sensor updates—this is likely not the cause of the reported issue.

The enable() method's state machine (lines 124–155) controls boiler on/off cycles but does not trigger or affect temperature sensor updates. Temperature readings are updated independently via async_track_state_report_event() (line 280), which listens directly to the underlying sensor entity's state changes, not to PWM state transitions.

The user's reported issue ("current temperature sensor updates only once at restart") is unlikely to be caused by PWM logic and should instead be investigated through:

  • Coordinator initialization and state propagation
  • Event listener registration timing during Home Assistant startup
  • Underlying sensor entity availability and state reporting

The PWM state machine and temperature sensor updates are decoupled systems.

Likely an incorrect or invalid review comment.

custom_components/sat/cycles.py (1)

521-584: The flame state transition logic, _current_cycle_start initialization, and sample collection mechanisms are all correctly implemented and should be functioning.

Lines 334-365 correctly detect flame OFF→ON transitions and set _current_cycle_start at line 345. Samples are appended at line 350 whenever the flame is active, and the minimum sample requirement (default 3) is enforced at line 396 before building a cycle. The code flow from climate.pyasync_control_heating_loop()_cycle_tracker.update() appears sound, with the guard at line 386-387 checking that _last_pwm_state is available.

If users are seeing INSUFFICIENT_DATA classifications, the actual cause is likely one of:

  • Line 524: cycle duration calculated as <= 0.0 (though max(0.0, end_time - start_time) at line 393 should prevent this)
  • Line 556: tail_p90_delta returning None (which returns UNCERTAIN, not INSUFFICIENT_DATA)
  • Line 396-398: cycles discarded due to fewer than 3 samples collected during the cycle
  • Runtime issue where pwm_state is None at the call site, preventing cycle tracking from engaging

Debug logging at cycle start/end would help isolate the root cause, but the three systems flagged in the original comment are not the problem.

Likely an incorrect or invalid review comment.

Comment on lines +51 to +52
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
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: High maximum_cycles produces negative OFF times and invalid duty cycles.

Past reviews flagged this issue as unresolved, and it remains present in the current code. When maximum_cycles is high (e.g., 50), the calculated thresholds become inconsistent:

Example calculation with maximum_cycles = 50:

  • _on_time_upper_threshold = 3600 / 50 = 72 seconds (line 51)
  • _on_time_maximum_threshold = 72 * 2 = 144 seconds (line 52)
  • _on_time_lower_threshold = 180 seconds (constant, line 50)

Result: _on_time_maximum_threshold (144s) < _on_time_lower_threshold (180s)

This causes negative off_time at line 197:

off_time = self._on_time_maximum_threshold - self._on_time_lower_threshold  # 144 - 180 = -36

Negative durations break PWM state transitions and may be related to the user-reported issue where "boiler status has been unavailable since some commits/weeks ago."

🔎 Proposed fix to enforce minimum thresholds
 # Timing thresholds for duty cycle management
 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 margin
+)

Additionally, add a guard at line 197:

 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
+    off_time = max(60, self._on_time_maximum_threshold - self._on_time_lower_threshold)

Consider validating maximum_cycles at configuration time to prevent values that would produce invalid thresholds (e.g., require 3600 / maximum_cycles >= 120).

🤖 Prompt for AI Agents
In custom_components/sat/pwm.py around lines 51-52 (and referenced line ~197),
the computed _on_time_upper_threshold and _on_time_maximum_threshold can end up
smaller than the hardcoded _on_time_lower_threshold when maximum_cycles is
large, producing negative off_time; fix by validating/clamping values: compute
upper = max(3600 / max(1, maximum_cycles), MIN_ON_TIME_UPPER) and set maximum =
max(upper * 2, upper, MIN_ON_TIME_MAXIMUM) or otherwise ensure maximum >= lower;
additionally add a runtime guard before line 197 to clamp off_time = max(0.0,
self._on_time_maximum_threshold - self._on_time_lower_threshold) and validate
maximum_cycles at configuration time (reject or coerce values that would make
3600/maximum_cycles < MIN_ON_TIME_UPPER, e.g., require 3600/maximum_cycles >=
120) so thresholds remain consistent and never produce negative durations.

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

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/const.py (1)

107-160: Add CONF_MAXIMUM_SETPOINT to OPTIONS_DEFAULTS.

CONF_MAXIMUM_SETPOINT is referenced in util.py (line 51) but missing from OPTIONS_DEFAULTS. This causes config_options.get(CONF_MAXIMUM_SETPOINT) to return None for existing installations, breaking the MinimumSetpointConfig initialization.

Add a default value alongside the existing CONF_MINIMUM_SETPOINT:

     CONF_MINIMUM_SETPOINT: 10,
+    CONF_MAXIMUM_SETPOINT: 65,
     CONF_MAXIMUM_RELATIVE_MODULATION: 100,

This matches the MAXIMUM_SETPOINT constant value defined at line 35.

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

1-1: Fix duplicate annotations import.

Line 1 imports annotations twice from __future__, which is redundant and could cause issues with some linters or Python versions.

🔎 Proposed fix
-from __future__ import annotations, annotations
+from __future__ import annotations
♻️ Duplicate comments (11)
custom_components/sat/util.py (1)

45-53: CONF_MAXIMUM_SETPOINT still lacks a default value.

Line 51 passes config_options.get(CONF_MAXIMUM_SETPOINT) without a default to MinimumSetpointConfig. Since CONF_MAXIMUM_SETPOINT is not in OPTIONS_DEFAULTS (confirmed in const.py lines 107-160), it will return None for existing configurations, causing a type error when MinimumSetpointConfig expects a float.

Add a sensible default:

         config=MinimumSetpointConfig(
             minimum_setpoint=config_options.get(CONF_MINIMUM_SETPOINT),
-            maximum_setpoint=config_options.get(CONF_MAXIMUM_SETPOINT)
+            maximum_setpoint=config_options.get(CONF_MAXIMUM_SETPOINT, MAXIMUM_SETPOINT)
         )

Alternatively, add CONF_MAXIMUM_SETPOINT to OPTIONS_DEFAULTS in const.py to ensure it's never None.

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

87-87: Fix implicit Optional type annotation.

The timestamp parameter defaults to None but is typed as float, violating PEP 484. The unused parameters are acceptable for interface compatibility but could be prefixed with _.

🔎 Proposed fix
-    async def async_control_heating_loop(self, climate: Optional["SatClimate"] = None, control_sample: Optional["ControlLoopSample"] = None, timestamp: float = None) -> None:
+    async def async_control_heating_loop(self, _climate: Optional["SatClimate"] = None, _control_sample: Optional["ControlLoopSample"] = None, _timestamp: Optional[float] = None) -> None:
custom_components/sat/boiler.py (2)

147-157: Unsubscribe handle for periodic save is not stored.

async_track_time_interval returns an unsubscribe callback that should be stored and called during cleanup. Currently, the periodic save will continue indefinitely.

🔎 Proposed 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))

Add cleanup in a shutdown method:

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

386-411: Modulation reliability detection only marks as unreliable, never recovers.

Once _modulation_reliable is set to False, there's no path to restore it. If the boiler temporarily reports stuck modulation (e.g., during startup) but later reports varying values, reliability won't recover 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.")
custom_components/sat/minimum_setpoint.py (1)

141-142: Use async_create_task instead of create_task for Home Assistant.

Home Assistant's HomeAssistant object uses async_create_task() for scheduling coroutines.

🔎 Proposed fix
         if self._hass is not None:
-            self._hass.create_task(self.async_save_regimes())
+            self._hass.async_create_task(self.async_save_regimes())
custom_components/sat/coordinator.py (2)

278-281: Potential TypeError if CONF_MINIMUM_SETPOINT is missing.

self._config_data.get(CONF_MINIMUM_SETPOINT) returns None if the key is absent, and float(None) raises a TypeError.

🔎 Proposed fix
-        return max(float(self._config_data.get(CONF_MINIMUM_SETPOINT)), MINIMUM_SETPOINT)
+        return max(float(self._config_data.get(CONF_MINIMUM_SETPOINT, MINIMUM_SETPOINT)), MINIMUM_SETPOINT)

349-349: Fix implicit Optional type annotation.

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

🔎 Proposed fix
-    async def async_control_heating_loop(self, climate: Optional["SatClimate"] = None, control_sample: Optional["ControlLoopSample"] = None, timestamp: float = None) -> None:
+    async def async_control_heating_loop(self, _climate: Optional["SatClimate"] = None, control_sample: Optional["ControlLoopSample"] = None, timestamp: Optional[float] = None) -> None:
custom_components/sat/cycles.py (1)

343-346: Convert Cycle dataclass to dict before firing event.

Home Assistant's event bus requires JSON-serializable data for persistence and logging. The frozen Cycle dataclass contains nested dataclasses (CycleMetrics with Percentiles) and enums that won't serialize properly.

🔎 Proposed fix
+from dataclasses import asdict
...
             cycle = self._build_cycle_state(sample.pwm, end_time=sample.timestamp)
             if cycle is not None:
                 self._history.record_cycle(cycle)
-                self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle, "sample": sample})
+                self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle), "sample": asdict(sample)})
custom_components/sat/climate.py (3)

375-378: Potential AttributeError when error is None.

The error property can return None (lines 436-437), but extra_state_attributes unconditionally accesses .value on it.

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

903-903: Critical: HassJob cannot accept async methods directly.

HassJob(self.async_control_heating_loop) passes an async method to HassJob, which will raise ValueError at runtime. Home Assistant forbids passing coroutines to HassJob.

🔎 Proposed fix
-        self._control_heating_loop_unsub = async_call_later(self.hass, 5, HassJob(self.async_control_heating_loop))
+        self._control_heating_loop_unsub = async_call_later(
+            self.hass, 5, 
+            lambda _: self.hass.async_create_task(self.async_control_heating_loop())
+        )

677-677: Remove extraneous f-string prefix.

The f-string has no placeholders.

🔎 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 b9956a9 and d23e133.

📒 Files selected for processing (20)
  • custom_components/sat/boiler.py
  • custom_components/sat/climate.py
  • custom_components/sat/const.py
  • custom_components/sat/coordinator.py
  • custom_components/sat/cycles.py
  • custom_components/sat/esphome/__init__.py
  • custom_components/sat/fake/__init__.py
  • custom_components/sat/minimum_setpoint.py
  • custom_components/sat/mqtt/ems.py
  • custom_components/sat/mqtt/opentherm.py
  • custom_components/sat/number.py
  • custom_components/sat/overshoot_protection.py
  • custom_components/sat/serial/__init__.py
  • custom_components/sat/simulator/__init__.py
  • custom_components/sat/switch/__init__.py
  • custom_components/sat/translations/de.json
  • custom_components/sat/translations/es.json
  • custom_components/sat/translations/it.json
  • custom_components/sat/translations/nl.json
  • custom_components/sat/util.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • custom_components/sat/esphome/init.py
🧰 Additional context used
🧬 Code graph analysis (9)
custom_components/sat/mqtt/opentherm.py (1)
custom_components/sat/const.py (1)
  • DeviceState (231-233)
custom_components/sat/switch/__init__.py (2)
custom_components/sat/coordinator.py (1)
  • SatDataUpdateCoordinator (101-416)
custom_components/sat/const.py (1)
  • DeviceState (231-233)
custom_components/sat/util.py (5)
custom_components/sat/minimum_setpoint.py (2)
  • DynamicMinimumSetpoint (61-558)
  • MinimumSetpointConfig (48-50)
custom_components/sat/pid.py (1)
  • PID (15-239)
custom_components/sat/pwm.py (2)
  • PWM (38-266)
  • PWMConfig (30-35)
custom_components/sat/heating_curve.py (1)
  • HeatingCurve (10-91)
custom_components/sat/helpers.py (1)
  • convert_time_str_to_seconds (19-28)
custom_components/sat/overshoot_protection.py (2)
custom_components/sat/const.py (1)
  • DeviceState (231-233)
custom_components/sat/coordinator.py (1)
  • SatDataUpdateCoordinator (101-416)
custom_components/sat/serial/__init__.py (1)
custom_components/sat/const.py (1)
  • DeviceState (231-233)
custom_components/sat/cycles.py (4)
custom_components/sat/const.py (4)
  • CycleClassification (219-228)
  • CycleKind (212-216)
  • PWMStatus (236-239)
  • Percentiles (251-253)
custom_components/sat/helpers.py (3)
  • clamp (90-95)
  • min_max (112-118)
  • percentile_interpolated (121-137)
custom_components/sat/pwm.py (3)
  • PWMState (18-26)
  • state (259-266)
  • status (255-256)
custom_components/sat/coordinator.py (1)
  • ControlLoopSample (31-37)
custom_components/sat/boiler.py (2)
custom_components/sat/const.py (1)
  • BoilerStatus (189-209)
custom_components/sat/cycles.py (1)
  • Cycle (47-64)
custom_components/sat/mqtt/ems.py (1)
custom_components/sat/const.py (1)
  • DeviceState (231-233)
custom_components/sat/fake/__init__.py (2)
custom_components/sat/coordinator.py (1)
  • SatDataUpdateCoordinator (101-416)
custom_components/sat/const.py (1)
  • DeviceState (231-233)
🪛 Pylint (4.0.4)
custom_components/sat/cycles.py

[refactor] 47-47: Too many instance attributes (14/7)

(R0902)


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

(R0902)


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

(R0902)


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

(R0914)


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

(R0911)

custom_components/sat/boiler.py

[refactor] 34-34: Too many instance attributes (10/7)

(R0902)


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

(R0902)


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

(R0913)


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

(R0917)


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

(R0911)


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

(R0912)


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

(R0911)


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

(R0911)

custom_components/sat/climate.py

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

(R0912)

custom_components/sat/minimum_setpoint.py

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

(R0902)


[refactor] 286-289: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it

(R1705)


[refactor] 343-381: Unnecessary "elif" after "return", remove the leading "el" from "elif"

(R1705)


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

(R0911)


[refactor] 291-291: Too many branches (15/12)

(R0912)


[refactor] 383-383: Too many local variables (18/15)

(R0914)

🪛 Ruff (0.14.10)
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)


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

(F405)


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

(F405)


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

(F405)

custom_components/sat/simulator/__init__.py

87-87: Unused method argument: climate

(ARG002)


87-87: Unused method argument: control_sample

(ARG002)


87-87: Unused method argument: timestamp

(ARG002)


87-87: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

custom_components/sat/number.py

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

(F405)

custom_components/sat/cycles.py

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

(TRY003)


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

(TRY003)


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

(TRY003)


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

(RUF002)


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

(TRY003)

custom_components/sat/coordinator.py

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

(F403)


23-23: TYPE_CHECKING may be undefined, or defined from star imports

(F405)


30-30: dataclass may be undefined, or defined from star imports

(F405)


37-37: Optional may be undefined, or defined from star imports

(F405)


111-111: Optional may be undefined, or defined from star imports

(F405)


112-112: Optional may be undefined, or defined from star imports

(F405)


171-171: Optional may be undefined, or defined from star imports

(F405)


230-230: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


349-349: Unused method argument: climate

(ARG002)


349-349: Optional may be undefined, or defined from star imports

(F405)


349-349: Optional may be undefined, or defined from star imports

(F405)


349-349: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)


369-369: DeviceState may be undefined, or defined from star imports

(F405)

custom_components/sat/climate.py

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

(F403)


121-121: Optional may be undefined, or defined from star imports

(F405)


122-122: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


234-234: Unused method argument: event

(ARG002)


234-234: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


413-413: Optional may be undefined, or defined from star imports

(F405)


421-421: Optional may be undefined, or defined from star imports

(F405)


426-426: Optional may be undefined, or defined from star imports

(F405)


431-431: Optional may be undefined, or defined from star imports

(F405)


442-442: Optional may be undefined, or defined from star imports

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


677-677: f-string without any placeholders

Remove extraneous f prefix

(F541)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


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

(F405)


855-855: Optional 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)


887-887: Optional may be undefined, or defined from star imports

(F405)


905-905: Unused method argument: timestamp

(ARG002)


905-905: Optional may be undefined, or defined from star imports

(F405)

🔇 Additional comments (26)
custom_components/sat/translations/it.json (1)

192-192: Translation updates look consistent.

The spacing correction in line 192 and the standardization to the hyphenated "Pre-impostazioni" form across lines 207, 219, and 222 appear intentional and consistent throughout the file.

Also applies to: 207-207, 219-219, 222-222

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

92-92: LGTM! Translation improvements align with the refactoring.

The spacing adjustments to PID-related terminology (e.g., "PID-Regler parameter", "Integral term") improve readability and consistency with the cycle-centric architecture changes across the codebase.

Also applies to: 130-130, 184-184, 195-195

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

13-13: LGTM! Dutch translation improvements are consistent.

The spacing and hyphenation adjustments (e.g., "overschot beveiliging", "afgeleide term", "socket-verbinding") improve clarity and align with the standardized terminology introduced in this refactoring.

Also applies to: 92-92, 109-109, 191-192

custom_components/sat/util.py (1)

66-77: LGTM! PWM controller creation aligns with new architecture.

The updated signature accepting HeatingCurve and using PWMConfig is consistent with the cycle-centric refactoring. The configuration extraction and PWM instantiation follow the established patterns.

custom_components/sat/const.py (1)

189-264: LGTM! Cycle-centric enums and dataclasses are well-structured.

The new BoilerStatus, CycleKind, CycleClassification, RelativeModulationState, and Percentiles types properly support the architectural shift from flame-based to cycle-based heating control. The UNHEALTHY_CYCLES tuple provides a clear health classification boundary.

custom_components/sat/overshoot_protection.py (1)

5-6: LGTM! Import refactoring aligns with const.py reorganization.

Moving DeviceState import from .coordinator to .const is consistent with the broader public API reorganization where enums and constants are now centralized in const.py as part of the cycle-centric refactoring.

custom_components/sat/mqtt/opentherm.py (2)

7-7: LGTM: Import refactored to public const module.

The DeviceState import has been correctly moved from the coordinator module to the centralized const module, aligning with the broader cycle-centric architecture refactor.


166-184: LGTM: Tracked entities expanded to support cycle-centric architecture.

The additional tracked entities (DATA_MAXIMUM_CONTROL_SETPOINT, DATA_DHW_ENABLE, DATA_CENTRAL_HEATING, DATA_SLAVE_MEMBERID) provide more comprehensive device state information, which aligns with the refactor to a cycle-centric heating control system.

custom_components/sat/mqtt/ems.py (1)

8-8: LGTM: Import refactored to public const module.

Consistent with the broader refactor, DeviceState is now imported from the centralized const module.

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

8-9: LGTM: Imports refactored correctly.

The DeviceState import has been moved to the const module while SatDataUpdateCoordinator remains imported from the coordinator module, maintaining proper separation of concerns.

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

13-14: LGTM: Imports refactored to align with new architecture.

The DeviceState import has been correctly moved to the const module, consistent with the cycle-centric refactor across the codebase.

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

12-13: LGTM: Imports updated consistently with architecture refactor.

The import changes correctly separate the DeviceState enum (now in const) from the coordinator class, improving module organization.

custom_components/sat/number.py (3)

13-17: LGTM: Conditional entity instantiation based on coordinator capabilities.

The setup logic correctly gates entity creation on coordinator support flags.


59-66: Fixed bounds for hot water setpoint are reasonable.

The change from dynamic coordinator values to fixed 30-60°C bounds for hot water setpoint aligns with typical DHW temperature ranges.


73-123: New SatMaximumSetpointEntity implementation looks correct.

The entity follows the same pattern as SatHotWaterSetpointEntity with appropriate bounds (30-80°C for maximum setpoint). The implementation correctly delegates to coordinator.async_set_control_max_setpoint.

custom_components/sat/boiler.py (1)

60-81: Well-structured Boiler class with comprehensive status derivation.

The _derive_status method handles multiple boiler states clearly. The Pylint warnings about too many attributes/branches are acceptable given the domain complexity.

Also applies to: 184-259

custom_components/sat/minimum_setpoint.py (2)

61-94: Well-designed regime-based minimum setpoint learning with persistence.

The DynamicMinimumSetpoint class properly handles:

  • Regime state management with hysteresis-based bucketing
  • Persistence via Home Assistant's Store
  • Graceful handling of missing/corrupt stored data

Also applies to: 144-193


291-381: Tuning logic correctly adjusts minimum setpoint based on cycle classification.

The _maybe_tune_minimum method properly handles different cycle classifications with appropriate step sizes. The guards for tunable regime and space heating fraction prevent erroneous adjustments.

custom_components/sat/coordinator.py (2)

30-38: Good introduction of ControlLoopSample and debounced update pattern.

The ControlLoopSample dataclass properly captures the control loop state. The debouncer pattern for listener notification reduces redundant updates while ensuring timely state propagation.

Also applies to: 107-115


397-416: Debounced control update and listener notification flow.

The separation of async_update_control (boiler/cycle updates) from async_notify_listeners (HA state updates) with debouncing is a clean pattern that should reduce unnecessary processing.

custom_components/sat/cycles.py (2)

86-128: Well-designed CycleHistory with rolling window statistics.

The implementation properly:

  • Maintains separate windows for different time horizons (15m duty, 1h cycles, 4h median)
  • Prunes old entries on access
  • Provides accurate statistics via CycleStatistics snapshot

Also applies to: 195-221


498-544: Comprehensive cycle classification logic.

The _classify_cycle method correctly handles:

  • Ultra-short vs short vs long cycles
  • Overshoot vs underheat detection
  • PWM phase considerations for uncertain classification
  • Fallback to GOOD for stable cycles
custom_components/sat/climate.py (4)

222-232: Event listeners for cycle lifecycle are properly registered.

The cycle start/end event handlers correctly pass coordinator capabilities and cycle data to the minimum setpoint controller. Using async_on_remove ensures cleanup.


553-614: Well-structured PWM enable/disable logic with hysteresis.

The refactored pulse_width_modulation_enabled property with helper methods (_is_pwm_forced, _should_enable_static_pwm, _should_enable_dynamic_pwm) provides clear, maintainable decision logic with proper hysteresis to prevent flapping.


743-805: Improved setpoint control with persistence tracking.

The _async_control_setpoint method now includes:

  • Down-tick persistence to avoid premature setpoint drops
  • Near-target hold logic to prevent flame-off during active heating
  • Clear logging for debugging

This addresses potential issues with erratic setpoint changes.


939-945: No action required—device_state correctly provides a fresh state snapshot on each call.

The device_state property constructs a new BoilerState instance with current values each time it's accessed, ensuring ControlLoopSample receives the current state snapshot as intended.

Comment on lines +139 to +145
@property
def flame_on_since(self) -> Optional[int]:
return self._last_flame_on_at

@property
def flame_off_since(self) -> Optional[int]:
return self._last_flame_off_at
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: properties return Optional[int] but store Optional[float].

flame_on_since and flame_off_since are typed to return Optional[int] but _last_flame_on_at and _last_flame_off_at store float values (from monotonic()).

🔎 Proposed fix
     @property
-    def flame_on_since(self) -> Optional[int]:
+    def flame_on_since(self) -> Optional[float]:
         return self._last_flame_on_at

     @property
-    def flame_off_since(self) -> Optional[int]:
+    def flame_off_since(self) -> Optional[float]:
         return self._last_flame_off_at
🤖 Prompt for AI Agents
In custom_components/sat/boiler.py around lines 139 to 145, the properties
flame_on_since and flame_off_since are annotated as returning Optional[int] but
they return values stored from monotonic() (Optional[float]); update the
annotations to Optional[float] (or cast the stored float to int before
returning) so the return type matches the stored value; ensure any callers
expecting int are updated or handle the float accordingly.

_LOGGER.debug("Flame transition OFF->ON, starting new cycle.")
self._current_cycle_start = sample.timestamp
self._current_samples.clear()
self._hass.bus.fire(EVENT_SAT_CYCLE_STARTED, {"sample": sample})
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

EVENT_SAT_CYCLE_STARTED event also needs serialization.

The sample object is a ControlLoopSample dataclass and should also be serialized.

🔎 Proposed fix
-            self._hass.bus.fire(EVENT_SAT_CYCLE_STARTED, {"sample": sample})
+            self._hass.bus.fire(EVENT_SAT_CYCLE_STARTED, {"sample": asdict(sample)})
🤖 Prompt for AI Agents
In custom_components/sat/cycles.py around line 334, the code fires
EVENT_SAT_CYCLE_STARTED with a ControlLoopSample dataclass instance which is not
JSON-serializable; replace the raw sample in the event payload with a serialized
representation (e.g., use dataclasses.asdict(sample) or sample.to_dict() if the
dataclass provides one) and ensure any nested non-serializable fields are
converted to primitives before calling self._hass.bus.fire so the event payload
is fully serializable.


# Notify listeners to ensure the entities are updated
self.hass.async_create_task(self.async_notify_listeners())
self.hass.async_create_task(self.async_notify_listeners)
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: Missing parentheses when scheduling coroutine.

async_notify_listeners is passed as a method reference instead of being called. This means async_create_task receives a method object, not a coroutine, causing listeners to never be notified.

🔎 Proposed fix
-        self.hass.async_create_task(self.async_notify_listeners)
+        self.hass.async_create_task(self.async_notify_listeners())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.hass.async_create_task(self.async_notify_listeners)
self.hass.async_create_task(self.async_notify_listeners())
🤖 Prompt for AI Agents
In custom_components/sat/simulator/__init__.py around line 111, the code passes
the method reference self.async_notify_listeners to hass.async_create_task
instead of calling it, so a coroutine is never scheduled; call the coroutine by
changing the call to pass self.async_notify_listeners() (i.e.,
self.hass.async_create_task(self.async_notify_listeners())) so async_create_task
receives the coroutine object; ensure async_notify_listeners is an async def
coroutine and import any required helpers if needed.

"description": "Optimice su sistema de calefacción determinando automáticamente los valores PID óptimos para su configuración. Al seleccionar Ganancias Automáticas, tenga en cuenta que el sistema pasará por un proceso de calibración que puede tardar aproximadamente 20 minutos en completarse.\n\nSe recomienda Ganancias Automáticas para la mayoría de los usuarios, ya que simplifica el proceso de configuración y asegura un rendimiento óptimo. Sin embargo, si está familiarizado con el control PID y prefiere configurar los valores manualmente, puede optar por omitir las Ganancias Automáticas.\n\nTenga en cuenta que elegir omitir las Ganancias Automáticas requiere un buen entendimiento del control PID y puede requerir ajustes manuales adicionales para lograr un rendimiento óptimo.",
"menu_options": {
"calibrate": "Calibrar y determinar su valor de protección contra sobrecalentamiento (aprox. 20 min).",
"calibrate": "Calibrar y determinar su valor de protección contra sobrecalentamiento (approx. 20 min).",
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

Incorrect abbreviation: use Spanish "aprox." instead of English "approx."

The change introduces an English abbreviation into Spanish translations. "Approx." is English; the correct Spanish abbreviation is "aprox." (for "aproximadamente").

🔎 Proposed fix
-          "calibrate": "Calibrar y determinar su valor de protección contra sobrecalentamiento (approx. 20 min).",
+          "calibrate": "Calibrar y determinar su valor de protección contra sobrecalentamiento (aprox. 20 min).",
📝 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
"calibrate": "Calibrar y determinar su valor de protección contra sobrecalentamiento (approx. 20 min).",
"calibrate": "Calibrar y determinar su valor de protección contra sobrecalentamiento (aprox. 20 min).",
🤖 Prompt for AI Agents
In custom_components/sat/translations/es.json around line 34, the Spanish string
uses the English abbreviation "approx."; replace it with the correct Spanish
abbreviation "aprox." so the line reads that the calibration takes "aprox. 20
min" (keep punctuation and spacing consistent with surrounding entries).

Comment on lines +191 to +192
"derivative": "El término derivativo (kD) en el controlador PID, responsable de mitigar el sobre impulso.",
"derivative_time_weight": "Un parámetro para ajustar la influencia del término derivativo a lo largo del tiempo, especialmente útil para reducir el infra impulso durante la fase de calentamiento cuando el coeficiente de la curva de calefacción está correctamente ajustado.",
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

Preserve compound technical terms: "sobreimpulso" and "infraimpulso"

In Spanish technical terminology, "sobreimpulso" (overshoot) and "infraimpulso" (undershoot) are typically written as single compound words without spaces. The spacing changes ("sobre impulso", "infra impulso") deviate from standard technical conventions and may reduce clarity for users familiar with these terms.

🔎 Proposed fix
-          "derivative": "El término derivativo (kD) en el controlador PID, responsable de mitigar el sobre impulso.",
-          "derivative_time_weight": "Un parámetro para ajustar la influencia del término derivativo a lo largo del tiempo, especialmente útil para reducir el infra impulso durante la fase de calentamiento cuando el coeficiente de la curva de calefacción está correctamente ajustado.",
+          "derivative": "El término derivativo (kD) en el controlador PID, responsable de mitigar el sobreimpulso.",
+          "derivative_time_weight": "Un parámetro para ajustar la influencia del término derivativo a lo largo del tiempo, especialmente útil para reducir el infraimpulso durante la fase de calentamiento cuando el coeficiente de la curva de calefacción está correctamente ajustado.",
📝 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
"derivative": "El término derivativo (kD) en el controlador PID, responsable de mitigar el sobre impulso.",
"derivative_time_weight": "Un parámetro para ajustar la influencia del término derivativo a lo largo del tiempo, especialmente útil para reducir el infra impulso durante la fase de calentamiento cuando el coeficiente de la curva de calefacción está correctamente ajustado.",
"derivative": "El término derivativo (kD) en el controlador PID, responsable de mitigar el sobreimpulso.",
"derivative_time_weight": "Un parámetro para ajustar la influencia del término derivativo a lo largo del tiempo, especialmente útil para reducir el infraimpulso durante la fase de calentamiento cuando el coeficiente de la curva de calefacción está correctamente ajustado.",
🤖 Prompt for AI Agents
In custom_components/sat/translations/es.json around lines 191 to 192, the
Spanish translations split technical compound terms "sobreimpulso" and
"infraimpulso" into two words ("sobre impulso", "infra impulso"); update those
strings to use the single-word standard terms "sobreimpulso" and "infraimpulso"
respectively, preserving the rest of each sentence and punctuation exactly
as-is.

"advanced": "Opciones Avanzadas",
"general": "General",
"presets": "Preajustes",
"presets": "Pre ajustes",
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 standard Spanish term: "preajuste" and "Preajustes" (single words)

The changes split "preajuste" into "pre ajuste" throughout the presets section. In Spanish, "preajuste" (preset) is the standard form written as one word. Splitting it is non-standard and affects terminology consistency.

🔎 Proposed fix
         "menu_options": {
           "advanced": "Opciones Avanzadas",
           "general": "General",
-          "presets": "Pre ajustes",
+          "presets": "Preajustes",
           "system_configuration": "Configuración del Sistema"
         }
       },
       "presets": {
         "data": {
           "activity_temperature": "Temperatura de Actividad",
           "away_temperature": "Temperatura de Ausencia",
           "comfort_temperature": "Temperatura de Confort",
           "home_temperature": "Temperatura de Casa",
           "sleep_temperature": "Temperatura de Sueño",
           "push_setpoint_to_thermostat": "Sincronizar punto de ajuste con el termostato",
-          "sync_climates_with_preset": "Sincronizar climas con pre ajuste (sueño / ausencia / actividad)"
+          "sync_climates_with_preset": "Sincronizar climas con preajuste (sueño / ausencia / actividad)"
         },
         "description": "Configuraciones de temperatura predefinidas para diferentes escenarios o actividades.",
-        "title": "Pre ajustes"
+        "title": "Preajustes"

Also applies to: 219-220, 222-222

🤖 Prompt for AI Agents
In custom_components/sat/translations/es.json around lines 207 (and also apply
to 219-220, 222), the term "Pre ajustes"/"pre ajuste" is split into two words;
replace these with the standard single-word Spanish "Preajuste" (capitalized
where used as a label/title) or "preajuste"/"preajustes" as appropriate for
singular/plural to ensure consistency across the presets section.

"sample_time": "L'intervallo di tempo minimo tra gli aggiornamenti del controllore PID.",
"target_temperature_step": "Regolare il passo della temperatura target per un'accurata regolazione dei livelli di comfort.",
"thermal_comfort": "Abilitare l'uso dell'Indice di Simmer per l'aggiustamento del comfort termico."
"thermal_comfort": "Abilitare l'uso dell Indice di Simmer per l'aggiustamento del comfort termico."
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 Italian grammar: missing apostrophe in "dell Indice".

The text contains a grammatical error. "dell Indice" should be "dell'Indice" (with apostrophe, no space) according to Italian grammar rules for articles before words starting with vowels.

🔎 Proposed fix
-          "thermal_comfort": "Abilitare l'uso dell Indice di Simmer per l'aggiustamento del comfort termico."
+          "thermal_comfort": "Abilitare l'uso dell'Indice di Simmer per l'aggiustamento del comfort termico."
📝 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
"thermal_comfort": "Abilitare l'uso dell Indice di Simmer per l'aggiustamento del comfort termico."
"thermal_comfort": "Abilitare l'uso dell'Indice di Simmer per l'aggiustamento del comfort termico."
🤖 Prompt for AI Agents
In custom_components/sat/translations/it.json around line 170, the Italian
string has a missing apostrophe: "dell Indice" should be "dell'Indice"; update
the string to remove the space and add the apostrophe so it reads "Abilitare
l'uso dell'Indice di Simmer per l'aggiustamento del comfort termico."

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.

3 participants