-
-
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
|
Warning Rate limit exceeded@Alexwijn has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 17 minutes and 30 seconds before requesting another review. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (3)
WalkthroughReplaces flame-centric components with cycle- and boiler-centric subsystems; adds CycleTracker/CycleHistory, Boiler, DynamicMinimumSetpoint, PWMConfig/PWMState, per-area Areas/PID aggregation and new sensors; removes flame module, SetpointAdjuster, PWM service and related tests; updates coordinator, climate, simulator, factories, constants and persistence keys. Changes
Sequence Diagram(s)sequenceDiagram
participant Climate as SatClimate
participant Coord as SatDataUpdateCoordinator
participant Boiler as Boiler
participant Cycles as CycleTracker
participant MinSP as DynamicMinimumSetpoint
participant PWM as PWM
participant AreasPIDs as Areas._PIDs
participant PID as PID
Note over Climate: PID control & cycle integration
Climate->>Coord: schedule_control_pid()
Climate->>Coord: async_control_heating_loop(timestamp)
Coord->>Boiler: update(state, last_cycle, timestamp)
Coord->>Cycles: update(state, pwm_state?, timestamp)
Cycles->>Cycles: detect & classify cycle
Cycles-->>Coord: emit EVENT_SAT_CYCLE_STARTED / EVENT_SAT_CYCLE_ENDED
Coord->>MinSP: on_cycle_start/on_cycle_end(..., requested_setpoint, outside_temp)
AreasPIDs->>PID: update(entity_id)
PID->>AreasPIDs: output()
Climate->>PWM: enable/disable/update(requested_setpoint, boiler_state)
Climate->>Coord: async_set_control_setpoint(requested_setpoint)
sequenceDiagram
participant Climate
participant MinSP
participant Store as HA_Store
Note over Climate,MinSP: Minimum-setpoint persistence lifecycle
Climate->>MinSP: async_added_to_hass(hass, device_id)
MinSP->>Store: load regimes
Note over MinSP: runtime tuning on classified cycles
MinSP->>Store: async_save_regimes() on changes or shutdown
Climate->>MinSP: async_will_remove_from_hass()
MinSP->>Store: async_save_regimes()
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Areas needing focused review:
Possibly related PRs
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
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.
custom_components/sat/cycles.py
Outdated
| def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: float = None) -> None: | ||
| timestamp = timestamp or monotonic() |
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.
🛠️ Refactor suggestion | 🟠 Major
Fix implicit Optional type annotation.
PEP 484 prohibits implicit Optional. The timestamp parameter defaults to None but is typed as float.
-def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: float = None) -> None:
+def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: Optional[float] = None) -> None:📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: float = None) -> None: | |
| timestamp = timestamp or monotonic() | |
| def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None, timestamp: Optional[float] = None) -> None: | |
| timestamp = timestamp or monotonic() |
🧰 Tools
🪛 Ruff (0.14.5)
246-246: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
🤖 Prompt for AI Agents
In custom_components/sat/cycles.py around lines 246-247, the update method
currently types timestamp as float while defaulting it to None; change the
parameter annotation to Optional[float] (or float | None for Python 3.10+) so
the signature reads timestamp: Optional[float] = None (ensure Optional is
imported), leaving the existing timestamp = timestamp or monotonic() logic
unchanged.
… 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.
…r updates, enhance PWM handling, and clean up unused code.
…s, and clean up unused code.
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 (8)
custom_components/sat/cycles.py (2)
250-251: Fix implicitOptionaltype annotation fortimestampparameter.The
timestampparameter defaults toNonebut is typed asfloat. PEP 484 prohibits implicitOptional.- def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: float = None) -> None: + def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: Optional[float] = None) -> None:
298-299: ConvertCycledataclass to dict before firing event.The
Cycledataclass is passed directly to the event bus, but Home Assistant events require JSON-serializable data. This will cause issues with event logging and persistence.+from dataclasses import asdict cycle_state = self._build_cycle_state(pwm_status, timestamp) -self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle_state}) +if cycle_state is not None: + self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle_state)})custom_components/sat/minimum_setpoint.py (3)
137-137: Fix malformed f-string missing placeholders.The f-string uses
%formatting syntax which won't work. Remove thefprefix or convert to proper f-string formatting.- _LOGGER.debug(f"Updated regime minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint) + _LOGGER.debug("Updated regime minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint)
29-31: Comments forincrease_stepanddecrease_stepare inverted.The comments describe the opposite of what the code does:
increase_step(line 30) is used when minimum is too low (overshoot/short-cycling) — lines 333-334decrease_step(line 31) is used when minimum is too high (underheat) — lines 323-324, 340-341- increase_step: float = 1.0 # when minimum is too low (underheat / too short) - decrease_step: float = 1.0 # when minimum is too high (overshoot / short-cycling) + increase_step: float = 1.0 # when minimum is too low (overshoot / short-cycling) + decrease_step: float = 1.0 # when minimum is too high (underheat)
221-234: Return type annotation is incorrect.
_regime_foralways returns aRegimeState(creating one if it doesn't exist at lines 225-229), but the return type is annotated asOptional[RegimeState].- def _regime_for(self, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: float, outside_temperature: Optional[float]) -> Optional[RegimeState]: + def _regime_for(self, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: float, outside_temperature: Optional[float]) -> RegimeState:custom_components/sat/boiler.py (2)
117-128: Store unsubscribe handle for periodic save cleanup.The return value of
async_track_time_intervalshould be stored and called during cleanup. Currently, the periodic save continues even if the boiler is removed.+ self._save_unsub: Optional[Callable[[], None]] = None ... - async_track_time_interval(hass, self.async_save_options, timedelta(minutes=15)) + self._save_unsub = async_track_time_interval(hass, self.async_save_options, timedelta(minutes=15))Add cleanup in
async_save_optionsor create anasync_will_remove_from_hassmethod:async def async_will_remove_from_hass(self) -> None: if self._save_unsub is not None: self._save_unsub() self._save_unsub = None await self.async_save_options()
358-384: Modulation reliability detection only marks as unreliable, never recovers.Once
_modulation_reliableis set toFalse(line 383), there's no path to restore it toTrue. If the boiler temporarily reports stuck modulation during startup but later reports varying values, reliability won't be restored until restart.Consider adding recovery logic when variation is detected:
if len(self._modulation_values_when_flame_on) < self._modulation_reliability_min_samples: return values = self._modulation_values_when_flame_on max_value = max(values) min_value = min(values) + # Recovery: if we see meaningful variation, restore reliability + if max_value - min_value >= self._modulation_delta_threshold: + if not self._modulation_reliable: + self._modulation_reliable = True + _LOGGER.info("Modulation reliability restored after detecting variation.") + return + if max_value - min_value < 1e-3 and abs(max_value) < 1e-3: # Modulation is effectively stuck at ~0 while the flame is on. self._modulation_reliable = Falsecustom_components/sat/climate.py (1)
717-717: Remove extraneous f-string prefix.The f-string uses
%formatting syntax with no{}placeholders.- _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature) + _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)
🧹 Nitpick comments (5)
custom_components/sat/cycles.py (1)
157-162: Consider making the staleness threshold configurable.The hardcoded 3600-second (1 hour) staleness threshold for
last_cyclemay not suit all use cases. Consider extracting this as a class constant or constructor parameter for flexibility.+LAST_CYCLE_STALENESS_SECONDS: float = 3600.0 + @property def last_cycle(self) -> Optional[Cycle]: - if self._last_cycle is None or (monotonic() - self._last_cycle.end) > 3600: + if self._last_cycle is None or (monotonic() - self._last_cycle.end) > LAST_CYCLE_STALENESS_SECONDS: return None return self._last_cyclecustom_components/sat/pid.py (1)
19-31: Consider using a configuration dataclass to reduce constructor parameters.The constructor has 9 parameters, which is flagged by Pylint. While acceptable for a controller, extracting configuration into a dataclass would improve maintainability and align with the pattern used elsewhere (e.g.,
PWMConfig,MinimumSetpointConfig).custom_components/sat/sensor.py (1)
63-66: Use explicitOptionalor union syntax for type annotation.PEP 484 prohibits implicit
Optional. Thearea_idparameter should use explicit typing.- def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None): + def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: Optional[str] = None):custom_components/sat/coordinator.py (1)
332-346: Remove unusedclimateparameter or document its intended use.The
climateparameter is declared but never used inasync_control_heating_loop. This was flagged by static analysis (ARG002).If the parameter is for future use, add a comment; otherwise, remove it:
- async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None: + async def async_control_heating_loop(self, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None:Also fix the implicit
Optionalfortimestamp:- async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None: + async def async_control_heating_loop(self, pwm_status: PWMStatus | None = None, timestamp: float | None = None) -> None:custom_components/sat/area.py (1)
213-216: Avoid catching blindException.Catching broad
Exceptioncan mask programming errors. Consider catching specific exceptions or at minimum logging the exception type.try: outputs.append(area.pid.output) - except Exception as exception: + except (TypeError, AttributeError) as exception: _LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
custom_components/sat/area.py(3 hunks)custom_components/sat/boiler.py(1 hunks)custom_components/sat/climate.py(21 hunks)custom_components/sat/config_flow.py(1 hunks)custom_components/sat/const.py(2 hunks)custom_components/sat/coordinator.py(7 hunks)custom_components/sat/cycles.py(1 hunks)custom_components/sat/minimum_setpoint.py(1 hunks)custom_components/sat/pid.py(4 hunks)custom_components/sat/sensor.py(5 hunks)custom_components/sat/util.py(3 hunks)tests/test_climate.py(8 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
custom_components/sat/pid.py (2)
custom_components/sat/errors.py (1)
Error(37-40)custom_components/sat/helpers.py (1)
seconds_since(11-16)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/boiler.py (1)
BoilerState(21-35)custom_components/sat/const.py (2)
CycleClassification(188-195)CycleKind(181-185)custom_components/sat/cycles.py (4)
CycleStatistics(69-74)Cycle(42-65)duty_ratio_last_15m(127-140)sample_count_4h(108-114)custom_components/sat/helpers.py (1)
clamp(90-95)
custom_components/sat/cycles.py (3)
custom_components/sat/const.py (3)
CycleKind(181-185)CycleClassification(188-195)PWMStatus(221-224)custom_components/sat/helpers.py (1)
clamp(90-95)custom_components/sat/boiler.py (1)
BoilerState(21-35)
custom_components/sat/coordinator.py (1)
custom_components/sat/cycles.py (6)
CycleTracker(223-432)CycleHistory(77-220)Cycle(42-65)update(250-309)last_cycle(158-162)statistics(165-173)
custom_components/sat/boiler.py (2)
custom_components/sat/const.py (2)
BoilerStatus(198-218)CycleClassification(188-195)custom_components/sat/cycles.py (2)
Cycle(42-65)last_cycle(158-162)
tests/test_climate.py (3)
custom_components/sat/climate.py (4)
async_control_pid(859-888)async_control_heating_loop(908-963)setpoint(526-527)requested_setpoint(530-539)custom_components/sat/coordinator.py (3)
async_control_heating_loop(332-350)setpoint(167-168)state(138-151)custom_components/sat/pwm.py (1)
state(239-244)
🪛 GitHub Check: build (3.13)
tests/test_climate.py
[failure] 59-59:
test_scenario_1[domains0-data0-options0-config0]
assert (2400, 0) == (277, 922)
At index 0 diff: 2400 != 277
Full diff:
(
-
277, -
922,
-
2400, -
)
0,
[failure] 103-103:
test_scenario_2[domains0-data0-options0-config0]
assert 58.0 == 10
- where 58.0 = .setpoint
[failure] 156-156:
test_scenario_3[domains0-data0-options0-config0]
assert 7719193.0 == 33.5
- where 7719193.0 = .requested_setpoint
🪛 Pylint (4.0.4)
custom_components/sat/pid.py
[refactor] 19-19: Too many arguments (9/5)
(R0913)
[refactor] 19-19: Too many positional arguments (9/5)
(R0917)
custom_components/sat/minimum_setpoint.py
[refactor] 24-24: Too many instance attributes (18/7)
(R0902)
[refactor] 104-105: Consider using 'minimum_setpoint = max(minimum_setpoint, allowed_minimum)' instead of unnecessary if block
(R1731)
[refactor] 115-115: Too many arguments (6/5)
(R0913)
[refactor] 115-115: Too many positional arguments (6/5)
(R0917)
[refactor] 139-139: Too many arguments (6/5)
(R0913)
[refactor] 139-139: Too many positional arguments (6/5)
(R0917)
[refactor] 267-267: Too many arguments (6/5)
(R0913)
[refactor] 267-267: Too many positional arguments (6/5)
(R0917)
[refactor] 267-267: Too many return statements (9/6)
(R0911)
[refactor] 267-267: Too many branches (13/12)
(R0912)
custom_components/sat/cycles.py
[refactor] 42-42: Too many instance attributes (16/7)
(R0902)
[refactor] 311-311: Too many local variables (24/15)
(R0914)
[refactor] 395-395: Too many return statements (7/6)
(R0911)
custom_components/sat/boiler.py
[refactor] 21-21: Too many instance attributes (9/7)
(R0902)
[refactor] 38-38: Too many instance attributes (24/7)
(R0902)
[refactor] 39-39: Too many arguments (14/5)
(R0913)
[refactor] 39-39: Too many positional arguments (14/5)
(R0917)
[refactor] 156-156: Too many return statements (18/6)
(R0911)
[refactor] 156-156: Too many branches (17/12)
(R0912)
[refactor] 299-299: Too many return statements (7/6)
(R0911)
[refactor] 385-385: Too many return statements (7/6)
(R0911)
custom_components/sat/climate.py
[refactor] 621-621: Too many return statements (7/6)
(R0911)
🪛 Ruff (0.14.7)
custom_components/sat/pid.py
57-57: HEATING_SYSTEM_UNDERFLOOR may be undefined, or defined from star imports
(F405)
110-110: DEADBAND may be undefined, or defined from star imports
(F405)
115-115: DEADBAND may be undefined, or defined from star imports
(F405)
162-162: DEADBAND may be undefined, or defined from star imports
(F405)
custom_components/sat/sensor.py
15-15: from .const import * used; unable to detect undefined names
(F403)
52-52: CONF_ROOMS may be undefined, or defined from star imports
(F405)
58-58: CONF_MINIMUM_CONSUMPTION may be undefined, or defined from star imports
(F405)
58-58: CONF_MAXIMUM_CONSUMPTION may be undefined, or defined from star imports
(F405)
63-63: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
81-81: CONF_NAME may be undefined, or defined from star imports
(F405)
83-83: CONF_NAME may be undefined, or defined from star imports
(F405)
105-105: CONF_NAME may be undefined, or defined from star imports
(F405)
107-107: CONF_NAME may be undefined, or defined from star imports
(F405)
321-321: CycleClassification may be undefined, or defined from star imports
(F405)
327-327: CONF_NAME may be undefined, or defined from star imports
(F405)
341-341: BoilerStatus may be undefined, or defined from star imports
(F405)
custom_components/sat/minimum_setpoint.py
115-115: Unused method argument: boiler_state
(ARG002)
137-137: f-string without any placeholders
Remove extraneous f prefix
(F541)
custom_components/sat/util.py
50-50: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
51-51: CONF_MAXIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
68-68: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports
(F405)
69-69: CONF_AUTOMATIC_DUTY_CYCLE may be undefined, or defined from star imports
(F405)
70-70: CONF_DUTY_CYCLE may be undefined, or defined from star imports
(F405)
custom_components/sat/cycles.py
83-83: Avoid specifying long messages outside the exception class
(TRY003)
86-86: Avoid specifying long messages outside the exception class
(TRY003)
89-89: Avoid specifying long messages outside the exception class
(TRY003)
128-128: Docstring contains ambiguous – (EN DASH). Did you mean - (HYPHEN-MINUS)?
(RUF002)
228-228: Avoid specifying long messages outside the exception class
(TRY003)
250-250: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
custom_components/sat/coordinator.py
14-14: from .const import * used; unable to detect undefined names
(F403)
106-106: PWMStatus may be undefined, or defined from star imports
(F405)
264-264: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
264-264: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
332-332: Unused method argument: climate
(ARG002)
332-332: PWMStatus may be undefined, or defined from star imports
(F405)
332-332: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
179-179: CONF_MODE may be undefined, or defined from star imports
(F405)
179-179: MODE_SWITCH may be undefined, or defined from star imports
(F405)
179-179: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports
(F405)
237-237: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports
(F405)
245-245: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports
(F405)
255-255: Unused method argument: event
(ARG002)
467-467: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
470-470: Consider iterable unpacking instead of concatenation
Replace with iterable unpacking
(RUF005)
536-536: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
617-617: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
631-631: UNHEALTHY_CYCLES may be undefined, or defined from star imports
(F405)
635-635: BoilerStatus may be undefined, or defined from star imports
(F405)
646-646: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
650-650: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
717-717: f-string without any placeholders
Remove extraneous f prefix
(F541)
788-788: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
789-789: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
805-805: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
806-806: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
809-809: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
809-809: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
878-878: DEADBAND may be undefined, or defined from star imports
(F405)
878-878: DEADBAND may be undefined, or defined from star imports
(F405)
936-936: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
custom_components/sat/area.py
215-215: Do not catch blind exception: Exception
(BLE001)
custom_components/sat/config_flow.py
592-592: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports
(F405)
🔇 Additional comments (30)
custom_components/sat/cycles.py (4)
35-66: LGTM!The
CycleSampleandCycledataclasses are well-structured with appropriate frozen and slots optimizations. The field organization with averages, extremes, and fractions is clear and maintainable.
77-141: LGTM!The
CycleHistoryclass has proper validation for window parameters, clean property implementations with pruning, and thecycles_last_hournormalization correctly extrapolates to cycles per hour regardless of window duration.
223-248: LGTM!The
CycleTrackerinitialization is clean with proper validation ofminimum_samples_per_cycle. Thestarted_sinceproperty andresetmethod provide a clear public API for managing tracking state.
394-432: LGTM!The
_classify_cyclemethod has clear classification logic with well-defined thresholds. The PWM status consideration for short cycles and the short-cycling context detection are thoughtfully implemented.custom_components/sat/config_flow.py (1)
592-595: LGTM!The simplified conditional properly shows PID gain controls when automatic gains are disabled. The removal of the PID version check aligns with the broader refactor removing version-based branching.
custom_components/sat/pid.py (7)
33-46: LGTM!The new read-only properties
last_error,previous_error, andlast_updatedprovide clean access to internal state for diagnostics and external monitoring.
48-74: LGTM!The dynamic computation of
kp,ki, andkdwhen automatic gains is enabled is well-implemented. The fallback to configured values when automatic gains is disabled maintains backward compatibility.
76-120: LGTM!The computed properties for
proportional,integral,derivative, andoutputproperly handleNonecases and provide safe defaults. Theintegral_enabledandderivative_enabledproperties encapsulate the deadband logic clearly.
139-152: LGTM!The
updatemethod correctly sequences integral and derivative updates before updating state. The transition from storing error values before updating state ensures derivatives are computed correctly.
154-183: LGTM!The
update_integralmethod properly handles:
- Reset timing when entering deadband
- Early exit when integral is disabled
- Guard against
Noneki- Integral windup prevention via clamping to heating curve bounds
185-219: LGTM!The two-stage low-pass filtering for the derivative term is a solid approach to reduce noise sensitivity. The guard against
Nonelast_error prevents division issues on first update.
221-240: LGTM!The
restoremethod properly restores PID state from saved attributes and resets timing anchors to prevent stale time calculations after restoration.custom_components/sat/util.py (2)
46-52: LGTM!The
create_dynamic_minimum_setpoint_controllerfunction properly constructs the newDynamicMinimumSetpointwithMinimumSetpointConfig. The function signature clearly indicates the parameters used.
65-76: LGTM!The
create_pwm_controllerfunction is updated to use the newPWMConfigdataclass, simplifying the PWM construction. The parameter rename to_config_datacorrectly indicates the parameter is unused.tests/test_climate.py (4)
4-4: LGTM!Using explicit module-qualified domain constants (
template.DOMAIN,sensor.DOMAIN) is cleaner than local aliases and improves code clarity.
149-160: Verify actual test execution and pipeline output before concluding critical issue.The code structure appears sound with proper initialization, guard conditions, and value clamping. The calculation logic for
requested_setpointdepends onpid.outputandareas.pids.output, both of which are properly bounded and rounded. The expected test value of 33.5°C is mathematically consistent with the heating curve value of 32.5°C plus typical PID adjustments. The claimed value of 7,719,193.0 cannot be reproduced through static code analysis of the current implementation. Recommend verifying whether this large value was actually observed in test execution or if it stems from misinterpretation of pipeline metrics.
50-60: Update test assertions to match current cycles-per-hour configuration.The expected duty cycle values
(277, 922)in this test correspond to a 3 cycles-per-hour configuration, but the test fixture uses the default of 4 cycles per hour (sinceCONF_CYCLES_PER_HOURis not explicitly set in the test options). With the actual configuration, the calculated values should be approximately(208, 691)at 23.15% duty cycle.Either update the assertions to reflect the current defaults, or explicitly set
CONF_CYCLES_PER_HOUR: 3in the test'soptionsdict if 3 cycles per hour is the intended configuration.Likely an incorrect or invalid review comment.
99-110: Test assertion failing due to PWM state handling or setpoint selection logic.The setpoint is being set to
58.0(the configuredCONF_MINIMUM_SETPOINTviaminimum_setpoint_value) when it should be10(theMINIMUM_SETPOINTconstant). This occurs in_async_control_setpoint()whenpwm.status == PWMStatus.ONtriggers line 802 instead of the expectedOFFstate at line 805.Given the test's duty_cycle expectation of
(0, 2400)(0ms ON time), the PWM state should transition toOFFinpwm.enable(), which would correctly set setpoint toMINIMUM_SETPOINT(10). Verify that PWM state transitions are working correctly on the first enable() call, or that the setpoint selection logic properly distinguishes between PWM ON/OFF states.custom_components/sat/const.py (1)
181-239: Well-structured cycle-centric enums and constants.The new
CycleKind,CycleClassification,BoilerStatus, andRelativeModulationStateenums provide a comprehensive state model for the refactored architecture. TheUNHEALTHY_CYCLEStuple is a clean way to group related classifications for conditional checks.custom_components/sat/sensor.py (1)
313-327: Cycle sensor implementation looks correct.The renamed
SatCycleSensorproperly handles theNonecase forlast_cycleby returningINSUFFICIENT_DATA, and the unique_id update maintains consistency with the new naming scheme.custom_components/sat/coordinator.py (2)
119-120: Lambda listeners captureselfcorrectly for updates.The listener lambdas properly delegate to the boiler and cycle tracker update methods, passing the current state and last cycle. This wiring integrates the new cycle-centric architecture cleanly.
328-331: CycleHistory is not currently persisted; clarify if this is intentional.Unlike
BoilerandMinimumSetpoint,CycleHistorydoes not use theStorepattern and has noasync_savemethods. Theasync_will_remove_from_hassmethod only persists boiler options. If cycle state (e.g., learned cycle data) should survive restarts, add similar persistence forCycleHistory; if cycle data is intentionally ephemeral, this is fine as-is.custom_components/sat/minimum_setpoint.py (1)
115-156: Event-driven tuning addresses the repeated tuning concern.The refactored
on_cycle_startandon_cycle_endmethods are now triggered byEVENT_SAT_CYCLE_STARTEDandEVENT_SAT_CYCLE_ENDEDevents (see climate.py lines 237-251), ensuring tuning happens once per cycle event rather than on every control loop iteration. This addresses the previous review concern about destabilizing learned minimums.custom_components/sat/boiler.py (1)
156-231: Comprehensive status derivation logic.The
_derive_statusmethod implements a thorough state machine covering all relevant boiler states including overshoot cooling, anti-cycling, stalled ignition, and modulation direction. The logic is well-structured despite the many return paths.custom_components/sat/climate.py (4)
253-263: HOMEASSISTANT_STOP listener properly handles async cleanup.The updated implementation passes
self.async_will_remove_from_hassdirectly toasync_listen_once, which correctly handles async callables. The method signature now acceptsOptional[Event]to support both direct calls and event-triggered invocations. This addresses previous review concerns.
584-654: PWM enable/disable logic is well-structured with clear conditions.The refactored
pulse_width_modulation_enabledproperty and helper methods (_is_pwm_forced,_should_enable_static_pwm,_should_enable_dynamic_pwm) provide clear separation of concerns. The hysteresis margins (PWM_ENABLE_MARGIN_CELSIUSandPWM_DISABLE_MARGIN_CELSIUS) help prevent flapping.
237-251: Cycle event handlers wire minimum setpoint updates correctly.The
EVENT_SAT_CYCLE_STARTEDandEVENT_SAT_CYCLE_ENDEDlisteners correctly invokeminimum_setpoint.on_cycle_startandon_cycle_endwith all required parameters. Since these methods are synchronous, using sync lambdas is appropriate.
906-906: No changes needed —HassJobwith async callables inasync_call_lateris valid and supported.The pattern
HassJob(self.async_control_heating_loop)withasync_call_lateris correct. Home Assistant'sHassJobexplicitly supports wrapping async functions, andasync_call_lateris designed to acceptHassJobinstances with async callables. This is the recommended approach for scheduling delayed async work in Home Assistant. The code correctly stores the cancel callable inself._control_heating_loop_unsubfor proper cleanup.custom_components/sat/area.py (2)
141-150: PID update correctly guards againstNonevalues.The
updatemethod properly checks forNoneerror and heating curve values before callingpid.update, addressing the concern from past reviews about passingNonetoPID.update.
205-221:area.pid.outputis correctly used as a property.Verified that
PID.outputis decorated with@propertyincustom_components/sat/pid.pyand returns a float. The code in lines 215 correctly accesses it without parentheses.
| @property | ||
| def native_value(self) -> float: | ||
| return self._pid.output | ||
|
|
||
| @property | ||
| def unique_id(self) -> str: | ||
| if self._area_id is None: | ||
| return f"{self._config_entry.data.get(CONF_NAME).lower()}-pid" | ||
|
|
||
| return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._area_id}-pid" | ||
|
|
||
| @property | ||
| def extra_state_attributes(self) -> Mapping[str, Any] | None: | ||
| return { | ||
| "proportional": self._pid.proportional, | ||
| "integral": self._pid.integral, | ||
| "derivative": self._pid.derivative, | ||
| } |
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.
Guard against None PID access in native_value and extra_state_attributes.
When _pid is None, the available property correctly returns False, but Home Assistant may still access native_value and extra_state_attributes before checking availability. This can cause AttributeError.
Apply this diff:
@property
def native_value(self) -> float:
+ if self._pid is None:
+ return None
return self._pid.output
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
+ if self._pid is None:
+ return None
return {
"proportional": self._pid.proportional,
"integral": self._pid.integral,
"derivative": self._pid.derivative,
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @property | |
| def native_value(self) -> float: | |
| return self._pid.output | |
| @property | |
| def unique_id(self) -> str: | |
| if self._area_id is None: | |
| return f"{self._config_entry.data.get(CONF_NAME).lower()}-pid" | |
| return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._area_id}-pid" | |
| @property | |
| def extra_state_attributes(self) -> Mapping[str, Any] | None: | |
| return { | |
| "proportional": self._pid.proportional, | |
| "integral": self._pid.integral, | |
| "derivative": self._pid.derivative, | |
| } | |
| @property | |
| def native_value(self) -> float: | |
| if self._pid is None: | |
| return None | |
| return self._pid.output | |
| @property | |
| def unique_id(self) -> str: | |
| if self._area_id is None: | |
| return f"{self._config_entry.data.get(CONF_NAME).lower()}-pid" | |
| return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._area_id}-pid" | |
| @property | |
| def extra_state_attributes(self) -> Mapping[str, Any] | None: | |
| if self._pid is None: | |
| return None | |
| return { | |
| "proportional": self._pid.proportional, | |
| "integral": self._pid.integral, | |
| "derivative": self._pid.derivative, | |
| } |
🧰 Tools
🪛 Ruff (0.14.7)
105-105: CONF_NAME may be undefined, or defined from star imports
(F405)
107-107: CONF_NAME may be undefined, or defined from star imports
(F405)
🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 98 to 115, native_value and
extra_state_attributes access self._pid without guarding for None which can
raise AttributeError; modify native_value to check if self._pid is None and
return None (or an appropriate default) when absent, and update
extra_state_attributes to return None or an empty mapping when self._pid is
None, otherwise return the proportional/integral/derivative values; ensure types
remain compatible with the property annotations.
…t handling, and optimize area and climate interactions.
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)
908-925: Fix HassJob usage with async function.
HassJobdoes not accept coroutine functions directly. Home Assistant will raise "Coroutine not allowed to be passed to HassJob" at runtime when the callback fires.Apply this diff to use the same pattern as line 917:
def schedule_control_heating_loop(self, _time: Optional[datetime] = None, force: bool = False) -> None: """Schedule a debounced execution of the heating control loop.""" # Force immediate execution if force: # Cancel previous scheduled run, if any if self._control_heating_loop_unsub is not None: self._control_heating_loop_unsub() self._control_heating_loop_unsub = None self.hass.async_create_task(self.async_control_heating_loop()) return # If a run is already scheduled, do nothing. if self._control_heating_loop_unsub is not None: return - self._control_heating_loop_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_heating_loop)) + self._control_heating_loop_unsub = async_call_later( + self.hass, 10, + lambda _now: self.hass.async_create_task(self.async_control_heating_loop()) + )
♻️ Duplicate comments (4)
custom_components/sat/sensor.py (1)
123-140: Guard againstNonePID access innative_valueandextra_state_attributes.When
_pidisNone, theavailableproperty correctly returnsFalse, but Home Assistant may still accessnative_valueandextra_state_attributesbefore checking availability. This can causeAttributeError.Apply this diff:
@property def native_value(self) -> float: + if self._pid is None: + return None return self._pid.output @property def extra_state_attributes(self) -> Mapping[str, Any] | None: + if self._pid is None: + return None return { "proportional": self._pid.proportional, "integral": self._pid.integral, "derivative": self._pid.derivative, }custom_components/sat/cycles.py (1)
255-255: Fix implicitOptionaltype annotation.PEP 484 prohibits implicit
Optional. Thetimestampparameter defaults toNonebut is typed asfloat.Apply this diff:
- def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: float = None) -> None: + def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: Optional[float] = None) -> None:custom_components/sat/climate.py (1)
733-733: Remove extraneous f-string prefix.The f-string has no placeholders and uses
%formatting instead. Remove thefprefix.Apply this diff:
- _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature) + _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)custom_components/sat/area.py (1)
283-292: Critical: Walrus operator precedence bug.The expression
if area := self._get_area(entity_id) is Nonebinds asarea := (self._get_area(entity_id) is None), assigningTrueorFalsetoareainstead of theAreaobject. This causesarea.errorandarea.heating_curveaccesses at lines 288-289 to fail withAttributeError.Apply this diff:
def update(self, entity_id: str) -> None: """Update the PID controller for a specific area.""" - if area := self._get_area(entity_id) is None: + if (area := self._get_area(entity_id)) is None: return if area.error is None or area.heating_curve.value is None: return _LOGGER.info("Updating PID for %s with error=%s", area.id, area.error.value) area.pid.update(area.error, area.heating_curve.value)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
custom_components/sat/area.py(3 hunks)custom_components/sat/climate.py(21 hunks)custom_components/sat/cycles.py(1 hunks)custom_components/sat/sensor.py(7 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
custom_components/sat/cycles.py (4)
custom_components/sat/const.py (3)
CycleKind(181-185)CycleClassification(188-195)PWMStatus(221-224)custom_components/sat/helpers.py (1)
clamp(90-95)custom_components/sat/boiler.py (1)
BoilerState(21-35)custom_components/sat/coordinator.py (5)
last_cycle(158-159)flame_active(181-182)hot_water_active(189-190)setpoint(167-168)return_temperature(201-202)
custom_components/sat/climate.py (4)
custom_components/sat/area.py (19)
Areas(158-299)errors(169-172)get(179-184)state(54-59)async_control_heating_loop(134-144)async_added_to_hass(114-126)async_added_to_hass(194-197)items(190-192)ids(186-188)current_temperature(70-81)target_temperature(62-67)error(84-92)output(213-244)pids(175-177)overshoot_cap(247-277)update(146-155)update(283-292)reset(279-281)async_control_heating_loops(199-202)custom_components/sat/helpers.py (1)
clamp(90-95)custom_components/sat/pid.py (10)
PID(16-240)integral(82-84)derivative(87-92)proportional(77-79)integral_enabled(108-110)output(100-105)update(139-152)reset(122-137)last_updated(44-46)update_integral(154-183)custom_components/sat/util.py (1)
create_dynamic_minimum_setpoint_controller(46-52)
🪛 Pylint (4.0.4)
custom_components/sat/cycles.py
[refactor] 42-42: Too many instance attributes (17/7)
(R0902)
[refactor] 226-226: Too many instance attributes (8/7)
(R0902)
[refactor] 318-318: Too many local variables (24/15)
(R0914)
[refactor] 404-404: Too many return statements (7/6)
(R0911)
custom_components/sat/climate.py
[refactor] 637-637: Too many return statements (7/6)
(R0911)
🪛 Ruff (0.14.8)
custom_components/sat/cycles.py
86-86: Avoid specifying long messages outside the exception class
(TRY003)
89-89: Avoid specifying long messages outside the exception class
(TRY003)
92-92: Avoid specifying long messages outside the exception class
(TRY003)
131-131: Docstring contains ambiguous – (EN DASH). Did you mean - (HYPHEN-MINUS)?
(RUF002)
231-231: Avoid specifying long messages outside the exception class
(TRY003)
255-255: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
custom_components/sat/area.py
227-227: Do not catch blind exception: Exception
(BLE001)
custom_components/sat/sensor.py
15-15: from .const import * used; unable to detect undefined names
(F403)
53-53: CONF_ROOMS may be undefined, or defined from star imports
(F405)
59-59: CONF_MINIMUM_CONSUMPTION may be undefined, or defined from star imports
(F405)
59-59: CONF_MAXIMUM_CONSUMPTION may be undefined, or defined from star imports
(F405)
67-67: CONF_NAME may be undefined, or defined from star imports
(F405)
84-84: CONF_NAME may be undefined, or defined from star imports
(F405)
88-88: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
106-106: CONF_NAME may be undefined, or defined from star imports
(F405)
108-108: CONF_NAME may be undefined, or defined from star imports
(F405)
130-130: CONF_NAME may be undefined, or defined from star imports
(F405)
132-132: CONF_NAME may be undefined, or defined from star imports
(F405)
311-311: CONF_NAME may be undefined, or defined from star imports
(F405)
330-330: CONF_NAME may be undefined, or defined from star imports
(F405)
335-335: CycleClassification may be undefined, or defined from star imports
(F405)
341-341: CONF_NAME may be undefined, or defined from star imports
(F405)
347-347: CONF_NAME may be undefined, or defined from star imports
(F405)
355-355: BoilerStatus may be undefined, or defined from star imports
(F405)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
179-179: CONF_MODE may be undefined, or defined from star imports
(F405)
179-179: MODE_SWITCH may be undefined, or defined from star imports
(F405)
179-179: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports
(F405)
237-237: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports
(F405)
245-245: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports
(F405)
255-255: Unused method argument: event
(ARG002)
467-467: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
470-470: Consider iterable unpacking instead of concatenation
Replace with iterable unpacking
(RUF005)
533-533: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
536-536: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
633-633: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
647-647: UNHEALTHY_CYCLES may be undefined, or defined from star imports
(F405)
651-651: BoilerStatus may be undefined, or defined from star imports
(F405)
662-662: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
666-666: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
733-733: f-string without any placeholders
Remove extraneous f prefix
(F541)
804-804: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
805-805: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
821-821: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
822-822: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
825-825: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
825-825: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
896-896: DEADBAND may be undefined, or defined from star imports
(F405)
896-896: DEADBAND may be undefined, or defined from star imports
(F405)
954-954: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
🔇 Additional comments (1)
custom_components/sat/area.py (1)
227-227: Broad exception handling is acceptable here.The broad
Exceptioncatch at line 227 is acceptable defensive coding. The method logs the exception and continues processing other areas, preventing one failing area from breaking the entire aggregation.
|
|
||
|
|
||
| class SatPidSensor(SatClimateEntity, SensorEntity): | ||
| def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None): |
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.
PEP 484 prohibits implicit Optional. The area_id parameter defaults to None but is typed as str.
Apply this diff:
- def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None):
+ def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: Optional[str] = None):📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None): | |
| def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: Optional[str] = None): |
🧰 Tools
🪛 Ruff (0.14.8)
88-88: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around line 88, the constructor parameter
area_id defaults to None but is annotated as str which creates an implicit
Optional; update the type annotation to explicitly allow None (e.g.,
Optional[str] or str | None depending on project typing standard) and add the
corresponding import from typing (Optional) if not already present so the
signature correctly reflects that area_id may be None.
…ce valve position handling, simplify PID updates, and optimize heating loop control.
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
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
custom_components/sat/climate.py (2)
398-434: AvoidAttributeErrorwhenself.errorisNonein state attributes.return { - "error": self.error.value, + "error": self.error.value if self.error is not None else None, "integral": self.pid.integral, "derivative": self.pid.derivative, "proportional": self.pid.proportional,
890-907: HassJob does not accept coroutine functions directly — this will raiseValueErrorat runtime. Replace with a sync callback that schedules the coroutine:- self._control_heating_loop_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_heating_loop)) + self._control_heating_loop_unsub = async_call_later( + self.hass, + 10, + HassJob(lambda _now: self.hass.async_create_task(self.async_control_heating_loop(_now))), + )Home Assistant's event helpers (
async_call_later, etc.) requireHassJobto wrap a non-coroutine callable. Passing a coroutine function or coroutine object directly will fail with "Coroutine not allowed to be passed to HassJob". Use a lambda or@callback-decorated function to schedule the coroutine viahass.async_create_task().
♻️ Duplicate comments (8)
custom_components/sat/minimum_setpoint.py (4)
355-369: Uselow_load_maximum_duty_ratio_15min_is_tunable_regime(currently unused).if statistics.last_hour_count < self._config.low_load_minimum_cycles_per_hour: return False + + if statistics.duty_ratio_last_15m > self._config.low_load_maximum_duty_ratio_15m: + return False
29-32: Fix inverted step comments (they contradict the actual tuning directions).Given
_maybe_tune_minimumincreases on overshoot and decreases on underheat, these comments should reflect that.- increase_step: float = 1.0 # when minimum is too low (underheat / too short) - decrease_step: float = 1.0 # when minimum is too high (overshoot / short-cycling) + increase_step: float = 1.0 # when minimum is too low (overshoot / short-cycling) + decrease_step: float = 1.0 # when minimum is too high (underheat / too short)
115-138: Fix malformed log formatting + unused arg inon_cycle_start.
- Line 137 mixes
f""with%placeholders.boiler_stateis unused (Ruff ARG002); either prefix_boiler_stateor use it.- def on_cycle_start(self, boiler_state: BoilerState, cycles: CycleStatistics, last_cycle: Optional[Cycle], requested_setpoint: Optional[float], outside_temperature: Optional[float]) -> None: + def on_cycle_start(self, _boiler_state: BoilerState, cycles: CycleStatistics, last_cycle: Optional[Cycle], requested_setpoint: Optional[float], outside_temperature: Optional[float]) -> None: @@ - _LOGGER.debug(f"Updated regime minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint) + _LOGGER.debug("Updated regime minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint)
222-236: Return type of_regime_forshould not beOptional.It always returns a
RegimeState.- def _regime_for(... ) -> Optional[RegimeState]: + def _regime_for(... ) -> RegimeState:custom_components/sat/area.py (2)
240-268: CallPID.output()(method) instead of taking the method object.Same underlying issue as previously flagged:
PID.outputis a method (percustom_components/sat/pid.pysnippet).- value = area.pid.output + value = area.pid.output()Also: catching bare
Exception(Line 251) is a bit broad; if you keep it, consider narrowing to expected errors.
307-316: Fix walrus precedence bug in_PIDs.update(areabecomes bool).- if area := self._get_area(entity_id) is None: + if (area := self._get_area(entity_id)) is None: returncustom_components/sat/sensor.py (1)
87-140: Makearea_idexplicitly optional and guard_piddereferences.-class SatPidSensor(SatClimateEntity, SensorEntity): - def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None): +class SatPidSensor(SatClimateEntity, SensorEntity): + def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str | None = None): @@ def native_value(self) -> float: - return self._pid.output + return self._pid.output if self._pid is not None else None @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: - return { + if self._pid is None: + return None + return { "proportional": self._pid.proportional, "integral": self._pid.integral, "derivative": self._pid.derivative, }custom_components/sat/climate.py (1)
708-721: Remove extraneousfprefix from logger format string.- _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature) + _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
custom_components/sat/area.py(3 hunks)custom_components/sat/climate.py(21 hunks)custom_components/sat/minimum_setpoint.py(1 hunks)custom_components/sat/sensor.py(7 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/area.py (6)
custom_components/sat/state.py (1)
State(10-12)custom_components/sat/errors.py (2)
Errors(5-33)Error(37-40)custom_components/sat/heating_curve.py (4)
HeatingCurve(11-92)value(91-92)update(27-30)reset(21-25)custom_components/sat/helpers.py (1)
float_value(60-69)custom_components/sat/pid.py (5)
PID(16-240)update(139-152)output(100-105)available(118-120)reset(122-137)custom_components/sat/coordinator.py (3)
state(138-151)get(409-424)update(43-48)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py
[refactor] 24-24: Too many instance attributes (18/7)
(R0902)
[refactor] 104-105: Consider using 'minimum_setpoint = max(minimum_setpoint, allowed_minimum)' instead of unnecessary if block
(R1731)
[refactor] 115-115: Too many arguments (6/5)
(R0913)
[refactor] 115-115: Too many positional arguments (6/5)
(R0917)
[refactor] 139-139: Too many arguments (6/5)
(R0913)
[refactor] 139-139: Too many positional arguments (6/5)
(R0917)
[refactor] 268-268: Too many arguments (6/5)
(R0913)
[refactor] 268-268: Too many positional arguments (6/5)
(R0917)
[refactor] 268-268: Too many return statements (9/6)
(R0911)
[refactor] 268-268: Too many branches (13/12)
(R0912)
custom_components/sat/climate.py
[refactor] 618-618: Too many return statements (7/6)
(R0911)
🪛 Ruff (0.14.8)
custom_components/sat/minimum_setpoint.py
115-115: Unused method argument: boiler_state
(ARG002)
137-137: f-string without any placeholders
Remove extraneous f prefix
(F541)
custom_components/sat/area.py
251-251: Do not catch blind exception: Exception
(BLE001)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
178-178: CONF_MODE may be undefined, or defined from star imports
(F405)
178-178: MODE_SWITCH may be undefined, or defined from star imports
(F405)
178-178: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports
(F405)
236-236: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports
(F405)
244-244: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports
(F405)
254-254: Unused method argument: event
(ARG002)
514-514: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
517-517: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
614-614: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
628-628: UNHEALTHY_CYCLES may be undefined, or defined from star imports
(F405)
632-632: BoilerStatus may be undefined, or defined from star imports
(F405)
643-643: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
647-647: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
714-714: f-string without any placeholders
Remove extraneous f prefix
(F541)
785-785: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
786-786: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
802-802: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
803-803: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
806-806: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
806-806: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
878-878: DEADBAND may be undefined, or defined from star imports
(F405)
878-878: DEADBAND may be undefined, or defined from star imports
(F405)
936-936: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
custom_components/sat/sensor.py
15-15: from .const import * used; unable to detect undefined names
(F403)
53-53: CONF_ROOMS may be undefined, or defined from star imports
(F405)
59-59: CONF_MINIMUM_CONSUMPTION may be undefined, or defined from star imports
(F405)
59-59: CONF_MAXIMUM_CONSUMPTION may be undefined, or defined from star imports
(F405)
67-67: CONF_NAME may be undefined, or defined from star imports
(F405)
84-84: CONF_NAME may be undefined, or defined from star imports
(F405)
88-88: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
106-106: CONF_NAME may be undefined, or defined from star imports
(F405)
108-108: CONF_NAME may be undefined, or defined from star imports
(F405)
130-130: CONF_NAME may be undefined, or defined from star imports
(F405)
132-132: CONF_NAME may be undefined, or defined from star imports
(F405)
311-311: CONF_NAME may be undefined, or defined from star imports
(F405)
330-330: CONF_NAME may be undefined, or defined from star imports
(F405)
335-335: CycleClassification may be undefined, or defined from star imports
(F405)
341-341: CONF_NAME may be undefined, or defined from star imports
(F405)
347-347: CONF_NAME may be undefined, or defined from star imports
(F405)
355-355: BoilerStatus may be undefined, or defined from star imports
(F405)
🔇 Additional comments (1)
custom_components/sat/climate.py (1)
236-250: Verify coordinator accessors used in cycle listeners are values (not methods).These pass
self._coordinator.state,self._coordinator.cycles,self._coordinator.last_cycle. If any of those are methods (vs@property), you’ll be passing callables intoDynamicMinimumSetpointand breaking classification logic. Please double-check the coordinator API shape on your branch.
| async def async_added_to_hass(self, hass: HomeAssistant) -> None: | ||
| """Run when the area is added to Home Assistant.""" | ||
| self._hass = hass | ||
|
|
||
| async def async_control_heating_loop(self, _time=None) -> None: | ||
| if hass.state is CoreState.running: | ||
| self.update() | ||
| else: | ||
| hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update) | ||
|
|
||
| # Periodic update as a fallback when we do not have a dedicated sensor listener | ||
| self._time_interval = async_track_time_interval( | ||
| self._hass, self.update, timedelta(seconds=60) | ||
| ) |
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.
Don’t register sync update() directly as an HA event callback.
EVENT_HOMEASSISTANT_STARTED handlers receive an Event; update() expects datetime | None. Wrap it.
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update)
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update())🤖 Prompt for AI Agents
custom_components/sat/area.py around lines 141-153: the code registers
self.update directly as the EVENT_HOMEASSISTANT_STARTED handler but that event
callback receives an Event while update() expects datetime | None, so create a
small wrapper that accepts the event and calls self.update(None) (or forwards
event.time_fired if desired) and register that wrapper instead; ensure the
wrapper has the correct signature (event) and is used in
hass.bus.async_listen_once.
| def requested_setpoint(self) -> float: | ||
| """Get the requested setpoint based on the heating curve and PID output.""" | ||
| """Get the requested setpoint based on the heating curve and PIDs.""" | ||
| if self.heating_curve.value is None: | ||
| return MINIMUM_SETPOINT | ||
|
|
||
| return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 1) | ||
| setpoint = self.pid.output | ||
| if self._heating_mode == HEATING_MODE_ECO: | ||
| return setpoint | ||
|
|
||
| # Secondary rooms: heating and overshoot information. | ||
| secondary_heating = self.areas.pids.output | ||
| overshoot_cap = self.areas.pids.overshoot_cap | ||
|
|
||
| if secondary_heating is not None: | ||
| if secondary_heating >= setpoint: | ||
| # Other rooms need more heat: allow some boost. | ||
| setpoint = min(secondary_heating, setpoint + 3.0) | ||
| else: | ||
| # Other rooms are satisfied sooner: allow some reduction. | ||
| setpoint = max(secondary_heating, setpoint - 2.0) | ||
|
|
||
| # Apply the overshoot cap so overshooting rooms can cool down. | ||
| if overshoot_cap is not None: | ||
| setpoint = min(setpoint, overshoot_cap) | ||
|
|
||
| return setpoint | ||
|
|
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.
Call PID.output() (method) when computing requested_setpoint.
If PID.output is the method shown in custom_components/sat/pid.py, this currently assigns a function object into arithmetic/returns.
- setpoint = self.pid.output
+ setpoint = self.pid.output()🧰 Tools
🪛 Ruff (0.14.8)
514-514: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
517-517: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
🤖 Prompt for AI Agents
In custom_components/sat/climate.py around lines 511-537, the code is using
PID.output (the method) as if it were a numeric attribute; call the method
instead. Replace uses of self.pid.output and self.areas.pids.output with
self.pid.output() and self.areas.pids.output() respectively, and keep
overshoot_cap as-is; also ensure you handle None return values from those method
calls before doing arithmetic (e.g., assign to local vars and check for is not
None).
| @property | ||
| def available(self): | ||
| """Return availability of the sensor.""" | ||
| return self._climate.extra_state_attributes.get("heating_curve") is not None | ||
| return self._climate.heating_curve.value is not None | ||
|
|
||
| @property | ||
| def native_value(self) -> float: | ||
| """Return the state of the device in native units. | ||
| In this case, the state represents the current heating curve value. | ||
| """ | ||
| return self._climate.extra_state_attributes.get("heating_curve") | ||
| """Return the state of the device in native units.""" | ||
| return self._climate.heating_curve.value | ||
|
|
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.
Align SatHeatingCurveSensor.native_value with optional curve values.
self._climate.heating_curve.value can be None, so the property type should allow None (or return a fallback).
- def native_value(self) -> float:
+ def native_value(self) -> float | None:
"""Return the state of the device in native units."""
return self._climate.heating_curve.value🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 255 to 264, native_value
currently types and returns float but self._climate.heating_curve.value can be
None; change native_value to return Optional[float] and simply return
self._climate.heating_curve.value (which may be None) so the type matches the
possible None value, and add the appropriate Optional import from typing (or
return a defined fallback numeric value if you prefer a non-None contract).
Ensure signatures and any callers/annotations are updated to accept
Optional[float].
| @property | ||
| def native_value(self) -> float: | ||
| """Return the state of the device in native units. | ||
| In this case, the state represents the current error value. | ||
| """ | ||
| return self._climate.extra_state_attributes.get("error") | ||
| """Return the state of the device in native units.""" | ||
| return self._climate.error.value | ||
|
|
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.
Guard SatErrorValueSensor.native_value when climate error is None.
- def native_value(self) -> float:
+ def native_value(self) -> float | None:
"""Return the state of the device in native units."""
- return self._climate.error.value
+ return self._climate.error.value if self._climate.error is not None else None🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 297-301, native_value currently
returns self._climate.error.value without checking if self._climate.error is
None which will raise an AttributeError; change the implementation to first
check if self._climate.error is None and return None in that case, otherwise
return the error value. Also update the return type hint to Optional[float]
(import typing.Optional) to reflect that None may be returned.
…m `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.
Actionable comments posted: 3
♻️ Duplicate comments (8)
custom_components/sat/minimum_setpoint.py (5)
266-352: Confirmon_cycle_end()runs once per completed cycle; otherwise add “already processed cycle” guard.This code will tune every time
on_cycle_end()is invoked. If upstream can call it multiple times with the samelast_cycle(e.g., “last completed cycle” stays constant for a while), you’ll repeat the same adjustment.
28-35: Fix invertedincrease_step/decrease_stepcomments + uselow_load_maximum_duty_ratio_15m.The comments don’t match
_maybe_tune_minimum()(overshoot increases, underheat decreases). Alsolow_load_maximum_duty_ratio_15mis currently unused, so tuning can run outside “genuine low load”.@@ - increase_step: float = 1.0 # when minimum is too low (underheat / too short) - decrease_step: float = 1.0 # when minimum is too high (overshoot / short-cycling) + increase_step: float = 1.0 # when minimum is too low (overshoot / short-cycling) + decrease_step: float = 1.0 # when minimum is too high (underheat / too short)@@ def _is_tunable_regime(self, boiler_state: BoilerState, statistics: CycleStatistics) -> bool: @@ if statistics.last_hour_count < self._config.low_load_minimum_cycles_per_hour: return False + + if statistics.duty_ratio_last_15m > self._config.low_load_maximum_duty_ratio_15m: + return False @@ return True
114-136: Remove the extraneousfprefix in the starvation log (it’s not formatting anything).- _LOGGER.debug(f"Updated regime minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint) + _LOGGER.debug("Updated regime minimum_setpoint=%.1f after starvation.", regime_state.minimum_setpoint)
138-156:async_save_regimes()coroutine is dropped; regimes may never persist.
on_cycle_end()is sync but calls an async function without awaiting/scheduling it.One minimal fix: store
hassinasync_added_to_hass()and schedule the task.class DynamicMinimumSetpoint: def __init__(self, config: MinimumSetpointConfig) -> None: @@ self._last_requested_setpoint: Optional[float] = None + self._hass: HomeAssistant | None = None @@ def on_cycle_end(self, boiler_state: BoilerState, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: Optional[float], outside_temperature: Optional[float]) -> None: @@ - self.async_save_regimes() + if self._hass is not None: + self._hass.async_create_task(self.async_save_regimes()) self._last_requested_setpoint = requested_setpoint @@ async def async_added_to_hass(self, hass: HomeAssistant, device_id: str) -> None: + self._hass = hass if self._store is None: self._store = Store(hass, STORAGE_VERSION, f"sat.minimum_setpoint.{device_id}")
220-234:_regime_for(...)return type should not beOptional.It always returns a
RegimeState(creating one if missing).- def _regime_for(self, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: float, outside_temperature: Optional[float]) -> Optional[RegimeState]: + def _regime_for(self, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: float, outside_temperature: Optional[float]) -> RegimeState:custom_components/sat/sensor.py (3)
87-141: Fix implicit Optional + guard_pid is Noneinnative_value/extra_state_attributes.Right now
native_valueandextra_state_attributescan throw if_pidisNone, andarea_id: str = Noneis an implicit Optional.-class SatPidSensor(SatClimateEntity, SensorEntity): - def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str = None): +class SatPidSensor(SatClimateEntity, SensorEntity): + def __init__(self, coordinator, config_entry: ConfigEntry, climate: SatClimate, area_id: str | None = None): @@ @property - def native_value(self) -> float: - return self._pid.output + def native_value(self) -> float | None: + if self._pid is None: + return None + return self._pid.output @@ @property def extra_state_attributes(self) -> Mapping[str, Any] | None: + if self._pid is None: + return None return { "proportional": self._pid.proportional, "integral": self._pid.integral, "derivative": self._pid.derivative, }
255-264: AlignSatHeatingCurveSensor.native_valuewith optional curve values (and guardheating_curveitself).@property def available(self): """Return availability of the sensor.""" - return self._climate.heating_curve.value is not None + return self._climate.heating_curve is not None and self._climate.heating_curve.value is not None @@ @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the state of the device in native units.""" - return self._climate.heating_curve.value + return self._climate.heating_curve.value if self._climate.heating_curve is not None else None
297-306: GuardSatErrorValueSensor.native_valuewhenerrorisNone(HA may access value even if unavailable).@property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the state of the device in native units.""" - return self._climate.error.value + return self._climate.error.value if self._climate.error is not None else None
🧹 Nitpick comments (1)
custom_components/sat/minimum_setpoint.py (1)
235-255: Avoid hard-coded thresholds in_make_regime_key(); use config for consistency.Right now load banding uses
4.5/0.4, but tunability useslow_load_minimum_cycles_per_hour(and should uselow_load_maximum_duty_ratio_15mtoo). Using the same config thresholds will make regime bucketing and tuning decisions align.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
custom_components/sat/minimum_setpoint.py(1 hunks)custom_components/sat/sensor.py(7 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
custom_components/sat/minimum_setpoint.py (4)
custom_components/sat/boiler.py (1)
BoilerState(21-35)custom_components/sat/const.py (2)
CycleClassification(188-195)CycleKind(181-185)custom_components/sat/cycles.py (4)
CycleStatistics(72-77)Cycle(42-68)duty_ratio_last_15m(130-143)sample_count_4h(111-117)custom_components/sat/helpers.py (1)
clamp(90-95)
custom_components/sat/sensor.py (7)
custom_components/sat/climate.py (2)
SatClimate(79-1101)name(389-391)custom_components/sat/coordinator.py (2)
get(409-424)last_cycle(158-159)custom_components/sat/entity.py (1)
SatEntity(19-38)custom_components/sat/pid.py (1)
available(118-120)custom_components/sat/minimum_setpoint.py (1)
value(88-106)custom_components/sat/cycles.py (1)
last_cycle(161-165)custom_components/sat/const.py (2)
CycleClassification(188-195)BoilerStatus(198-218)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py
[refactor] 23-23: Too many instance attributes (18/7)
(R0902)
[refactor] 103-104: Consider using 'minimum_setpoint = max(minimum_setpoint, allowed_minimum)' instead of unnecessary if block
(R1731)
[refactor] 114-114: Too many arguments (6/5)
(R0913)
[refactor] 114-114: Too many positional arguments (6/5)
(R0917)
[refactor] 138-138: Too many arguments (6/5)
(R0913)
[refactor] 138-138: Too many positional arguments (6/5)
(R0917)
[refactor] 266-266: Too many arguments (6/5)
(R0913)
[refactor] 266-266: Too many positional arguments (6/5)
(R0917)
[refactor] 266-266: Too many return statements (9/6)
(R0911)
[refactor] 266-266: Too many branches (13/12)
(R0912)
🪛 Ruff (0.14.8)
custom_components/sat/minimum_setpoint.py
114-114: Unused method argument: boiler_state
(ARG002)
136-136: f-string without any placeholders
Remove extraneous f prefix
(F541)
custom_components/sat/sensor.py
15-15: from .const import * used; unable to detect undefined names
(F403)
53-53: CONF_ROOMS may be undefined, or defined from star imports
(F405)
59-59: CONF_MINIMUM_CONSUMPTION may be undefined, or defined from star imports
(F405)
59-59: CONF_MAXIMUM_CONSUMPTION may be undefined, or defined from star imports
(F405)
67-67: CONF_NAME may be undefined, or defined from star imports
(F405)
84-84: CONF_NAME may be undefined, or defined from star imports
(F405)
88-88: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
106-106: CONF_NAME may be undefined, or defined from star imports
(F405)
108-108: CONF_NAME may be undefined, or defined from star imports
(F405)
130-130: CONF_NAME may be undefined, or defined from star imports
(F405)
132-132: CONF_NAME may be undefined, or defined from star imports
(F405)
316-316: CONF_NAME may be undefined, or defined from star imports
(F405)
335-335: CONF_NAME may be undefined, or defined from star imports
(F405)
340-340: CycleClassification may be undefined, or defined from star imports
(F405)
346-346: CONF_NAME may be undefined, or defined from star imports
(F405)
352-352: CONF_NAME may be undefined, or defined from star imports
(F405)
360-360: BoilerStatus may be undefined, or defined from star imports
(F405)
| def _clamp_setpoint(self, value: float) -> float: | ||
| return clamp(value, COLD_SETPOINT, self._config.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.
Lower clamp bound ignores MinimumSetpointConfig.minimum_setpoint.
_clamp_setpoint() clamps low to COLD_SETPOINT, but the config explicitly provides minimum_setpoint. If these differ, you can violate config bounds (or config becomes misleading).
def _clamp_setpoint(self, value: float) -> float:
- return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint)
+ return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def _clamp_setpoint(self, value: float) -> float: | |
| return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint) | |
| def _clamp_setpoint(self, value: float) -> float: | |
| return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint) |
🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around lines 411-412,
_clamp_setpoint currently uses the hardcoded COLD_SETPOINT as the lower bound
which ignores MinimumSetpointConfig.minimum_setpoint; change the clamp call to
use self._config.minimum_setpoint as the lower bound (e.g., clamp(value,
self._config.minimum_setpoint, self._config.maximum_setpoint)) and ensure you
validate/normalize that minimum_setpoint <= maximum_setpoint before clamping to
avoid inverted bounds.
| _async_add_entities([ | ||
| SatFlameSensor(coordinator, _config_entry), | ||
| SatCycleSensor(coordinator, _config_entry), | ||
| SatBoilerSensor(coordinator, _config_entry), | ||
| SatManufacturerSensor(coordinator, _config_entry), | ||
| SatPidSensor(coordinator, _config_entry, climate), | ||
| SatErrorValueSensor(coordinator, _config_entry, climate), | ||
| SatRequestedSetpoint(coordinator, _config_entry, climate), | ||
| SatHeatingCurveSensor(coordinator, _config_entry, climate), | ||
| ]) | ||
|
|
||
| for entity_id in _config_entry.data.get(CONF_ROOMS) or []: | ||
| _async_add_entities([SatPidSensor(coordinator, _config_entry, climate, entity_id)]) | ||
|
|
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.
Minor: rename loop variable entity_id → area_id to avoid confusion with HA entity_ids.
This is just readability (these look like room/area identifiers, not HA entity ids).
🧰 Tools
🪛 Ruff (0.14.8)
53-53: CONF_ROOMS may be undefined, or defined from star imports
(F405)
🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 43 to 55, the loop uses the
variable name `entity_id` which is misleading because it refers to room/area
identifiers, not Home Assistant entity_ids; rename the loop variable to
`area_id` and update its usage inside the loop to pass `area_id` into
SatPidSensor to improve readability and avoid confusion.
| class SatRequestedSetpoint(SatClimateEntity, SensorEntity): | ||
|
|
||
| @property | ||
| def name(self) -> str: | ||
| return f"Requested Setpoint {self._config_entry.data.get(CONF_NAME)}" | ||
|
|
||
| @property | ||
| def device_class(self) -> SensorDeviceClass: | ||
| return SensorDeviceClass.TEMPERATURE | ||
|
|
||
| @property | ||
| def native_unit_of_measurement(self): | ||
| """Return the unit of measurement.""" | ||
| return UnitOfTemperature.CELSIUS | ||
|
|
||
| @property | ||
| def native_value(self) -> float: | ||
| return self._climate.requested_setpoint | ||
|
|
||
| @property | ||
| def unique_id(self) -> str: | ||
| return f"{self._config_entry.data.get(CONF_NAME).lower()}-requested-setpoint" | ||
|
|
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 explore the repository structure
find . -type f -name "*.py" | grep -E "(sensor|climate|sat)" | head -20Repository: Alexwijn/SAT
Length of output: 958
🏁 Script executed:
# Let's look at the full sensor.py file to understand context
wc -l custom_components/sat/sensor.pyRepository: Alexwijn/SAT
Length of output: 91
🏁 Script executed:
# Get the parent class definition
find . -type f -name "*.py" -path "*/sat/*" | xargs grep -l "SatClimateEntity"Repository: Alexwijn/SAT
Length of output: 164
🏁 Script executed:
# Read the entity.py file to understand SatClimateEntity
cat custom_components/sat/entity.pyRepository: Alexwijn/SAT
Length of output: 1445
🏁 Script executed:
# Read the relevant portion of sensor.py around lines 63-85
sed -n '50,100p' custom_components/sat/sensor.pyRepository: Alexwijn/SAT
Length of output: 1810
🏁 Script executed:
# Search for requested_setpoint definition across the codebase
rg "requested_setpoint" --type py -A 3 -B 3Repository: Alexwijn/SAT
Length of output: 28635
🏁 Script executed:
# Let's find the requested_setpoint property implementation in climate.py
sed -n '/def requested_setpoint/,/^ def /p' custom_components/sat/climate.py | head -15Repository: Alexwijn/SAT
Length of output: 629
🏁 Script executed:
# Also check what MINIMUM_SETPOINT is
rg "MINIMUM_SETPOINT\s*=" --type py -A 1 -B 1Repository: Alexwijn/SAT
Length of output: 681
🏁 Script executed:
# Check the heating_curve.value property to understand if it can be None
rg "class.*HeatingCurve|def value.*:" custom_components/sat/ --type py -A 3 -B 1 | head -50Repository: Alexwijn/SAT
Length of output: 1520
🏁 Script executed:
# Let's see the full requested_setpoint implementation
sed -n '/def requested_setpoint/,/^ @property\|^ def [a-z]/p' custom_components/sat/climate.py | head -30Repository: Alexwijn/SAT
Length of output: 1119
🏁 Script executed:
# Check how other sensors handle unique_id in this file
rg "def unique_id" custom_components/sat/sensor.py -A 2 -B 2Repository: Alexwijn/SAT
Length of output: 1511
🏁 Script executed:
# Search for CONF_NAME usage to understand if it's meant to be stable
rg "CONF_NAME" custom_components/sat/ --type py -B 2 -A 2Repository: Alexwijn/SAT
Length of output: 20179
🏁 Script executed:
# Let's check if there's a config_entry.entry_id that could be used instead
rg "config_entry.entry_id" custom_components/sat/ --type pyRepository: Alexwijn/SAT
Length of output: 1403
🏁 Script executed:
# Check what the device identifiers use
rg "identifiers=\{" custom_components/sat/ --type py -A 1 -B 1Repository: Alexwijn/SAT
Length of output: 285
Fix unique_id to use stable identifier instead of user-configurable CONF_NAME.
The unique_id derived from CONF_NAME can churn when users rename the config, breaking entity tracking and automations in Home Assistant. Use self._config_entry.entry_id instead, e.g., f"{self._config_entry.entry_id}-requested-setpoint".
Note: native_value type annotation is correct as-is—requested_setpoint always returns a float (has fallback to MINIMUM_SETPOINT), never None.
🧰 Tools
🪛 Ruff (0.14.8)
67-67: CONF_NAME may be undefined, or defined from star imports
(F405)
84-84: CONF_NAME may be undefined, or defined from star imports
(F405)
🤖 Prompt for AI Agents
In custom_components/sat/sensor.py around lines 63 to 85, the unique_id
currently uses the user-editable CONF_NAME which can change and break entity
tracking; replace it to use the config entry's stable entry_id (e.g.
f"{self._config_entry.entry_id}-requested-setpoint") so the entity ID stays
stable across renames, leaving other properties (native_value typing) unchanged.
…ance PID logic, optimize valve and overshoot calculations, and improve heating arbitration.
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/area.py (1)
155-167: Potential issue: Event handler signature mismatch.Line 162 registers
self.updatedirectly as theEVENT_HOMEASSISTANT_STARTEDhandler, but:
updatesignature (line 187):def update(self, _now: datetime | None = None)- Event handlers receive an
Eventobject, notdatetime | NoneWhile the parameter is ignored (prefixed with
_), passing anEventwheredatetime | Noneis expected violates type contracts and could cause issues if the method is later refactored to use the parameter.Apply this fix:
if hass.state is CoreState.running: - self.update() + self.update(None) else: - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update(None))
🧹 Nitpick comments (1)
custom_components/sat/climate.py (1)
711-711: Optional: Remove unnecessary f-string prefix.The f-string has no interpolation placeholders, so the
fprefix is unnecessary.Apply this diff:
- _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature) + _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
custom_components/sat/area.py(3 hunks)custom_components/sat/climate.py(21 hunks)custom_components/sat/const.py(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
custom_components/sat/area.py (5)
custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)custom_components/sat/heating_curve.py (4)
HeatingCurve(11-92)update(27-30)value(91-92)reset(21-25)custom_components/sat/helpers.py (1)
float_value(60-69)custom_components/sat/pid.py (6)
PID(16-240)output(100-105)available(118-120)update(139-152)update_integral(154-183)reset(122-137)custom_components/sat/coordinator.py (3)
state(138-151)get(409-424)update(43-48)
custom_components/sat/climate.py (6)
custom_components/sat/area.py (17)
Areas(199-338)errors(210-213)get(220-226)state(64-69)async_control_heating_loop(175-185)async_added_to_hass(155-167)async_added_to_hass(236-239)items(232-234)ids(228-230)error(111-119)pids(216-218)heating_aggregate(255-283)overshoot_cap(286-316)update(187-196)update(322-331)reset(318-320)async_control_heating_loops(241-244)custom_components/sat/const.py (2)
PWMStatus(221-224)RelativeModulationState(227-231)custom_components/sat/helpers.py (1)
clamp(90-95)custom_components/sat/manufacturers/geminox.py (1)
Geminox(4-7)custom_components/sat/summer_simmer.py (2)
SummerSimmer(5-61)index(7-36)custom_components/sat/minimum_setpoint.py (3)
async_added_to_hass(157-198)value(88-106)reset(108-112)
🪛 Pylint (4.0.4)
custom_components/sat/climate.py
[refactor] 615-615: Too many return statements (7/6)
(R0911)
🪛 Ruff (0.14.8)
custom_components/sat/area.py
266-266: Do not catch blind exception: Exception
(BLE001)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
178-178: CONF_MODE may be undefined, or defined from star imports
(F405)
178-178: MODE_SWITCH may be undefined, or defined from star imports
(F405)
178-178: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports
(F405)
236-236: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports
(F405)
244-244: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports
(F405)
254-254: Unused method argument: event
(ARG002)
514-514: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
517-517: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
533-533: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
611-611: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
625-625: UNHEALTHY_CYCLES may be undefined, or defined from star imports
(F405)
629-629: BoilerStatus may be undefined, or defined from star imports
(F405)
640-640: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
644-644: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
711-711: f-string without any placeholders
Remove extraneous f prefix
(F541)
782-782: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
783-783: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
799-799: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
800-800: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
803-803: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
803-803: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
875-875: DEADBAND may be undefined, or defined from star imports
(F405)
875-875: DEADBAND may be undefined, or defined from star imports
(F405)
933-933: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
🔇 Additional comments (9)
custom_components/sat/const.py (2)
24-29: LGTM! PWM hysteresis margins and float precision look good.The PWM enable/disable margins (0.5°C and 1.5°C) establish a proper hysteresis band to prevent oscillation when operating near the dynamic minimum setpoint. The explicit float precision for MINIMUM/MAXIMUM_SETPOINT ensures consistent type handling.
175-239: LGTM! Cycle-centric enums and events are well-structured.The new events (
EVENT_SAT_CYCLE_STARTED,EVENT_SAT_CYCLE_ENDED) and enums (CycleKind,CycleClassification,BoilerStatus,RelativeModulationState) provide a comprehensive type-safe API for the cycle-centric architecture. TheUNHEALTHY_CYCLEStuple properly references the problematic classification values.custom_components/sat/climate.py (4)
209-262: LGTM! Lifecycle wiring looks correct.The lifecycle methods properly register event listeners and coordinate startup/shutdown:
- Conditional registration based on
CoreState.runningprevents race conditions- Event listeners for cycle start/end properly pass parameters to
minimum_setpointasync_will_remove_from_hassacceptsOptional[Event]parameter, making it compatible withasync_listen_once- Cleanup properly saves state and removes coordinators
511-533: LGTM! Multi-zone heating arbitration logic is well-designed.The
requested_setpointproperty implements a sophisticated multi-zone control strategy:
- Base setpoint from main PID controller
- ECO mode returns base setpoint directly
- COMFORT mode considers secondary rooms via
Areas.pids.heating_aggregate- Applies scaled headroom (2°C + 0.7°C per additional area) to balance responsiveness
- Enforces overshoot cap when rooms are above target
This percentile-based aggregation with headroom is a solid approach for multi-zone systems.
578-648: Excellent PWM enablement logic with proper hysteresis.The refactored PWM logic is much clearer:
- Lines 604-606: Correctly handles forced PWM for relay-only systems
- Lines 608-613: Static PWM uses
BOILER_DEADBANDhysteresis to prevent flapping- Lines 615-648: Dynamic PWM considers cycle health, boiler stall detection, and margin-based hysteresis
- Lines 640-648: Hysteresis band (
PWM_ENABLE_MARGIN_CELSIUSvsPWM_DISABLE_MARGIN_CELSIUS) prevents rapid state changesThe separation into helper methods improves testability and readability.
855-960: LGTM! Control loops properly orchestrate PID, PWM, and multi-zone heating.The control flow is well-structured:
async_control_pid(855-886):
- Guards against missing outside temperature and error
- Resets PID on stale sensor data
- Updates heating curve and performs autotuning in deadband
- Properly updates PID with error and curve value
schedule_control_heating_loop(887-903):
- Debounces with 10-second delay
- Force flag allows immediate execution
- Correctly uses
async_create_task(line 896)
async_control_heating_loop(905-960):
- Validates all required values upfront
- Smooths requested setpoint with low-pass filter (α=0.4)
- Orchestrates PWM, Areas, coordinator, and setpoint control
- Clamps setpoint to valid range
custom_components/sat/area.py (3)
44-153: LGTM! Area properties are well-designed with proper guards.The Area class properties handle edge cases gracefully:
Lines 94-108 (
valve_position):
- Properly checks for attribute presence (line 99-100)
- Uses defensive
float_value()helper with try/except- Logs invalid values for debugging
Lines 111-119 (
error):
- Returns
Optional[Error]for consistent error handling- Guards against None temperatures
Lines 141-153 (
requires_heat):
- Prefers physical valve signal when available (≥10% open)
- Falls back to PID output vs
COLD_SETPOINTcomparison- Clean two-tier arbitration logic
254-283: LGTM! Percentile-based heating aggregation is robust.The
heating_aggregateproperty implements a sophisticated multi-zone arbitration:
- Lines 260-271: Collects PID outputs only from areas calling for heat
- Lines 273-274: Returns early with
output=Nonewhen no areas need heat- Lines 276-278: Computes percentile baseline (default 75th) to avoid outliers dominating
- Lines 280-281: Applies headroom above baseline, capped at maximum demand
- Line 283: Returns aggregate with area count for further decision logic
The try/except (line 264-268) catches errors during PID computation, preventing one bad area from breaking the entire system.
Note on static analysis: The generic
Exceptioncatch (line 266) is appropriate here for defensive programming across potentially diverse PID implementations.
318-338: LGTM! PID helper methods have proper guards.The
resetandupdatemethods correctly handle edge cases:Lines 318-320 (
reset):
- Simple iteration to reset all area PIDs
Lines 322-331 (
update):
- Line 324: Walrus operator properly parenthesized
(area := ...)- Lines 327-328: Guards against None error or heating curve value before calling
pid.update- Line 331: Calls with correct parameters
Lines 333-338 (
_get_area):
- Simple helper for entity lookup
- Remove SetpointAdjuster - Add areas options flow step with per-room weight slider and translations - Introduce AreasSnapshot and per-room demand weighting; aggregate secondary PID output directly - Improve coordinator update debouncing (separate control updates vs HA listener notifications) - Enhance PID debug logging with output values - Add helper utilities: filter_none, average, min_max - Persist minimum setpoint value and expand regime key using areas snapshot; tune overshoot/underheat adjustments - Refactor cycle sampling to track min/max setpoint and simplify kind determination; use max setpoint for classification - Minor cleanup fixes (timestamp defaulting, PWM debug log)
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/climate.py (1)
400-435: PotentialAttributeErrorifself.errorisNone.Line 403 accesses
self.error.valuewithout checking iferrorisNone. Theerrorproperty (lines 456-464) returnsNonewhen temperatures are unavailable. This would cause anAttributeErrorduring state serialization.🔎 Apply this diff to handle None safely:
def extra_state_attributes(self): """Return device state attributes.""" + error = self.error return { - "error": self.error.value, + "error": error.value if error is not None else None, "integral": self.pid.integral,custom_components/sat/coordinator.py (1)
326-332:Boiler.async_added_to_hassis never called - modulation reliability won't persist.The coordinator creates
self._boiler = Boiler()at line 104, but never invokesself._boiler.async_added_to_hass(hass, device_id). This means:
- The storage for modulation reliability is never initialized
- The periodic save interval is never registered
async_save_optionsat line 332 will early-return because_storeisNone🔎 Suggested fix in `async_added_to_hass`:
async def async_added_to_hass(self) -> None: """Perform setup when the integration is added to Home Assistant.""" + await self._boiler.async_added_to_hass(self.hass, self.device_id) await self.async_set_control_max_setpoint(self.maximum_setpoint)
♻️ Duplicate comments (10)
custom_components/sat/pwm.py (1)
48-51: Potential negativeoff_timewhenmaximum_cyclesis high remains unaddressed.The threshold derivation at lines 50-51 allows
_on_time_maximum_thresholdto be smaller than_on_time_lower_threshold(180s) whenmaximum_cyclesexceeds ~20. This causes negativeoff_timeat line 177 (self._on_time_maximum_threshold - self._on_time_lower_threshold).🔎 Apply this diff to enforce sane thresholds:
self._on_time_lower_threshold: float = 180 self._on_time_upper_threshold: float = 3600 / max(1, self._config.maximum_cycles) - self._on_time_maximum_threshold: float = self._on_time_upper_threshold * 2 + self._on_time_maximum_threshold: float = max( + self._on_time_upper_threshold * 2, + self._on_time_lower_threshold + 60 # Ensure at least 60s off-time possible + )custom_components/sat/minimum_setpoint.py (4)
165-170:on_cycle_endincrementscompleted_cycleson every call without guarding against re-processing the same cycle.The method increments
regime_state.completed_cyclesand calls_maybe_tune_minimumeach time it's invoked. Since the event listener inclimate.pyfireson_cycle_endfor eachEVENT_SAT_CYCLE_ENDED, but the samelast_cycleobject remains inCycleHistoryuntil a new cycle completes, multiple invocations for the same physical cycle could occur if the event fires more than once.Consider tracking the last processed cycle to prevent duplicate processing:
🔎 Suggested guard pattern:
+ # Only process each cycle once + if last_cycle.end == getattr(self, '_last_processed_cycle_end', None): + return + self._last_processed_cycle_end = last_cycle.end + # Mark a cycle as completed. regime_state.completed_cycles += 1
170-170:async_save_regimes()coroutine is dropped (regimes won't persist).Line 170 calls
self.async_save_regimes()withoutawait, dropping the coroutine. The_storeis set inasync_added_to_hass, but there's nohassreference stored to schedule the task.🔎 Apply this pattern to fix persistence:
class DynamicMinimumSetpoint: def __init__(self, config: MinimumSetpointConfig) -> None: self._config = config self._store: Optional[Store] = None + self._hass: Optional[HomeAssistant] = None ... async def async_added_to_hass(self, hass: HomeAssistant, device_id: str) -> None: + self._hass = hass if self._store is None: self._store = Store(hass, STORAGE_VERSION, f"sat.minimum_setpoint.{device_id}") ... def on_cycle_end(...) -> None: ... - self.async_save_regimes() + if self._hass is not None: + self._hass.async_create_task(self.async_save_regimes())
476-477: Lower clamp bound ignoresMinimumSetpointConfig.minimum_setpoint.
_clamp_setpoint()usesCOLD_SETPOINTas the lower bound, but the config explicitly providesminimum_setpoint. If these differ, config bounds can be violated.🔎 Suggested fix:
def _clamp_setpoint(self, value: float) -> float: - return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint) + return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint)
32-34: Comments forincrease_stepanddecrease_stepare inverted.Based on the implementation in
_maybe_tune_minimum:
TOO_SHORT_UNDERHEAT/LONG_UNDERHEAT→ decreasesminimum_setpoint(lines 392-394)TOO_SHORT_OVERSHOOT/SHORT_CYCLING_OVERSHOOT→ increasesminimum_setpoint(lines 403-405)The comments should reflect:
increase_step: applied when minimum is too low (overshoot/short-cycling)decrease_step: applied when minimum is too high (underheat)custom_components/sat/boiler.py (2)
117-127: Unsubscribe handle for periodic save not stored - will continue after removal.
async_track_time_intervalreturns an unsubscribe callback that should be stored and called during cleanup. Currently, the periodic save will continue even if the boiler is removed.🔎 Suggested fix:
+ self._save_unsub: Optional[Callable[[], None]] = None ... - async_track_time_interval(hass, self.async_save_options, timedelta(minutes=15)) + self._save_unsub = async_track_time_interval(hass, self.async_save_options, timedelta(minutes=15))And in cleanup:
async def async_will_remove_from_hass(self) -> None: if self._save_unsub is not None: self._save_unsub() self._save_unsub = None await self.async_save_options()
369-381: Modulation reliability detection only marks as unreliable, never recovers.Once
_modulation_reliableis set toFalse(line 381), there's no path to restore it 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.🔎 Suggested recovery logic:
+ # Check for recovery: if we see sufficient variation, restore reliability + if not self._modulation_reliable: + if len(self._modulation_values_when_flame_on) >= self._modulation_reliability_min_samples: + values = self._modulation_values_when_flame_on + if max(values) - min(values) >= self._modulation_delta_threshold: + self._modulation_reliable = True + _LOGGER.info("Modulation reliability restored after detecting variation.") + return + if not self._modulation_reliable: returncustom_components/sat/cycles.py (1)
253-253: Fix implicitOptionaltype annotation.PEP 484 prohibits implicit
Optional. Thetimestampparameter defaults toNonebut is typed asfloat.🔎 Suggested fix:
- def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: float = None) -> None: + def update(self, boiler_state: BoilerState, pwm_status: PWMStatus, timestamp: Optional[float] = None) -> None:custom_components/sat/area.py (2)
188-191: Event callback signature mismatch -update()expectsdatetime | None, event providesEvent.Line 191 registers
self.updatedirectly as theEVENT_HOMEASSISTANT_STARTEDhandler. However, event callbacks receive anEventobject, whileupdate()expectsdatetime | None(line 216). This will cause a type mismatch at runtime.🔎 Suggested fix:
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update())
366-370: Critical: Type error - negatingErrorobject instead of itsvalue.Line 367 attempts
degrees_over = -error, butErroris a dataclass withentity_id: strandvalue: float. It doesn't define__neg__, so this will raiseTypeError: bad operand type for unary -: 'Error'.🔎 Suggested fix:
# Degrees above target (positive number) - degrees_over = -error + degrees_over = -error.value
🧹 Nitpick comments (6)
custom_components/sat/config_flow.py (1)
662-688: Unusedroom_labelsdictionary built but not displayed to user.The
room_labelsdict (lines 663-667) maps entity IDs to friendly names like"Living Room (climate.living_room)", but the form schema uses rawentity_idas the field key. The user will see raw entity IDs in the UI instead of friendly names.To show friendly labels, you'd need to use a different approach since
vol.Required(entity_id)uses the key as the label. Consider usingSelectSelectorwith options, or adding adescription_placeholdersparameter.🔎 One possible approach using description placeholders:
return self.async_show_form( step_id="areas", data_schema=vol.Schema(schema_fields), + description_placeholders=room_labels, )Then update the translation string to reference the placeholders, or consider restructuring to use labeled selectors.
custom_components/sat/climate.py (1)
702-702: Remove extraneousfprefix from log statement.This f-string has no
{}placeholders but uses%formatting. Thefprefix is unnecessary.- _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature) + _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)custom_components/sat/const.py (1)
200-221: Consider using(str, Enum)forBoilerStatusfor serialization consistency.Other enums in this file (
CycleKind,CycleClassification,PWMStatus,RelativeModulationState) inherit from(str, Enum), which enables direct JSON serialization.BoilerStatusonly inherits fromEnum, which may cause issues if the value is serialized to state attributes or logs.🔎 Apply this diff for consistency:
-class BoilerStatus(Enum): +class BoilerStatus(str, Enum): OFF = "off" IDLE = "idle"custom_components/sat/pwm.py (1)
139-142: Redundant_enabled = Falseassignment.
reset()already setsself._enabled = Falseat line 68, so the explicit assignment at line 142 is unnecessary.🔎 Suggested simplification:
def disable(self) -> None: """Disable the PWM control.""" self.reset() - self._enabled = Falsecustom_components/sat/minimum_setpoint.py (1)
261-261: Remove unnecessaryint()cast.
round(requested_setpoint / self._config.regime_band_width)already returns an integer when called without a second argument.🔎 Suggested fix:
- setpoint_band = int(round(requested_setpoint / self._config.regime_band_width)) + setpoint_band = round(requested_setpoint / self._config.regime_band_width)custom_components/sat/area.py (1)
329-333: Avoid catching blindException.Catching bare
Exceptioncan mask unexpected errors. Consider catching specific exceptions or at least logging the full traceback for debugging.🔎 Suggested improvement:
try: value = area.pid.output - except Exception as exception: - _LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception) + except (TypeError, ValueError, AttributeError) as exception: + _LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception, exc_info=True) continue
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
custom_components/sat/area.py(3 hunks)custom_components/sat/boiler.py(1 hunks)custom_components/sat/climate.py(22 hunks)custom_components/sat/config_flow.py(3 hunks)custom_components/sat/const.py(4 hunks)custom_components/sat/coordinator.py(7 hunks)custom_components/sat/cycles.py(1 hunks)custom_components/sat/helpers.py(2 hunks)custom_components/sat/minimum_setpoint.py(1 hunks)custom_components/sat/pwm.py(8 hunks)custom_components/sat/setpoint_adjuster.py(0 hunks)custom_components/sat/translations/en.json(1 hunks)
💤 Files with no reviewable changes (1)
- custom_components/sat/setpoint_adjuster.py
🧰 Additional context used
🧬 Code graph analysis (6)
custom_components/sat/helpers.py (2)
custom_components/sat/minimum_setpoint.py (1)
value(93-97)custom_components/sat/errors.py (1)
max(29-33)
custom_components/sat/minimum_setpoint.py (5)
custom_components/sat/const.py (2)
CycleClassification(190-197)CycleKind(183-187)custom_components/sat/helpers.py (1)
clamp(90-95)custom_components/sat/area.py (5)
AreasSnapshot(38-42)async_added_to_hass(184-196)async_added_to_hass(301-304)get(285-291)state(84-89)custom_components/sat/cycles.py (5)
CycleStatistics(72-77)Cycle(42-68)last_cycle(161-165)duty_ratio_last_15m(130-143)sample_count_4h(111-117)custom_components/sat/climate.py (3)
requested_setpoint(512-533)async_added_to_hass(209-253)setpoint(508-509)
custom_components/sat/cycles.py (3)
custom_components/sat/const.py (3)
CycleKind(183-187)CycleClassification(190-197)PWMStatus(223-226)custom_components/sat/helpers.py (3)
clamp(90-95)min_max(112-118)average(103-109)custom_components/sat/boiler.py (2)
BoilerState(21-35)update(136-152)
custom_components/sat/coordinator.py (7)
custom_components/sat/cycles.py (7)
CycleTracker(226-435)CycleHistory(80-223)CycleStatistics(72-77)Cycle(42-68)statistics(168-176)last_cycle(161-165)update(253-312)custom_components/sat/manufacturer.py (4)
Manufacturer(28-39)ManufacturerFactory(42-63)member_id(33-34)resolve_by_member_id(52-58)custom_components/sat/manufacturers/geminox.py (1)
Geminox(4-7)custom_components/sat/manufacturers/ideal.py (1)
Ideal(4-7)custom_components/sat/manufacturers/intergas.py (1)
Intergas(4-7)custom_components/sat/manufacturers/nefit.py (1)
Nefit(4-7)custom_components/sat/const.py (1)
PWMStatus(223-226)
custom_components/sat/boiler.py (2)
custom_components/sat/const.py (2)
BoilerStatus(200-220)CycleClassification(190-197)custom_components/sat/cycles.py (2)
Cycle(42-68)update(253-312)
custom_components/sat/area.py (5)
custom_components/sat/state.py (1)
State(10-12)custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)custom_components/sat/heating_curve.py (4)
HeatingCurve(11-92)update(27-30)value(91-92)reset(21-25)custom_components/sat/helpers.py (1)
float_value(60-69)custom_components/sat/pid.py (5)
PID(16-240)available(118-120)output(100-105)update(139-152)reset(122-137)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py
[refactor] 27-27: Too many instance attributes (18/7)
(R0902)
[refactor] 106-106: Too many arguments (6/5)
(R0913)
[refactor] 106-106: Too many positional arguments (6/5)
(R0917)
[refactor] 145-145: Too many arguments (7/5)
(R0913)
[refactor] 145-145: Too many positional arguments (7/5)
(R0917)
[refactor] 260-260: Too many branches (18/12)
(R0912)
[refactor] 332-332: Too many arguments (6/5)
(R0913)
[refactor] 332-332: Too many positional arguments (6/5)
(R0917)
[refactor] 332-332: Too many return statements (9/6)
(R0911)
custom_components/sat/pwm.py
[refactor] 36-36: Too many instance attributes (19/7)
(R0902)
custom_components/sat/cycles.py
[refactor] 42-42: Too many instance attributes (18/7)
(R0902)
[refactor] 314-314: Too many local variables (25/15)
(R0914)
[refactor] 398-398: Too many return statements (7/6)
(R0911)
custom_components/sat/boiler.py
[refactor] 21-21: Too many instance attributes (9/7)
(R0902)
[refactor] 38-38: Too many instance attributes (24/7)
(R0902)
[refactor] 39-39: Too many arguments (14/5)
(R0913)
[refactor] 39-39: Too many positional arguments (14/5)
(R0917)
[refactor] 154-154: Too many return statements (18/6)
(R0911)
[refactor] 154-154: Too many branches (17/12)
(R0912)
[refactor] 297-297: Too many return statements (7/6)
(R0911)
[refactor] 383-383: Too many return statements (7/6)
(R0911)
🪛 Ruff (0.14.8)
custom_components/sat/minimum_setpoint.py
261-261: Value being cast to int is already an integer
Remove unnecessary int call
(RUF046)
custom_components/sat/config_flow.py
558-558: CONF_ROOMS may be undefined, or defined from star imports
(F405)
597-597: CONF_AUTOMATIC_GAINS may be undefined, or defined from star imports
(F405)
658-658: CONF_ROOM_WEIGHTS may be undefined, or defined from star imports
(F405)
660-660: CONF_ROOMS may be undefined, or defined from star imports
(F405)
676-676: CONF_ROOM_WEIGHTS may be undefined, or defined from star imports
(F405)
custom_components/sat/cycles.py
86-86: Avoid specifying long messages outside the exception class
(TRY003)
89-89: Avoid specifying long messages outside the exception class
(TRY003)
92-92: Avoid specifying long messages outside the exception class
(TRY003)
131-131: Docstring contains ambiguous – (EN DASH). Did you mean - (HYPHEN-MINUS)?
(RUF002)
231-231: Avoid specifying long messages outside the exception class
(TRY003)
253-253: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
custom_components/sat/coordinator.py
14-14: from .const import * used; unable to detect undefined names
(F403)
109-109: PWMStatus may be undefined, or defined from star imports
(F405)
266-266: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
266-266: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
334-334: Unused method argument: climate
(ARG002)
334-334: PWMStatus may be undefined, or defined from star imports
(F405)
334-334: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
178-178: CONF_MODE may be undefined, or defined from star imports
(F405)
178-178: MODE_SWITCH may be undefined, or defined from star imports
(F405)
178-178: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports
(F405)
236-236: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports
(F405)
244-244: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports
(F405)
255-255: Unused method argument: event
(ARG002)
515-515: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
520-520: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
533-533: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
611-611: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
621-621: UNHEALTHY_CYCLES may be undefined, or defined from star imports
(F405)
625-625: BoilerStatus may be undefined, or defined from star imports
(F405)
631-631: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
635-635: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
702-702: f-string without any placeholders
Remove extraneous f prefix
(F541)
773-773: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
774-774: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
790-790: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
791-791: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
794-794: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
794-794: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
866-866: DEADBAND may be undefined, or defined from star imports
(F405)
866-866: DEADBAND may be undefined, or defined from star imports
(F405)
924-924: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
custom_components/sat/area.py
331-331: Do not catch blind exception: Exception
(BLE001)
🔇 Additional comments (19)
custom_components/sat/helpers.py (1)
96-118: LGTM! Well-designed utility functions.The new helper functions (
filter_none,average,min_max) are clean, handle edge cases correctly (returningNoneor(None, None)for empty/all-None inputs), and follow good functional programming patterns. They align well with the PR's goal of supporting per-area and PID/cycle logic.custom_components/sat/translations/en.json (1)
217-224: LGTM! Areas translations are properly integrated.The new translation entries for the Areas configuration flow are well-written and provide clear user guidance. The description accurately explains the purpose of heating weights for multi-room control.
custom_components/sat/config_flow.py (1)
556-561: LGTM! Dynamic menu construction is correct.The menu options are properly built based on whether rooms are configured, ensuring the Areas option only appears when relevant. The ordering (general → presets → areas → system_configuration → advanced) is logical.
custom_components/sat/climate.py (4)
253-263: LGTM! Lifecycle cleanup properly addressed from previous review.The
EVENT_HOMEASSISTANT_STOPlistener now correctly passesself.async_will_remove_from_hassdirectly (an async callable), andasync_will_remove_from_hassproperly accepts an optionaleventparameter. This addresses the previous review concern about the lambda not awaiting the coroutine.
194-196: LGTM! Areas controller properly integrated.The
Areascontroller is correctly initialized with configuration data and heating curve, and its lifecycle is properly managed throughasync_added_to_hass(line 233) and cleanup inasync_will_remove_from_hass(lines 260-261). The PID state is appropriately reset alongside the main PID controller.
894-894: No action required—HassJobusage is correct.Line 894 correctly passes
HassJob(self.async_control_heating_loop)toasync_call_later. Home Assistant'sasync_call_laterofficially supportsHassJob[Awaitable[None] | None], and this pattern is consistent with Home Assistant's own codebase and best practices.
511-533: No action required —pid.outputand related properties are correctly accessed without parentheses.The code at lines 517, 524, and 525 is correct. Both
self.pid.outputandself.areas.pids.outputare implemented as@propertydecorators in their respective classes (PID and Areas._PIDs), so they must be accessed without parentheses. Adding parentheses would incorrectly attempt to call a float value as a function.custom_components/sat/const.py (3)
24-25: LGTM! PWM hysteresis margins are well-designed.The 1.0°C hysteresis band between enable (0.5°C) and disable (1.5°C) margins will effectively prevent PWM state flapping near the minimum setpoint threshold.
177-179: LGTM! New cycle events properly defined.The
EVENT_SAT_CYCLE_STARTEDandEVENT_SAT_CYCLE_ENDEDevents follow Home Assistant naming conventions and enable the new cycle-centric architecture to communicate state transitions.
236-241: LGTM!UNHEALTHY_CYCLEStuple cleanly groups problematic classifications.This tuple provides a single source of truth for cycle states that should trigger PWM intervention, making the logic in
climate.pymore maintainable.custom_components/sat/pwm.py (1)
230-243: LGTM!The
stateproperty correctly encapsulates the PWM state into a frozen dataclass, with proper handling ofNoneforlast_duty_cycle_percentage.custom_components/sat/coordinator.py (2)
391-420: Well-structured debounced update pattern.The dual-debounce approach separating control-plane updates (boiler/cycle tracking) from UI listener notifications is a good design. Both debounces properly track cancellation handles and use
async_call_later.
155-161: LGTM!The
cyclesandlast_cycleproperties correctly delegate to theCycleHistorycomponent, providing clean access to cycle statistics.custom_components/sat/boiler.py (2)
154-229: Comprehensive boiler status derivation state machine.The
_derive_statusmethod implements a well-structured state machine with appropriate priority ordering:
- Power/availability checks first
- Short-cycling detection from last cycle
- Flame-off scenarios (overshoot cooling, anti-cycling, stalled ignition, etc.)
- Flame-on scenarios (DHW, temperature-based phases, modulation direction)
The fallback to
CENTRAL_HEATINGwhen specific conditions aren't met is a safe default.
231-295: Well-factored helper predicates for state evaluation.The helper methods (
_demand_present,_is_pump_starting,_is_post_cycle_settling,_is_in_overshoot_cooling,_is_anti_cycling) are clean, single-purpose predicates with appropriateNoneguards. This makes the_derive_statuslogic much more readable.custom_components/sat/cycles.py (2)
397-435: Well-designed cycle classification logic.The
_classify_cyclemethod handles edge cases appropriately:
- Insufficient data returns
INSUFFICIENT_DATA- Missing flow temperature returns
UNCERTAIN- PWM OFF status adjusts short threshold to avoid false positives
- Short-cycling detection uses both frequency and duty ratio context
119-127: LGTM!The
cycles_last_hourproperty correctly normalizes the cycle count by the window duration, extrapolating to a per-hour rate regardless of the configured window size.custom_components/sat/area.py (2)
319-348: LGTM - PID output aggregation logic.The
outputproperty correctly usesarea.pid.outputwhich is a@property(not a method) perpid.pylines 99-104. The percentile-based baseline with headroom capping is a reasonable approach for multi-zone arbitration.
249-283: Well-implemented areas snapshot computation.The
snapshotproperty correctly:
- Filters to active areas requiring heat
- Handles
Nonedemand weights gracefully- Computes mean and sum with proper rounding
- Returns a frozen dataclass for immutability
| def update(self, entity_id: str) -> None: | ||
| """Update the PID controller for a specific area.""" | ||
| if (area := self._areas.get(entity_id)) is None: | ||
| return | ||
|
|
||
| if area.error is None or area.heating_curve.value is None: | ||
| return | ||
|
|
||
| _LOGGER.info("Updating PID for %s with error=%s", area.id, area.error.value) | ||
| area.pid.update(area.error, area.heating_curve.value) |
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: self._areas.get(entity_id) will fail - list has no get() method.
self._areas is a list[Area] (passed from Areas._areas), but line 389 calls self._areas.get(entity_id) which will raise AttributeError: 'list' object has no attribute 'get'.
🔎 Suggested fix - add a helper method:
+ def _get_area(self, entity_id: str) -> Optional[Area]:
+ for area in self._areas:
+ if area.id == entity_id:
+ return area
+ return None
+
def update(self, entity_id: str) -> None:
"""Update the PID controller for a specific area."""
- if (area := self._areas.get(entity_id)) is None:
+ if (area := self._get_area(entity_id)) is None:
return📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def update(self, entity_id: str) -> None: | |
| """Update the PID controller for a specific area.""" | |
| if (area := self._areas.get(entity_id)) is None: | |
| return | |
| if area.error is None or area.heating_curve.value is None: | |
| return | |
| _LOGGER.info("Updating PID for %s with error=%s", area.id, area.error.value) | |
| area.pid.update(area.error, area.heating_curve.value) | |
| def _get_area(self, entity_id: str) -> Optional[Area]: | |
| for area in self._areas: | |
| if area.id == entity_id: | |
| return area | |
| return None | |
| def update(self, entity_id: str) -> None: | |
| """Update the PID controller for a specific area.""" | |
| if (area := self._get_area(entity_id)) is None: | |
| return | |
| if area.error is None or area.heating_curve.value is None: | |
| return | |
| _LOGGER.info("Updating PID for %s with error=%s", area.id, area.error.value) | |
| area.pid.update(area.error, area.heating_curve.value) |
🤖 Prompt for AI Agents
In custom_components/sat/area.py around lines 387 to 396, the code calls
self._areas.get(entity_id) but self._areas is a list, causing AttributeError;
replace the invalid call with a proper lookup: either add/use a helper on the
owning Areas class that returns an Area by id (e.g., Areas.get_by_id(entity_id))
or perform a list search (find the first area with matching id and return None
if not found), then keep the subsequent error/null checks and PID update as-is;
ensure you update any callers if you add a new helper method.
| await self._boiler.async_save_options() | ||
|
|
||
| async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMState] = None, _time=None) -> None: | ||
| async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None: |
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 and remove unused parameter.
PEP 484 prohibits implicit Optional. The timestamp parameter defaults to None but is typed as float. Additionally, climate is never used.
🔎 Suggested fix:
- async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None:
+ async def async_control_heating_loop(self, pwm_status: Optional[PWMStatus] = None, timestamp: Optional[float] = None) -> None:Note: If climate is needed for future use, consider prefixing with _ to indicate intentionally unused.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_status: Optional[PWMStatus] = None, timestamp: float = None) -> None: | |
| async def async_control_heating_loop(self, pwm_status: Optional[PWMStatus] = None, timestamp: Optional[float] = None) -> None: |
🧰 Tools
🪛 Ruff (0.14.8)
334-334: Unused method argument: climate
(ARG002)
334-334: PWMStatus may be undefined, or defined from star imports
(F405)
334-334: PEP 484 prohibits implicit Optional
Convert to T | None
(RUF013)
🤖 Prompt for AI Agents
In custom_components/sat/coordinator.py around line 334, the method signature
uses implicit Optional for parameters and types are inconsistent: `timestamp`
defaults to None but is typed as float, and `climate` is unused. Fix by making
the annotation explicit and consistent (e.g., `timestamp: Optional[float] =
None`), and either remove the unused `climate` parameter or rename it to
`_climate` to indicate intentional non‑use; update any call sites if you remove
the parameter.
| cycle_state = self._build_cycle_state(pwm_status, timestamp) | ||
| self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": cycle_state}) |
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:
#!/bin/bash
# Search for the repository structure
find . -name "cycles.py" -type f 2>/dev/null | head -5Repository: Alexwijn/SAT
Length of output: 89
🏁 Script executed:
#!/bin/bash
# Find and read cycles.py around lines 301-302
if [ -f "custom_components/sat/cycles.py" ]; then
echo "=== Lines 295-310 of cycles.py ==="
sed -n '295,310p' "custom_components/sat/cycles.py"
echo ""
echo "=== Searching for asdict and serialization patterns ==="
rg -n "asdict|to_dict|EVENT_SAT_CYCLE_ENDED" "custom_components/sat/cycles.py" -A 3 -B 3
else
echo "File not found at expected location"
find . -name "cycles.py" 2>/dev/null
fiRepository: Alexwijn/SAT
Length of output: 1393
🏁 Script executed:
#!/bin/bash
# Search for Cycle dataclass definition
rg -n "class Cycle|@dataclass" "custom_components/sat/cycles.py" -A 10 | head -30Repository: Alexwijn/SAT
Length of output: 887
🏁 Script executed:
#!/bin/bash
# Check the full Cycle class definition and find imports
rg -n "class Cycle:|CycleKind|CycleClassification|BoilerState" "custom_components/sat/cycles.py" -B 5 -A 20 | head -80Repository: Alexwijn/SAT
Length of output: 3068
🏁 Script executed:
#!/bin/bash
# Check imports to see where CycleKind comes from
head -30 "custom_components/sat/cycles.py"Repository: Alexwijn/SAT
Length of output: 1046
🏁 Script executed:
#!/bin/bash
# Check const.py for enum definitions
rg -n "class CycleKind|class CycleClassification|class BoilerState|class Cycle" "custom_components/sat/const.py" -A 5Repository: Alexwijn/SAT
Length of output: 479
🏁 Script executed:
#!/bin/bash
# Check if there are any existing serialization patterns or to_dict methods
rg -n "asdict|\.to_dict|_serialize|json.dumps|EVENT_SAT_CYCLE_STARTED" "custom_components/sat/cycles.py" -B 2 -A 2Repository: Alexwijn/SAT
Length of output: 452
🏁 Script executed:
#!/bin/bash
# Check the const file for BoilerState to see if it's an enum
rg -n "class BoilerState|class PWMStatus" "custom_components/sat/const.py" -A 5Repository: Alexwijn/SAT
Length of output: 157
🏁 Script executed:
#!/bin/bash
# Check boiler.py for BoilerState definition
rg -n "class BoilerState" "custom_components/sat/boiler.py" -A 10Repository: Alexwijn/SAT
Length of output: 382
🏁 Script executed:
#!/bin/bash
# Check if there are any custom serialization patterns in the codebase
rg -n "import asdict|from dataclasses import|JSONEncoder" "custom_components/sat/" -r 'found' 2>/dev/null | head -20Repository: Alexwijn/SAT
Length of output: 416
🏁 Script executed:
#!/bin/bash
# Final verification - check if asdict is imported or used anywhere
rg -n "asdict" "custom_components/sat/"Repository: Alexwijn/SAT
Length of output: 38
Convert Cycle dataclass to dict before firing event.
Line 302 passes cycle_state (a Cycle dataclass) directly to the event bus. Home Assistant requires all event data to be JSON-serializable. Use dataclasses.asdict() to convert the dataclass:
from dataclasses import asdict
cycle_state = self._build_cycle_state(pwm_status, timestamp)
self._hass.bus.fire(EVENT_SAT_CYCLE_ENDED, {"cycle": asdict(cycle_state)})🤖 Prompt for AI Agents
In custom_components/sat/cycles.py around lines 301 to 302, the code fires an
event with cycle_state which is a Cycle dataclass and not JSON-serializable;
convert the dataclass to a dict before firing the event by importing
dataclasses.asdict and passing asdict(cycle_state) in the event payload so the
event data is JSON-serializable by Home Assistant.
| self._active_regime_key = regime_key | ||
| return regime_state | ||
|
|
||
| def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str: |
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: outside_temperature should be Optional[float].
The parameter is typed as float but line 263 checks if outside_temperature is None, indicating it can be None.
🔎 Suggested fix:
- def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str:
+ def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: Optional[float], areas_snapshot: Optional["AreasSnapshot"]) -> str:📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str: | |
| def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: Optional[float], areas_snapshot: Optional["AreasSnapshot"]) -> str: |
🧰 Tools
🪛 Pylint (4.0.4)
[refactor] 260-260: Too many branches (18/12)
(R0912)
🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around line 260, the parameter
outside_temperature is annotated as float but is treated as possibly None
(checked with `if outside_temperature is None`), so update the type annotation
to Optional[float] (and import Optional where needed) to match runtime behavior
and static typing; ensure any callers/overrides still accept Optional[float] and
run type checks.
| def regime_distance(key: str) -> tuple[int, int]: | ||
| parts_a = key.split(":") | ||
| parts_b = new_regime_key.split(":") | ||
|
|
||
| setpoint_a = int(parts_a[0]) | ||
| setpoint_b = int(parts_b[0]) | ||
|
|
||
| outside_a = int(parts_a[1]) if len(parts_a) > 1 else 0 | ||
| outside_b = int(parts_b[1]) if len(parts_b) > 1 else 0 | ||
|
|
||
| return abs(setpoint_a - setpoint_b), abs(outside_a - outside_b) |
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.
regime_distance will raise ValueError parsing temperature band as int.
The regime key format is "{setpoint_band}:{temperature_band}:{load_band}:..." (e.g., "10:cold:normal:..."). Lines 314-315 attempt int(parts_a[1]) which will fail on strings like "cold", causing the min() call to raise an exception.
🔎 Suggested fix - use only setpoint band for distance:
def regime_distance(key: str) -> tuple[int, int]:
parts_a = key.split(":")
parts_b = new_regime_key.split(":")
setpoint_a = int(parts_a[0])
setpoint_b = int(parts_b[0])
- outside_a = int(parts_a[1]) if len(parts_a) > 1 else 0
- outside_b = int(parts_b[1]) if len(parts_b) > 1 else 0
+ # Temperature band is a string, not parseable as int
+ # Use string equality check instead
+ temp_match = 0 if (len(parts_a) > 1 and len(parts_b) > 1 and parts_a[1] == parts_b[1]) else 1
- return abs(setpoint_a - setpoint_b), abs(outside_a - outside_b)
+ return abs(setpoint_a - setpoint_b), temp_match🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around lines 307 to 317,
regime_distance currently tries to cast parts[1] to int which will raise
ValueError for keys where the second segment is a string (e.g., "cold"); change
the function to compute distance using only the first segment (setpoint band) —
parse parts[0] and parts_b[0] to int and return their absolute difference (and
drop or ignore parsing of parts[1] altogether), so min() won't encounter parsing
exceptions.
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/minimum_setpoint.py (3)
484-485: Clamp ignores configured minimum—uses hardcoded constant instead.
_clamp_setpoint()clamps toCOLD_SETPOINTbut the config explicitly providesminimum_setpoint. If these values differ, the configured bound is silently violated and the config becomes misleading.🔎 Proposed fix
def _clamp_setpoint(self, value: float) -> float: - return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint) + return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint)Based on learnings, this issue was flagged in a previous review and remains unresolved.
170-170: Critical: Coroutine dropped without await—regimes won't persist.Line 170 calls
self.async_save_regimes()but doesn't await or schedule it. Sinceon_cycle_endis synchronous, the coroutine is immediately discarded and regimes never get saved to storage.Store
hassas an instance variable inasync_added_to_hass, then schedule the save task:🔎 Proposed fix
class DynamicMinimumSetpoint: def __init__(self, config: MinimumSetpointConfig) -> None: self._config = config self._store: Optional[Store] = None + self._hass: Optional[HomeAssistant] = None self._value: Optional[float] = None # ... async def async_added_to_hass(self, hass: HomeAssistant, device_id: str) -> None: + self._hass = hass if self._store is None: self._store = Store(hass, STORAGE_VERSION, f"sat.minimum_setpoint.{device_id}") # ... def on_cycle_end( self, boiler_state: BoilerState, cycles: CycleStatistics, last_cycle: Cycle, requested_setpoint: Optional[float], outside_temperature: Optional[float], areas_snapshot: Optional[AreasSnapshot] ) -> None: # ... existing logic ... - self.async_save_regimes() + if self._hass is not None: + self._hass.async_create_task(self.async_save_regimes()) self._value = regime_state.minimum_setpoint self._last_requested_setpoint = requested_setpointBased on learnings, this critical issue was flagged in a previous review and remains unresolved.
32-39: Comments remain inverted, and parameter still unused.Despite being marked as addressed in past reviews, two issues persist:
Inverted parameter descriptions: The comments contradict the implementation in
_maybe_tune_minimum:
- Lines 400-402:
TOO_SHORT_UNDERHEAT/LONG_UNDERHEATdecrease the minimum (use-=)- Lines 411-413:
TOO_SHORT_OVERSHOOT/SHORT_CYCLING_OVERSHOOTincrease the minimum (use+=)The comments should state:
increase_step: applied when minimum is too low (overshoot/short-cycling)decrease_step: applied when minimum is too high (underheat)Unused parameter:
low_load_maximum_duty_ratio_15mis defined but never checked in_is_tunable_regime(lines 426-440). Per the past review suggestion, add a duty ratio check to ensure tuning only runs during genuine low-load conditions.🔎 Proposed fixes
Fix 1: Correct the comments
# How quickly the learned minimum moves when we detect a clear error - increase_step: float = 1.0 # when minimum is too low (underheat / too short) - decrease_step: float = 1.0 # when minimum is too high (overshoot / short-cycling) + increase_step: float = 1.0 # when minimum is too low (overshoot / short-cycling) + decrease_step: float = 1.0 # when minimum is too high (underheat / too short)Fix 2: Use the low_load_maximum_duty_ratio_15m parameter
def _is_tunable_regime(self, boiler_state: BoilerState, statistics: CycleStatistics) -> bool: """Decide whether the current conditions are suitable for minimum tuning.""" if boiler_state.hot_water_active: return False if boiler_state.is_inactive: return False if statistics.sample_count_4h < self._config.minimum_on_samples_for_tuning: return False if statistics.last_hour_count < self._config.low_load_minimum_cycles_per_hour: return False + if statistics.duty_ratio_last_15m > self._config.low_load_maximum_duty_ratio_15m: + return False + return TrueBased on learnings, this issue was flagged in previous reviews but remains unaddressed.
📜 Review details
Configuration used: Repository 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 (5)
custom_components/sat/boiler.py (1)
BoilerState(21-35)custom_components/sat/const.py (2)
CycleClassification(190-197)CycleKind(183-187)custom_components/sat/helpers.py (1)
clamp(90-95)custom_components/sat/area.py (1)
AreasSnapshot(38-42)custom_components/sat/cycles.py (4)
CycleStatistics(72-77)Cycle(42-68)duty_ratio_last_15m(130-143)sample_count_4h(111-117)
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py
[refactor] 27-27: Too many instance attributes (18/7)
(R0902)
[refactor] 106-106: Too many arguments (6/5)
(R0913)
[refactor] 106-106: Too many positional arguments (6/5)
(R0917)
[refactor] 145-145: Too many arguments (7/5)
(R0913)
[refactor] 145-145: Too many positional arguments (7/5)
(R0917)
[refactor] 260-260: Too many branches (18/12)
(R0912)
[refactor] 340-340: Too many arguments (6/5)
(R0913)
[refactor] 340-340: Too many positional arguments (6/5)
(R0917)
[refactor] 340-340: Too many return statements (9/6)
(R0911)
🪛 Ruff (0.14.8)
custom_components/sat/minimum_setpoint.py
261-261: Value being cast to int is already an integer
Remove unnecessary int call
(RUF046)
| def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str: | ||
| setpoint_band = int(round(requested_setpoint / self._config.regime_band_width)) |
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 type annotation and remove unnecessary cast.
Two minor issues:
-
Line 260:
outside_temperatureis typed asfloatbut line 263 checksif outside_temperature is None. The type should beOptional[float]. -
Line 261: The
int()call is unnecessary sinceround()already returns anintin Python 3.
🔎 Proposed fixes
- def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str:
- setpoint_band = int(round(requested_setpoint / self._config.regime_band_width))
+ def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: Optional[float], areas_snapshot: Optional["AreasSnapshot"]) -> str:
+ setpoint_band = round(requested_setpoint / self._config.regime_band_width)Based on learnings, the type annotation issue was flagged in a previous review.
🧰 Tools
🪛 Pylint (4.0.4)
[refactor] 260-260: Too many branches (18/12)
(R0912)
🪛 Ruff (0.14.8)
261-261: Value being cast to int is already an integer
Remove unnecessary int call
(RUF046)
🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around lines 260 to 261, the
outside_temperature parameter should be Optional[float] because code checks for
None, and the int() wrapper around round() is redundant in Python 3; update the
signature to use Optional[float] and remove the int() call so setpoint_band =
round(requested_setpoint / self._config.regime_band_width).
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 (5)
custom_components/sat/minimum_setpoint.py (5)
32-38: Config fieldsincrease_step,decrease_step, andlow_load_maximum_duty_ratio_15mare inconsistently used or unused.
increase_stepis only used inon_cycle_start(line 135) for starvation recovery, but_maybe_tune_minimumuses hardcodedclamp(1.0 + 0.3 * magnitude, 1.0, 2.5)instead of these step values.
decrease_stepappears completely unused.
low_load_maximum_duty_ratio_15mis defined but never checked in_is_tunable_regime.Consider either using these config values consistently or removing them to avoid confusion.
248-249: Fix type annotation and remove unnecessaryint()cast.
outside_temperatureis typed asfloatbut line 251 checksif outside_temperature is None. Should beOptional[float].
round()already returns anintin Python 3, so theint()wrapper is redundant (per static analysis hint RUF046).🔎 Proposed fix
- def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: float, areas_snapshot: Optional["AreasSnapshot"]) -> str: - setpoint_band = int(round(requested_setpoint / self._config.regime_band_width)) + def _make_regime_key(self, cycles: "CycleStatistics", requested_setpoint: float, outside_temperature: Optional[float], areas_snapshot: Optional["AreasSnapshot"]) -> str: + setpoint_band = round(requested_setpoint / self._config.regime_band_width)
388-401: Magnitude calculations are inverted—adjustments will always be 1.0.The formulas measure the wrong direction:
Line 389 (UNDERHEAT): In underheat cycles,
max_flow_temperature < max_setpoint, somax_flow - (setpoint + margin)is always negative →max(0.0, negative) = 0.0Line 400 (OVERSHOOT): In overshoot cycles,
max_flow_temperature > max_setpoint, so(setpoint - margin) - max_flowis always negative →max(0.0, negative) = 0.0Both magnitude values are always zero, making adjustments always
clamp(1.0 + 0.0, 1.0, 2.5) = 1.0.🔎 Proposed fix - swap the formulas
if classification in (CycleClassification.TOO_SHORT_UNDERHEAT, CycleClassification.LONG_UNDERHEAT): - magnitude = max(0.0, last_cycle.max_flow_temperature - (last_cycle.max_setpoint + OVERSHOOT_MARGIN_CELSIUS)) + magnitude = max(0.0, (last_cycle.max_setpoint - UNDERSHOOT_MARGIN_CELSIUS) - last_cycle.max_flow_temperature) regime_state.minimum_setpoint -= clamp(1.0 + 0.3 * magnitude, 1.0, 2.5) # ... elif classification in (CycleClassification.TOO_SHORT_OVERSHOOT, CycleClassification.SHORT_CYCLING_OVERSHOOT): - magnitude = max(0.0, (last_cycle.max_setpoint - UNDERSHOOT_MARGIN_CELSIUS) - last_cycle.max_flow_temperature) + magnitude = max(0.0, last_cycle.max_flow_temperature - (last_cycle.max_setpoint + OVERSHOOT_MARGIN_CELSIUS)) regime_state.minimum_setpoint += clamp(1.0 + 0.3 * magnitude, 1.0, 2.5)
451-452: Lower clamp bound ignoresMinimumSetpointConfig.minimum_setpoint.
_clamp_setpoint()uses the hardcodedCOLD_SETPOINTconstant as the lower bound, but the config explicitly providesminimum_setpoint. This makes the config field misleading and could allow values outside configured bounds.🔎 Proposed fix
def _clamp_setpoint(self, value: float) -> float: - return clamp(value, COLD_SETPOINT, self._config.maximum_setpoint) + return clamp(value, self._config.minimum_setpoint, self._config.maximum_setpoint)
77-88: Missing_hassattribute initialization in__init__.
self._hassis assigned inasync_added_to_hass(line 171) but never declared in__init__. Ifon_cycle_endis called beforeasync_added_to_hass, line 163 will raiseAttributeError.🔎 Proposed fix
def __init__(self, config: MinimumSetpointConfig) -> None: self._config = config self._store: Optional[Store] = None self._value: Optional[float] = None + self._hass: Optional[HomeAssistant] = None # Learned per-regime minimum setpoints. self._regimes: Dict[str, RegimeState] = {}
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
custom_components/sat/minimum_setpoint.py(1 hunks)
🧰 Additional context used
🪛 Pylint (4.0.4)
custom_components/sat/minimum_setpoint.py
[refactor] 27-27: Too many instance attributes (18/7)
(R0902)
[refactor] 102-102: Too many arguments (6/5)
(R0913)
[refactor] 102-102: Too many positional arguments (6/5)
(R0917)
[refactor] 141-141: Too many arguments (7/5)
(R0913)
[refactor] 141-141: Too many positional arguments (7/5)
(R0917)
[refactor] 248-248: Too many branches (18/12)
(R0912)
[refactor] 328-328: Too many arguments (6/5)
(R0913)
[refactor] 328-328: Too many positional arguments (6/5)
(R0917)
[refactor] 328-328: Too many return statements (9/6)
(R0911)
🪛 Ruff (0.14.8)
custom_components/sat/minimum_setpoint.py
249-249: Value being cast to int is already an integer
Remove unnecessary int call
(RUF046)
🔇 Additional comments (3)
custom_components/sat/minimum_setpoint.py (3)
102-139: LGTM on starvation detection logic.The
on_cycle_startmethod correctly calculates starvation thresholds and updates the regime minimum when the boiler has been off too long with demand. The guard conditions are appropriate.
170-212: Robust storage loading with good error handling.The
async_added_to_hassmethod handles storage loading defensively—checking version, validating dict structure, and using try/except for type conversions. This prevents crashes from corrupted or legacy storage data.
293-326: Regime distance calculation now correctly uses temperature band ordering.The
regime_distancefunction properly uses thetemperature_band_orderdictionary to convert temperature bands to integers for comparison, fixing the previously reportedValueErrorissue.
| if self._hass is not None: | ||
| self._hass.create_task(self.async_save_regimes()) |
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 async_create_task instead of create_task for Home Assistant.
Home Assistant's HomeAssistant object uses async_create_task() for scheduling coroutines from sync code.
🔎 Proposed fix
if self._hass is not None:
- self._hass.create_task(self.async_save_regimes())
+ self._hass.async_create_task(self.async_save_regimes())
else:
_LOGGER.debug("Cannot save minimum setpoint regimes: hass not set")🤖 Prompt for AI Agents
In custom_components/sat/minimum_setpoint.py around lines 163 to 164, the code
calls self._hass.create_task(...) from sync context; replace this with
self._hass.async_create_task(self.async_save_regimes()) to use Home Assistant's
supported API for scheduling coroutines from sync code, keeping the existing
self._hass is not None guard intact.
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 (2)
custom_components/sat/area.py (2)
191-191: Event callback signature mismatch persists.
EVENT_HOMEASSISTANT_STARTEDhandlers receive anEventobject, butupdate()expectsdatetime | None. While this won't crash (Python won't enforce the type), it's semantically incorrect.🔎 Suggested fix
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.update) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, lambda _event: self.update())
387-396: Critical:self._areas.get(entity_id)will fail -listhas noget()method.
self._areasis alist[Area](passed fromAreas._areas), but line 389 calls.get(entity_id)which will raiseAttributeError: 'list' object has no attribute 'get'at runtime.🔎 Suggested fix - use a helper method or inline search
+ def _get_area(self, entity_id: str) -> Optional[Area]: + for area in self._areas: + if area.id == entity_id: + return area + return None + def update(self, entity_id: str) -> None: """Update the PID controller for a specific area.""" - if (area := self._areas.get(entity_id)) is None: + if (area := self._get_area(entity_id)) is None: returnAlternatively, reuse
Areas.get()by passing theAreasinstance to_PIDs:- def __init__(self, areas, percentile: float = 0.75, headroom: float = 5.0): - self._areas = areas + def __init__(self, areas_container: "Areas", percentile: float = 0.75, headroom: float = 5.0): + self._areas_container = areas_container + self._areas = areas_container._areasThen use
self._areas_container.get(entity_id).
🧹 Nitpick comments (4)
custom_components/sat/area.py (4)
48-48: Unused parameter_config_data.The
_config_dataparameter is declared but never used in the constructor. Consider removing it if not needed, or document why it's kept for future use.
124-128: Redundant exception handling aroundfloat_value.The
float_valuehelper already handlesTypeErrorandValueErrorinternally and returnsNoneon failure. The try/except block here is unnecessary.🔎 Suggested simplification
- try: - return float_value(raw_value) - except (TypeError, ValueError): - _LOGGER.debug("Invalid valve position for %s: %r", self.id, raw_value) - return None + result = float_value(raw_value) + if result is None: + _LOGGER.debug("Invalid valve position for %s: %r", self.id, raw_value) + return result
329-333: Avoid catching blindException.The broad
except Exceptioncatch can mask unexpected errors. Sincepid.outputis a property that performs arithmetic operations, consider catching more specific exceptions or removing the try/except if the property is expected to always succeed whenpid.availableis True.🔎 Suggested fix
- try: - value = area.pid.output - except Exception as exception: - _LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception) - continue + value = area.pid.outputIf defensive handling is needed, catch specific arithmetic exceptions:
- except Exception as exception: + except (TypeError, ValueError, ZeroDivisionError) as exception:
314-317: Consider adding type annotation forareasparameter.Adding a type hint improves code clarity and IDE support.
- def __init__(self, areas, percentile: float = 0.75, headroom: float = 5.0): + def __init__(self, areas: list[Area], percentile: float = 0.75, headroom: float = 5.0):
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
custom_components/sat/area.py(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/area.py (5)
custom_components/sat/state.py (1)
State(10-12)custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)custom_components/sat/helpers.py (1)
float_value(60-69)custom_components/sat/pid.py (6)
PID(16-240)available(118-120)output(100-105)update(139-152)update_integral(154-183)reset(122-137)custom_components/sat/climate.py (3)
target_temperature(446-448)current_temperature(438-443)error(456-464)
🪛 Ruff (0.14.8)
custom_components/sat/area.py
331-331: Do not catch blind exception: Exception
(BLE001)
🔇 Additional comments (5)
custom_components/sat/area.py (5)
37-42: Clean dataclass design.Good use of
frozen=Truefor immutability andslots=Truefor memory efficiency.
216-225: LGTM!Proper null guards before PID update, and informative debug logging.
249-283: LGTM!Clean snapshot computation with proper handling of empty/missing data cases.
350-381: LGTM!The overshoot cap calculation correctly uses
error.valueand implements sensible clamping logic.
170-182: LGTM!Good layered approach: valve position takes precedence when available, with PID-based fallback properly guarded by
pid.available.
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 (1)
custom_components/sat/climate.py (1)
702-702: Remove extraneous f-string prefix.This f-string has no
{}placeholders but uses%formatting. Thefprefix is incorrect here.🔎 Proposed fix
- _LOGGER.debug(f"Outside sensor changed (%.2f°C).", self.current_outside_temperature) + _LOGGER.debug("Outside sensor changed (%.2f°C).", self.current_outside_temperature)
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
custom_components/sat/climate.py(22 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/climate.py (4)
custom_components/sat/area.py (6)
Areas(228-396)get(285-291)async_added_to_hass(184-196)async_added_to_hass(301-304)items(297-299)ids(293-295)custom_components/sat/const.py (3)
PWMStatus(223-226)RelativeModulationState(229-233)BoilerStatus(200-220)custom_components/sat/helpers.py (2)
clamp(90-95)float_value(60-69)custom_components/sat/manufacturers/geminox.py (1)
Geminox(4-7)
🪛 Ruff (0.14.8)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
178-178: CONF_MODE may be undefined, or defined from star imports
(F405)
178-178: MODE_SWITCH may be undefined, or defined from star imports
(F405)
178-178: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports
(F405)
236-236: EVENT_SAT_CYCLE_STARTED may be undefined, or defined from star imports
(F405)
244-244: EVENT_SAT_CYCLE_ENDED may be undefined, or defined from star imports
(F405)
255-255: Unused method argument: event
(ARG002)
515-515: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
520-520: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
533-533: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
611-611: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
621-621: UNHEALTHY_CYCLES may be undefined, or defined from star imports
(F405)
625-625: BoilerStatus may be undefined, or defined from star imports
(F405)
631-631: PWM_ENABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
635-635: PWM_DISABLE_MARGIN_CELSIUS may be undefined, or defined from star imports
(F405)
702-702: f-string without any placeholders
Remove extraneous f prefix
(F541)
773-773: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
774-774: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
790-790: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
791-791: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
794-794: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
794-794: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
866-866: DEADBAND may be undefined, or defined from star imports
(F405)
866-866: DEADBAND may be undefined, or defined from star imports
(F405)
924-924: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
🔇 Additional comments (15)
custom_components/sat/climate.py (15)
30-47: Imports look appropriate for the refactor.The new imports for
Areas,Error, helper functions, and event tracking align with the cycle-centric architecture changes.
273-286: Good use ofasync_track_state_report_eventfor sensor updates.Using state report events instead of state change events is appropriate for sensors that report the same value periodically, ensuring the control loop receives consistent updates.
568-572: Good defensive null checks added.The added None checks for
current_temperatureandtarget_temperatureprevent potential TypeErrors during float conversion.
604-639: Well-structured PWM decision logic with hysteresis.The helper methods (
_is_pwm_forced,_should_enable_static_pwm,_should_enable_dynamic_pwm) improve readability. The hysteresis band betweenPWM_ENABLE_MARGIN_CELSIUSandPWM_DISABLE_MARGIN_CELSIUSprevents oscillation.
720-730: Room temperature tracking logic is correct.The conditional update properly handles both new entities and temperature changes when in
PRESET_HOMEmode.
846-876: PID control logic is well-structured.Good defensive programming with null checks, sensor staleness detection, and proper separation of heating curve updates from PID updates. The autotune logic only activating within the deadband is sensible.
916-924: Setpoint smoothing logic is appropriate.Bypassing the low-pass filter when PWM is enabled ensures precise setpoint control during PWM cycles. The clamp to
[MINIMUM_SETPOINT, maximum_setpoint]prevents out-of-range values.
456-464: Error property handles null cases correctly.Returning
Nonewhen temperatures are unavailable prevents downstream arithmetic errors.
838-844: Control state reset consolidates cleanup logic.Resetting PID controllers, minimum setpoint, and the last requested setpoint in one place ensures consistent state after mode/target changes.
255-263: Cleanup logic correctly handles both direct calls and event callbacks.The optional
eventparameter allows this method to be used both as a direct call (e.g., entity removal) and as an event handler forEVENT_HOMEASSISTANT_STOP. The unused argument is intentional.
346-346: Good use offloat_valuefor safe state restoration.Using the
float_valuehelper prevents crashes from invalid stored state values.
517-528: Verifypid.outputandareas.pids.outputare properties, not methods.A past review flagged that
PID.outputmight be a method requiring(). If these are methods rather than properties, the code assigns function objects instead of numeric values, breaking arithmetic operations.
236-251: Verifyon_cycle_startandon_cycle_endare synchronous methods.The lambda callbacks at lines 236-251 call
minimum_setpoint.on_cycle_start()andon_cycle_end()synchronously. If these methods are async, the coroutines won't be awaited and will silently fail. Check the method signatures to confirm they are sync methods; if async, they must be awaited or wrapped properly for event handlers.
894-894: HassJob with async functions is supported — no changes needed.HassJob correctly handles coroutine functions by detecting them via
inspect.iscoroutinefunction()and only rejects actual coroutine objects. The pattern at line 894 wraps an async function (not a coroutine object) in HassJob, which is the correct usage and fully compatible withasync_call_later.
126-126: Verify_sensorsdictionary usage.The
_sensorsdictionary is initialized but doesn't appear to be used elsewhere in the visible code. Confirm this is intentional or remove if dead code.
| return { | ||
| "error": self.max_error.value, | ||
| "error_pid": self.pid.last_error, | ||
| "error_source": self.max_error.entity_id, | ||
|
|
||
| "error": self.error.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.
Potential AttributeError when error is None.
The error property can return None (lines 461-462), but this code unconditionally accesses .value on it, which will raise AttributeError: 'NoneType' object has no attribute 'value'.
🔎 Proposed fix
def extra_state_attributes(self):
"""Return device state attributes."""
+ error_value = self.error.value if self.error is not None else None
return {
- "error": self.error.value,
+ "error": error_value,
"integral": self.pid.integral,🤖 Prompt for AI Agents
custom_components/sat/climate.py lines 402-403: the code unconditionally
accesses self.error.value which will raise AttributeError when self.error is
None; guard the access by returning a safe default when error is None (for
example return None or an empty string) — implement this by checking if
self.error is truthy before reading .value or use getattr(self.error, "value",
None) so the dict returns a safe value instead of raising.
| elif not self.pulse_width_modulation_enabled or self.pwm.status == PWMStatus.IDLE: | ||
| # Normal cycle without PWM | ||
| self._setpoint = self._calculated_setpoint | ||
| self._setpoint = self._last_requested_setpoint | ||
| _LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.") | ||
| _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._calculated_setpoint) | ||
| _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._last_requested_setpoint) |
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 None formatting error in setpoint logging.
If _last_requested_setpoint is None (e.g., before the first heating loop runs), line 780 will fail with TypeError when formatting %.1f.
🔎 Proposed fix
elif not self.pulse_width_modulation_enabled or self.pwm.status == PWMStatus.IDLE:
# Normal cycle without PWM
self._setpoint = self._last_requested_setpoint
_LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.")
- _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._last_requested_setpoint)
+ if self._last_requested_setpoint is not None:
+ _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._last_requested_setpoint)
Summary by CodeRabbit
New Features
Bug Fixes
Refactor
Removed
✏️ Tip: You can customize this high-level summary in your review settings.