-
-
Notifications
You must be signed in to change notification settings - Fork 28
Refactor heating control to cycle-centric architecture #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
… improved multiple states determination
WalkthroughReplaces 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
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)
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()
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
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. Comment |
There was a problem hiding this 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_setpointassumes presence ofCONF_MINIMUM_SETPOINTin config
minimum_setpointcurrently does:return max(float(self._config_data.get(CONF_MINIMUM_SETPOINT)), MINIMUM_SETPOINT)If
CONF_MINIMUM_SETPOINTis missing orNoneinconfig_data(e.g. due to a migrated or manually edited entry),float(None)will raise aTypeErrorand 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_pidnow returns early with a warning whencurrent_outside_temperatureisNone:if self.current_outside_temperature is None: _LOGGER.warning("Current outside temperature is not available. Skipping PID control.") returnPreviously, 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_loopalso depends onself.current_outside_temperatureandself.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
timestampparameter should use explicitOptionalorT | Nonesyntax 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 robustnessThe updated tests align with the new control flow (
async_control_pid(reset=True)followed byasync_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). Usingtemplate.DOMAIN/sensor.DOMAINin 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.approxto 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 stateSwitching the three properties to
Optional[float]matches how the internal fields are initialized and used (reset toNoneuntil 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) asOptional[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.constand drop unused local constantRuff’s F405 warning about
HEATING_SYSTEM_UNDERFLOORstems from the wildcard import; it also makes it harder to see which constants this module actually needs. Additionally,MAX_BOILER_TEMPERATURE_AGE = 300is 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_AGEdefinition.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 importThe new
SatCycleSensorand the adjustedSatBoilerSensor.availablelogic look consistent with the cycle‑centric model:
SatCycleSensor.native_valuereturnsCycleClassification.INSUFFICIENT_DATA.namewhen there is nolast_cycle, otherwise the classification name, which matches the new enum.SatBoilerSensor.availabletreatingBoilerStatus.INSUFFICIENT_DATAas “unavailable” is reasonable as long asdevice_statusis always aBoilerStatusmember (neverNone).Two follow‑ups worth considering:
- Coordinator defaults: Double‑check that
self._coordinator.device_statusis initialized toBoilerStatus.INSUFFICIENT_DATA(or anotherBoilerStatusmember) and neverNone, sonative_value’sdevice_status.nameaccess is always safe.- Imports:
from .const import *is triggering Ruff F403/F405 and hides which names come fromconst. 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 simplifiedThe
valueproperty’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_minimumwhile preserving behavior.
203-214: Unnecessaryint()aroundround()when computing the regime bucket
round(base_setpoint / width)already returns anintin Python 3 when called withoutndigits, so the outerint(...)is redundant:- bucket = int(round(base_setpoint / width)) + bucket = round(base_setpoint / width)This addresses the Ruff
RUF046hint without changing semantics.
20-64: Config / helper fields appear unused; consider pruning or wiring through
MinimumSetpointConfig.target_min_on_time_secondsandminimum_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_basewithminimum_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 explicitis Nonecheck for heating curve availabilityIn
updateyou 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 forlast_duty_cycle_percentageinPWMState
PWM.statecurrently 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_percentageis 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.constobscures which symbols are required
from .const import *together with use ofCONF_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 unusedclimateargument forasync_control_heating_loop
async_control_heating_loopstill takes aclimate: Optional[SatClimate] = Noneargument 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_directionto reduce complexityBoth
_derive_statusand_modulation_directionhave 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_reliablevs 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 modeIn
extra_state_attributesyou 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_setpointSo when
_dynamic_minimum_setpointisFalse:
- PWM/control logic uses the coordinator’s static
minimum_setpoint.- The attribute
minimum_setpointstill reports the dynamic controller’s learned value (which you continue to update insideasync_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 flagInside
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 theminimum_setpointattribute.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
📒 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, andPWMConfig.
48-54: LGTM! Factory function correctly updated.The function now returns
DynamicMinimumSetpointwith properMinimumSetpointConfiginitialization. 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
PWMConfigandheating_curveparameters.custom_components/sat/cycles.py (4)
30-70: LGTM! Well-designed immutable data structures.The dataclasses use
frozen=Trueandslots=Truefor immutability and memory efficiency. TheCycledataclass 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_15mcorrectly 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
updatemethod correctly callsarea.pid.update(area.error, area.heating_curve.value)to match the refactored PID controller signature that no longer requiresboiler_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
SatCycleHealthSensorcorrectly useslast_cycle.classificationto 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
timestampandclimateparameters 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 comparisonsThe new
CycleKind,CycleClassification,BoilerStatus,RelativeModulationState, andUNHEALTHY_CYCLESdefinitions 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 removedFlameStatusvalues 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 soundInitializing and wiring the new components:
_boiler: Boiler = Boiler()_cycles: CycleHistory = CycleHistory()_cycle_tracker: CycleTracker = CycleTracker(self._cycles)and then updating both from
async_add_listenercallbacks and fromasync_control_heating_loopusing the synthesizedBoilerStateplus a monotonictimestampensures 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 consistentThe new
Boilerclass 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_overshootin 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_enablednow:
- Immediately disables PWM when
_calculated_setpointisNone.- Forces PWM on when setpoint management is unsupported or
_force_pulse_width_modulationis true (_is_pwm_forced).- Respects
_overshoot_protectionto allow full disabling via config.- Switches between:
_should_enable_static_pwm()when_dynamic_minimum_setpointis false, comparing coordinatorminimum_setpointto_calculated_setpointwith and withoutBOILER_DEADBANDdepending on currentpwm.enabled._should_enable_dynamic_pwm()when_dynamic_minimum_setpointis true, using the learnedminimum_setpoint.valueplus 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_cycleisNone” rule in_should_enable_dynamic_pwmshould also help prevent flapping at startup.Also applies to: 565-613
211-236: Debounced PID scheduling looks correct and should reduce thrashThe new
schedule_control_pid/async_control_pidflow:
- 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_unsubensuring only one pending run at a time.- Clears
_control_pid_unsubat the start ofasync_control_pidso subsequent changes can schedule again.And
async_control_piditself:
- Skips when
current_outside_temperatureis 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 loopIn
async_control_heating_loopyou 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): returnCombining 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
There was a problem hiding this 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 modulationstateandenabledare still inverted w.r.t. PWM.Current logic:
if self._pulse_width_modulation_enabled: return RelativeModulationState.OFF return RelativeModulationState.PWM_OFFand:
return self.state != RelativeModulationState.OFFmeans:
- PWM enabled → state =
OFF→enabled == False- PWM disabled → state =
PWM_OFF→enabled == TrueDownstream,
SatClimate.relative_modulation_valueusesenabledto decide whether to useMINIMUM_RELATIVE_MODULATIONor_maximum_relative_modulation, so this inversion causes the system to clamp to the minimum modulation when PWM is actually active.You likely want
enabledto beTruewhen 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.OFFThis aligns
enabledwith “relative modulation allowed / active” and matches the prior review feedback.custom_components/sat/pwm.py (1)
17-24: PWM config/state refactor is solid; butmaximum_cyclescan still produce invalid (negative) OFF times.The new
PWMConfig/PWMStatetypes andstateexposure look good and make PWM much easier to introspect.The earlier concern about
maximum_cyclesdriving 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 * 2In 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_thresholdFor sufficiently large
maximum_cycles(e.g. > ~40),_on_time_maximum_thresholdbecomes smaller than_on_time_lower_threshold, makingoff_timenegative. That then feeds into transitions usingself._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_cyclesat 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 forentity_id.The switch to only import
SERVICE_RESET_INTEGRALis consistent with dropping the PWM service. However, the service schema still useslist[str]as the validator:schema=vol.Schema({vol.Required("entity_id"): list[str]})In Voluptuous, you typically want something like
[str]or Home Assistant'scv.entity_idshelper, notlist[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 andUNHEALTHY_CYCLESlook coherent; consider makingBoilerStatusastrenum for consistency.The new cycle‑related enums and
UNHEALTHY_CYCLEStuple are consistent and easy to consume across the codebase.One minor consistency nit:
BoilerStatusis declared as a plainEnum, whereasCycleKind,CycleClassification,PWMStatus, andRelativeModulationStateall inherit fromstr, Enum. IfBoilerStatusvalues are ever exposed to Home Assistant as state/attributes or used in logging without.value, having it inherit fromstr, Enumwould 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 andminimum_setpointfloor look good;climatearg inasync_control_heating_loopis unused.The move to a composed
Boiler+CycleHistory/CycleTrackerand the newstate/cycles/last_cycleproperties looks coherent, and tighteningminimum_setpointwith:return max(float(self._config_data.get(CONF_MINIMUM_SETPOINT)), MINIMUM_SETPOINT)is a nice safety improvement.
One small cleanup:
async_control_heating_loopnow takes aclimate: Optional[SatClimate] = Noneargument, and callers passclimate=self, but the parameter is not used anywhere in the method. This is causing the static analysisARG002warning and adds noise to the public API.Unless you expect to use the
climateparameter 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:
- Line 231: PEP 484 prohibits implicit
Optional. The parameter should be explicitly typed asOptional[float] = None.- Line 232: The
timestamp or monotonic()pattern incorrectly treats0.0as falsy, which would usemonotonic()even when a valid timestamp of0.0is provided. Use an explicitNonecheck 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, and0.1should be defined as module-level constants (similar toOVERSHOOT_MARGIN_CELSIUSand 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 modeThen 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.0on 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/hourThen 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
📒 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=Trueandslots=Trueis excellent for immutability and memory efficiency. TheCycledataclass 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
Cycleobject. The local_avghelper is appropriately scoped.
There was a problem hiding this 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 inupdate/ tuning
DynamicMinimumSetpoint.updateincrementsregime_state.completed_cyclesand calls_maybe_tune_minimumon every invocation wherelast_cycleis non‑None(Lines 134–136), butRegimeStatehas no notion of which cycle was last processed. If the caller passes the last completedCyclerepeatedly between new completions (a common pattern for “last completed cycle” APIs), this will:
- Inflate
completed_cyclesfar beyond the true number of physical cycles.- Apply the same classification adjustment multiple times for a single real cycle, quickly driving
minimum_setpointup 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_cyclesaligned 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 ofvalueclamping logicThe 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_regimefor “low‑load” semanticsCurrent 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_15mhere (see previous comment).- Optionally rename or document
low_load_minimum_cycles_per_hourto 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
📒 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 soundBucketing setpoints into coarse
regime_band_widthbands 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 correctlyThe large‑jump damping logic around
base_setpointtransitions is well‑contained: it no‑ops when there is no previous base, respectsmax_setpoint_jump_without_damping, damps toward the absolute minimum usinglarge_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.
There was a problem hiding this 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: FixEVENT_HOMEASSISTANT_STOPlistener to avoid unsafehass.async_create_taskusage.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_taskfrom 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_taskusage 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_hintchoice 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_cycletype hints with actualOptionalusage.The implementation correctly handles missing data (
average_setpoint is None,max_flow_temperature is None) and returnsINSUFFICIENT_DATA/UNCERTAINappropriately, but the signature still types these asfloat: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 asOptional[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_enabledplus_is_pwm_forced,_should_enable_static_pwm, and_should_enable_dynamic_pwmhelpers make the decision-making around PWM explicit and easier to reason about:
- Require a valid
_calculated_setpointbefore 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.valueand recentlast_cycle.classificationviaUNHEALTHY_CYCLES.Behavior-wise this is sound. As a small polish, a brief comment on the intended hysteresis in
_should_enable_static_pwmand on the “no history yet: keep current PWM state” strategy in_should_enable_dynamic_pwmwould help future readers understand why PWM doesn’t flap at startup.
795-824:_async_control_setpointmatches PWM state model; first branch is currently unreachable.The refactored
_async_control_setpointaligns 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_valuewhenPWMStatus.ON, andMINIMUM_SETPOINTwhen PWM is effectively “off”.- Always delegates the final setpoint application to the coordinator.
Given
async_control_heating_loopalready early-returns ifhvac_mode != HVACMode.HEAT, the top branch that sets_setpoint = MINIMUM_SETPOINTfor non-HEAT modes is currently unreachable. Not harmful, but you could simplify by either removing that branch or documenting that_async_control_setpointis intended to be reusable from other call sites in the future.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 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 andcycles_last_hournormalization look correct.Validation of the rolling windows and
cycles_last_hourrate is solid: you validate positive window sizes, prune before computing, and correctly normalize the cycle count byself._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-
Nonesamples, and collect min/max temps is logically sound and robust to missing telemetry. The thresholds forCycleKind(> 0.8,< 0.2, etc.) are simple and easy to reason about, and thesample_count/minimum_samples_per_cycleguard 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, andasync_control_pid(reset=True)viaEVENT_HOMEASSISTANT_STARTEDwhen 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_setpointasync_added_to_hassare ordered sensibly.
395-438: State exposure for dynamic minimum setpoint and PWM state is consistent.Adding
minimum_setpoint: self.minimum_setpoint.valueand switching PWM-related attributes toself.pwm.status.name/self.pwm.state.duty_cyclekeeps 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_setpointnow cleanly returnsMINIMUM_SETPOINTwhen the heating curve has no value and otherwise usesround(self.heating_curve.value + self.pid.output, 1). Combined with the new debounce inasync_control_pid, this gives a clear separation between curve+PID math and later clamping/filtering.
634-641:minimum_setpoint_valuenicely encapsulates dynamic vs. static behavior.Routing the minimum setpoint through
minimum_setpoint.valuewhen_dynamic_minimum_setpointis enabled, and otherwise falling back toself._coordinator.minimum_setpoint, centralizes this choice and keeps_async_control_setpointand other callers simple.
669-693: Usingschedule_control_pidfor sensor changes is a good debouncing strategy.Switching the inside/outside/humidity sensor handlers to call
schedule_control_pid()(andschedule_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 withschedule_control_pidcovers key cases.The updated
_async_climate_changedlogic 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
_roomsin 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_changeis 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_temperatureis missing.- You reset PID/area PIDs when
pid.last_updatedexceeds_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_resetvspid.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_setpointwith a low-pass filter and clamps viaclamp(..., MINIMUM_SETPOINT, self._coordinator.maximum_setpoint).- Drives PWM via
pulse_width_modulation_enabledand then passesself.pwm.statusinto_async_control_setpoint.These steps are consistent and keep boiler control within sane bounds.
1073-1078: Resetting PID viaschedule_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)andreset_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.
There was a problem hiding this 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: SameHassJobcoroutine issue: wrapasync_control_heating_loopin a callback.Line 968 has the same issue as line 909: passing a coroutine function (
self.async_control_heating_loop) directly toHassJobwill raiseValueErrorat 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 definedlow_load_maximum_duty_ratio_15mthreshold.As flagged in previous reviews, the comments for
increase_stepanddecrease_stepremain 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_15mis defined but never used. Add a check in_is_tunable_regime(lines 334–348) to gate tuning whenstatistics.duty_ratio_last_15mexceeds this threshold.Based on past review comments.
112-140: [Duplicate] Prevent repeated tuning on the same completed cycle.As flagged in previous reviews, the
updatemethod incrementsregime_state.completed_cyclesand calls_maybe_tune_minimumevery timelast_cycleis non-None(lines 134–137), but there is no guard to ensure each physical cycle is processed only once.Since
last_cycleinCycleHistoryis the last completed cycle and remains constant until a new one finishes, your heating loop will callupdate(...)many times for the sameCycle, re-applying the same classification adjustment on each call.Consequences:
- For a single bad cycle (e.g.,
TOO_SHORT_UNDERHEAT),minimum_setpointwill 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.0could move the learned minimum by many degrees based on a single cycle.Fix: Track the last processed cycle identity (e.g.,
last_cycle.endorid(last_cycle)) inRegimeStateorDynamicMinimumSetpoint, and skip_maybe_tune_minimumwhen 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.endAlso update storage load/save logic (lines 141–203) to persist/restore
last_processed_cycle_end.custom_components/sat/climate.py (1)
893-909: [Duplicate] FixHassJobwith coroutine function: wrap in callback that schedules the coroutine.As flagged in previous reviews, line 909 passes
self.async_control_pid(a coroutine function) toHassJob. Whenasync_call_latercalls it, it will return a coroutine object, which Home Assistant forbids passing toHassJob(raisesValueError).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_uncertainmethod.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
📒 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_pwmimproves 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 toschedule_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.
There was a problem hiding this 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: Negativeoff_timeissue with highmaximum_cyclesalready flagged.The threshold calculation issue where high
maximum_cyclescan produce negativeoff_timehas been identified in a previous review. The suggested fix to clamp_on_time_maximum_thresholdand usemax(0, ...)foroff_timeshould be applied.custom_components/sat/minimum_setpoint.py (2)
28-35: Config comments are inverted relative to actual behavior.The comments on
increase_stepanddecrease_stepare reversed compared to how they're applied in_maybe_tune_minimum:
TOO_SHORT_UNDERHEAT→minimum_setpoint -= decrease_step(Line 352)TOO_SHORT_OVERSHOOT/SHORT_CYCLING_OVERSHOOT→minimum_setpoint += increase_step(Line 362)Additionally,
low_load_maximum_duty_ratio_15mis defined but never used in tuning logic.
140-156: Tuning occurs on everyon_cycle_endcall without deduplication.The
on_cycle_endmethod incrementscompleted_cyclesand calls_maybe_tune_minimumevery time it's invoked. If the samelast_cycleobject 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 fromstrfor consistency with other enums.
BoilerStatusinherits only fromEnum, whileCycleKind,CycleClassification,PWMStatus, andRelativeModulationStateall 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 theUNHEALTHY_CYCLESconstant for maintainability.The health check logic manually excludes
GOOD,UNCERTAIN, andINSUFFICIENT_DATA, which is the inverse of theUNHEALTHY_CYCLEStuple defined inconst.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_CYCLESThen update the
is_onproperty:@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_CYCLEScustom_components/sat/pwm.py (1)
139-142: Redundant assignment indisable().
reset()already setsself._enabled = Falseat line 68, so the assignment at line 142 is redundant.def disable(self) -> None: """Disable the PWM control.""" self.reset() - self._enabled = Falsecustom_components/sat/minimum_setpoint.py (1)
116-116: Unusedboiler_stateparameter.The
boiler_stateparameter is accepted but never used inon_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: Unusedclimateparameter inasync_control_heating_loop.The
climateparameter 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 implicitfloat = Noneper PEP 484:timestamp: Optional[float] = None
119-120: Listener callbacks invoke properties that may trigger side effects.The listeners call
self.stateandself.last_cyclewhich 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
dequewithmaxlen=50would 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_startandon_cycle_endmethods 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 explicitOptionaltype hint per PEP 484.The
timestampparameter uses implicitOptional(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_statemethod 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
📒 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!
CycleKindandCycleClassificationenums are well-structured with the(str, Enum)pattern, enabling clean serialization for Home Assistant state attributes.
226-238: LGTM!
RelativeModulationStatefollows the(str, Enum)pattern andUNHEALTHY_CYCLEScorrectly 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, andpwm_stateas 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
BoilerStatusenum.custom_components/sat/area.py (2)
181-188: LGTM!The
outputmethod correctly handles the case where no areas have valid heating curve values by falling back toMINIMUM_SETPOINT.
58-64: RemoveHVACMode.OFFfrom 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 nameSENSOR_TEMPERATURE_IDand docstring say "sensor." Temperature sensors never have state "off"—only climate entities do.Two options:
- If only temperature sensors should be supported: remove
HVACMode.OFFfrom the check (it's unnecessary and misleading).- If both temperature sensors AND climate entities are supported: rename the constant to something like
TEMPERATURE_OVERRIDE_ENTITY_IDor 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=Trueandslots=Truefor efficiency.PWMStatecleanly encapsulates the observable state, andPWMConfigcleanly encapsulates configuration.
238-244: LGTM!The
stateproperty correctly wraps internal state into an immutablePWMStateobject, with proper handling of the optionallast_duty_cycle_percentage.custom_components/sat/coordinator.py (2)
102-104: LGTM: Clean initialization of cycle-centric components.The new
Boiler,CycleHistory, andCycleTrackercomposition replaces the old flame-centric tracking cleanly. The wiring is straightforward and the responsibility separation is clear.
152-158: LGTM: Newcyclesandlast_cycleproperties.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_statusmethod 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 sincetime_since_off < self._anti_cycling_min_off_seconds(Line 293) would also beFalsefor 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_pwmhelpers 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
CycleHistoryclass correctly manages rolling windows for cycle tracking with proper pruning, duty ratio calculation, and median computation. Thecycles_last_hourproperty 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_durationby 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_cyclemethod handles the various scenarios correctly:
- Returns
INSUFFICIENT_DATAwhen duration or setpoint is missing- Returns
UNCERTAINfor 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
CycleKinduse> 0.8and< 0.2with an intermediate check for> 0.1. Consider what happens when both fractions are exactly0.1— this falls through toUNKNOWN. This seems intentional but verify it matches the expected behavior.
| 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)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.")There was a problem hiding this 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
📒 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_CHECKINGguard 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
UNCERTAINwhen both overshoot and underheat conditions are ambiguous).
310-391: LGTM!The cycle building logic correctly aggregates samples, handles
Nonevalues gracefully in averages and min/max calculations, and uses sensible thresholds for kind classification. The local_avghelper 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!
CycleHistoryis well-implemented with proper validation, efficientdequeusage for rolling windows, and correct pruning semantics. The_current_time_hintpattern elegantly handles the absence of a system clock by deriving time from recorded data.
413-414: No issues found withduty_cycleaccess.The type definition
Optional[Tuple[int, int]]guarantees that whenduty_cycleis not None (checked by the truthy condition), it always contains exactly 2 elements. Therefore, accessingpwm_state.duty_cycle[0]is safe and no additional length check is needed.Likely an incorrect or invalid review comment.
… heating loop control, and clean up unused lines.
There was a problem hiding this 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: PotentialNonepassed toPID.updatewherefloatis expected.The guard at lines 168 and 177 only checks
area.error is not Nonebut notarea.heating_curve.value. Sinceheating_curve.valuecan beNone(asState.valueisOptional[float]), andPID.updateexpects afloat, 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
timestampparameter defaults toNonebut is typed asfloat, 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
Cycledataclass 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_stepanddecrease_stepare inverted relative to their actual usage in_maybe_tune_minimum. Also,low_load_maximum_duty_ratio_15mis 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 underheatingAnd 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_foralways returns aRegimeState(creating one if missing), so the return type should not beOptional.- 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", causingint()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 0custom_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_laterrequires a sync callback. Passing an async function directly toHassJobwill 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.0is 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 adict[str, Area]keyed byentity_idfor O(1) lookups.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 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
Nonecases and usefloat_valuefor safe type conversion.
59-64: Questionable fallback logic and HVACMode check.Two concerns:
Line 61: Comparing
sensor_state.stateagainstHVACMode.OFFseems semantically incorrect.HVACMode.OFFis 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.Line 64: Falling back to
target_temperaturewhencurrent_temperatureis 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 withNoneheating curve values before computing the maximum, providing a safe fallback toMINIMUM_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, andCycleStatisticsdataclasses are well-structured withfrozen=Trueandslots=Truefor immutability and memory efficiency. WhileCyclehas 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_hourto 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_statemethod correctly:
- Validates minimum sample requirements
- Computes cycle kind based on DHW/heating fractions
- Calculates accurate averages and extremes
- Handles None values appropriately
The
_classify_cyclemethod 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_minimummethod 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
partialfor 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 enablementThe 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_setpointfor 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) andasync_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
There was a problem hiding this 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_15mis unused.The comments on
increase_stepanddecrease_stepdescribe the opposite of what the code does:
increase_stepis applied for overshoot/short-cycling (to raise the minimum)decrease_stepis applied for underheat (to lower the minimum)Additionally,
low_load_maximum_duty_ratio_15mis 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 asOptional[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 causesint()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
%sand%.1fformat specifiers instead of f-string placeholders. This will log a literal%sinstead 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.debugis preferred for lazy evaluation.
🧹 Nitpick comments (3)
custom_components/sat/minimum_setpoint.py (3)
101-107: Consider simplifying withmax().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:
- Clearing regimes and starting fresh on version mismatch
- 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 unusedboiler_stateparameter.The
boiler_stateparameter is unused inon_cycle_start. If this is intentional for API consistency withon_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
📒 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=Truefor 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 minimumUNCERTAIN: relax toward baseWhile 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_15mconfig 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
clamphelper.
…ates, and improve sensor tracking logic.
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.
There was a problem hiding this 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: PotentialAttributeErrorwhenerrorisNone.The
errorproperty (lines 452-460) returnsNonewhentarget_temperatureorcurrent_temperatureis unavailable. Line 399 unconditionally accesses.valueon 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: ConvertCycledataclass to JSON-serializable dict before firing event.Home Assistant's event bus requires event data to be JSON-serializable for persistence and logging. The
Cycledataclass 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: Highmaximum_cyclescan produce negativeoff_time.The threshold calculations can produce invalid duty times when
maximum_cyclesis large:_on_time_upper_threshold = 3600 / max(1, maximum_cycles) # Line 51 _on_time_maximum_threshold = _on_time_upper_threshold * 2 # Line 52At Line 178, the special low-duty case computes:
off_time = self._on_time_maximum_threshold - self._on_time_lower_thresholdExample failure: If
maximum_cycles = 50:
_on_time_upper_threshold = 72 seconds_on_time_maximum_threshold = 144 secondsoff_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_cyclesat configuration time to ensure these invariants hold.custom_components/sat/coordinator.py (1)
335-335: Fix parameter issues inasync_control_heating_loopsignature.Two issues with the method signature:
- Unused parameter:
climateis never referenced in the method body- Implicit
Optional:timestamp: float = Noneviolates 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
climateis needed for future use, prefix it with_climateto indicate it's intentionally unused.custom_components/sat/sensor.py (3)
87-87: Fix implicitOptionaltype annotation.PEP 484 prohibits implicit
Optional. Thearea_idparameter defaults toNonebut is typed asstr.🔎 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 againstNonePID access innative_valueandextra_state_attributes.Home Assistant may call
native_valueandextra_state_attributesbefore checkingavailable. When_pidisNone, accessing its attributes will raiseAttributeError.🔎 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: GuardSatErrorValueSensorwhen climate error isNone.Line 299 accesses
self._climate.error.valuewithout checking iferrorisNone, which will raiseAttributeError.🔎 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 Nonecustom_components/sat/climate.py (2)
774-778: PotentialTypeErrorwhen formattingNonesetpoint in debug log.If
hvac_mode == HVACMode.HEATbut_last_requested_setpointisNone(e.g., before the first heating loop completes), this branch is entered becausepulse_width_modulation_enabledreturnsFalse. Line 778 will then fail withTypeError: must be real number, not NoneTypewhen 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: ImportOptionalexplicitly to resolve F405 warnings.The type annotations use
Optional[float]butOptionalis 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 aPIDConfigdataclass 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] = Noneparameter is intentionally unused but required for theasync_listen_oncecallback signature. The static analysis warning (ARG002) can be silenced with_eventnaming 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
📒 Files selected for processing (10)
custom_components/sat/climate.pycustom_components/sat/config_flow.pycustom_components/sat/coordinator.pycustom_components/sat/cycles.pycustom_components/sat/heating_curve.pycustom_components/sat/pid.pycustom_components/sat/pwm.pycustom_components/sat/sensor.pycustom_components/sat/serial/sensor.pycustom_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, andPWMConfigtypes introduced by the cycle-centric refactor.custom_components/sat/pid.py (1)
196-198: Good None-safety guard inupdate_derivative.The early return when
self._last_error is NonepreventsTypeErrorwhen computing the derivative. This is essential since_last_errorisOptional[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_setpointfield 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_enabledproperty correctly:
- Guards against
Nonesetpoint (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
Nonevalues 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 usesitems()for Area objects.Line 256 uses
self.areas.items()to iterate over Area objects (needed to callasync_will_remove_from_hass), while other locations correctly useself.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_setpointfield. This ensures clean state transitions when mode or target changes.
507-529: Thepid.output,areas.pids.output, andareas.pids.overshoot_capare all Python@propertydecorated 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'sHassJobis 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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, OptionalOption 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.
| 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.
There was a problem hiding this 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
Cycledataclass 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
Cycledataclass contains nested dataclasses (CycleTailMetrics,Percentiles) and enums (CycleKind,CycleClassification) that cannot be directly serialized to JSON.🔎 Proposed fix
Import
asdictat the top of the file:from dataclasses import dataclass +from dataclasses import asdictThen 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: Useasync_create_taskinstead ofcreate_task.Based on learnings, this issue was flagged in a previous review and remains unresolved. Home Assistant's
HomeAssistantobject usesasync_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:
Comments are inverted: The comments at lines 32-33 contradict the implementation in
_maybe_tune_minimum:
- Line 364:
TOO_SHORT_UNDERHEATdecreases minimum usingdecrease_step- Line 374:
TOO_SHORT_OVERSHOOTincreases minimum usingincrease_stepThe comments should state:
increase_stepapplies when minimum is too low (overshoot/short-cycling), anddecrease_stepapplies when minimum is too high (underheat).Unused parameter:
low_load_maximum_duty_ratio_15mis defined but never used. Add a check in_is_tunable_regimeto 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 redundantint()cast.The
//operator already returns an integer, making the outerint()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
📒 Files selected for processing (2)
custom_components/sat/cycles.pycustom_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_statemethod has 20 local variables and substantial logic, which Pylint flags. However, the method is cohesive and performs a single well-defined task: constructing a completeCycleobject 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.
|
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) |
There was a problem hiding this 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
📒 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
DynamicMinimumSetpointandPWMConfigclasses introduced in this PR.
64-75: Remove unused_config_dataparameter from function signature.The
_config_dataparameter is not used in the function body and should be removed from the signature at line 64. Theconfig_optionsparameter already provides all required configuration values; theconfig_dataargument is redundant.
custom_components/sat/util.py
Outdated
| 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) | ||
| )) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Check all usages of create_dynamic_minimum_setpoint_controller
rg -n 'create_dynamic_minimum_setpoint_controller' -A 3 -B 3Repository: 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 2Repository: 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 -50Repository: Alexwijn/SAT
Length of output: 38
🏁 Script executed:
# Read the MinimumSetpointConfig class definition
cat custom_components/sat/minimum_setpoint.pyRepository: 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 2Repository: 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 -80Repository: 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 2Repository: 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.pyRepository: 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 5Repository: 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 30Repository: 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 -20Repository: 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.
There was a problem hiding this 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 implicitOptionaltype annotation and unusedclimateparameter.The
timestampparameter defaults toNonebut is typed asfloat, violating PEP 484. Theclimateparameter 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: Addasdictimport and use it to serialize theCycledataclass for the event bus.The
Cycledataclass 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. Importasdictfromdataclassesand 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 forOptionalandTYPE_CHECKING.The code relies on
OptionalandTYPE_CHECKINGbeing available via the star import from.const, which is fragile. If these are removed fromconst.py, this file will break. Consider importing them explicitly fromtyping.🔎 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
📒 Files selected for processing (2)
custom_components/sat/coordinator.pycustom_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
stateproperty correctly constructs aBoilerStatewith all required fields sourced from coordinator properties and the internal_boilerinstance.
153-159: LGTM!Clean delegation to
CycleHistoryfor cycle statistics and last cycle access.
380-384: LGTM!The control update correctly delegates to
Boiler.update()andCycleTracker.update(). Both methods handle timestamp internally viamonotonic()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
Cycleis appropriate for a comprehensive data container representing cycle state.
92-116: LGTM!Good defensive validation of window parameters. The use of
dequefor 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
Cycleobject. 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.
| @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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this 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_intervalcall 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_reliableis set toFalse(Line 381), there's no path to restore it toTrue. 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 againstTypeErrorwhen accessingduty_cycle[0].Line 446 accesses
pwm_state.duty_cycle[0]without first checking ifpwm_state.duty_cycleitself is notNone. According to thePWMStatedataclass,duty_cycleisOptional[Tuple[int, int]], so this will raise aTypeErrorifduty_cycleisNone.- 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: Useasync_create_taskinstead ofcreate_taskfor Home Assistant.Line 169 calls
self._hass.create_task(...)from synchronous context. Home Assistant'sHomeAssistantobject usesasync_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:
Lines 32-33: The comments for
increase_stepanddecrease_stepare inverted relative to the implementation:
- Line 368:
TOO_SHORT_UNDERHEATdecreases the minimum using-= 1.0- Line 374:
TOO_SHORT_OVERSHOOTincreases the minimum using+= 1.0The comments should state:
increase_stepis applied when the minimum is too low (overshoot/short-cycling), anddecrease_stepis applied when the minimum is too high (underheat/too short).Line 38:
low_load_maximum_duty_ratio_15mis 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 FalseBased 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 redundantint()cast.Line 286 wraps
round()inint(), butround()already returns anintin 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
📒 Files selected for processing (4)
custom_components/sat/boiler.pycustom_components/sat/const.pycustom_components/sat/cycles.pycustom_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)
custom_components/sat/cycles.py
Outdated
|
|
||
| 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}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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 2Repository: 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 10Repository: 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 2Repository: 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 pyRepository: Alexwijn/SAT
Length of output: 38
🏁 Script executed:
# Check if there are any tests for the event firing
fd -e py test | head -20Repository: 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 5Repository: 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.pyRepository: 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.
There was a problem hiding this 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
Cycledataclass instance. Home Assistant's event bus requires JSON-serializable data for persistence. According to previous reviews, this should be converted usingdataclasses.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 implicitOptionaltype 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 againstNonewhen accessingduty_cycle[0].Line 465 accesses
pwm_state.duty_cycle[0]without verifying thatpwm_state.duty_cycleis notNone. According to thePWMStatedefinition,duty_cycleisOptional[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 matchesasync_notify_listeners.Same issue as in
esphome/__init__.py: Line 112 passesself.async_notify_listenersas a method reference. Ifasync_notify_listenersexpects 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: Useasync_create_taskinstead ofcreate_task.Line 147 calls
self._hass.create_task(...), but Home Assistant'sHomeAssistantobject usesasync_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 raiseTypeErrorif the key is absent (sinceget()returnsNone). 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 implicitOptionaltype annotation and clarify unused parameter.Line 332 has
timestamp: float = None, which violates PEP 484. Theclimateparameter 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
climateis 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.updatedirectly as theEVENT_HOMEASSISTANT_STARTEDhandler. However, event handlers receive anEventobject, whileupdate()expectsdatetime | 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:listhas noget()method.Line 346 calls
self._areas.get(entity_id), butself._areasis alist[Area](not a dict). This will raiseAttributeErrorat runtime. Use the helper method defined in the parentAreasclass: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: returnOr better, change
_PIDs.__init__to accept the parentAreasinstance and call itsget()method:if (area := self._parent.get(entity_id)) is None: returnThis 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 thefprefix.🔎 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 againstAttributeErrorwhenself.errorisNone.The
errorproperty (line 452) can returnNonewhen temperature values are unavailable. Line 399 unconditionally accessesself.error.value, which will raiseAttributeErrorwhenerrorisNone.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: PotentialNonein setpoint assignment and logging.Lines 776 and 778 use
self._last_requested_setpointwhich can beNone:
- Line 770 explicitly sets it to
Nonewhen not in HEAT mode- Line 914 shows it starts as
Nonebefore first heating loopWhile line 778 would fail with
TypeErrorif_last_requested_setpointisNone, the real issue is line 776 settingself._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 comparesNone > COLD_SETPOINTwhich will raiseTypeError.🔎 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
📒 Files selected for processing (9)
custom_components/sat/area.pycustom_components/sat/climate.pycustom_components/sat/const.pycustom_components/sat/coordinator.pycustom_components/sat/cycles.pycustom_components/sat/esphome/__init__.pycustom_components/sat/helpers.pycustom_components/sat/minimum_setpoint.pycustom_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 matchesasync_notify_listeners.Line 166 passes
self.async_notify_listeners(method reference) toasync_create_task. According to the coordinator snippet,async_notify_listenershas a signaturedef async_notify_listeners(self, force: bool = True). When passing a method reference without parentheses, the task will call it without arguments, which should work sinceforcehas a default value. However, if the intent was to passforce=Trueexplicitly or control theforceparameter, 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 outNonevaluesaverage: safely computes mean with empty-list handlingmin_max: returns both extremes in one passpercentile_interpolated: implements linear interpolation for percentilesThe 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
DynamicMinimumSetpointsystem 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, andcompleted_cyclescounters- 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,RelativeModulationStateprovide 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_WEIGHTSenables per-area demand weighting- PWM margins:
PWM_ENABLE_MARGIN_CELSIUSandPWM_DISABLE_MARGIN_CELSIUSprovide clear hysteresis thresholdsRemoving
FlameStatusand 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_trackerreplace flame-centric logic- Debouncing: Proper use of
Debouncerfor both listener updates and control updates prevents update storms- Renamed properties:
state()andcycles()are clearer than the previous names- Async lifecycle:
async_will_remove_from_hasscorrectly delegates to boiler cleanup- Timestamp handling: Line 335 properly defaults to
monotonic()when timestamp is not providedThe integration with
CycleTracker.update()andBoiler.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:
CycleTrackerproperly detects flame ON/OFF transitions and manages cycle lifecycle- Statistics:
CycleHistorymaintains rolling windows with correct pruning logic- Tail metrics: Percentile-based tail analysis provides robust cycle characterization
- Classification:
_classify_cycleimplements sensible rules for detecting overshoot, underheat, and short-cycling- Event integration: Fires
EVENT_SAT_CYCLE_STARTEDandEVENT_SAT_CYCLE_ENDEDat appropriate timesThe 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
AreaandAreasclasses provide clean per-room control:
- Room weighting:
room_weightconfiguration enables user-defined per-room influence on demand calculations- Demand calculation:
demand_weightproperly scales base weight by room configuration- Heat arbitration:
requires_heatintelligently 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.outputmethod implements a sensible percentile-based approach with headroom- Overshoot protection:
overshoot_capcomputes cooling-driven caps based on per-room overshoot severityThe integration with
HeatingCurveand 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_enabledproperty 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. ThePID.outputattribute is defined as a@propertyin the PID class, making it a computed property that should be accessed without parentheses. Lines 513, 520, and 521 correctly accessself.pid.outputandself.areas.pids.outputwithout parentheses, consistent with the entire codebase.
726-726: Thereset()method signature atcustom_components/sat/area.py:338acceptsentity_idas 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 withnew_state.entity_idis valid and properly supported by the custom API.
| async_track_state_report_event( | ||
| self.hass, [self.inside_sensor_entity_id], self._async_inside_sensor_reported | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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 70Repository: Alexwijn/SAT
Length of output: 2588
🏁 Script executed:
# Search for EventStateReportedData and EventStateChangedData usage/definitions
rg -n 'EventStateReportedData|EventStateChangedData' --type=py -B2 -A2Repository: 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 -A2Repository: 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.pyRepository: 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 -A1Repository: 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 -100Repository: 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.pyRepository: 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)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
…ues (median and percentiles) in cycle statistics, improving tuning precision.
There was a problem hiding this 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
Cycledataclass directly to the Home Assistant event bus. Home Assistant requires event data to be JSON-serializable for persistence and logging. TheCycledataclass 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 dataclassThen 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.pyexpects aCycleobject, 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:
- Detects temperature stalls over a 120s window
- Extends the ON phase in 30s increments when error > 2°C, up to 180s total
- 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_TIMEFRAMEis 60, themax(..., 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-202This 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
📒 Files selected for processing (2)
custom_components/sat/cycles.pycustom_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 toflow_temperatureis 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
PWMStateAPI 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'
| # 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 | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 * 2When 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 = -36Negative 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.
| 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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the PWM class structure and state variables
cat -n custom_components/sat/pwm.py | head -150Repository: 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 -30Repository: 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 -40Repository: 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.pyRepository: 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.pyRepository: 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 -50Repository: 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=pyRepository: 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.pyRepository: Alexwijn/SAT
Length of output: 1353
🏁 Script executed:
# Check the complete PWMState property definition
sed -n '331,345p' custom_components/sat/pwm.pyRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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 -20Repository: Alexwijn/SAT
Length of output: 1380
🏁 Script executed:
# Verify the full restore method and understand context
sed -n '322,335p' custom_components/sat/climate.pyRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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.
There was a problem hiding this 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 – highmaximum_cyclescan produce negative OFF times.The thresholds are still derived solely from
maximum_cycleswithout safeguards:self._on_time_upper_threshold = 3600 / max(1, self._config.maximum_cycles) self._on_time_maximum_threshold = self._on_time_upper_threshold * 2When
maximum_cyclesis large (e.g., 50),_on_time_maximum_threshold(144s) becomes smaller than_on_time_lower_threshold(180s). This causes negativeoff_timeat line 271:off_time = self._on_time_maximum_threshold - self._on_time_lower_threshold # e.g., 144 - 180 = -36Negative 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_cycleat 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_cyclesat configuration time to ensure3600 / maximum_cycles >= 120.
94-98:_duty_cycleis persisted but still not restored.While debug logging was added (line 98), the core issue remains:
pulse_width_modulation_duty_cycleis exposed in entity attributes and persisted, but the restore() method does not restore_duty_cyclefrom 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_intervalreturns 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_reliableis set toFalse(line 392), there's no path to set it back toTrue. 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
passstatement and serves no purpose. Either add the intended type-only imports or remove the block entirely.🔎 Proposed cleanup
-if TYPE_CHECKING: - passcustom_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
PWMConfigor 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
Boilerclass has 14 constructor parameters (similar toPWMConfigpattern used in pwm.py). While all parameters have defaults and the current approach works, aBoilerConfigdataclass 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 parametersThen constructor becomes:
def __init__(self, config: BoilerConfig) -> None: self._config = config
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
custom_components/sat/boiler.pycustom_components/sat/const.pycustom_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
CycleKindandCycleClassificationenums 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
BoilerStatusenum provides detailed state tracking (includingINSUFFICIENT_DATA) which should help diagnose the user-reported issues with boiler status and cycle tracking. TheUNHEALTHY_CYCLEStuple correctly groups problematic cycle classifications for easy filtering.
245-248: ThePercentilesdataclass is complete as designed. Nomake_from_cycle_samplesmethod 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
PWMStatedataclass, 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 reportedduty_cycletuple 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_cyclemethod 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_DATAis returned whenlast_cycle is None(no cycle completed), not from cycles being classified with that status (which only happens ifduration <= 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_statusmethod 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_ignitionlogic 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 withupdate()itself but with how the Home AssistantUpdateCoordinatorschedules listener notifications.The boiler.update() method correctly sequences state updates and status derivation. However, Home Assistant changed the
UpdateCoordinatorAPI to require explicit listener notifications viaasync_update_listeners()and proper handling of_unschedule_refresh(). Without these changes, entity state updates won't propagate to Home Assistant after initialization, even thoughupdate()continues to execute. Ensure the coordinator implementation usesasync_update_listeners()to push updates to listeners and overrides_unschedule_refresh()to detect when no listeners are present.
…erature and "end on phase"
…ier step for improved classification accuracy.
… are respected during state changes.
There was a problem hiding this 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: ConvertCycledataclass to dict before firing event.Home Assistant's event bus requires JSON-serializable data for persistence and logging. The
Cycledataclass contains enum fields (CycleKind,CycleClassification) and nestedCycleTailMetricsthat 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_cycleattribute is exposed in entity attributes (persisted to Home Assistant state), but therestore()method only restores theenabledflag. The_duty_cyclefield 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
📒 Files selected for processing (2)
custom_components/sat/cycles.pycustom_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 viaasync_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_startat 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 fromclimate.py→async_control_heating_loop()→_cycle_tracker.update()appears sound, with the guard at line 386-387 checking that_last_pwm_stateis available.If users are seeing
INSUFFICIENT_DATAclassifications, the actual cause is likely one of:
- Line 524: cycle duration calculated as
<= 0.0(thoughmax(0.0, end_time - start_time)at line 393 should prevent this)- Line 556:
tail_p90_deltareturningNone(which returnsUNCERTAIN, notINSUFFICIENT_DATA)- Line 396-398: cycles discarded due to fewer than 3 samples collected during the cycle
- Runtime issue where
pwm_stateisNoneat the call site, preventing cycle tracking from engagingDebug 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.
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 = 72seconds (line 51)_on_time_maximum_threshold = 72 * 2 = 144seconds (line 52)_on_time_lower_threshold = 180seconds (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 = -36Negative 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.
There was a problem hiding this 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: AddCONF_MAXIMUM_SETPOINTtoOPTIONS_DEFAULTS.
CONF_MAXIMUM_SETPOINTis referenced in util.py (line 51) but missing fromOPTIONS_DEFAULTS. This causesconfig_options.get(CONF_MAXIMUM_SETPOINT)to returnNonefor existing installations, breaking theMinimumSetpointConfiginitialization.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_SETPOINTconstant value defined at line 35.custom_components/sat/fake/__init__.py (1)
1-1: Fix duplicateannotationsimport.Line 1 imports
annotationstwice 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_SETPOINTstill lacks a default value.Line 51 passes
config_options.get(CONF_MAXIMUM_SETPOINT)without a default toMinimumSetpointConfig. SinceCONF_MAXIMUM_SETPOINTis not inOPTIONS_DEFAULTS(confirmed in const.py lines 107-160), it will returnNonefor existing configurations, causing a type error whenMinimumSetpointConfigexpects afloat.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_SETPOINTtoOPTIONS_DEFAULTSin const.py to ensure it's neverNone.custom_components/sat/simulator/__init__.py (1)
87-87: Fix implicitOptionaltype annotation.The
timestampparameter defaults toNonebut is typed asfloat, 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_intervalreturns 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_reliableis set toFalse, 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: Useasync_create_taskinstead ofcreate_taskfor Home Assistant.Home Assistant's
HomeAssistantobject usesasync_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: PotentialTypeErrorifCONF_MINIMUM_SETPOINTis missing.
self._config_data.get(CONF_MINIMUM_SETPOINT)returnsNoneif the key is absent, andfloat(None)raises aTypeError.🔎 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 implicitOptionaltype annotation.The
timestampparameter defaults toNonebut is typed asfloat, violating PEP 484. Theclimateparameter 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: ConvertCycledataclass to dict before firing event.Home Assistant's event bus requires JSON-serializable data for persistence and logging. The frozen
Cycledataclass contains nested dataclasses (CycleMetricswithPercentiles) 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: PotentialAttributeErrorwhenerrorisNone.The
errorproperty can returnNone(lines 436-437), butextra_state_attributesunconditionally accesses.valueon 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:HassJobcannot accept async methods directly.
HassJob(self.async_control_heating_loop)passes an async method toHassJob, which will raiseValueErrorat runtime. Home Assistant forbids passing coroutines toHassJob.🔎 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
📒 Files selected for processing (20)
custom_components/sat/boiler.pycustom_components/sat/climate.pycustom_components/sat/const.pycustom_components/sat/coordinator.pycustom_components/sat/cycles.pycustom_components/sat/esphome/__init__.pycustom_components/sat/fake/__init__.pycustom_components/sat/minimum_setpoint.pycustom_components/sat/mqtt/ems.pycustom_components/sat/mqtt/opentherm.pycustom_components/sat/number.pycustom_components/sat/overshoot_protection.pycustom_components/sat/serial/__init__.pycustom_components/sat/simulator/__init__.pycustom_components/sat/switch/__init__.pycustom_components/sat/translations/de.jsoncustom_components/sat/translations/es.jsoncustom_components/sat/translations/it.jsoncustom_components/sat/translations/nl.jsoncustom_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
HeatingCurveand usingPWMConfigis 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, andPercentilestypes properly support the architectural shift from flame-based to cycle-based heating control. TheUNHEALTHY_CYCLEStuple provides a clear health classification boundary.custom_components/sat/overshoot_protection.py (1)
5-6: LGTM! Import refactoring aligns with const.py reorganization.Moving
DeviceStateimport from.coordinatorto.constis 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
DeviceStateimport 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,
DeviceStateis now imported from the centralized const module.custom_components/sat/fake/__init__.py (1)
8-9: LGTM: Imports refactored correctly.The
DeviceStateimport has been moved to the const module whileSatDataUpdateCoordinatorremains 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
DeviceStateimport 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
DeviceStateenum (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: NewSatMaximumSetpointEntityimplementation looks correct.The entity follows the same pattern as
SatHotWaterSetpointEntitywith appropriate bounds (30-80°C for maximum setpoint). The implementation correctly delegates tocoordinator.async_set_control_max_setpoint.custom_components/sat/boiler.py (1)
60-81: Well-structured Boiler class with comprehensive status derivation.The
_derive_statusmethod 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
DynamicMinimumSetpointclass 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_minimummethod 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 ofControlLoopSampleand debounced update pattern.The
ControlLoopSampledataclass 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) fromasync_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-designedCycleHistorywith 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
CycleStatisticssnapshotAlso applies to: 195-221
498-544: Comprehensive cycle classification logic.The
_classify_cyclemethod 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_removeensures cleanup.
553-614: Well-structured PWM enable/disable logic with hysteresis.The refactored
pulse_width_modulation_enabledproperty 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_setpointmethod 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_statecorrectly provides a fresh state snapshot on each call.The
device_stateproperty constructs a newBoilerStateinstance with current values each time it's accessed, ensuringControlLoopSamplereceives the current state snapshot as intended.
| @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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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).", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| "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).
| "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.", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| "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", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| "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."
Summary by CodeRabbit
New Features
Bug Fixes
Refactor
Removed
✏️ Tip: You can customize this high-level summary in your review settings.