Conversation
Refactor Manufacturer Identification
…or reports it doesn't support it
Add service constants, rename boiler state, update PWM
WalkthroughRefactors core state models to Error/Errors and immutable State; introduces BoilerState, Flame health tracking, cycle-based PWM with CycleConfig/PWMState; manufacturer API moved to friendly_name/member_id; coordinators accept config_data; adds services reset_integral and pulse_width_modulation; many typing/API adjustments across modules. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant User
participant HA as Home Assistant
participant Services as SAT Services
participant Registry as EntityRegistry
participant Util as util.get_climate_entities
participant Climate as SatClimate
Note over Services,Climate: Service call flow (reset_integral / pulse_width_modulation)
User->>HA: Call sat.reset_integral or sat.pulse_width_modulation with entity_ids
HA->>Services: Dispatch service
Services->>Registry: Resolve entity_ids → config entries
Registry-->>Services: registry results
Services->>Util: get_climate_entities(hass, entity_ids)
Util-->>Services: list[SatClimate]
Services->>Climate: climate.pid.reset() or climate.pwm.enable()/disable()
Climate-->>Services: complete
Services-->>HA: service completed
sequenceDiagram
autonumber
participant Coord as SatDataUpdateCoordinator
participant Boiler as BoilerState
participant Flame as Flame
participant PWM as PWM
participant Minimum as MinimumSetpoint
participant Climate as SatClimate
Note over Coord,Climate: Debounced control-loop scheduling
Coord->>Boiler: read BoilerState (temps, setpoint, status)
Coord->>Flame: Flame.update(Boiler, pwm_state?)
Coord->>Minimum: Minimum.calculate(Boiler, pwm_state)
alt PWM active
Coord->>PWM: PWM.update(boiler=Boiler, flame=Flame, requested_setpoint)
PWM-->>Coord: PWMState (status, duty_cycle)
end
Coord->>Climate: schedule_control_heating_loop(force? / debounced)
Climate-->>Coord: scheduled
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Pre-merge checks❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
📜 Recent review detailsConfiguration used: Repository UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🔇 Additional comments (2)
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 |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (9)
custom_components/sat/pwm.py (1)
66-66: Potential type mismatch in_setpoint_offset.The annotation declares
_setpoint_offset: int, but it is sometimes assigned a float value (0.5).
Consider updating the type annotation to float or using an integer to avoid confusion and improve type consistency.- self._setpoint_offset: int = 0.5 if supports_relative_modulation_management else 1 + self._setpoint_offset: float = 0.5 if supports_relative_modulation_management else 1custom_components/sat/coordinator.py (1)
334-337: Simplify return logic.These blocks return False if
isinstance(...), otherwise True. Inline the negation to eliminate the explicit if-else.
Although minor, this makes the code shorter and more readable:-if isinstance(self.manufacturer, (Ideal, Intergas, Geminox, Nefit)): - return False -return True +return not isinstance(self.manufacturer, (Ideal, Intergas, Geminox, Nefit))Also applies to: 346-349
🧰 Tools
🪛 Ruff (0.8.2)
334-337: Return the negated condition directly
Inline condition
(SIM103)
custom_components/sat/services.py (2)
14-29: Add error handling for the reset operation.Consider adding try-catch blocks to handle potential errors during the reset operation and log any failures.
Apply this diff to add error handling:
async def reset_integral(call: ServiceCall): """Service to reset the integral part of the PID controller.""" target_entities = call.data.get("entity_id", []) for climate in get_climate_entities(hass, target_entities): _LOGGER.info("Reset Integral action called for %s", climate.entity_id) + try: climate.pid.reset() climate.areas.pids.reset() + except Exception as e: + _LOGGER.error("Failed to reset integral for %s: %s", climate.entity_id, str(e))
31-49: Add error handling and state validation.Consider adding:
- Try-catch blocks to handle potential errors during enable/disable operations
- State validation to prevent redundant operations
Apply this diff to add error handling and state validation:
async def pulse_width_modulation(call: ServiceCall): """Service to enable or disable Pulse Width Modulation.""" enabled = call.data.get("enabled") target_entities = call.data.get("entity_id", []) for climate in get_climate_entities(hass, target_entities): _LOGGER.info("Pulse Width Modulation action called for %s with enabled=%s", climate.entity_id, enabled) + try: + # Skip if already in desired state + if enabled == climate.pwm.is_enabled(): + _LOGGER.debug("PWM already in desired state for %s", climate.entity_id) + continue + if enabled: climate.pwm.enable() else: climate.pwm.disable() + except Exception as e: + _LOGGER.error("Failed to set PWM state for %s: %s", climate.entity_id, str(e))custom_components/sat/fake/__init__.py (1)
93-95: Consider removing this pass-through property in a future update.The property
supports_relative_modulationsimply returnssupports_relative_modulation_management. While this is likely part of a transition to rename the property across the codebase, consider removing this pass-through property in a future update once all references have been updated to usesupports_relative_modulationdirectly.custom_components/sat/util.py (2)
71-83: Consider replacing star imports with explicit imports.The function changes look good, but there are several constants imported with star imports that could be made explicit for better maintainability.
Replace star imports with explicit imports:
-from .const import * +from .const import ( + CONF_CYCLES_PER_HOUR, + CONF_AUTOMATIC_DUTY_CYCLE, + CONF_DUTY_CYCLE, + CONF_MODE, + MODE_SWITCH, + CONF_FORCE_PULSE_WIDTH_MODULATION, +)🧰 Tools
🪛 Ruff (0.8.2)
74-74:
CONF_CYCLES_PER_HOURmay be undefined, or defined from star imports(F405)
75-75:
CONF_AUTOMATIC_DUTY_CYCLEmay be undefined, or defined from star imports(F405)
76-76:
CONF_DUTY_CYCLEmay be undefined, or defined from star imports(F405)
77-77:
CONF_MODEmay be undefined, or defined from star imports(F405)
77-77:
MODE_SWITCHmay be undefined, or defined from star imports(F405)
77-77:
CONF_FORCE_PULSE_WIDTH_MODULATIONmay be undefined, or defined from star imports(F405)
86-103: Consider replacing star imports with explicit imports.The function implementation looks good, but there are constants imported with star imports that could be made explicit for better maintainability.
Replace star imports with explicit imports:
-from .const import * +from .const import DOMAIN, CLIMATE🧰 Tools
🪛 Ruff (0.8.2)
95-95:
DOMAINmay be undefined, or defined from star imports(F405)
98-98:
CLIMATEmay be undefined, or defined from star imports(F405)
custom_components/sat/services.yaml (1)
1-6: Service 'reset_integral' DefinitionThe new
reset_integralservice is added with a target specifying that only climate entities integrated with "sat" will be affected. While the target configuration is correctly defined, consider whether adding an inline description here would improve clarity and consistency with the translations file.custom_components/sat/translations/en.json (1)
259-268: Translation Entry for 'pulse_width_modulation' ServiceThe translation entry for
pulse_width_modulationis functional; however, the description could be rephrased for improved clarity. For example, consider updating it to:
"Force enable or disable Pulse Width Modulation; note that the PWM state may automatically revert shortly after being set."
This adjustment would help avoid potential confusion about the transient nature of the setting.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (45)
.github/FUNDING.yml(1 hunks)custom_components/sat/__init__.py(2 hunks)custom_components/sat/area.py(0 hunks)custom_components/sat/binary_sensor.py(1 hunks)custom_components/sat/boiler.py(3 hunks)custom_components/sat/climate.py(8 hunks)custom_components/sat/config_flow.py(4 hunks)custom_components/sat/const.py(2 hunks)custom_components/sat/coordinator.py(7 hunks)custom_components/sat/entity.py(1 hunks)custom_components/sat/esphome/__init__.py(1 hunks)custom_components/sat/fake/__init__.py(1 hunks)custom_components/sat/manufacturer.py(1 hunks)custom_components/sat/manufacturers/atag.py(1 hunks)custom_components/sat/manufacturers/baxi.py(1 hunks)custom_components/sat/manufacturers/brotge.py(1 hunks)custom_components/sat/manufacturers/de_dietrich.py(1 hunks)custom_components/sat/manufacturers/ferroli.py(1 hunks)custom_components/sat/manufacturers/geminox.py(1 hunks)custom_components/sat/manufacturers/ideal.py(1 hunks)custom_components/sat/manufacturers/immergas.py(1 hunks)custom_components/sat/manufacturers/intergas.py(1 hunks)custom_components/sat/manufacturers/itho.py(1 hunks)custom_components/sat/manufacturers/nefit.py(1 hunks)custom_components/sat/manufacturers/other.py(1 hunks)custom_components/sat/manufacturers/radiant.py(1 hunks)custom_components/sat/manufacturers/remeha.py(1 hunks)custom_components/sat/manufacturers/sime.py(1 hunks)custom_components/sat/manufacturers/simulator.py(0 hunks)custom_components/sat/manufacturers/vaillant.py(1 hunks)custom_components/sat/manufacturers/viessmann.py(1 hunks)custom_components/sat/manufacturers/worcester.py(1 hunks)custom_components/sat/mqtt/ems.py(1 hunks)custom_components/sat/mqtt/opentherm.py(1 hunks)custom_components/sat/overshoot_protection.py(2 hunks)custom_components/sat/pwm.py(11 hunks)custom_components/sat/relative_modulation.py(1 hunks)custom_components/sat/sensor.py(1 hunks)custom_components/sat/serial/__init__.py(1 hunks)custom_components/sat/services.py(1 hunks)custom_components/sat/services.yaml(1 hunks)custom_components/sat/simulator/__init__.py(1 hunks)custom_components/sat/translations/en.json(1 hunks)custom_components/sat/util.py(2 hunks)tests/test_manufacturer.py(2 hunks)
💤 Files with no reviewable changes (2)
- custom_components/sat/manufacturers/simulator.py
- custom_components/sat/area.py
✅ Files skipped from review due to trivial changes (14)
- .github/FUNDING.yml
- custom_components/sat/manufacturers/sime.py
- custom_components/sat/manufacturers/brotge.py
- custom_components/sat/manufacturers/nefit.py
- custom_components/sat/manufacturers/intergas.py
- custom_components/sat/manufacturers/de_dietrich.py
- custom_components/sat/manufacturers/viessmann.py
- custom_components/sat/manufacturers/atag.py
- custom_components/sat/mqtt/ems.py
- custom_components/sat/serial/init.py
- custom_components/sat/mqtt/opentherm.py
- custom_components/sat/overshoot_protection.py
- custom_components/sat/esphome/init.py
- custom_components/sat/simulator/init.py
🧰 Additional context used
🪛 Ruff (0.8.2)
custom_components/sat/__init__.py
24-24: .const.SERVICE_RESET_INTEGRAL imported but unused; consider removing, adding to __all__, or using a redundant alias
(F401)
25-25: .const.SERVICE_PULSE_WIDTH_MODULATION imported but unused; consider removing, adding to __all__, or using a redundant alias
(F401)
29-29: .util.get_climate_entities imported but unused; consider removing, adding to __all__, or using a redundant alias
(F401)
custom_components/sat/util.py
10-10: from .const import * used; unable to detect undefined names
(F403)
74-74: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports
(F405)
75-75: CONF_AUTOMATIC_DUTY_CYCLE may be undefined, or defined from star imports
(F405)
76-76: CONF_DUTY_CYCLE may be undefined, or defined from star imports
(F405)
77-77: CONF_MODE may be undefined, or defined from star imports
(F405)
77-77: MODE_SWITCH may be undefined, or defined from star imports
(F405)
77-77: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports
(F405)
95-95: DOMAIN may be undefined, or defined from star imports
(F405)
98-98: CLIMATE may be undefined, or defined from star imports
(F405)
custom_components/sat/config_flow.py
357-357: CONF_MODE may be undefined, or defined from star imports
(F405)
357-357: MODE_SIMULATOR may be undefined, or defined from star imports
(F405)
446-446: CONF_MODE may be undefined, or defined from star imports
(F405)
446-446: MODE_SIMULATOR may be undefined, or defined from star imports
(F405)
467-467: CONF_MODE may be undefined, or defined from star imports
(F405)
467-467: MODE_SIMULATOR 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)
108-108: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
111-112: Use a single if statement instead of nested if statements
(SIM102)
128-128: DEADBAND may be undefined, or defined from star imports
(F405)
301-301: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
334-337: Return the negated condition directly
Inline condition
(SIM103)
346-349: Return the negated condition directly
Inline condition
(SIM103)
custom_components/sat/manufacturer.py
46-46: Local variable member_id is assigned to but never used
(F841)
custom_components/sat/climate.py
42-42: from .const import * used; unable to detect undefined names
(F403)
564-564: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports
(F405)
🔇 Additional comments (38)
custom_components/sat/pwm.py (3)
103-103: Verify the parameter order inupdatemethod.The method signature has changed to
async def update(self, boiler: BoilerState, requested_setpoint: float).
If existing call sites rely on the previous parameter order, this will cause breakage.
Please confirm or update all call sites accordingly.
137-143: Double-check logic for adjusted setpoint transitions.In these lines,
_setpointis either adjusted downward (- _setpoint_offset), set to_setpoint_adjuster.current, or forced to boiler temperature plus 10.
Ensure each transition is thoroughly tested to avoid abrupt or unintended temperature jumps.
269-272: Return fallback setpoint.Returning
self._setpoint or MINIMUM_SETPOINTensures a valid setpoint even if_setpointisNone.
This is a reasonable fallback, but confirm any edge cases where an uninitialized_setpointmight mask a bug.
Overall, the logic appears appropriate.custom_components/sat/coordinator.py (2)
103-143: Comprehensive expansion ofBoilerStatus.The newly introduced and refined statuses (
INITIALIZING,HOT_WATER,IDLE, etc.) add clarity.
Ensure that any custom logic (e.g.,COOLING_DOWN,OVERSHOOT_HANDLING) is covered by appropriate tests and that downstream components handle the new states correctly.🧰 Tools
🪛 Ruff (0.8.2)
108-108:
MINIMUM_SETPOINTmay be undefined, or defined from star imports(F405)
111-112: Use a single
ifstatement instead of nestedifstatements(SIM102)
128-128:
DEADBANDmay be undefined, or defined from star imports(F405)
298-302: Potential undefined import forCONF_MINIMUM_SETPOINT.Referencing
self._data.get(CONF_MINIMUM_SETPOINT)relies on the star import from.const.
Ensure thatCONF_MINIMUM_SETPOINTis always in scope or consider importing explicitly to avoid potential reference issues highlighted by static analysis.🧰 Tools
🪛 Ruff (0.8.2)
301-301:
CONF_MINIMUM_SETPOINTmay be undefined, or defined from star imports(F405)
custom_components/sat/climate.py (3)
563-565: Check definition ofMINIMUM_RELATIVE_MODULATION.Static analysis indicates that
MINIMUM_RELATIVE_MODULATIONmay come from a star import.
Verify that it is indeed defined in.constor elsewhere to prevent runtime NameError.🧰 Tools
🪛 Ruff (0.8.2)
564-564:
MINIMUM_RELATIVE_MODULATIONmay be undefined, or defined from star imports(F405)
928-930: Confirm logic for disabling PWM during cooling phase.Disabling PWM when the system transitions to
COOLING_DOWNand the calculated setpoint is higher thanpwm.setpointhelps avoid overshoot.
Ensure that any partial or mid-cycle transitions won’t leave the system in an unintended state.
950-959: Sequential checks for dynamic minimum setpoint.When
self._minimum_setpoint_version == 1and the device is actively heating, the code updates the dynamic minimum setpoint and then toggles heater state depending on_setpoint.
Confirm that timing edge cases (e.g., partial flame cycles or quick toggles) don’t lead to frequent on/off cycling.🧰 Tools
🪛 Ruff (0.8.2)
959-959:
MINIMUM_SETPOINTmay be undefined, or defined from star imports(F405)
custom_components/sat/manufacturers/baxi.py (1)
6-7: LGTM! Consistent with the codebase-wide property renaming.The renaming from
nametofriendly_nameis well-implemented and maintains the original functionality.custom_components/sat/manufacturers/ideal.py (1)
6-7: LGTM! Maintains consistency with other manufacturer implementations.The property renaming is implemented correctly and follows the same pattern as other manufacturer classes.
custom_components/sat/manufacturers/geminox.py (1)
6-7: LGTM! Follows the established pattern for manufacturer classes.The property renaming is implemented consistently with other manufacturer classes.
custom_components/sat/manufacturers/ferroli.py (1)
6-7:Details
✅ Verification successful
LGTM! Consistent implementation across all manufacturer classes.
The property renaming is implemented correctly. Let's verify the impact across the codebase.
🏁 Script executed:
#!/bin/bash # Description: Verify that all manufacturer classes use the new property name # and there are no remaining references to the old property name # Check for any remaining uses of the old property name echo "Checking for remaining uses of 'def name' in manufacturer classes..." rg "def name" "custom_components/sat/manufacturers/" # Check for consistent use of the new property name echo "Verifying consistent use of 'friendly_name' in manufacturer classes..." rg "def friendly_name" "custom_components/sat/manufacturers/" # Check for any remaining references to '.name' in the codebase echo "Checking for remaining references to '.name' property..." rg "\.name" "custom_components/sat/"Length of output: 2149
Approved: Manufacturer Implementation Consistency Confirmed
The update to use
friendly_namein manufacturer classes is correctly applied across the board. Our checks confirm that no manufacturer file still declares anameproperty, and all manufacturer classes (includingferroli.py) consistently implementfriendly_name.
- All manufacturer files now define and use
friendly_name(as seen in multiple files includingcustom_components/sat/manufacturers/ferroli.py).- The only remaining reference to
.nameis incustom_components/sat/switch/__init__.py. If that reference is meant for a different type of entity or is intentional, no further action is needed.LGTM!
custom_components/sat/manufacturers/itho.py (1)
4-7: LGTM!The implementation follows the established pattern for manufacturer classes, with proper inheritance, property decoration, and type annotation.
custom_components/sat/manufacturers/radiant.py (1)
6-6: LGTM!The renaming from
nametofriendly_namealigns with the broader changes across manufacturer classes while maintaining the same functionality.custom_components/sat/manufacturers/other.py (1)
4-7: LGTM!The implementation follows the established pattern for manufacturer classes, with proper inheritance, property decoration, and type annotation.
custom_components/sat/manufacturers/vaillant.py (1)
6-6: LGTM!The renaming from
nametofriendly_namealigns with the broader changes across manufacturer classes while maintaining the same functionality.custom_components/sat/manufacturers/immergas.py (1)
6-7: LGTM!The renaming of the
nameproperty tofriendly_nameis consistent with the broader refactoring effort to standardize manufacturer naming across the integration.custom_components/sat/manufacturers/remeha.py (1)
4-7: LGTM!The new
Remehamanufacturer class follows the established pattern and correctly implements thefriendly_nameproperty.custom_components/sat/manufacturers/worcester.py (1)
4-7: LGTM!The new
Worcestermanufacturer class follows the established pattern and correctly implements thefriendly_nameproperty.custom_components/sat/entity.py (1)
30-30: LGTM!The update to use
friendly_nameinstead ofnameis consistent with the manufacturer class changes while maintaining proper null checks and default values.tests/test_manufacturer.py (2)
10-10: LGTM!The assertion correctly verifies that the resolved manufacturer's class name matches the name from MANUFACTURERS.
19-21: LGTM!The changes improve test coverage by:
- Correctly building the member_id_to_names dictionary using the new MANUFACTURERS structure
- Adding an additional assertion to verify member_id
- Using friendly_name in the assertion message for better clarity
Also applies to: 28-29
custom_components/sat/manufacturer.py (2)
6-25: LGTM!The simplified MANUFACTURERS dictionary structure improves maintainability by directly mapping manufacturer names to their IDs.
28-39: LGTM!The changes improve the Manufacturer class by:
- Making it an abstract base class
- Adding member_id property with proper initialization
- Renaming name to friendly_name for better clarity
custom_components/sat/relative_modulation.py (1)
37-43: LGTM!The changes improve the code by:
- Simplifying the control flow
- Reducing nested conditions
- Making the order of checks more logical and easier to follow
custom_components/sat/util.py (1)
79-80: LGTM! Good use of the Cycles class.The introduction of the
Cyclesclass to encapsulate cycle-related parameters improves code organization and maintainability.custom_components/sat/const.py (2)
22-23: LGTM! Good improvement in constant naming.The renaming from
*_MODto*_MODULATIONimproves code clarity by using the full word.
169-169: LGTM! Service constant follows naming convention.The new service constant follows the existing naming pattern and is properly grouped with other service-related constants.
custom_components/sat/boiler.py (4)
10-10: Verify the impact of increased setpoint margin.The
EXCEED_SETPOINT_MARGINhas been increased from0.1to1.0, which could significantly impact temperature tracking behavior. Please ensure this change aligns with the expected boiler behavior.
13-28: LGTM! Well-structured enum for boiler states.The
BoilerStatusenum provides a comprehensive and well-named set of boiler states. Good use ofstrinheritance for string representation.
35-44: LGTM! Good enhancement of boiler state tracking.The
BoilerStateclass has been enhanced with proper typing and additional state tracking capabilities through thedevice_statusandflame_on_sinceparameters.
135-138: LGTM! Improved temperature tracking logic.The temperature tracking logic has been improved with:
- Simplified condition for exceeding setpoint
- Added margin to stabilization check
- More precise temperature comparisons
custom_components/sat/__init__.py (1)
65-66: LGTM! Service registration added.The addition of service registration enhances the functionality of the integration.
custom_components/sat/sensor.py (1)
241-241: LGTM! Property renamed for consistency.The change from
nametofriendly_nameis consistent with the broader property renaming effort across the codebase.custom_components/sat/config_flow.py (2)
357-359: LGTM! Simulator mode flow optimization.The changes optimize the configuration flow by bypassing unnecessary steps when in simulator mode, providing a more streamlined user experience.
Also applies to: 446-448, 467-469
🧰 Tools
🪛 Ruff (0.8.2)
357-357:
CONF_MODEmay be undefined, or defined from star imports(F405)
357-357:
MODE_SIMULATORmay be undefined, or defined from star imports(F405)
492-492: LGTM! Manufacturer resolution improved.The changes improve manufacturer resolution by:
- Using a default value of -1 when no manufacturers are found
- Consistently using friendly_name for manufacturer display
Also applies to: 497-499
custom_components/sat/services.yaml (1)
7-15: Service 'pulse_width_modulation' DefinitionThe
pulse_width_modulationservice is defined with the necessary target details and includes a required fieldenabledwith an example value. The YAML structure appears correct and follows standard configuration practices. Optionally, you might want to add an inline description if it isn’t solely provided via translations for clarity.custom_components/sat/translations/en.json (1)
254-258: Translation Entry for 'reset_integral' ServiceThe JSON entry for
reset_integralincludes a clear name and description that appropriately explains its functionality in resetting the integral part of the PID controller. The content is both descriptive and user-friendly.
There was a problem hiding this comment.
Actionable comments posted: 1
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
tests/test_climate.py(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/test_climate.py (2)
tests/conftest.py (1)
climate(41-42)custom_components/sat/climate.py (1)
schedule_control_heating_loop(915-927)
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
custom_components/sat/area.py (1)
78-78: Fix EN DASH in docstring.The docstring uses an EN DASH (–) instead of a HYPHEN-MINUS (-) in "0–2 range". Replace with the standard ASCII hyphen for consistency, as flagged in the previous review.
Apply this diff:
- Room heating demand weight (0–2 range). + Room heating demand weight (0-2 range).
🧹 Nitpick comments (4)
custom_components/sat/area.py (1)
87-91: Consider simplifying the weight calculation.The multiplication by 1.0 at line 89 is redundant and can be removed for clarity.
Apply this diff:
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)custom_components/sat/helpers.py (2)
72-76: Consider simplifying the return statement.The explicit
float()casts on line 75 are redundant sincefloat_valuealready returnsfloatanddefaultis typed asfloat. The expressionresult if result is not None else defaultalready yields a float.Apply this diff to simplify:
def to_float(value: Union[int, float, str, None], default: float = 0.0) -> float: """Convert to float, returning default if conversion fails or result is None.""" result = float_value(value) - return float(result) if result is not None else float(default) + return result if result is not None else default
84-88: Consider simplifying the return statement.Similar to
to_float, the explicitint()casts on line 87 are redundant sinceint_valuealready returnsintanddefaultis typed asint.Apply this diff to simplify:
def to_int(value: Union[int, float, str, None], default: int = 0) -> int: """Convert to int, returning default if conversion fails or result is None.""" result = int_value(value) - return int(result) if result is not None else int(default) + return result if result is not None else defaultcustom_components/sat/climate.py (1)
497-506: Consider usingErrors.max()method.The current implementation works correctly, but the
Errorsclass provides amax()method (see custom_components/sat/errors.py lines 28-32) that could simplify line 506.Apply this diff if you prefer the dedicated method:
@property def max_error(self) -> Error: - return max(self.errors, key=lambda error: error.value) + return self.errors.max()
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
custom_components/sat/area.py(4 hunks)custom_components/sat/binary_sensor.py(3 hunks)custom_components/sat/climate.py(32 hunks)custom_components/sat/helpers.py(2 hunks)custom_components/sat/minimum_setpoint.py(2 hunks)custom_components/sat/state.py(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
custom_components/sat/minimum_setpoint.py (5)
custom_components/sat/coordinator.py (3)
boiler(147-159)return_temperature(218-219)setpoint(184-185)custom_components/sat/boiler.py (1)
BoilerState(16-29)custom_components/sat/const.py (1)
PWMStatus(209-213)custom_components/sat/helpers.py (2)
clamp(90-97)to_int(84-87)custom_components/sat/state.py (2)
State(10-12)update_state(15-25)
custom_components/sat/helpers.py (2)
custom_components/sat/heating_curve.py (1)
value(90-91)custom_components/sat/errors.py (1)
max(29-33)
custom_components/sat/area.py (3)
custom_components/sat/climate.py (4)
errors(497-502)error(448-453)target_temperature(438-440)current_temperature(430-435)custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)custom_components/sat/pwm.py (1)
reset(71-82)
custom_components/sat/binary_sensor.py (4)
custom_components/sat/const.py (1)
FlameStatus(182-189)custom_components/sat/entity.py (1)
SatEntity(19-38)custom_components/sat/coordinator.py (2)
flame(162-176)get(476-491)custom_components/sat/flame.py (1)
health_status(105-106)
custom_components/sat/state.py (1)
custom_components/sat/heating_curve.py (1)
value(90-91)
custom_components/sat/climate.py (9)
custom_components/sat/area.py (11)
Areas(105-162)errors(112-114)async_added_to_hass(93-94)async_added_to_hass(129-131)state(35-40)error(65-73)target_temperature(43-48)reset(159-162)update(142-148)update(154-157)async_control_heating_loop(96-102)custom_components/sat/const.py (2)
PWMStatus(209-213)BoilerStatus(192-206)custom_components/sat/coordinator.py (12)
SatDataUpdateCoordinator(68-472)minimum_setpoint(315-317)supports_relative_modulation_management(356-362)boiler_temperature(214-215)async_added_to_hass(377-379)flame(162-176)relative_modulation_value(266-267)setpoint(184-185)get(476-491)async_control_heating_loop(385-431)device_status(101-144)boiler(147-159)custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)custom_components/sat/util.py (1)
create_pwm_controller(70-82)custom_components/sat/relative_modulation.py (5)
RelativeModulation(18-48)enabled(46-48)state(32-43)RelativeModulationState(11-15)update(27-29)custom_components/sat/flame.py (2)
average_on_time_seconds(138-139)update(163-269)custom_components/sat/pwm.py (7)
enabled(257-258)state(261-262)setpoint(275-277)reset(71-82)update(99-168)disable(93-97)enable(89-91)custom_components/sat/minimum_setpoint.py (3)
current(75-76)warming_up(37-45)calculate(47-72)
🪛 Pylint (4.0.2)
custom_components/sat/binary_sensor.py
[refactor] 49-49: Too few public methods (1/2)
(R0903)
custom_components/sat/climate.py
[refactor] 304-304: Too many branches (16/12)
(R0912)
🪛 Ruff (0.14.4)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
499-499: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
580-580: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports
(F405)
606-606: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
789-789: DEADBAND may be undefined, or defined from star imports
(F405)
789-789: DEADBAND 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)
829-829: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
830-830: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
831-831: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
840-840: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
841-841: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
953-953: BoilerStatus may be undefined, or defined from star imports
(F405)
979-979: BoilerStatus may be undefined, or defined from star imports
(F405)
986-986: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
🔇 Additional comments (33)
custom_components/sat/area.py (4)
2-2: LGTM!The import additions are appropriate for the new type annotations and Error/Errors usage throughout the file.
Also applies to: 9-9
112-114: LGTM!The refactored
errorsproperty correctly returns anErrorscollection, properly filtering outNonevalues and wrapping each area's error in the new container type.
65-74: Error class is properly defined as a dataclass and accepts the positional arguments correctly.The
Errorclass is decorated with@dataclass(frozen=True, slots=True)and has fieldsentity_id: strandvalue: float. The dataclass automatically generates an__init__method that accepts these fields as positional arguments, so the construction at line 74 (Error(self._entity_id, round(target_temperature - current_temperature, 2))) is correct and will not raiseTypeError. The past review's concern has been properly addressed.
102-102: ****The PID methods (
update,update_integral,update_reset,update_derivative) have already been updated to acceptErrorobjects as parameters. These methods properly extract the numeric value viaerror.valuethroughout their implementations. The code at lines 102 and 156 is correct and will not cause runtime errors.Likely an incorrect or invalid review comment.
custom_components/sat/helpers.py (4)
1-4: LGTM!The new imports support the type-safe utilities and finite value checks added below.
60-70: LGTM!The addition of
math.isfiniteguards againstinfandnanvalues, and the explicitOptional[float]return type improves type safety.
78-82: LGTM!The function correctly delegates to
float_valuefor validation and finite checks, then converts toint.
90-97: LGTM!The clamp implementation correctly handles both one-sided (lower bound only) and two-sided (lower and upper bounds) clamping.
custom_components/sat/binary_sensor.py (3)
15-16: LGTM!The new imports support the
SatFlameHealthSensorclass added below.
52-52: LGTM!Making the delay default explicit (60 seconds) improves code clarity without changing behavior for existing subclasses.
172-198: LGTM!The flame health sensor correctly reports problems when the status indicates issues (stuck on/off, short cycling, etc.) and becomes available once sufficient data is collected.
custom_components/sat/state.py (2)
9-12: LGTM!The immutable, slotted dataclass design encourages proper state management and reduces memory overhead.
15-25: LGTM!The function correctly preserves timestamps when values haven't changed significantly (within tolerance) and creates new states with updated timestamps when they have.
custom_components/sat/minimum_setpoint.py (7)
2-11: LGTM!All new imports support the refactored state management and enhanced type safety.
20-27: LGTM!State wrappers are correctly initialized, and type hints improve code clarity.
33-35: LGTM!The persisted value is now correctly wrapped in a
Stateinstance, preserving type consistency.
37-45: LGTM!The warming up logic correctly uses
State.valuefor comparisons andupdate_statefor state transitions.
47-72: LGTM!The calculation correctly guards against missing state values, handles both normal and non-normal modes, and enforces a 30°C minimum.
74-79: LGTM!The
currentproperty now correctly returns a float value, and_data_to_saveproperly serializes the state.
81-87: LGTM!The helper encapsulates the normal-mode detection logic with appropriate guards for None values and reasonable thresholds.
custom_components/sat/climate.py (13)
8-44: LGTM!The updated imports support the new scheduling mechanism, enhanced type safety, and manufacturer-specific handling.
153-153: LGTM!The
_control_heating_loop_unsubfield correctly tracks the scheduled heating loop callback for proper cleanup.
202-208: LGTM!Switching to
schedule_control_heating_loopimplements proper debouncing of heating loop updates.
238-302: LGTM!Event listeners correctly use the new scheduling mechanism for debounced heating loop control.
378-427: LGTM!State attributes correctly expose the new error tracking, flame state, and renamed minimum setpoint properties.
577-586: LGTM!Relative modulation value correctly reflects the enabled state and coordinator capabilities.
589-601: LGTM!The renamed property clearly returns a float value and correctly handles both dynamic minimum setpoint versions.
610-729: LGTM!Event handlers correctly use the new scheduling mechanism and improved type hints. The guard at line 714 prevents potential KeyError issues.
765-810: LGTM!The PID control logic now logs error details clearly (value and entity_id) and correctly handles Error objects.
812-845: LGTM!The setpoint control logic is clear, the typo has been fixed, and the function correctly uses the renamed
minimum_setpoint_valueproperty.
915-927: LGTM!The debounced scheduling mechanism correctly cancels previous runs, supports forced immediate execution, and uses a 10-second delay for normal scheduling.
929-987: LGTM!The heating control loop correctly clears the scheduled callback, integrates with the new boiler/flame state APIs, and uses the refactored minimum setpoint controller.
1010-1132: LGTM!The updated service methods correctly integrate with the new scheduling mechanism, use safe dictionary access for rooms, and align with the updated notification API.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
custom_components/sat/serial/__init__.py (1)
182-187: Call the correct super method for hot‑water setpoint
async_set_control_hot_water_setpointcurrently delegates tosuper().async_set_control_thermostat_setpoint, which mixes up thermostat and hot‑water state and can corrupt coordinator state.Delegate to the dedicated hot‑water helper instead:
async def async_set_control_hot_water_setpoint(self, value: float) -> None: if not self._simulation: await self._api.set_dhw_setpoint(value) - await super().async_set_control_thermostat_setpoint(value) + await super().async_set_control_hot_water_setpoint(value)custom_components/sat/fake/__init__.py (1)
1-1: Remove duplicateannotationsin__future__import
from __future__ import annotations, annotationsrepeats the same future feature and is redundant (and may confuse linters). Keep a single occurrence:-from __future__ import annotations, annotations +from __future__ import annotationscustom_components/sat/config_flow.py (2)
232-237: Fix simulator device-id string constructionLine 236:
self.data[CONF_DEVICE] = f"%s_%s".format(MODE_SIMULATOR, snake_case(_user_input.get(CONF_NAME)))This combines an f-string with
str.formatincorrectly and produces the literal"%s_%s"for every simulator instead of a unique device id. That will break uniqueness/identification and can cause config-entry collisions.Use one formatting style only, e.g.:
- self.data[CONF_MODE] = MODE_SIMULATOR - self.data[CONF_DEVICE] = f"%s_%s".format(MODE_SIMULATOR, snake_case(_user_input.get(CONF_NAME))) + self.data[CONF_MODE] = MODE_SIMULATOR + self.data[CONF_DEVICE] = f"{MODE_SIMULATOR}_{snake_case(_user_input.get(CONF_NAME))}"
477-504: Default manufacturer should match optionvalue, notfriendly_nameHere:
manufacturers = ManufacturerFactory.resolve_by_member_id(coordinator.member_id) default_manufacturer = manufacturers[0].friendly_name if len(manufacturers) > 0 else -1 ... for name in MANUFACTURERS: manufacturer = ManufacturerFactory.resolve_by_name(name) options.append({"value": name, "label": manufacturer.friendly_name})
- The select’s
valueis the MANUFACTURERS key (name).default_manufactureris currently set tofriendly_name, which may differ from the key (e.g., localized or accented names).This will cause the default value not to match any option, so the dropdown won’t preselect the detected manufacturer and could even raise validation issues.
Use the MANUFACTURERS key (or class name) for the default, e.g.:
- try: - manufacturers = ManufacturerFactory.resolve_by_member_id(coordinator.member_id) - default_manufacturer = manufacturers[0].friendly_name if len(manufacturers) > 0 else -1 + try: + manufacturers = ManufacturerFactory.resolve_by_member_id(coordinator.member_id) + default_manufacturer = ( + manufacturers[0].__class__.__name__ if manufacturers else -1 + )This aligns the default with the
valuefield used inoptions.custom_components/sat/esphome/__init__.py (1)
1-1: Remove duplicateannotationsin__future__importLine 1:
from __future__ import annotations, annotations
annotationsis listed twice. It’s redundant and may trigger a “future feature repeated” syntax error in some tools. It should be:-from __future__ import annotations, annotations +from __future__ import annotations
♻️ Duplicate comments (1)
custom_components/sat/simulator/__init__.py (1)
47-48: Correct the return type ofsupports_relative_modulationThis property always returns
True(a boolean), but the annotation isfloat | None, which is misleading for callers and type checkers. Align it with the other coordinators:- def supports_relative_modulation(self) -> float | None: + def supports_relative_modulation(self) -> bool: return True
🧹 Nitpick comments (8)
custom_components/sat/serial/__init__.py (1)
28-31: Tighten imports for pyotgw constants to satisfy lintersThe use of
DEFAULT_STATUS,DATA_CONTROL_SETPOINT,DATA_DHW_SETPOINT,DATA_CH_WATER_TEMP,DATA_REL_MOD_LEVEL,DATA_SLAVE_MAX_CAPACITY,DATA_SLAVE_MIN_MOD_LEVEL, etc., relies onfrom pyotgw.vars import *, which Ruff and Pylint flag as potentially undefined.To keep linters happy and make dependencies explicit, consider replacing the star import with explicit attributes, e.g.:
-from pyotgw import vars as gw_vars, OpenThermGateway -from pyotgw.vars import * +from pyotgw import vars as gw_vars, OpenThermGateway + +DEFAULT_STATUS = gw_vars.DEFAULT_STATUS +BOILER = gw_vars.BOILER +DATA_CONTROL_SETPOINT = gw_vars.DATA_CONTROL_SETPOINT +DATA_DHW_SETPOINT = gw_vars.DATA_DHW_SETPOINT +DATA_CH_WATER_TEMP = gw_vars.DATA_CH_WATER_TEMP +DATA_REL_MOD_LEVEL = gw_vars.DATA_REL_MOD_LEVEL +DATA_SLAVE_MAX_CAPACITY = gw_vars.DATA_SLAVE_MAX_CAPACITY +DATA_SLAVE_MIN_MOD_LEVEL = gw_vars.DATA_SLAVE_MIN_MOD_LEVEL(And similarly for any other
DATA_*/BOILERconstants used in this file.)Also applies to: 70-71, 77-78, 84-85, 112-113, 119-120, 126-127
custom_components/sat/simulator/__init__.py (1)
87-88: Clarify unusedclimate/pwm_stateparameters in simulator loop
async_control_heating_loopacceptsclimateandpwm_statefor API compatibility but doesn’t use them, which Ruff flags as unused. That’s fine semantically for the simulator, but it may be worth making the intent explicit and silencing the lint warning, for example:- async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMStatus] = None, _time=None) -> None: + async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMStatus] = None, _time=None) -> None: # noqa: ARG002Optionally, you could also forward
pwm_stateinto whatever flame/PWM tracking you want for the simulator to better mirror real coordinators.Also applies to: 110-111
custom_components/sat/fake/__init__.py (1)
92-95: Add an explicit return type forsupports_relative_modulationFor consistency with the base coordinator (and other coordinators), it’s clearer to type this property as returning
bool:- @property - def supports_relative_modulation(self): - return self.supports_relative_modulation_management + @property + def supports_relative_modulation(self) -> bool: + return self.supports_relative_modulation_managementcustom_components/sat/area.py (1)
75-91: Minor cleanups forweightandAreas.errorsTwo small polish points:
- Line 114:
Errors([area.error for area in self._areas if area.error is not None])recomputesarea.errortwice; consider binding once in the comprehension.- Line 113: The docstring still says “list of all the error values” but the method now returns an
Errorscollection ofErrorobjects, not a plain list/float.Suggested diff:
- def errors(self) -> Errors: - """Return a list of all the error values for all areas.""" - return Errors([area.error for area in self._areas if area.error is not None]) + def errors(self) -> Errors: + """Return a collection of error objects for all areas.""" + return Errors([err for area in self._areas if (err := area.error) is not None])Also applies to: 111-115
custom_components/sat/config_flow.py (2)
297-315: UseSelectOptionDictconsistently (optional)You’ve imported
SelectOptionDict, but here options are still simple dicts wrapped byselector.SelectSelectorConfig. For consistency with other parts of the file and HA style, you can useSelectOptionDictdirectly:- selector.SelectSelectorConfig(options=[ - SelectOptionDict(value=HEATING_SYSTEM_RADIATORS, label="Radiators"), - SelectOptionDict(value=HEATING_SYSTEM_HEAT_PUMP, label="Heat Pump"), - SelectOptionDict(value=HEATING_SYSTEM_UNDERFLOOR, label="Underfloor"), - ]) + selector.SelectSelectorConfig(options=[ + SelectOptionDict(value=HEATING_SYSTEM_RADIATORS, label="Radiators"), + SelectOptionDict(value=HEATING_SYSTEM_HEAT_PUMP, label="Heat Pump"), + SelectOptionDict(value=HEATING_SYSTEM_UNDERFLOOR, label="Underfloor"), + ])(Not functionally required.)
20-24: Star-import noise from.const(optional future cleanup)Static analysis is flagging a number of F405 warnings (e.g.,
CONF_OUTSIDE_SENSOR_ENTITY_ID,HEATING_SYSTEM_RADIATORS,CONF_ROOMS,CONF_WINDOW_SENSORS) because they come fromfrom .const import *.This is stylistic rather than functional, but if you want to quiet these and improve readability, consider explicitly importing the small subset used in this module instead of
*. That can be deferred to a separate cleanup PR.Also applies to: 682-705, 717-745
custom_components/sat/pid.py (1)
19-33: Consider grouping related parameters into configuration objects.The
__init__method has 13 parameters, which can make the API difficult to use and maintain. Consider grouping related parameters (e.g., gain settings, time limits, system configuration) into dedicated configuration objects or dataclasses.For example:
@dataclass class PIDGains: kp: float ki: float kd: float automatic: bool = False automatic_gain_value: float = 1.0 @dataclass class PIDTimeLimits: integral_time_limit: float = 300 sample_time_limit: Optional[float] = 10This would improve readability and make it easier to extend the configuration in the future.
custom_components/sat/mqtt/ems.py (1)
146-150: LGTM: Payload processing updated to use coordinator's data update pattern.The change to use
async_set_updated_data()instead of direct data assignment provides:
- Automatic dirty tracking via the
SatDataclass- Debounced listener notifications (5-second delay)
- Consistent update handling across all coordinators
As an optional improvement, consider using
logging.exceptioninstead oflogging.erroron line 150 to automatically include the stack trace:- _LOGGER.error("Failed to decode JSON payload: %s. Error: %s", value, error) + _LOGGER.exception("Failed to decode JSON payload: %s", value)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (16)
custom_components/sat/area.py(5 hunks)custom_components/sat/climate.py(32 hunks)custom_components/sat/config_flow.py(16 hunks)custom_components/sat/coordinator.py(14 hunks)custom_components/sat/esphome/__init__.py(5 hunks)custom_components/sat/fake/__init__.py(3 hunks)custom_components/sat/flame.py(1 hunks)custom_components/sat/helpers.py(2 hunks)custom_components/sat/mqtt/__init__.py(4 hunks)custom_components/sat/mqtt/ems.py(5 hunks)custom_components/sat/mqtt/opentherm.py(5 hunks)custom_components/sat/pid.py(8 hunks)custom_components/sat/serial/__init__.py(3 hunks)custom_components/sat/simulator/__init__.py(5 hunks)custom_components/sat/summer_simmer.py(1 hunks)custom_components/sat/switch/__init__.py(1 hunks)
✅ Files skipped from review due to trivial changes (1)
- custom_components/sat/summer_simmer.py
🧰 Additional context used
🧬 Code graph analysis (14)
custom_components/sat/area.py (2)
custom_components/sat/climate.py (4)
errors(497-502)error(448-453)target_temperature(438-440)current_temperature(430-435)custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)
custom_components/sat/helpers.py (2)
custom_components/sat/heating_curve.py (1)
value(90-91)custom_components/sat/errors.py (1)
max(29-33)
custom_components/sat/config_flow.py (5)
custom_components/sat/simulator/sensor.py (3)
device_class(37-39)device_class(77-79)name(32-34)custom_components/sat/manufacturer.py (3)
friendly_name(38-39)ManufacturerFactory(42-63)resolve_by_name(44-49)custom_components/sat/manufacturers/atag.py (1)
friendly_name(6-7)custom_components/sat/manufacturers/brotge.py (1)
friendly_name(6-7)custom_components/sat/manufacturers/other.py (1)
friendly_name(6-7)
custom_components/sat/serial/__init__.py (1)
custom_components/sat/coordinator.py (11)
async_set_updated_data(502-517)supports_relative_modulation(375-384)setpoint(215-216)get(534-549)hot_water_setpoint(241-242)boiler_temperature(245-246)return_temperature(249-250)relative_modulation_value(297-298)boiler_capacity(305-306)minimum_relative_modulation_value(338-339)maximum_relative_modulation_value(342-343)
custom_components/sat/mqtt/opentherm.py (3)
custom_components/sat/mqtt/__init__.py (3)
SatMqttCoordinator(19-117)boot(79-81)_publish_command(102-117)custom_components/sat/coordinator.py (3)
supports_relative_modulation(375-384)get(534-549)manufacturer(210-211)custom_components/sat/manufacturers/nefit.py (1)
Nefit(4-7)
custom_components/sat/pid.py (4)
custom_components/sat/area.py (4)
errors(112-114)update(142-148)update(154-157)error(65-73)custom_components/sat/errors.py (2)
Error(37-40)max(29-33)custom_components/sat/heating_curve.py (2)
update(26-29)value(90-91)custom_components/sat/helpers.py (1)
seconds_since(11-16)
custom_components/sat/esphome/__init__.py (2)
custom_components/sat/coordinator.py (6)
SatDataUpdateCoordinator(95-530)SatEntityCoordinator(533-553)get(534-549)boiler_temperature(245-246)_get_entity_id(552-553)async_notify_listeners(492-499)custom_components/sat/helpers.py (2)
float_value(60-69)int_value(78-81)
custom_components/sat/flame.py (3)
custom_components/sat/const.py (3)
FlameStatus(182-189)BoilerStatus(192-206)PWMStatus(209-213)custom_components/sat/boiler.py (2)
BoilerState(16-29)update(42-64)custom_components/sat/pwm.py (2)
update(99-168)state(261-262)
custom_components/sat/simulator/__init__.py (4)
custom_components/sat/const.py (1)
PWMStatus(209-213)custom_components/sat/coordinator.py (3)
DeviceState(29-31)async_control_heating_loop(416-462)async_notify_listeners(492-499)custom_components/sat/helpers.py (1)
convert_time_str_to_seconds(19-28)custom_components/sat/climate.py (2)
async_control_heating_loop(929-988)SatClimate(79-1132)
custom_components/sat/mqtt/__init__.py (2)
custom_components/sat/coordinator.py (4)
SatDataUpdateCoordinator(95-530)device_id(123-124)get(534-549)async_set_updated_data(502-517)custom_components/sat/mqtt/ems.py (1)
_process_message_payload(146-150)
custom_components/sat/fake/__init__.py (1)
custom_components/sat/coordinator.py (2)
supports_relative_modulation(375-384)supports_relative_modulation_management(387-393)
custom_components/sat/coordinator.py (14)
custom_components/sat/boiler.py (4)
BoilerTemperatureTracker(32-141)BoilerState(16-29)update(42-64)active(134-136)custom_components/sat/flame.py (14)
Flame(34-403)FlameState(19-31)update(163-269)is_active(109-110)is_inactive(113-114)health_status(105-106)latest_on_time_seconds(131-135)average_on_time_seconds(138-139)last_cycle_duration_seconds(142-143)sample_count_4h(159-161)cycles_last_hour(146-147)duty_ratio_last_15m(150-151)median_on_duration_seconds_4h(154-156)on_since(117-121)custom_components/sat/helpers.py (2)
calculate_default_maximum_setpoint(44-49)seconds_since(11-16)custom_components/sat/manufacturer.py (4)
Manufacturer(28-39)ManufacturerFactory(42-63)resolve_by_name(44-49)member_id(33-34)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/serial/__init__.py (10)
get(150-156)device_id(38-39)SatSerialCoordinator(25-210)boiler_temperature(84-88)hot_water_active(50-51)setpoint(70-74)device_active(46-47)flame_active(147-148)return_temperature(91-95)member_id(140-144)custom_components/sat/pwm.py (2)
update(99-168)setpoint(275-277)custom_components/sat/fake/__init__.py (6)
SatFakeCoordinator(27-122)device_id(29-30)boiler_temperature(57-58)setpoint(53-54)device_active(61-62)member_id(37-38)custom_components/sat/simulator/__init__.py (7)
SatSimulatorCoordinator(16-123)device_id(31-32)boiler_temperature(55-56)setpoint(51-52)device_active(59-60)flame_active(63-64)member_id(71-72)custom_components/sat/esphome/__init__.py (9)
SatEspHomeCoordinator(41-213)device_id(54-55)boiler_temperature(110-111)hot_water_active(86-87)setpoint(90-91)device_active(78-79)flame_active(82-83)return_temperature(114-115)member_id(134-135)custom_components/sat/const.py (2)
BoilerStatus(192-206)PWMStatus(209-213)
custom_components/sat/climate.py (10)
custom_components/sat/area.py (10)
Areas(105-162)errors(112-114)async_added_to_hass(93-94)async_added_to_hass(129-131)state(35-40)error(65-73)reset(159-162)update(142-148)update(154-157)async_control_heating_loop(96-102)custom_components/sat/const.py (2)
PWMStatus(209-213)BoilerStatus(192-206)custom_components/sat/coordinator.py (16)
SatDataUpdateCoordinator(95-530)minimum_setpoint(346-348)supports_relative_modulation_management(387-393)boiler_temperature(245-246)async_added_to_hass(408-410)flame(193-207)relative_modulation_value(297-298)setpoint(215-216)get(534-549)async_set_control_setpoint(468-471)update(43-48)manufacturer(210-211)async_set_control_max_relative_modulation(483-486)async_control_heating_loop(416-462)device_status(132-175)boiler(178-190)custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)custom_components/sat/manufacturers/geminox.py (1)
Geminox(4-7)custom_components/sat/util.py (4)
create_pid_controller(21-47)create_heating_curve_controller(60-67)create_minimum_setpoint_controller(50-57)create_pwm_controller(70-82)custom_components/sat/relative_modulation.py (5)
RelativeModulation(18-48)enabled(46-48)state(32-43)RelativeModulationState(11-15)update(27-29)custom_components/sat/flame.py (2)
average_on_time_seconds(138-139)update(163-269)custom_components/sat/pwm.py (7)
enabled(257-258)state(261-262)setpoint(275-277)reset(71-82)update(99-168)disable(93-97)enable(89-91)custom_components/sat/minimum_setpoint.py (3)
current(75-76)warming_up(37-45)calculate(47-72)
custom_components/sat/mqtt/ems.py (3)
custom_components/sat/coordinator.py (2)
supports_relative_modulation(375-384)async_set_updated_data(502-517)custom_components/sat/helpers.py (1)
float_value(60-69)custom_components/sat/mqtt/__init__.py (1)
_process_message_payload(98-100)
🪛 Pylint (4.0.2)
custom_components/sat/serial/__init__.py
[error] 31-31: Undefined variable 'DEFAULT_STATUS'
(E0602)
[error] 71-71: Undefined variable 'DATA_CONTROL_SETPOINT'
(E0602)
[error] 78-78: Undefined variable 'DATA_DHW_SETPOINT'
(E0602)
[error] 85-85: Undefined variable 'DATA_CH_WATER_TEMP'
(E0602)
[error] 113-113: Undefined variable 'DATA_REL_MOD_LEVEL'
(E0602)
[error] 120-120: Undefined variable 'DATA_SLAVE_MAX_CAPACITY'
(E0602)
[error] 127-127: Undefined variable 'DATA_SLAVE_MIN_MOD_LEVEL'
(E0602)
custom_components/sat/pid.py
[refactor] 19-19: Too many arguments (13/5)
(R0913)
[refactor] 19-19: Too many positional arguments (13/5)
(R0917)
custom_components/sat/flame.py
[refactor] 19-19: Too many instance attributes (10/7)
(R0902)
[refactor] 34-34: Too many instance attributes (14/7)
(R0902)
[refactor] 271-271: Too many return statements (13/6)
(R0911)
[refactor] 271-271: Too many branches (21/12)
(R0912)
custom_components/sat/coordinator.py
[refactor] 63-63: Too many return statements (7/6)
(R0911)
[refactor] 61-61: Too few public methods (1/2)
(R0903)
[refactor] 95-95: Too many instance attributes (12/7)
(R0902)
[refactor] 95-95: Too many public methods (47/20)
(R0904)
[refactor] 132-132: Too many return statements (14/6)
(R0911)
[refactor] 132-132: Too many branches (15/12)
(R0912)
custom_components/sat/climate.py
[refactor] 304-304: Too many branches (16/12)
(R0912)
🪛 Ruff (0.14.4)
custom_components/sat/config_flow.py
288-288: CONF_OUTSIDE_SENSOR_ENTITY_ID may be undefined, or defined from star imports
(F405)
291-291: CONF_HUMIDITY_SENSOR_ENTITY_ID may be undefined, or defined from star imports
(F405)
309-309: HEATING_SYSTEM_RADIATORS may be undefined, or defined from star imports
(F405)
310-310: HEATING_SYSTEM_HEAT_PUMP may be undefined, or defined from star imports
(F405)
311-311: HEATING_SYSTEM_UNDERFLOOR may be undefined, or defined from star imports
(F405)
336-336: CONF_RADIATORS may be undefined, or defined from star imports
(F405)
339-339: CONF_ROOMS may be undefined, or defined from star imports
(F405)
352-352: CONF_MODE may be undefined, or defined from star imports
(F405)
352-352: MODE_SIMULATOR may be undefined, or defined from star imports
(F405)
373-373: DOMAIN may be undefined, or defined from star imports
(F405)
401-401: CONF_RADIATORS may be undefined, or defined from star imports
(F405)
401-401: CONF_ROOMS may be undefined, or defined from star imports
(F405)
441-441: CONF_MODE may be undefined, or defined from star imports
(F405)
441-441: MODE_SIMULATOR may be undefined, or defined from star imports
(F405)
462-462: CONF_MODE may be undefined, or defined from star imports
(F405)
462-462: MODE_SIMULATOR may be undefined, or defined from star imports
(F405)
631-631: DOMAIN may be undefined, or defined from star imports
(F405)
633-633: CONF_WINDOW_SENSORS may be undefined, or defined from star imports
(F405)
633-633: CONF_WINDOW_SENSORS may be undefined, or defined from star imports
(F405)
custom_components/sat/serial/__init__.py
31-31: DEFAULT_STATUS may be undefined, or defined from star imports
(F405)
71-71: DATA_CONTROL_SETPOINT may be undefined, or defined from star imports
(F405)
78-78: DATA_DHW_SETPOINT may be undefined, or defined from star imports
(F405)
85-85: DATA_CH_WATER_TEMP may be undefined, or defined from star imports
(F405)
113-113: DATA_REL_MOD_LEVEL may be undefined, or defined from star imports
(F405)
120-120: DATA_SLAVE_MAX_CAPACITY may be undefined, or defined from star imports
(F405)
127-127: DATA_SLAVE_MIN_MOD_LEVEL may be undefined, or defined from star imports
(F405)
custom_components/sat/pid.py
28-28: DEADBAND may be undefined, or defined from star imports
(F405)
custom_components/sat/flame.py
78-78: Avoid specifying long messages outside the exception class
(TRY003)
custom_components/sat/simulator/__init__.py
87-87: Unused method argument: climate
(ARG002)
87-87: Unused method argument: pwm_state
(ARG002)
custom_components/sat/coordinator.py
14-14: from .const import * used; unable to detect undefined names
(F403)
43-43: Unused method argument: kwargs
(ARG002)
64-64: MODE_FAKE may be undefined, or defined from star imports
(F405)
68-68: MODE_SIMULATOR may be undefined, or defined from star imports
(F405)
72-72: MODE_SWITCH may be undefined, or defined from star imports
(F405)
76-76: MODE_ESPHOME may be undefined, or defined from star imports
(F405)
80-80: MODE_MQTT_EMS may be undefined, or defined from star imports
(F405)
84-84: MODE_MQTT_OPENTHERM may be undefined, or defined from star imports
(F405)
88-88: MODE_SERIAL may be undefined, or defined from star imports
(F405)
92-92: Create your own exception
(TRY002)
92-92: Avoid specifying long messages outside the exception class
(TRY003)
98-98: DOMAIN may be undefined, or defined from star imports
(F405)
113-113: CONF_SIMULATION may be undefined, or defined from star imports
(F405)
114-114: CONF_HEATING_SYSTEM may be undefined, or defined from star imports
(F405)
114-114: HEATING_SYSTEM_UNKNOWN may be undefined, or defined from star imports
(F405)
116-116: CONF_MANUFACTURER may be undefined, or defined from star imports
(F405)
117-117: CONF_MANUFACTURER may be undefined, or defined from star imports
(F405)
132-132: BoilerStatus may be undefined, or defined from star imports
(F405)
135-135: BoilerStatus may be undefined, or defined from star imports
(F405)
138-138: BoilerStatus may be undefined, or defined from star imports
(F405)
140-140: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
141-141: BoilerStatus may be undefined, or defined from star imports
(F405)
146-146: BoilerStatus may be undefined, or defined from star imports
(F405)
149-149: BoilerStatus may be undefined, or defined from star imports
(F405)
154-154: BoilerStatus may be undefined, or defined from star imports
(F405)
156-156: BoilerStatus may be undefined, or defined from star imports
(F405)
158-158: BoilerStatus may be undefined, or defined from star imports
(F405)
160-160: DEADBAND may be undefined, or defined from star imports
(F405)
161-161: BoilerStatus may be undefined, or defined from star imports
(F405)
167-167: BoilerStatus may be undefined, or defined from star imports
(F405)
169-169: BoilerStatus may be undefined, or defined from star imports
(F405)
171-171: BoilerStatus may be undefined, or defined from star imports
(F405)
173-173: BoilerStatus may be undefined, or defined from star imports
(F405)
175-175: BoilerStatus may be undefined, or defined from star imports
(F405)
348-348: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
416-416: Unused method argument: climate
(ARG002)
416-416: PWMStatus may be undefined, or defined from star imports
(F405)
437-437: 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)
499-499: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
580-580: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports
(F405)
606-606: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
789-789: DEADBAND may be undefined, or defined from star imports
(F405)
789-789: DEADBAND 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)
829-829: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
830-830: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
831-831: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
840-840: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
841-841: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
953-953: BoilerStatus may be undefined, or defined from star imports
(F405)
979-979: BoilerStatus may be undefined, or defined from star imports
(F405)
986-986: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
custom_components/sat/mqtt/ems.py
146-146: Unused method argument: key
(ARG002)
150-150: Use logging.exception instead of logging.error
Replace with exception
(TRY400)
🔇 Additional comments (36)
custom_components/sat/switch/__init__.py (1)
20-24: Constructor rename toconfig_datalooks consistentThe coordinator now takes
config_dataand passes it through toSatDataUpdateCoordinatorlike the other coordinators; no functional issues spotted here.custom_components/sat/helpers.py (1)
60-68: Numeric helper utilities look solid and safeThe new
float_value/to_float/int_value/to_inthelpers handleNone, bad input, and non‑finite floats cleanly, andclampcorrectly enforces lower and optional upper bounds. The implementations are straightforward and align well with the integration’s needs.Also applies to: 72-76, 78-82, 84-88, 90-95
custom_components/sat/area.py (1)
64-73: Verify PID API now acceptsErrorobjects instead of raw floats
Area.errornow returns anErrorinstance, but bothasync_control_heating_loopand_PIDs.updatepassarea.errordirectly into PID methods:
- Line 102:
self.pid.update_integral(self.error, self.heating_curve.value)- Line 157:
area.pid.update(area.error, area.heating_curve.value, boiler_temperature)If
PID.update_integral/PID.updatestill expect a numeric error, this will raise or silently misbehave; they would needself.error.value/area.error.valueinstead. If you already updatedPIDto acceptErrorinstances, this is fine, but then the type hints on those methods should reflect that.Also applies to: 96-103, 150-157
custom_components/sat/config_flow.py (5)
281-295: Confirm that outside sensor selector now returning a list is supported downstreamThe
CONF_OUTSIDE_SENSOR_ENTITY_IDselector was changed to:selector.EntitySelector( selector.EntitySelectorConfig( domain=[sensor.DOMAIN, weather.DOMAIN], multiple=True, ) )This means
self.data[CONF_OUTSIDE_SENSOR_ENTITY_ID]becomes a list of entities instead of a single string. Please ensure all consumers of this config value (e.g., coordinator / sensors logic) are updated to handle a list, or keepmultiple=Falseif only a single entity is expected.
345-361: Simulator short-circuiting looks goodThe new early returns for simulator mode in
async_step_automatic_gains,async_step_overshoot_protection, andasync_step_pid_controllercorrectly skip manufacturer-specific paths when running in simulator mode:
- Lines 352–353
- Lines 441–442
- Lines 462–463
This matches expectations for a purely simulated setup where manufacturer details are irrelevant.
Also applies to: 435-454, 456-475
369-404: Climate service calls refactor looks correctThe refactor to use
climate.DOMAIN,climate.SERVICE_SET_HVAC_MODEandclimate.HVACMode.*when turning the climate off / on and restoring the mode is consistent with current HA APIs and avoids stringly-typed service names.The
datapayloads on lines 397–404 and 423–424 look correct.Also applies to: 421-425
629-643: Window sensor selector refactor looks correctThe changes to:
- Use
binary_sensor.DOMAINwhen resolvingwindow_id, and- Filter
CONF_WINDOW_SENSORSbybinary_sensor.DOMAINand device classesDOOR,WINDOW,GARAGE_DOORare consistent with HA’s binary_sensor device-class usage and should behave as intended.
676-705: Typedschema: dict[Marker, Any]annotations are fine but optionalThe new
schema: dict[Marker, Any]annotations inasync_step_system_configurationandasync_step_advancedare correct for volumptuous markers and make the mutating pattern clearer to type-checkers. They’re not required for runtime, but they don’t pose any issues and keep the code explicit.Also applies to: 711-752
custom_components/sat/mqtt/__init__.py (1)
22-28: ConfirmSatDatais JSON-serializable forStorepersistenceThe coordinator now uses
SatDataUpdateCoordinator’sself.dataand persists it via:await self._store.async_save(self.data)Given
self.datais aSatDatainstance (not a plain dict),Storewill expect it to be JSON-serializable. IfSatDataisn’t directly serializable, you may need to persistself.data.as_dict()or similar instead.Likewise,
_load_stored_dataassumesstored_datais a dict, which is correct if what you save is a mapping.Please verify that:
SatDatais JSON-serializable when passed toasync_save, and- The schema of
stored_datamatches whatasync_set_updated_dataexpects.Also applies to: 54-62, 98-101
custom_components/sat/mqtt/opentherm.py (3)
70-83: Typing and Optional handling look correctThe updated properties (
setpoint,maximum_setpoint_value,hot_water_setpoint,boiler_temperature,return_temperature,relative_modulation_value,boiler_capacity,minimum_relative_modulation_value,maximum_relative_modulation_value,member_id) correctly:
- Guard on
is not None,- Cast to
float/int, and- Fall back to
super()defaults where appropriate.This makes the API clearer and is consistent with
Optional[...]annotations.Also applies to: 85-89, 106-149, 152-157
54-57: Double-checksupports_relative_modulationoverride vs base manufacturer logicHere
supports_relative_modulationis overridden to always returnTrue, while the baseSatDataUpdateCoordinator.supports_relative_modulationappears to disable relative modulation for some manufacturers (e.g.,Ideal,Intergas,Geminox,Nefit).If you intend to preserve the base manufacturer-specific behaviour, you might not need this override at all, or you may want to defer to
super().supports_relative_modulation. If the OTGW path is guaranteed to support relative modulation regardless of manufacturer, this override is fine; just be sure the divergence is intentional.
158-165: Boot sequence additions look reasonableThe extra
await self._publish_command("PM=15")and expandedMI=500handling for(Ideal, Intergas, Nefit)are straightforward and fit the existing pattern inboot(). No issues spotted here.custom_components/sat/esphome/__init__.py (3)
90-136: Numeric parsing viafloat_value/int_valuelooks goodThe refactor to use
float_value/int_valuefor all numeric properties (setpoint,maximum_setpoint_value, hot water setpoints, boiler temps, modulation levels, capacity, member_id) is consistent and robust against invalid/unavailable states. This should reduce conversion errors vs directfloat()/int()calls.Also applies to: 118-132
137-157: Confirm domains for tracked ESPHome entities are correctIn
async_added_to_hassand_get_entity_id:
- Tracked IDs for
DATA_FLAME_ACTIVE,DATA_REL_MOD_LEVEL,DATA_SLAVE_MEMBERID,DATA_BOILER_TEMPERATURE,DATA_RETURN_TEMPERATUREusesensor.DOMAIN.device_active/flame_active/hot_water_activeuseswitch.DOMAINandbinary_sensor.DOMAINwhen callingget.If the underlying ESPHome entities are actually
binary_sensor/switch(common for on/off flags), mixing domains here will cause_get_entity_idto returnNonefor some lookups and lead to missing updates.Please double-check that:
- The ESPHome entities for
flame_on,ch_enable,dhw_enable, etc. are defined under the domains you’re using here, and- The domain passed to
self.get(domain, key)matches the one used in_get_entity_id(domain, key)for those keys.Adjust
sensor.DOMAINvsbinary_sensor.DOMAIN/switch.DOMAINas needed to match the actual ESPHome entities.Also applies to: 192-213
204-213: Command helpers look correctThe
_send_command_stateand_send_command_valuehelpers correctly:
- Resolve the ESPHome entity via
_get_entity_id,- Call the appropriate domain service (
switch.SERVICE_TURN_ON/OFF,number.SERVICE_SET_VALUE), and- Respect the
_simulationflag in_send_command.No functional issues spotted here.
custom_components/sat/pid.py (4)
254-298: LGTM: Gain calculation properties handle None cases safely.The
kp,ki, andkdproperties properly handle cases where automatic gains are enabled but_last_heating_curve_valueis None, returning safe default values (0.0). This prevents potential runtime errors during initialization or when heating curve data is unavailable.
301-316: LGTM: Proportional and derivative properties are properly guarded.The properties correctly handle None values for gains:
proportionalreturns 0.0 whenkpis Nonederivativereturns 0.0 whenkdis NoneThis ensures safe fallback behavior when automatic gains haven't been calculated yet.
84-114: LGTM: Error type integration is consistent and correct.The refactoring to accept
Errorobjects instead of raw float values is implemented consistently across all methods (update,update_reset,update_integral,update_derivative). All methods correctly extract the numeric value usingerror.valuewhere needed, maintaining type safety throughout the codebase.Also applies to: 115-134, 136-170, 172-203
205-224: LGTM: State restoration logic is robust.The
restoremethod properly:
- Checks for None before converting state attributes to float
- Restores all PID state components (error, integral, derivative, heating curve)
- Resets timing anchors to current monotonic time to avoid using stale timestamps from the previous session
This ensures the PID controller can resume from saved state without timing inconsistencies.
custom_components/sat/mqtt/ems.py (1)
52-53: LGTM: API rename and typing updates improve consistency.The method rename from
supports_relative_modulation_managementtosupports_relative_modulationand the typing updates to useOptional[float]align with broader refactoring across the codebase, improving consistency without changing functionality.Also applies to: 68-100
custom_components/sat/flame.py (4)
76-102: LGTM: Initialization is thorough and validates inputs properly.The constructor validates
smoothing_alphawithin [0.0, 1.0] and initializes all tracking state, rolling windows, and timing anchors. The initial health status ofINSUFFICIENT_DATAis appropriate until enough samples are collected.As a minor style note, the static analyzer suggests externalizing the ValueError message, but this is a nitpick and doesn't affect functionality.
163-269: LGTM: State transition logic is well-structured and comprehensive.The
updatemethod implements a clear state machine with four transitions (OFF→ON, ON→ON, ON→OFF, OFF→OFF). Each transition:
- Properly updates timestamps and timing metrics
- Maintains rolling windows with appropriate pruning
- Uses exponential smoothing for average on-time (correct algorithm)
- Triggers health recomputation
- Includes detailed debug logging
The use of monotonic time throughout ensures accurate timing regardless of system time changes.
271-368: LGTM: Health classification logic is complex but well-organized.The
_recompute_healthmethod implements a decision tree that systematically handles different boiler operating scenarios:
- Insufficient data for reliable health assessment
- Idle state (no demand)
- Domestic hot water mode (continuous flame allowed)
- Space heating with PWM control
- Space heating with internal modulation
While the method has many branches and return statements (triggering static analysis warnings), this complexity is justified and appropriate for the domain. The logic is clear, uses well-named constants for thresholds, and each branch returns the correct health status.
370-401: LGTM: Rolling window management and metric calculations are correct.The helper methods for maintaining rolling windows and calculating metrics are straightforward and correct:
- Window pruning uses monotonic time cutoffs consistently
- Duty ratio is properly calculated as on-time / window-size and clamped to [0, 1]
- Cycles per hour counts cycle completions in the window
- Median calculation uses Python's built-in
statistics.medianThese methods provide the foundation for accurate health assessments.
custom_components/sat/coordinator.py (6)
34-58: LGTM: SatData dirty tracking is cleanly implemented.The
SatDataclass extendsdictto provide automatic dirty tracking when values change. This enables efficient listener notifications (only when data actually changes) as used inasync_set_updated_dataon lines 502-517.The implementation correctly:
- Sets
_is_dirtyonly when values differ- Handles both
__setitem__andupdate()paths- Provides reset and query methods
The unused
kwargsparameter inupdate()flagged by static analysis maintains signature compatibility withdict.update()and can be ignored.
177-207: LGTM: Boiler and flame state aggregation provides clean API.The
boilerandflameproperties aggregate multiple coordinator attributes into structuredBoilerStateandFlameStateobjects. This provides:
- Clean, type-safe API for consumers (PWM controller, climate entity, etc.)
- Single access point for related state data
- Proper encapsulation of internal implementation
The structured approach improves code maintainability compared to accessing individual properties.
492-517: LGTM: Debounced notification system prevents update spam.The
async_set_updated_dataandasync_notify_listenersmethods implement a clean debouncing pattern:
- Data updates are applied immediately via
SatData.update()- Dirty tracking determines if listeners need notification
- Notifications are debounced with a 5-second delay via
async_call_later- Previous scheduled notifications are cancelled to ensure only one pending notification
This prevents excessive entity updates when coordinator data changes rapidly (e.g., MQTT messages, serial data).
519-530: LGTM: Cold temperature calculation correctly identifies pre-heating baseline.The refactored
_get_latest_boiler_cold_temperaturecorrectly identifies the maximum boiler temperature from timestamps before both device activation and flame ignition. This provides an accurate "cold" baseline for tracking temperature rise during heating.The method properly:
- Returns None when no qualifying temperatures exist
- Uses strict comparisons to ensure timestamps are truly "before" activation
- Returns the maximum (not minimum) cold temperature, which is correct for capturing the actual starting point
416-462: LGTM: Control loop enhancements integrate PWM and improve temperature tracking.The updated
async_control_heating_loopproperly:
- Accepts optional
pwm_stateparameter for flame health monitoring- Passes PWM state to
flame.update()for accurate health classification- Includes
device_statusin temperature tracker updates for better state awareness- Implements fallback cold temperature logic (uses minimum of current and stored cold) when no new cold temperature is available
These changes improve the coordinator's ability to track boiler behavior across different operating modes.
345-348: <!-- [scratchpad_start]
[task verify_migration_handles_missing_field]
Found async_migrate_entry - NOW I can make definitive assessment[observations]
- Line 126-132: Migration code explicitly handles MISSING minimum_setpoint in legacy configs
- Line 126:
if not entry.data.get("minimum_setpoint"):- Line 129:
new_data["minimum_setpoint"] = 10(default value)- This proves the field CAN be missing in legacy configs
[analysis]
- Migration adds field if missing for version < 2
- BUT: Migration runs via async_migrate_entry which should run before async_setup_entry
- However, the code at line 348 still does float(self._config_data.get(CONF_MINIMUM_SETPOINT))
- Without defensive default, this will raise TypeError if field is somehow missing
- The fact that migration was needed proves this was a real problem in the past
- Defensive coding should still add a fallback
[critical_finding]
- Original review comment is VALID
- Migration code proves field can be missing (was missing in v<2)
- Even if migration runs, defensive coding is better practice
- Line 348 needs defensive handling like line 129 shows: provide a default
[pending]
- Check if other similar properties have defensive handling
- Verify if CONF_MAXIMUM_SETPOINT has same issue
[actions]
- Search for similar properties that use .get() on config_data
- Check CONF_MAXIMUM_SETPOINT and others
custom_components/sat/climate.py (6)
497-506: LGTM: Error aggregation is cleanly implemented with structured types.The refactored error handling using
ErrorsandErrortypes provides:
- Clear separation between main thermostat error and area errors
- Proper handling of ECO mode (excludes areas)
- Type-safe error aggregation
- Correct max error selection by value
This is a significant improvement over passing raw float values, as it maintains error source information (entity_id) throughout the system.
915-927: LGTM: Debounced scheduling prevents excessive heating loop executions.The
schedule_control_heating_loopmethod implements proper debouncing:
- Cancels any previously scheduled execution
- Supports immediate execution via
force=Trueparameter (used when temperature setpoint changes)- Default 10-second delay for batching multiple rapid state changes
- Uses
HassJobfor efficient async task schedulingThis prevents the heating loop from running too frequently when multiple sensor values update in quick succession.
610-729: LGTM: Event handlers properly delegate to scheduled control loop.The event handlers have been correctly updated to:
- Accept typed
Event[EventStateChangedData]parameters- Call the synchronous
_async_control_pid()method (renamed despite the prefix, it's now sync)- Schedule the control loop via
schedule_control_heating_loop()instead of directly awaitingThis pattern ensures:
- Event handlers return quickly without blocking
- Multiple rapid events are debounced into single control loop executions
- Proper separation between event handling and control logic
589-601: LGTM: Minimum setpoint calculation handles multiple strategies correctly.The
minimum_setpoint_valueproperty properly implements three strategies:
- Version 1: Uses
MinimumSetpointcontroller with dynamic calculation based on modulation- Version 2: Uses PWM controller's setpoint
- Fallback: Uses coordinator's configured minimum setpoint
All paths correctly use
max()to ensure the result never falls below the coordinator's absolute minimum, providing a safety floor.
812-844: LGTM: Setpoint control logic properly handles different operating modes.The
_async_control_setpointmethod correctly manages setpoint based on:
- HVAC OFF: Sets to
MINIMUM_SETPOINT- Normal heating (no PWM): Uses calculated setpoint with
COLD_SETPOINTsafety check- PWM ON: Uses
minimum_setpoint_valueto maintain minimum flow- PWM OFF/IDLE: Uses
MINIMUM_SETPOINTto allow cooldownThe final application correctly caps the setpoint at
maximum_setpointto respect device limits. The safety check againstCOLD_SETPOINTprevents sending unrealistically low setpoints to the boiler.
929-988: LGTM: Main control loop orchestrates all heating subsystems correctly.The
async_control_heating_loopproperly coordinates:
- Coordinator updates: Flame tracking, device state, temperature tracking
- Setpoint calculation: Low-pass filtered to avoid rapid changes
- Overshoot detection: Automatically enables PWM when needed
- PWM control: Updates with structured flame/boiler state
- Setpoint application: Respects PWM state and device limits
- PID integral: Periodic updates independent of main PID updates
- Area control: Updates multi-zone heating
- Dynamic minimum: Calculates based on boiler state and modulation
- Heater state: Enables/disables based on setpoint
The orchestration is clean, and all subsystems receive the correct structured state objects (BoilerState, FlameState, PWMStatus) for type-safe operation.
…class, streamlined flame monitoring, and improved logging.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (2)
custom_components/sat/pwm.py (1)
137-157: Extract magic numbers to constants for maintainability.The setpoint adjustment logic is sound, but the hardcoded thresholds should be defined as named constants in
const.pyfor consistency with the codebase pattern (similar toHEATER_STARTUP_TIMEFRAME):
- Line 137:
6→ defineFLAME_ON_TIME_THRESHOLD_SECONDS- Line 141:
60→ defineFLAME_AVERAGE_ON_TIME_THRESHOLD_SECONDS- Line 138:
-3→ defineSETPOINT_ADJUST_DOWN_DEGREES- Line 142:
+10→ defineSETPOINT_ADJUST_UP_DEGREESThen import and use these constants in this file.
Based on past review comments.
custom_components/sat/coordinator.py (1)
346-349: Guard against missing config data to prevent TypeError.
self._config_data.get(CONF_MINIMUM_SETPOINT)can beNonefor legacy or incomplete configs, andfloat(None)will raiseTypeError, breaking the coordinator at runtime. Add a fallback:+ @property + def minimum_setpoint(self) -> float: + """Return the minimum setpoint temperature before the device starts to overshoot.""" - return float(self._config_data.get(CONF_MINIMUM_SETPOINT)) + if (configured := self._config_data.get(CONF_MINIMUM_SETPOINT)) is None: + return MINIMUM_SETPOINT + + return float(configured)Based on past review comments.
🧹 Nitpick comments (1)
custom_components/sat/coordinator.py (1)
35-59: Remove unusedkwargsparameter.The
update()method'skwargsparameter is unused. Remove it to clean up the signature:- def update(self, other: dict, **kwargs): + def update(self, other: dict): for key, value in other.items(): if self.get(key) != value: self._is_dirty = True super().__setitem__(key, value)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
custom_components/sat/climate.py(33 hunks)custom_components/sat/coordinator.py(14 hunks)custom_components/sat/flame.py(1 hunks)custom_components/sat/pwm.py(9 hunks)custom_components/sat/setpoint_adjuster.py(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- custom_components/sat/setpoint_adjuster.py
🧰 Additional context used
🧬 Code graph analysis (4)
custom_components/sat/pwm.py (5)
custom_components/sat/const.py (2)
BoilerStatus(192-206)PWMStatus(209-213)custom_components/sat/heating_curve.py (5)
HeatingCurve(10-91)reset(20-24)update(26-29)value(90-91)base_offset(77-79)custom_components/sat/setpoint_adjuster.py (5)
SetpointAdjuster(10-47)force(24-28)reset(20-22)adjust(30-47)current(16-18)custom_components/sat/flame.py (6)
FlameState(20-32)update(159-265)is_active(105-106)latest_on_time_seconds(127-131)is_inactive(109-110)average_on_time_seconds(134-135)custom_components/sat/boiler.py (2)
BoilerState(16-29)update(42-64)
custom_components/sat/coordinator.py (12)
custom_components/sat/boiler.py (4)
BoilerTemperatureTracker(32-141)BoilerState(16-29)update(42-64)active(134-136)custom_components/sat/flame.py (14)
Flame(35-391)FlameState(20-32)update(159-265)is_active(105-106)is_inactive(109-110)health_status(101-102)latest_on_time_seconds(127-131)average_on_time_seconds(134-135)last_cycle_duration_seconds(138-139)sample_count_4h(155-157)cycles_last_hour(142-143)duty_ratio_last_15m(146-147)median_on_duration_seconds_4h(150-152)on_since(113-117)custom_components/sat/manufacturer.py (4)
Manufacturer(28-39)ManufacturerFactory(42-63)resolve_by_name(44-49)member_id(33-34)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/pwm.py (3)
PWMState(30-36)update(103-177)setpoint(292-294)custom_components/sat/serial/__init__.py (12)
get(150-156)device_id(38-39)SatSerialCoordinator(25-210)boiler_temperature(84-88)hot_water_active(50-51)setpoint(70-74)device_active(46-47)flame_active(147-148)return_temperature(91-95)relative_modulation_value(112-116)member_id(140-144)hot_water_setpoint(77-81)custom_components/sat/fake/__init__.py (6)
SatFakeCoordinator(27-122)device_id(29-30)boiler_temperature(57-58)setpoint(53-54)device_active(61-62)member_id(37-38)custom_components/sat/simulator/__init__.py (9)
SatSimulatorCoordinator(16-123)device_id(31-32)boiler_temperature(55-56)setpoint(51-52)device_active(59-60)flame_active(63-64)relative_modulation_value(67-68)member_id(71-72)async_control_heating_loop(87-111)custom_components/sat/const.py (1)
BoilerStatus(192-206)
custom_components/sat/flame.py (3)
custom_components/sat/const.py (3)
BoilerStatus(192-206)FlameStatus(182-189)PWMStatus(209-213)custom_components/sat/pwm.py (4)
PWMState(30-36)state(274-279)status(270-271)duty_cycle(282-284)custom_components/sat/boiler.py (1)
BoilerState(16-29)
custom_components/sat/climate.py (9)
custom_components/sat/area.py (8)
Areas(105-162)errors(112-114)state(35-40)error(65-73)reset(159-162)update(142-148)update(154-157)async_control_heating_loop(96-102)custom_components/sat/const.py (2)
PWMStatus(209-213)BoilerStatus(192-206)custom_components/sat/coordinator.py (12)
SatDataUpdateCoordinator(96-531)minimum_setpoint(347-349)supports_relative_modulation_management(388-394)boiler_temperature(246-247)flame(194-208)setpoint(216-217)get(535-550)update(44-49)manufacturer(211-212)async_control_heating_loop(417-463)device_status(133-176)boiler(179-191)custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)custom_components/sat/manufacturers/geminox.py (1)
Geminox(4-7)custom_components/sat/util.py (1)
create_pwm_controller(70-82)custom_components/sat/relative_modulation.py (5)
RelativeModulation(18-48)enabled(46-48)state(32-43)RelativeModulationState(11-15)update(27-29)custom_components/sat/pwm.py (8)
enabled(266-267)status(270-271)state(274-279)setpoint(292-294)reset(75-86)update(103-177)disable(97-101)enable(93-95)custom_components/sat/minimum_setpoint.py (3)
current(75-76)warming_up(37-45)calculate(47-72)
🪛 Pylint (4.0.2)
custom_components/sat/pwm.py
[refactor] 39-39: Too many instance attributes (23/7)
(R0902)
[refactor] 42-42: Too many arguments (6/5)
(R0913)
[refactor] 42-42: Too many positional arguments (6/5)
(R0917)
[refactor] 103-103: Too many branches (13/12)
(R0912)
[refactor] 179-179: Too many return statements (7/6)
(R0911)
custom_components/sat/coordinator.py
[refactor] 64-64: Too many return statements (7/6)
(R0911)
[refactor] 62-62: Too few public methods (1/2)
(R0903)
[refactor] 96-96: Too many instance attributes (12/7)
(R0902)
[refactor] 96-96: Too many public methods (47/20)
(R0904)
[refactor] 133-133: Too many return statements (14/6)
(R0911)
[refactor] 133-133: Too many branches (15/12)
(R0912)
custom_components/sat/flame.py
[refactor] 20-20: Too many instance attributes (10/7)
(R0902)
[refactor] 35-35: Too many instance attributes (14/7)
(R0902)
[refactor] 267-267: Too many return statements (13/6)
(R0911)
[refactor] 267-267: Too many branches (21/12)
(R0912)
custom_components/sat/climate.py
[refactor] 305-305: Too many branches (16/12)
(R0912)
🪛 Ruff (0.14.4)
custom_components/sat/coordinator.py
14-14: from .const import * used; unable to detect undefined names
(F403)
44-44: Unused method argument: kwargs
(ARG002)
65-65: MODE_FAKE may be undefined, or defined from star imports
(F405)
69-69: MODE_SIMULATOR may be undefined, or defined from star imports
(F405)
73-73: MODE_SWITCH may be undefined, or defined from star imports
(F405)
77-77: MODE_ESPHOME may be undefined, or defined from star imports
(F405)
81-81: MODE_MQTT_EMS may be undefined, or defined from star imports
(F405)
85-85: MODE_MQTT_OPENTHERM may be undefined, or defined from star imports
(F405)
89-89: MODE_SERIAL may be undefined, or defined from star imports
(F405)
93-93: Create your own exception
(TRY002)
93-93: Avoid specifying long messages outside the exception class
(TRY003)
99-99: DOMAIN may be undefined, or defined from star imports
(F405)
114-114: CONF_SIMULATION may be undefined, or defined from star imports
(F405)
115-115: CONF_HEATING_SYSTEM may be undefined, or defined from star imports
(F405)
115-115: HEATING_SYSTEM_UNKNOWN may be undefined, or defined from star imports
(F405)
117-117: CONF_MANUFACTURER may be undefined, or defined from star imports
(F405)
118-118: CONF_MANUFACTURER may be undefined, or defined from star imports
(F405)
133-133: BoilerStatus may be undefined, or defined from star imports
(F405)
136-136: BoilerStatus may be undefined, or defined from star imports
(F405)
139-139: BoilerStatus may be undefined, or defined from star imports
(F405)
141-141: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
142-142: BoilerStatus may be undefined, or defined from star imports
(F405)
147-147: BoilerStatus may be undefined, or defined from star imports
(F405)
150-150: BoilerStatus may be undefined, or defined from star imports
(F405)
155-155: BoilerStatus may be undefined, or defined from star imports
(F405)
157-157: BoilerStatus may be undefined, or defined from star imports
(F405)
159-159: BoilerStatus may be undefined, or defined from star imports
(F405)
161-161: DEADBAND may be undefined, or defined from star imports
(F405)
162-162: BoilerStatus may be undefined, or defined from star imports
(F405)
168-168: BoilerStatus may be undefined, or defined from star imports
(F405)
170-170: BoilerStatus may be undefined, or defined from star imports
(F405)
172-172: BoilerStatus may be undefined, or defined from star imports
(F405)
174-174: BoilerStatus may be undefined, or defined from star imports
(F405)
176-176: BoilerStatus may be undefined, or defined from star imports
(F405)
349-349: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
417-417: Unused method argument: climate
(ARG002)
438-438: BoilerStatus may be undefined, or defined from star imports
(F405)
custom_components/sat/flame.py
74-74: Avoid specifying long messages outside the exception class
(TRY003)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
500-500: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
581-581: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports
(F405)
607-607: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
790-790: DEADBAND may be undefined, or defined from star imports
(F405)
790-790: DEADBAND may be undefined, or defined from star imports
(F405)
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)
830-830: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
831-831: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
832-832: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
841-841: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
842-842: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
958-958: BoilerStatus may be undefined, or defined from star imports
(F405)
984-984: BoilerStatus may be undefined, or defined from star imports
(F405)
991-991: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
🔇 Additional comments (23)
custom_components/sat/pwm.py (5)
20-36: LGTM!The new
CyclesandPWMStatedataclasses are well-designed with immutability (frozen=True), memory efficiency (slots=True), and call-site clarity (kw_only=True).
42-71: LGTM!The updated constructor signature is well-typed and clear. The initialization of
_setpoint_offsetbased onsupports_relative_modulation_managementis appropriate (0.5°C for modulating boilers, 1°C otherwise), and the debug logging provides good visibility into the configuration.
97-101: LGTM!The
disable()method properly resets both the PWM state and the setpoint adjuster, ensuring clean state management.
179-263: LGTM!The
_calculate_duty_cycle()method has been correctly updated to use the newCyclesdataclass andBoilerStatetype hints. The duty cycle calculation logic remains sound.
265-294: LGTM!The property methods provide a clean public API. The
stateproperty correctly encapsulates the PWM state, and thesetpointproperty appropriately falls back toMINIMUM_SETPOINTwhen no adjusted setpoint is set.custom_components/sat/coordinator.py (6)
97-120: LGTM!The constructor properly initializes the new
SatDatainstance, storesconfig_data, and registers a listener to keep flame state synchronized with boiler state changes.
133-176: LGTM!The
device_statusproperty correctly implements the boiler state machine logic with appropriate None guards. The complexity (flagged by static analysis) is inherent to comprehensive state determination and the logic is clear.
178-208: LGTM!The new
boilerandflameproperties provide a clean, immutable public API by constructingBoilerStateandFlameStatefrom internal tracking. This design improves encapsulation and prevents external mutation.
375-385: LGTM!The
supports_relative_modulationproperty correctly implements manufacturer-specific behavior by checking against known manufacturers that don't support relative modulation.
493-518: LGTM!The debounced notification mechanism is well-implemented. The dirty tracking ensures listeners are only notified when data actually changes, and the 5-second delay batches rapid updates for better performance.
520-531: LGTM!The
_get_latest_boiler_cold_temperature()method correctly identifies the maximum cold temperature by filtering for readings taken before both device activation and flame ignition. The logic is sound.custom_components/sat/flame.py (5)
19-32: LGTM!The
FlameStatedataclass is well-designed with comprehensive flame tracking metrics and proper immutability (frozen=True).
35-98: LGTM!The
Flameclass initialization is well-structured with clear constants, proper validation ofsmoothing_alpha, and comprehensive internal state tracking. The detailed error message at line 74 enhances debuggability despite the static analysis hint.
159-265: LGTM!The
update()method implements a clear and comprehensive state machine for flame tracking, correctly handling all four state transitions (OFF→ON, ON→ON, ON→OFF, OFF→OFF) with proper window management and metric updates. The debug logging enhances observability.
267-356: LGTM!The
_recompute_health()method implements comprehensive flame health classification logic with appropriate handling for insufficient data, different demand scenarios (heating vs. hot water), PWM vs. non-PWM operation, and modulating vs. non-modulating boilers. The complexity is inherent to robust health monitoring.
358-391: LGTM!The helper methods are well-implemented with efficient window pruning using
deque.popleft()and proper metric calculations. The code is clear and correct.custom_components/sat/climate.py (7)
8-49: LGTM!The new imports are properly organized and all appear to be used in the updated code.
382-428: LGTM!The
extra_state_attributescorrectly exposes the new error model fields (error,error_pid,error_source), flame timing metrics, and updated property references.
498-507: LGTM!The
errorsandmax_errorproperties correctly implement the new error model, properly handling eco mode vs. area errors and usingmax()with the appropriate key function.
590-602: LGTM!The
minimum_setpoint_valueproperty implements a clear versioned approach for dynamic minimum setpoint calculation, with appropriate fallbacks and safety checks.
604-609: LGTM!The simplified
_calculate_control_setpoint()correctly delegates to therequested_setpointproperty, streamlining the logic.
916-933: LGTM!The
schedule_control_heating_loop()method implements a clean debouncing pattern with a 10-second delay to prevent excessive heating loop calls, while allowing forced immediate execution when needed.
934-993: LGTM!The
async_control_heating_loop()method correctly manages the debounced unsub flag, passes the new state objects (pwm.state,coordinator.boiler,coordinator.flame) to the appropriate methods, and implements proper overshoot handling with PWM enable.
There was a problem hiding this comment.
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)
813-846: Add explicit None handling for maximum_setpoint property.The verification confirms the property could theoretically fail if
CONF_MAXIMUM_SETPOINTis explicitly set toNonein the options dict. While the config flow's NumberSelector validation prevents this in normal operation, defensive coding should handle this edge case.Line 355 in
coordinator.pyshould ensure the value is neverNonebefore callingfloat():return float(self._options.get(CONF_MAXIMUM_SETPOINT) or calculate_default_maximum_setpoint(self._heating_system))This prevents
float(None)errors and ensures the property always returns a validfloat, matching the type annotation.
🧹 Nitpick comments (2)
custom_components/sat/climate.py (2)
505-507: Consider using the Errors.max() API for consistency.The
max_errorproperty directly uses the built-inmax()function onself.errorsrather than callingself.errors.max(). While this works (errors always has at least one element), using theErrors.max()method would be more consistent with the API design.If you prefer consistency with the Errors API, apply this diff:
@property def max_error(self) -> Error: - return max(self.errors, key=lambda error: error.value) + error = self.errors.max() + assert error is not None, "errors should always contain at least one error" + return errorNote: The current implementation is functionally correct since
errorsis guaranteed to be non-empty (line 499 always includes the main climate's error).
895-914: Consider renaming non-async method.The method
async_track_sensor_temperaturehas anasync_prefix but is not anasync deffunction. While this works (it's synchronous), the naming is misleading and violates Python async conventions.Apply this diff to align the method name with its implementation:
- def async_track_sensor_temperature(self, entity_id): + def track_sensor_temperature(self, entity_id): """ Track the temperature of the sensor specified by the given entity_id.Note: If renaming, also update the two call sites at lines 303 and 701.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
custom_components/sat/climate.py(33 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
custom_components/sat/climate.py (9)
custom_components/sat/area.py (10)
Areas(105-162)errors(112-114)async_added_to_hass(93-94)async_added_to_hass(129-131)state(35-40)error(65-73)reset(159-162)update(142-148)update(154-157)async_control_heating_loop(96-102)custom_components/sat/const.py (2)
PWMStatus(209-213)BoilerStatus(192-206)custom_components/sat/coordinator.py (15)
SatDataUpdateCoordinator(96-531)minimum_setpoint(347-349)supports_relative_modulation_management(388-394)boiler_temperature(246-247)async_added_to_hass(409-411)flame(194-208)setpoint(216-217)get(535-550)async_set_control_setpoint(469-472)update(44-49)manufacturer(211-212)async_set_control_max_relative_modulation(484-487)async_control_heating_loop(417-463)device_status(133-176)boiler(179-191)custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)custom_components/sat/util.py (1)
create_pwm_controller(70-82)custom_components/sat/relative_modulation.py (5)
RelativeModulation(18-48)enabled(46-48)state(32-43)RelativeModulationState(11-15)update(27-29)custom_components/sat/flame.py (2)
average_on_time_seconds(134-135)update(159-265)custom_components/sat/pwm.py (8)
enabled(266-267)status(270-271)state(274-279)setpoint(292-294)reset(75-86)update(103-177)disable(97-101)enable(93-95)custom_components/sat/minimum_setpoint.py (3)
current(75-76)warming_up(37-45)calculate(47-72)
🪛 Pylint (4.0.2)
custom_components/sat/climate.py
[refactor] 305-305: Too many branches (16/12)
(R0912)
🪛 Ruff (0.14.4)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
500-500: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
581-581: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports
(F405)
607-607: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
790-790: DEADBAND may be undefined, or defined from star imports
(F405)
790-790: DEADBAND may be undefined, or defined from star imports
(F405)
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)
830-830: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
831-831: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
832-832: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
841-841: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
842-842: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
958-958: BoilerStatus may be undefined, or defined from star imports
(F405)
984-984: BoilerStatus may be undefined, or defined from star imports
(F405)
991-991: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
🔇 Additional comments (5)
custom_components/sat/climate.py (5)
766-811: LGTM: Error handling refactor is well-implemented.The refactoring to use
ErrorandErrorstypes improves type safety and code clarity. The logic correctly:
- Creates an
Errorfor the main climate entity- Merges with area errors when not in ECO mode
- Extracts
max_error.valueandmax_error.entity_idfor logging (addressing the previous review comment)- Uses the error values consistently throughout PID control
The deadband check (line 790) and error reset logic (lines 804-809) properly handle the new error structure.
855-869: Well-designed manufacturer-specific handling.The relative modulation control properly:
- Updates state based on PWM enablement
- Applies manufacturer-specific constraints (Geminox minimum of 10%)
- Avoids unnecessary coordinator calls when value is unchanged
The
isinstance()check forGeminoxmanufacturer is a clean way to handle device-specific quirks.
934-993: Control loop orchestration is solid.The refactored control loop properly:
- Clears the debounce handle at entry (line 937)
- Guards against missing sensor data (lines 940-945)
- Passes PWM state to the coordinator (line 948)
- Applies low-pass filtering to setpoint changes (lines 954-955)
- Enables PWM on overshoot detection (lines 958-960)
- Orchestrates minimum setpoint calculation for both versions
The coordination between PWM, PID, relative modulation, and areas is well-structured. The conditional logic correctly handles different configurations (dynamic minimum setpoint versions, PWM enablement, etc.).
1096-1099: LGTM: Defensive room temperature lookup.The use of
.get(entity_id, self._presets[preset_mode])with a fallback addresses the previous review comment about potentialKeyError. When an entity isn't found in_rooms, it falls back to the preset temperature, which is a reasonable default.
1134-1137: Notification service call is correct.The change to use
notify.DOMAIN(line 1137) and the importednotifymodule (line 10) properly aligns with Home Assistant's service architecture. The default servicenotify.SERVICE_PERSISTENT_NOTIFICATIONis appropriate for integration notifications.
…djusted `BoilerStatus` transitions with `BOILER_DEADBAND`, and incorporated `ANTI_CYCLING` state.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
custom_components/sat/pwm.py (1)
134-154: Extract magic numbers to constants in const.py.The setpoint adjustment logic uses hardcoded thresholds that should be defined as named constants for maintainability:
- Line 135:
6→ defineFLAME_ON_TIME_THRESHOLD_SECONDS- Line 139:
60→ defineFLAME_AVERAGE_ON_TIME_THRESHOLD_SECONDS- Line 136:
-3→ defineSETPOINT_ADJUST_DOWN_DEGREES- Line 140:
+10→ defineSETPOINT_ADJUST_UP_DEGREESAdd these constants to
custom_components/sat/const.py(similar toHEATER_STARTUP_TIMEFRAME) and import them here.Based on past review comments.
custom_components/sat/coordinator.py (1)
356-359: Add None guard to prevent TypeError on missing config key.
self._config_data.get(CONF_MINIMUM_SETPOINT)can returnNonefor legacy or incomplete configurations, causingfloat(None)to raiseTypeErrorat runtime.Apply this diff to add a safe fallback:
@property def minimum_setpoint(self) -> float: """Return the minimum setpoint temperature before the device starts to overshoot.""" - return float(self._config_data.get(CONF_MINIMUM_SETPOINT)) + if (configured := self._config_data.get(CONF_MINIMUM_SETPOINT)) is None: + return MINIMUM_SETPOINT + + return float(configured)Based on past review comments.
🧹 Nitpick comments (3)
custom_components/sat/const.py (2)
22-25: Add inline documentation for the new temperature constants.
BOILER_DEADBANDandCOLD_SETPOINTlack documentation explaining their purpose and units, which makes maintenance harder.Apply this diff to add inline comments:
DEADBAND = 0.1 -BOILER_DEADBAND = 2 +BOILER_DEADBAND = 2 # Temperature tolerance (°C) for boiler setpoint comparisons HEATER_STARTUP_TIMEFRAME = 180 -COLD_SETPOINT = 28.2 +COLD_SETPOINT = 28.2 # Minimum operational flow temperature (°C) for heating mode MINIMUM_SETPOINT = 10
183-215: Add docstrings to FlameStatus and BoilerStatus for consistency.
PWMStatusincludes a docstring, butFlameStatusandBoilerStatuslack them. Adding brief docstrings improves API clarity and maintains consistency.Apply this diff to add docstrings:
class FlameStatus(str, Enum): + """Health and activity status of the boiler flame.""" HEALTHY = "healthy" IDLE_OK = "idle_ok" STUCK_ON = "stuck_on" STUCK_OFF = "stuck_off" PWM_SHORT = "pwm_short" SHORT_CYCLING = "short_cycling" INSUFFICIENT_DATA = "insufficient_data" class BoilerStatus(str, Enum): + """Operational status of the boiler during heating cycles.""" HOT_WATER = "hot_water" PREHEATING = "preheating" HEATING_UP = "heating_up"custom_components/sat/util.py (1)
85-102: Move entity registry lookup outside the loop for efficiency.The entity registry is fetched on every iteration (line 89), but it only needs to be retrieved once before the loop.
Apply this diff to improve efficiency:
def get_climate_entities(hass: "HomeAssistant", entity_ids: list[str]) -> list["SatClimate"]: """Retrieve climate entities for the given entity IDs.""" entities = [] + registry = entity_registry.async_get(hass) + for entity_id in entity_ids: - registry = entity_registry.async_get(hass) - if not (entry := registry.async_get(entity_id)): continue if not (config_entry := hass.data[DOMAIN].get(entry.config_entry_id)): continue if not (climate := config_entry.get(CLIMATE)): continue entities.append(climate) return entities
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
custom_components/sat/const.py(3 hunks)custom_components/sat/coordinator.py(14 hunks)custom_components/sat/pwm.py(9 hunks)custom_components/sat/util.py(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
custom_components/sat/pwm.py (6)
custom_components/sat/const.py (2)
BoilerStatus(193-208)PWMStatus(211-215)custom_components/sat/heating_curve.py (5)
HeatingCurve(10-91)reset(20-24)update(26-29)value(90-91)base_offset(77-79)custom_components/sat/setpoint_adjuster.py (5)
SetpointAdjuster(10-47)force(24-28)reset(20-22)adjust(30-47)current(16-18)custom_components/sat/flame.py (6)
FlameState(20-32)update(159-265)is_active(105-106)latest_on_time_seconds(127-131)is_inactive(109-110)average_on_time_seconds(134-135)custom_components/sat/boiler.py (2)
BoilerState(16-29)update(42-64)custom_components/sat/minimum_setpoint.py (1)
current(75-76)
custom_components/sat/coordinator.py (10)
custom_components/sat/boiler.py (4)
BoilerTemperatureTracker(32-141)BoilerState(16-29)update(42-64)active(134-136)custom_components/sat/flame.py (13)
Flame(35-391)update(159-265)is_active(105-106)is_inactive(109-110)health_status(101-102)latest_on_time_seconds(127-131)average_on_time_seconds(134-135)last_cycle_duration_seconds(138-139)sample_count_4h(155-157)cycles_last_hour(142-143)duty_ratio_last_15m(146-147)median_on_duration_seconds_4h(150-152)on_since(113-117)custom_components/sat/helpers.py (2)
calculate_default_maximum_setpoint(44-49)seconds_since(11-16)custom_components/sat/manufacturer.py (4)
Manufacturer(28-39)ManufacturerFactory(42-63)resolve_by_name(44-49)member_id(33-34)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/pwm.py (3)
PWMState(30-36)update(101-175)setpoint(290-292)custom_components/sat/const.py (1)
BoilerStatus(193-208)
custom_components/sat/util.py (4)
custom_components/sat/heating_curve.py (1)
HeatingCurve(10-91)custom_components/sat/pwm.py (2)
PWM(39-292)CycleConfig(21-26)tests/conftest.py (2)
climate(41-42)entry(20-37)custom_components/sat/climate.py (1)
SatClimate(80-1137)
🪛 Pylint (4.0.2)
custom_components/sat/pwm.py
[refactor] 39-39: Too many instance attributes (23/7)
(R0902)
[refactor] 42-42: Too many arguments (6/5)
(R0913)
[refactor] 42-42: Too many positional arguments (6/5)
(R0917)
[refactor] 101-101: Too many branches (13/12)
(R0912)
[refactor] 177-177: Too many return statements (7/6)
(R0911)
custom_components/sat/coordinator.py
[refactor] 64-64: Too many return statements (7/6)
(R0911)
[refactor] 62-62: Too few public methods (1/2)
(R0903)
[refactor] 96-96: Too many instance attributes (12/7)
(R0902)
[refactor] 96-96: Too many public methods (47/20)
(R0904)
[refactor] 157-163: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it
(R1705)
[refactor] 133-133: Too many return statements (14/6)
(R0911)
[refactor] 133-133: Too many branches (16/12)
(R0912)
🪛 Ruff (0.14.4)
custom_components/sat/coordinator.py
14-14: from .const import * used; unable to detect undefined names
(F403)
44-44: Unused method argument: kwargs
(ARG002)
65-65: MODE_FAKE may be undefined, or defined from star imports
(F405)
69-69: MODE_SIMULATOR may be undefined, or defined from star imports
(F405)
73-73: MODE_SWITCH may be undefined, or defined from star imports
(F405)
77-77: MODE_ESPHOME may be undefined, or defined from star imports
(F405)
81-81: MODE_MQTT_EMS may be undefined, or defined from star imports
(F405)
85-85: MODE_MQTT_OPENTHERM may be undefined, or defined from star imports
(F405)
89-89: MODE_SERIAL may be undefined, or defined from star imports
(F405)
93-93: Create your own exception
(TRY002)
93-93: Avoid specifying long messages outside the exception class
(TRY003)
99-99: DOMAIN may be undefined, or defined from star imports
(F405)
114-114: CONF_SIMULATION may be undefined, or defined from star imports
(F405)
115-115: CONF_HEATING_SYSTEM may be undefined, or defined from star imports
(F405)
115-115: HEATING_SYSTEM_UNKNOWN may be undefined, or defined from star imports
(F405)
117-117: CONF_MANUFACTURER may be undefined, or defined from star imports
(F405)
118-118: CONF_MANUFACTURER may be undefined, or defined from star imports
(F405)
133-133: BoilerStatus may be undefined, or defined from star imports
(F405)
136-136: BoilerStatus may be undefined, or defined from star imports
(F405)
139-139: BoilerStatus may be undefined, or defined from star imports
(F405)
141-141: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
142-142: BoilerStatus may be undefined, or defined from star imports
(F405)
147-147: BoilerStatus may be undefined, or defined from star imports
(F405)
150-150: BoilerStatus may be undefined, or defined from star imports
(F405)
156-156: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
159-159: BoilerStatus may be undefined, or defined from star imports
(F405)
161-161: BoilerStatus may be undefined, or defined from star imports
(F405)
163-163: BoilerStatus may be undefined, or defined from star imports
(F405)
166-166: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
167-167: BoilerStatus may be undefined, or defined from star imports
(F405)
170-170: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
175-175: BoilerStatus may be undefined, or defined from star imports
(F405)
178-178: BoilerStatus may be undefined, or defined from star imports
(F405)
181-181: BoilerStatus may be undefined, or defined from star imports
(F405)
184-184: BoilerStatus may be undefined, or defined from star imports
(F405)
186-186: BoilerStatus may be undefined, or defined from star imports
(F405)
359-359: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
427-427: Unused method argument: climate
(ARG002)
448-448: BoilerStatus may be undefined, or defined from star imports
(F405)
custom_components/sat/util.py
10-10: from .const import * used; unable to detect undefined names
(F403)
73-73: CONF_CYCLES_PER_HOUR may be undefined, or defined from star imports
(F405)
74-74: CONF_AUTOMATIC_DUTY_CYCLE may be undefined, or defined from star imports
(F405)
75-75: CONF_DUTY_CYCLE may be undefined, or defined from star imports
(F405)
76-76: CONF_MODE may be undefined, or defined from star imports
(F405)
76-76: MODE_SWITCH may be undefined, or defined from star imports
(F405)
76-76: CONF_FORCE_PULSE_WIDTH_MODULATION may be undefined, or defined from star imports
(F405)
94-94: DOMAIN may be undefined, or defined from star imports
(F405)
97-97: CLIMATE may be undefined, or defined from star imports
(F405)
🔇 Additional comments (18)
custom_components/sat/const.py (2)
2-4: LGTM! Standard modernization.The addition of future annotations and Enum import aligns with modern Python practices and supports the new enum definitions introduced later in the file.
28-29: Constant renaming verified successfully.All references to
MINIMUM_RELATIVE_MODandMAXIMUM_RELATIVE_MODhave been updated to use the new namesMINIMUM_RELATIVE_MODULATIONandMAXIMUM_RELATIVE_MODULATION. The refactoring is complete with no stale references remaining in the codebase.custom_components/sat/pwm.py (6)
20-37: LGTM! Well-designed immutable dataclasses.Both
CycleConfigandPWMStatefollow best practices withfrozen=Truefor immutability,slots=Truefor memory efficiency, andkw_only=Truefor explicit parameter passing. The encapsulation improves the API surface.
42-71: LGTM! Improved parameter encapsulation.The refactored constructor using
CycleConfigfor cycle parameters provides better encapsulation and clearer semantics compared to separatemax_cycle_time/max_cyclesparameters. Thesupports_relative_modulation_managementparameter appropriately influences the setpoint offset calculation.
95-99: LGTM! Proper cleanup on disable.The
disable()method correctly resets both internal PWM state and the setpoint adjuster, ensuring no stale state persists after PWM is disabled.
101-110: LGTM! Improved API with state objects.The updated
update()signature acceptingBoilerStateandFlameStateobjects provides richer context for PWM control decisions while avoiding parameter explosion. The TYPE_CHECKING pattern correctly handles circular imports.
177-261: LGTM! Duty cycle calculation properly updated.The
_calculate_duty_cyclemethod correctly adapts to the newBoilerStateparameter and usescycles.maximum_timefrom theCycleConfigobject. The current cycle count logging aids debugging.
268-292: LGTM! Well-designed property accessors.The new
status,state, andsetpointproperties provide clean access to PWM state:
statereturns an immutablePWMStatesnapshotsetpointhas a safe fallback toMINIMUM_SETPOINT- Proper encapsulation of internal state
custom_components/sat/util.py (3)
1-18: LGTM! Proper typing infrastructure.The addition of future annotations, TYPE_CHECKING, and updated imports properly supports the new
CycleConfigusage and theget_climate_entitieshelper while avoiding circular import issues.
60-67: LGTM! HeatingCurve API simplified.The removal of the version parameter from
HeatingCurveinstantiation aligns with the broader refactor removingCONF_HEATING_CURVE_VERSION. The simplified API is cleaner.
70-82: LGTM! Proper CycleConfig encapsulation.The refactored
create_pwm_controllercorrectly:
- Accepts the new
supports_relative_modulation_managementparameter- Encapsulates cycle parameters in a
CycleConfigobject- Passes the updated parameters to the PWM constructor
The changes maintain consistency with the PWM class refactor.
custom_components/sat/coordinator.py (7)
64-91: LGTM! Consistent parameter naming across factory methods.All coordinator factory methods correctly pass
config_datainstead of the genericdataparameter, improving clarity and aligning with the constructor signature change.
97-121: LGTM with caveat on SatData.The constructor properly:
- Stores
config_datafor use in properties likeminimum_setpoint- Initializes state tracking attributes
- Registers a listener for flame updates (line 120)
However, the
SatData()instantiation on line 100 will be affected by the class-level_is_dirtybug flagged in the previous comment.
133-186: LGTM! Comprehensive boiler status determination.The expanded
device_statusproperty provides detailed status classification using the newBoilerStatusenum andBOILER_DEADBANDconstant. The logic is well-commented and handles various operating states (preheating, heating up, cooling down, anti-cycling, etc.) appropriately.
189-218: LGTM! Clean state encapsulation with immutable snapshots.The
boilerandflameproperties effectively encapsulate coordinator state into immutableBoilerStateandFlameStatedataclasses, providing a clean, consistent API for consumers while preventing external state mutations.
385-395: LGTM! Manufacturer-specific behavior centralized.The
supports_relative_modulationproperty appropriately centralizes manufacturer-specific behavior, explicitly returningFalsefor manufacturers (Ideal, Intergas, Geminox, Nefit) that don't support relative modulation.
503-528: LGTM! Well-implemented debounced notification pattern.The async notification methods properly implement debouncing:
async_set_updated_datatracks changes via dirty flag and schedules a 5-second delayed notification- Cancels previous pending notifications to prevent spam
async_notify_listenersperforms immediate notification after cleanupThe pattern effectively batches rapid state changes.
530-541: LGTM! Correct cold temperature determination logic.The
_get_latest_boiler_cold_temperaturemethod properly:
- Finds the maximum temperature recorded when both device and flame were off
- Handles optional timestamps with proper None checks
- Returns
Nonewhen no valid cold temperature samples existThe caller (lines 472-473) appropriately handles the
Nonereturn with a fallback.
…N` with `INSUFFICIENT_DATA`, updated `BoilerState` attributes, and streamlined status references.
…s == INSUFFICIENT_DATA`.
…tion of status enums
There was a problem hiding this comment.
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 (3)
custom_components/sat/pwm.py (1)
42-71: Fix_setpoint_offsettype/logging and consider its actual usage
_setpoint_offsetis declared asintbut initialized with0.5when relative modulation is supported, and logged with%d, which will truncate0.5to0. Also, beyond logging,_setpoint_offsetdoesn’t appear to influence any control logic in this class.Two suggestions:
- Change its type and logging to treat it as a float, e.g.
self._setpoint_offset: float = 0.5 if ... else 1.0andOffset: %.1f°C.- If the offset is not actually used anywhere else, either wire it into the setpoint logic or drop the field/parameter to avoid confusion.
custom_components/sat/sensor.py (1)
44-50: Fix type annotation inflame.pyforhealth_statusYour concern was valid—there is a type mismatch. However, it's in
flame.py, not the sensor code. At runtime,health_statusactually returnsFlameStatusenum members, not plain strings. The type annotations falsely claimstr:
custom_components/sat/flame.py, line 23:health_status: strshould behealth_status: FlameStatuscustom_components/sat/flame.py, line 97:self._health_status: str = ...should beself._health_status: FlameStatus = ...custom_components/sat/flame.py, line 100:-> str:should be-> FlameStatus:The sensor code calling
.name(line 261) is correct and will work because the actual values are enums. Once the type annotations are corrected inflame.py, the sensor code will also pass static type checking.custom_components/sat/climate.py (1)
766-811: Fix missing await calls or convert to synchronous method.
_async_control_pidis defined as an async method but is called throughout the file withoutawait(lines 638, 649, 662, 705, 709, 713, 729, 1042, 1123). This means the coroutines are created but never executed, causing the PID controller to never actually update.Solution: Since the method doesn't perform any async operations internally, convert it to a synchronous method:
- def _async_control_pid(self, reset: bool = False) -> None: + def _control_pid(self, reset: bool = False) -> None: """Control the PID controller."""Then update all call sites:
- self._async_control_pid() + self._control_pid()(Apply to all call sites: lines 638, 649, 662, 705, 709, 713, 729, 1042, 1123)
♻️ Duplicate comments (5)
custom_components/sat/binary_sensor.py (1)
37-38: Re‑verifysupports_relative_modulation_managementvs new coordinator APIThis still relies on
coordinator.supports_relative_modulation_management; earlier review mentioned a rename tosupports_relative_modulation. Please confirm the coordinator’s public property name and update this check if the new name is now canonical, to avoid subtle attribute errors or divergence from other modules.#!/bin/bash # Check all usages of both names to confirm the canonical property rg -n "supports_relative_modulation(_management)?" custom_components/satcustom_components/sat/pwm.py (1)
134-145: Setpoint adjustment logic is reasonable; extract magic thresholds as constantsThe new setpoint management around flame timing is nicely structured (adjust down when flame runs long, force up on short cycles, otherwise maintain current). However, the thresholds (
> 6seconds,< 60seconds,-3°C,+10°C) are still baked in here.Prior review already suggested moving these to named constants in
const.py(e.g.FLAME_ON_TIME_THRESHOLD_SECONDS,FLAME_AVERAGE_ON_TIME_THRESHOLD_SECONDS,SETPOINT_ADJUST_DOWN_DEGREES,SETPOINT_ADJUST_UP_DEGREES) and importing them here for clarity and reuse.custom_components/sat/coordinator.py (2)
35-59: Critical: Class-level_is_dirtycauses shared state across instances.Line 36 defines
_is_dirtyas a class attribute, meaning allSatDatainstances will share the same dirty flag. This will cause dirty tracking to bleed between different coordinator instances, leading to incorrect update notifications.Apply this diff to make
_is_dirtyan instance attribute:class SatData(dict): - _is_dirty: bool = False + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._is_dirty = False def __setitem__(self, key, value):Based on past review comments.
357-360: Guardminimum_setpointagainst missing config data.
self._config_data.get(CONF_MINIMUM_SETPOINT)can be absent (e.g., legacy configs or incomplete options), andfloat(None)will raiseTypeError. Add a fallback to the integration'sMINIMUM_SETPOINTconstant before casting.Apply this diff:
@property def minimum_setpoint(self) -> float: """Return the minimum setpoint temperature before the device starts to overshoot.""" - return float(self._config_data.get(CONF_MINIMUM_SETPOINT)) + if (configured := self._config_data.get(CONF_MINIMUM_SETPOINT)) is None: + return MINIMUM_SETPOINT + return float(configured)Based on past review comments.
custom_components/sat/climate.py (1)
916-932: Critical: Fix indentation in force execution path.When
force=Truebut there's no pending scheduled run (_control_heating_loop_unsub is None), the method falls through to the normal scheduling logic instead of executing immediately. Lines 925-926 are incorrectly indented inside theif self._control_heating_loop_unsub is not None:block.Apply this diff to fix the indentation:
def schedule_control_heating_loop(self, _time: Optional[datetime] = None, force: bool = False, ) -> None: """Schedule a debounced execution of the heating control loop.""" # Force immediate execution if force: # Cancel previous scheduled run, if any if self._control_heating_loop_unsub is not None: self._control_heating_loop_unsub() self._control_heating_loop_unsub = None - - self.hass.async_create_task(self.async_control_heating_loop()) - return + + self.hass.async_create_task(self.async_control_heating_loop()) + return # If a run is already scheduled, do nothing. if self._control_heating_loop_unsub is not None: return self._control_heating_loop_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_heating_loop))Based on past review comments.
🧹 Nitpick comments (4)
custom_components/sat/binary_sensor.py (1)
50-57: Delay increase inSatSynchroSensorconstructorThe default delay went from 30s to 60s. Functionally this is fine; just be aware it doubles the time before a mismatch is surfaced and may make troubleshooting feel more “sluggish”. If this value is likely to be tuned again, consider promoting it to a constant in
const.py.custom_components/sat/sensor.py (1)
241-243: Manufacturer sensor: consider updating return type annotationThe new implementation safely handles
Noneand usesfriendly_name, which fixes the previousAttributeErrorrisk. Given it can now returnNone, you might want to update the return annotation from-> strto-> str | None(orOptional[str]) for accuracy.custom_components/sat/pwm.py (1)
289-292: Clarifysetpointfallback expression
return self._setpoint or MINIMUM_SETPOINTworks because_setpointis expected to be eitherNoneor a positive float; however, it relies on truthiness and would behave oddly if_setpointwere ever0.0. For clarity and robustness, consider switching to an explicitNonecheck:- return self._setpoint or MINIMUM_SETPOINT + return self._setpoint if self._setpoint is not None else MINIMUM_SETPOINTcustom_components/sat/flame.py (1)
19-33: Consider using FlameStatus enum type for health_status.The
health_statusfield is currently typed asstr, but based on the context it should beFlameStatus(fromconst.py) for better type safety and IDE support.Apply this diff:
@dataclass(frozen=True, slots=True) class FlameState: is_active: bool is_inactive: bool - health_status: str + health_status: FlameStatus latest_on_time_seconds: Optional[float]
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
custom_components/sat/binary_sensor.py(3 hunks)custom_components/sat/boiler.py(3 hunks)custom_components/sat/climate.py(33 hunks)custom_components/sat/const.py(3 hunks)custom_components/sat/coordinator.py(14 hunks)custom_components/sat/flame.py(1 hunks)custom_components/sat/pwm.py(9 hunks)custom_components/sat/sensor.py(7 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
custom_components/sat/pwm.py (6)
custom_components/sat/const.py (2)
BoilerStatus(193-207)PWMStatus(210-214)custom_components/sat/heating_curve.py (5)
HeatingCurve(10-91)reset(20-24)update(26-29)value(90-91)base_offset(77-79)custom_components/sat/setpoint_adjuster.py (5)
SetpointAdjuster(10-47)force(24-28)reset(20-22)adjust(30-47)current(16-18)custom_components/sat/coordinator.py (5)
flame(205-219)boiler(189-202)update(44-49)boiler_temperature(257-258)setpoint(227-228)custom_components/sat/flame.py (6)
FlameState(20-32)update(158-264)is_active(104-105)latest_on_time_seconds(126-130)is_inactive(108-109)average_on_time_seconds(133-134)custom_components/sat/boiler.py (2)
BoilerState(16-30)update(43-65)
custom_components/sat/boiler.py (2)
custom_components/sat/const.py (1)
BoilerStatus(193-207)custom_components/sat/pwm.py (1)
setpoint(290-292)
custom_components/sat/coordinator.py (10)
custom_components/sat/boiler.py (4)
BoilerTemperatureTracker(33-142)BoilerState(16-30)update(43-65)active(135-137)custom_components/sat/flame.py (14)
Flame(35-390)FlameState(20-32)update(158-264)is_active(104-105)is_inactive(108-109)health_status(100-101)latest_on_time_seconds(126-130)average_on_time_seconds(133-134)last_cycle_duration_seconds(137-138)sample_count_4h(154-156)cycles_last_hour(141-142)duty_ratio_last_15m(145-146)median_on_duration_seconds_4h(149-151)on_since(112-116)custom_components/sat/helpers.py (2)
calculate_default_maximum_setpoint(44-49)seconds_since(11-16)custom_components/sat/manufacturer.py (4)
Manufacturer(28-39)ManufacturerFactory(42-63)resolve_by_name(44-49)member_id(33-34)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/pwm.py (4)
PWMState(30-36)update(101-175)setpoint(290-292)status(268-269)custom_components/sat/serial/__init__.py (13)
get(150-156)SatSerialCoordinator(25-210)boiler_temperature(84-88)hot_water_active(50-51)setpoint(70-74)device_active(46-47)flame_active(147-148)return_temperature(91-95)relative_modulation_value(112-116)member_id(140-144)boiler_capacity(119-123)minimum_relative_modulation_value(126-130)maximum_relative_modulation_value(133-137)
custom_components/sat/binary_sensor.py (4)
custom_components/sat/const.py (2)
FlameStatus(183-190)BoilerStatus(193-207)custom_components/sat/entity.py (2)
SatClimateEntity(41-45)SatEntity(19-38)custom_components/sat/coordinator.py (3)
boiler(189-202)get(546-561)flame(205-219)custom_components/sat/flame.py (1)
health_status(100-101)
custom_components/sat/flame.py (4)
custom_components/sat/const.py (3)
BoilerStatus(193-207)FlameStatus(183-190)PWMStatus(210-214)custom_components/sat/pwm.py (4)
PWMState(30-36)state(272-277)status(268-269)duty_cycle(280-282)custom_components/sat/coordinator.py (3)
boiler(189-202)flame_active(241-242)hot_water_active(249-250)custom_components/sat/boiler.py (1)
BoilerState(16-30)
custom_components/sat/climate.py (8)
custom_components/sat/area.py (10)
Areas(105-162)errors(112-114)async_added_to_hass(93-94)async_added_to_hass(129-131)state(35-40)error(65-73)reset(159-162)update(142-148)update(154-157)async_control_heating_loop(96-102)custom_components/sat/const.py (2)
PWMStatus(210-214)BoilerStatus(193-207)custom_components/sat/coordinator.py (16)
SatDataUpdateCoordinator(96-542)minimum_setpoint(358-360)supports_relative_modulation_management(399-405)boiler_temperature(257-258)async_added_to_hass(420-422)flame(205-219)relative_modulation_value(309-310)setpoint(227-228)get(546-561)async_set_control_setpoint(480-483)update(44-49)manufacturer(222-223)async_set_control_max_relative_modulation(495-498)async_control_heating_loop(428-474)device_status(133-186)boiler(189-202)custom_components/sat/errors.py (3)
Errors(5-33)Error(37-40)max(29-33)custom_components/sat/relative_modulation.py (5)
RelativeModulation(18-48)enabled(46-48)state(32-43)RelativeModulationState(11-15)update(27-29)custom_components/sat/flame.py (2)
average_on_time_seconds(133-134)update(158-264)custom_components/sat/pwm.py (8)
enabled(264-265)status(268-269)state(272-277)setpoint(290-292)reset(75-84)update(101-175)disable(95-99)enable(91-93)custom_components/sat/minimum_setpoint.py (3)
current(75-76)warming_up(37-45)calculate(47-72)
custom_components/sat/sensor.py (7)
custom_components/sat/coordinator.py (6)
device_active(232-233)flame_active(241-242)manufacturer(222-223)flame(205-219)get(546-561)device_status(133-186)custom_components/sat/manufacturer.py (1)
friendly_name(38-39)custom_components/sat/manufacturers/ferroli.py (1)
friendly_name(6-7)custom_components/sat/manufacturers/immergas.py (1)
friendly_name(6-7)custom_components/sat/manufacturers/nefit.py (1)
friendly_name(6-7)custom_components/sat/entity.py (1)
SatEntity(19-38)custom_components/sat/flame.py (1)
health_status(100-101)
🪛 Pylint (4.0.2)
custom_components/sat/pwm.py
[refactor] 39-39: Too many instance attributes (23/7)
(R0902)
[refactor] 42-42: Too many arguments (6/5)
(R0913)
[refactor] 42-42: Too many positional arguments (6/5)
(R0917)
[refactor] 101-101: Too many branches (13/12)
(R0912)
[refactor] 177-177: Too many return statements (7/6)
(R0911)
custom_components/sat/boiler.py
[refactor] 16-16: Too many instance attributes (9/7)
(R0902)
custom_components/sat/coordinator.py
[refactor] 64-64: Too many return statements (7/6)
(R0911)
[refactor] 62-62: Too few public methods (1/2)
(R0903)
[refactor] 96-96: Too many instance attributes (12/7)
(R0902)
[refactor] 96-96: Too many public methods (47/20)
(R0904)
[refactor] 157-163: Unnecessary "else" after "return", remove the "else" and de-indent the code inside it
(R1705)
[refactor] 133-133: Too many return statements (14/6)
(R0911)
[refactor] 133-133: Too many branches (16/12)
(R0912)
custom_components/sat/binary_sensor.py
[refactor] 50-50: Too few public methods (1/2)
(R0903)
custom_components/sat/flame.py
[refactor] 20-20: Too many instance attributes (10/7)
(R0902)
[refactor] 35-35: Too many instance attributes (14/7)
(R0902)
[refactor] 266-266: Too many return statements (13/6)
(R0911)
[refactor] 266-266: Too many branches (21/12)
(R0912)
custom_components/sat/climate.py
[refactor] 305-305: Too many branches (16/12)
(R0912)
🪛 Ruff (0.14.4)
custom_components/sat/coordinator.py
14-14: from .const import * used; unable to detect undefined names
(F403)
44-44: Unused method argument: kwargs
(ARG002)
65-65: MODE_FAKE may be undefined, or defined from star imports
(F405)
69-69: MODE_SIMULATOR may be undefined, or defined from star imports
(F405)
73-73: MODE_SWITCH may be undefined, or defined from star imports
(F405)
77-77: MODE_ESPHOME may be undefined, or defined from star imports
(F405)
81-81: MODE_MQTT_EMS may be undefined, or defined from star imports
(F405)
85-85: MODE_MQTT_OPENTHERM may be undefined, or defined from star imports
(F405)
89-89: MODE_SERIAL may be undefined, or defined from star imports
(F405)
93-93: Create your own exception
(TRY002)
93-93: Avoid specifying long messages outside the exception class
(TRY003)
99-99: DOMAIN may be undefined, or defined from star imports
(F405)
114-114: CONF_SIMULATION may be undefined, or defined from star imports
(F405)
115-115: CONF_HEATING_SYSTEM may be undefined, or defined from star imports
(F405)
115-115: HEATING_SYSTEM_UNKNOWN may be undefined, or defined from star imports
(F405)
117-117: CONF_MANUFACTURER may be undefined, or defined from star imports
(F405)
118-118: CONF_MANUFACTURER may be undefined, or defined from star imports
(F405)
133-133: BoilerStatus may be undefined, or defined from star imports
(F405)
136-136: BoilerStatus may be undefined, or defined from star imports
(F405)
139-139: BoilerStatus may be undefined, or defined from star imports
(F405)
141-141: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
142-142: BoilerStatus may be undefined, or defined from star imports
(F405)
147-147: BoilerStatus may be undefined, or defined from star imports
(F405)
150-150: BoilerStatus may be undefined, or defined from star imports
(F405)
156-156: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
159-159: BoilerStatus may be undefined, or defined from star imports
(F405)
161-161: BoilerStatus may be undefined, or defined from star imports
(F405)
163-163: BoilerStatus may be undefined, or defined from star imports
(F405)
166-166: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
167-167: BoilerStatus may be undefined, or defined from star imports
(F405)
170-170: BOILER_DEADBAND may be undefined, or defined from star imports
(F405)
175-175: BoilerStatus may be undefined, or defined from star imports
(F405)
178-178: BoilerStatus may be undefined, or defined from star imports
(F405)
181-181: BoilerStatus may be undefined, or defined from star imports
(F405)
184-184: BoilerStatus may be undefined, or defined from star imports
(F405)
186-186: BoilerStatus may be undefined, or defined from star imports
(F405)
360-360: CONF_MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
428-428: Unused method argument: climate
(ARG002)
449-449: BoilerStatus may be undefined, or defined from star imports
(F405)
custom_components/sat/flame.py
73-73: Avoid specifying long messages outside the exception class
(TRY003)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
500-500: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
581-581: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports
(F405)
607-607: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
790-790: DEADBAND may be undefined, or defined from star imports
(F405)
790-790: DEADBAND may be undefined, or defined from star imports
(F405)
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)
830-830: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
831-831: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
832-832: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
841-841: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
842-842: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
958-958: BoilerStatus may be undefined, or defined from star imports
(F405)
984-984: BoilerStatus may be undefined, or defined from star imports
(F405)
991-991: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
🔇 Additional comments (35)
custom_components/sat/binary_sensor.py (2)
43-47: Confirm always‑on registration of synchro + health sensors is intentional
SatCentralHeatingSynchroSensor,SatBoilerHealthSensor, andSatFlameHealthSensorare now always added regardless of feature flags. This is probably desired, but it does change behavior from “conditional” to “always present”. Please confirm this matches your UX expectations (e.g. no unwanted entities when certain features are disabled).
173-221: Health binary sensors logic looks consistent with new enums
SatBoilerHealthSensorandSatFlameHealthSensorcorrectly useBoilerStatus/FlameStatuswithBinarySensorDeviceClass.PROBLEMand stable unique IDs. Using “on == problem” (INSUFFICIENT_DATA for boiler, anything not HEALTHY/IDLE_OK for flame) matches HA semantics and the new health model.custom_components/sat/sensor.py (3)
128-137: Consumption early‑return guards read clean and correctUsing
if not self._coordinator.device_active/if not self._coordinator.flame_activeis a clear way to short‑circuit consumption to 0 when the boiler/flame aren’t active. This matches the intended semantics and is easier to read than explicit== Falsechecks.
147-155: Event typing updates improve callback signaturesAnnotating
on_state_changeasEvent[EventStateChangedData]in both sensors is a nice type‑safety win and matches Home Assistant’s event model. No functional concerns.Also applies to: 192-200
278-280: Boiler sensor now exposes enum name — check desired state formatReturning
self._coordinator.device_status.nameexposes values likeCOOLING_DOWNinstead of the enum’s raw value. That’s consistent with howSatFlameSensoris intended to work and with the newBoilerStatusenum, but it’s a format change for consumers. If any dashboards/automations relied on the old string values, consider documenting the new format.custom_components/sat/const.py (3)
2-5: Module‑level future annotations and Enum import are appropriateEnabling future annotations and importing
Enumhere is appropriate given the new string‑based enums in this module; no issues.
173-214: New enums are coherent with the rest of the model
FlameStatus,BoilerStatus, andPWMStatusasstr, Enumsubclasses line up well with how the rest of the code compares status values (string‑compatible, but type‑safe). The member set looks comprehensive for the current boiler/flame state machine and PWM controller.
22-29: Migration to*_RELATIVE_MODULATIONnames verified as completeAll callers have been successfully migrated to the new constant names. No remaining references to the old
MINIMUM_RELATIVE_MODorMAXIMUM_RELATIVE_MODconstants were found in the codebase.custom_components/sat/boiler.py (2)
1-8:BoilerStatedataclass looks well‑structured and aligns with coordinator usageThe frozen, slotted
BoilerStatewithBoilerStatusand the various temperatures/modulation fields matches the coordinator’sboiler()factory and provides a clean, immutable surface for consumers. No functional concerns here.Also applies to: 15-31
43-66: The call site is already correctly updatedVerification found only one call site at
custom_components/sat/coordinator.py:450, which already passes theflame_activeargument correctly using keyword arguments. All four required parameters (boiler_temperature,boiler_temperature_derivative,flame_active,setpoint) are present and properly named.custom_components/sat/pwm.py (7)
20-37: CycleConfig/PWMState dataclasses are a good structural improvementEncapsulating cycle configuration and PWM state into frozen, slotted dataclasses makes the API clearer and reduces incidental coupling to internal attributes. This is a solid refactor.
75-100: Reset/disable semantics look consistent with new state fields
reset()now initializes_current_cycle,_status, and timing fields, anddisable()callsreset()plusself._setpoint_adjuster.reset(). That keeps PWM state and the adjuster in sync when PWM is turned off.
147-155: Overshoot stabilization guard looks correctThe overshoot handling branch that disables PWM when
boiler.status == BoilerStatus.COOLING_DOWNand the adjusted setpoint is below the requested setpoint matches the intent of “stabilization detected, stop PWM”. The interplay betweenrequested_setpoint,self._setpoint_adjuster.current, andself._setpointis consistent with the adjustment paths above.
157-176: Duty‑cycle state transitions are coherentThe ON/OFF transitions based on
HEATER_STARTUP_TIMEFRAME, elapsed time, and_cycles.maximum_countform a consistent state machine:
- ON only starts when
on_time >= HEATER_STARTUP_TIMEFRAME, we’ve waited at leastoff_time(or we’re IDLE), and we haven’t exceededmaximum_count.- OFF triggers when
on_timeis too short or we’ve been ON for at leaston_time.This matches typical PWM behavior with a startup window and per‑hour cycle limits.
177-241: Duty‑cycle calculation uses CycleConfig correctly
_calculate_duty_cyclenow usesCycleConfig.maximum_timeand_current_cyclein the logging, and clamps the duty‑cycle percentage to [0,1] before mapping to on/off times. Special low‑duty handling for flame active vs inactive avoids divide‑by‑zero and matches the heating strategy; the logging that includesself._current_cycleis helpful for diagnostics.
263-277:status/stateaccessors provide a clean public surfaceThe
statusproperty and the newstateproperty that returns aPWMStatesnapshot (enabled,status,duty_cycle) give external components a tidy read‑only view of controller state without exposing internals. This is a good API shape.
101-155: No issues found; logic is sound and guarded appropriately.Verification confirms both points:
HeatingCurve.value guard – Correct. The formula computes
base_offset + adjustment, and with base_offset ≥ 20, the value cannot legitimately be 0. The falsy check appropriately handles bothNone(uninitialized) and invalid states.PWM update without
_enabledcheck – Not an issue. The only call topwm.update()(climate.py:966) is already guarded byif self.pulse_width_modulation_enabled:, with reset called on the False branch. PWM correctly uses_status(ON/OFF/IDLE) for operational state transitions withinupdate(), while_enabledis reserved for higher-level enable/disable control. The design is sound.custom_components/sat/flame.py (4)
71-97: LGTM: Initialization logic is well-structured.The initialization properly validates
smoothing_alphaand sets up all necessary tracking structures (timing internals, rolling windows, health status). The numerous instance attributes are justified given the complex state tracking requirements.
99-156: LGTM: Properties are well-implemented.The properties correctly expose flame state with appropriate null checks and conditional logic. The
average_on_time_secondsproperty properly guards against returning values before the first cycle completes.
158-264: LGTM: State transition logic is comprehensive.The
updatemethod correctly handles all four state transitions (OFF→ON, ON→ON, ON→OFF, OFF→OFF) with appropriate timing accumulation, window updates, and health recomputation. The exponential moving average calculation foraverage_on_time_secondsis properly implemented.
357-389: LGTM: Helper methods are correctly implemented.The window pruning methods, duty ratio calculation, cycles-per-hour computation, median calculation, and internal flame state check are all properly implemented with appropriate bounds checking and fallbacks.
custom_components/sat/coordinator.py (8)
62-93: LGTM: Factory method updated for config_data parameter.The resolve method correctly passes
config_datato all coordinator constructors, aligning with the new initialization signature.
96-120: LGTM: Initialization correctly sets up config_data and flame tracking.The constructor properly stores
config_dataand sets up the flame update listener to keep flame state synchronized with boiler state changes.
133-186: LGTM: BoilerStatus logic is comprehensive and well-structured.The
device_statusproperty correctly classifies boiler state across multiple scenarios (hot water, idle, pump starting, preheating, heating phases, overshoot handling, anti-cycling) with appropriate null checks and deadband logic.
188-219: LGTM: Boiler and flame state properties provide clean immutable snapshots.The
boilerandflameproperties correctly construct immutable state objects (BoilerStateandFlameState) from internal coordinator state, providing a clean public API for consumers.
386-396: LGTM: Manufacturer-specific relative modulation support is clearly defined.The
supports_relative_modulationproperty correctly identifies manufacturers that don't support relative modulation (Ideal, Intergas, Geminox, Nefit) with a clean fallback to True for others.
428-475: LGTM: Heating loop correctly integrates flame tracking and temperature management.The
async_control_heating_loopmethod properly updates flame state, tracks device state, manages temperature history, and updates cold temperature with appropriate fallback logic.Note: The
climateparameter is currently unused (static analysis ARG002), which may be intentional for future use or subclass overrides.
504-529: LGTM: Debounced listener notification is well-implemented.The
async_set_updated_dataandasync_notify_listenersmethods correctly implement a 5-second debounced notification system that batches updates and prevents excessive listener calls.
531-542: LGTM: Boiler cold temperature calculation is correct.The
_get_latest_boiler_cold_temperaturemethod correctly identifies the maximum temperature from readings taken before both the device and flame activation, providing an accurate baseline for cold temperature tracking.custom_components/sat/climate.py (6)
1-49: LGTM: Imports and initialization properly support new scheduling and error handling.The added imports (
Callable,notify,EventStateChangedData,HassJob,async_call_later,Errors,Error, etc.) and initialization of controllers align with the refactored scheduling and error handling infrastructure.Also applies to: 83-199
379-428: LGTM: Properties correctly expose new error and state structures.The
extra_state_attributes,errors,max_error, andminimum_setpoint_valueproperties properly utilize the newError/Errorstypes and controller-specific state accessors.Also applies to: 498-602
611-764: LGTM: Event handlers correctly use EventStateChangedData and scheduling.All event handlers properly use the
Event[EventStateChangedData]type and correctly schedule heating loop updates throughschedule_control_heating_loop()instead of directly awaiting, aligning with the new debounced scheduling pattern.
813-846: LGTM: Setpoint control logic correctly handles PWM states.The
_async_control_setpointmethod properly handles different heating modes (normal cycle, PWM ON, PWM OFF/IDLE) and correctly applies both minimum and maximum setpoint constraints.
848-869: LGTM: Relative modulation control includes manufacturer-specific handling.The method correctly updates relative modulation state and applies Geminox-specific filtering (minimum 10%) before sending commands to the coordinator.
934-1137: LGTM: Control loop and async methods correctly integrate new patterns.The
async_control_heating_loopand related async methods (async_set_temperature,async_set_hvac_mode,async_set_preset_mode,async_set_target_temperature) correctly integrate with the new coordinator flame/boiler state, PWM updates, and scheduling patterns. Theasync_send_notificationmethod properly usesnotify.DOMAIN.Note: The missing
awaiton_async_control_pidcalls was already flagged in a separate comment.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
custom_components/sat/climate.py (1)
916-933: Regression:force=Truedoes not always execute the heating loop immediatelyThe
schedule_control_heating_loopimplementation still has the mis-indentation that was flagged earlier:if force: 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()) returnWhen
force=Trueand_control_heating_loop_unsub isNone`** (no pending run), the method falls through and just schedules a run 10 seconds later instead of executing immediately. That contradicts the docstring (“Force immediate execution”) and affects callers like:
async_set_target_temperature(..., cascade=True)(line 1132),- Tests that call
schedule_control_heating_loop(force=True).You want the immediate execution regardless of whether a previous run was scheduled; only the cancellation should be conditional.
Suggested fix:
def schedule_control_heating_loop(self, _time: Optional[datetime] = None, force: bool = False, ) -> None: """Schedule a debounced execution of the heating control loop.""" # Force immediate execution if force: # Cancel previous scheduled run, if any if self._control_heating_loop_unsub is not None: self._control_heating_loop_unsub() self._control_heating_loop_unsub = None - - self.hass.async_create_task(self.async_control_heating_loop()) - return + self.hass.async_create_task(self.async_control_heating_loop()) + returnThe rest of the debounced path (early return if
_control_heating_loop_unsubis notNone, thenasync_call_later) can remain as-is.
🧹 Nitpick comments (6)
custom_components/sat/climate.py (6)
38-40: Star import from.constkeeps Ruff warnings and obscures dependencies
from .const import *combined with specific imports likePWMStatusmakes it hard to see which constants are actually used and triggers Ruff F403/F405. Consider replacing the star import with explicit symbols (MINIMUM_SETPOINT,DEADBAND,COLD_SETPOINT,BoilerStatus, etc.) referenced in this file.This is style/tooling rather than correctness, but it will quiet the lints and clarify dependencies.
86-199: Init wiring for presets/controllers looks coherent; minor cleanup possibleThe initialization flow (config->
config_options->conf_presets/_presets, and creation ofpid,areas,relative_modulation,heating_curve,minimum_setpoint,pwm) is consistent and ordered correctly; controllers are constructed before first use in restore and event handlers, which is good.One small nit: in
conf_presets = {p: f"{p}_temperature" for p in (...)} self._presets = {key: config_options[value] for key, value in conf_presets.items() if key in conf_presets}the
if key in conf_presetsclause is redundant because you iterate overconf_presets.items().You can safely drop that condition to simplify the comprehension.
305-359: State restore logic is correct but quite branchy
_restore_previous_state_or_set_defaultscorrectly restores PID, PWM, heating curve autotune, preset-related attributes, and_rooms, with sensible fallbacks when no old state exists.However, the method is now quite branch-heavy (Pylint R0912), which makes future changes risky. Consider extracting small helpers like:
_restore_pid_and_pwm(old_state)_restore_temps_and_presets(old_state)_restore_rooms_or_discover(old_state)to reduce complexity and improve testability.
Functionally this looks fine; the suggestion is purely for maintainability.
457-471: Outside temperature handling is reasonable; be aware of non-numeric statesThe
current_outside_temperatureimplementation now distinguishessensorvsweatherdomains and prefers sensors, which makes sense.One thing to keep in mind:
float(state.state)orfloat(state.attributes.get("temperature"))will raise if the upstream entity exposes non-numeric orNonevalues. If that is possible in your deployment, you may want an extra guard ortry/exceptaround the conversions.Not a blocker, just a robustness consideration.
579-588: Relative modulation control: behavior OK, possible clarification of “disabled” valueThe relative modulation pipeline:
await self.relative_modulation.update(self.pulse_width_modulation_enabled),- Use of
relative_modulation_valueplus Geminox lower bound,- No-op if
maximum_relative_modulation_valueis unchanged,is coherent.
One nuance:
relative_modulation_valuecurrently returns_maximum_relative_modulationwheneverself.relative_modulation.enabledisTrueor relative-modulation management is unsupported; the “disabled” case only yieldsMINIMUM_RELATIVE_MODULATIONwhen management is supported andenabledisFalse. That’s fine if intentional, but if you want the attribute to reflect “not applicable” when the device doesn’t support management, you might instead emit a sentinel (e.g.0orNone) for the unsupported case.No functional bug, just a semantics/UX detail.
Also applies to: 848-870
813-847: Setpoint control logic with PWM looks correct; minor safeguard possibleThe new
_async_control_setpoint:
- Forces
_setpoint = MINIMUM_SETPOINTand clears_calculated_setpointwhen HVAC is notHEAT,- Uses
_calculated_setpointfor the non-PWM/IDLE path with a “too cold” guard usingCOLD_SETPOINT,- Switches between
minimum_setpoint_valueand absoluteMINIMUM_SETPOINTbased onPWMStatus.Both
_setpointand_calculated_setpointare guaranteed to be set before use by the surroundingasync_control_heating_loop, so this is functionally safe.If you want extra defensive coding, you could assert or early-return if
_calculated_setpointisNonein the non-PWM branch, but given the current flow that would only fire in programming errors.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
custom_components/sat/climate.py(34 hunks)
🧰 Additional context used
🪛 Pylint (4.0.4)
custom_components/sat/climate.py
[refactor] 305-305: Too many branches (16/12)
(R0912)
🪛 Ruff (0.14.7)
custom_components/sat/climate.py
38-38: from .const import * used; unable to detect undefined names
(F403)
500-500: HEATING_MODE_ECO may be undefined, or defined from star imports
(F405)
581-581: MINIMUM_RELATIVE_MODULATION may be undefined, or defined from star imports
(F405)
607-607: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
790-790: DEADBAND may be undefined, or defined from star imports
(F405)
790-790: DEADBAND may be undefined, or defined from star imports
(F405)
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)
830-830: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
831-831: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
832-832: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
841-841: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
842-842: MINIMUM_SETPOINT may be undefined, or defined from star imports
(F405)
958-958: BoilerStatus may be undefined, or defined from star imports
(F405)
984-984: BoilerStatus may be undefined, or defined from star imports
(F405)
991-991: COLD_SETPOINT may be undefined, or defined from star imports
(F405)
🔇 Additional comments (14)
custom_components/sat/climate.py (14)
203-245: Scheduling-based triggering is a good changeSwitching
async_track_coordinator_dataand the time-interval listener to callschedule_control_heating_loop()instead of invokingasync_control_heating_loopdirectly provides natural debouncing and centralizes scheduling via_control_heating_loop_unsub. The callback signature also matchesasync_track_time_interval’s expectations.No issues here.
300-304: Room tracking via climates and restore path are consistentThe combination of:
- Initial population via
_async_update_rooms_from_climates(lines 871–889),- Restore from old state, and
- On-the-fly updates in
_async_climate_changedwhen_roomsis notNone(lines 715–717)ensures
_roomsis always initialized before use and stays in sync with the climates. This also addresses the earlier KeyError/None issues.Looks solid.
Also applies to: 871-889
382-428: Error exposure viaErrors/Errorand extra attributes is well-structuredUsing
errors: Errorsandmax_error: Errorand then exposing:
"error"/"error_pid"/"error_source",- PID internals,
- PWM and boiler status,
in
extra_state_attributesis a clean way to surface diagnostics. Theerrorsimplementation always includes at least this climate’s error, somax(self.errors, ...)is safe and avoids empty-iterable issues.No functional problems spotted here.
Also applies to: 498-507
527-559: Valve-open heuristic forvalves_openis sensibleThe simplified “no radiators → assume valves open” path and the subsequent checks against HVAC action/temperature deltas across
self._radiators + self.areas.items()give a pragmatic, fault-tolerant signal forvalves_open.Logic is consistent and aligns with the heating control decisions later (e.g. in
async_set_heater_state).
562-603: PWM enable and minimum setpoint computation are consistent with config flags
pulse_width_modulation_enabledandminimum_setpoint_valuecorrectly layer:
- Coordinator capabilities (
supports_setpoint_management,minimum_setpoint),- Feature flags (
_force_pulse_width_modulation,_dynamic_minimum_setpoint),- Versioned behavior (v1 using
minimum_setpoint.current, v2 usingpwm.setpoint).This matches the later use in
_async_control_setpointand the minimum-setpoint controller integration. I don’t see obvious logical gaps here.Looks good.
611-721: Event handlers now correctly debounce through PID + schedulerThe updated handlers for:
- Thermostat, inside temp, outside entity, humidity,
- Main climate and per-room climates, and
- Raw sensor temperature,
all:
- Filter out
None/unknown/unavailable states,- Update local cached values,
- Call
_async_control_pid()(optionally with reset) appropriately, and- Use
schedule_control_heating_loop()instead of driving the loop directly.This centralizes heavy work in the scheduled control loop and should significantly reduce thrash.
Implementation looks correct.
Also applies to: 722-731
766-811: PID control path and logging aroundmax_errorare sound
_async_control_pidnow:
- Resets PID/area PIDs if sensor data is stale,
- Uses
max_error = self.max_errorbased on the newErrors/Errormodel,- Autotunes only when within
±DEADBAND,- Updates area PIDs and main PID only when boiler temperature data is available, and
- Distinguishes normal vs reset updates with clear logging.
The
elif max_error.value != self.pid.last_error:gate for the reset path is a good guard against redundant resets.No issues here.
848-870: Relative modulation update correctly avoids redundant coordinator callsThe logic that:
- Updates the
RelativeModulationcontroller with the current PWM-enabled state,- Applies Geminox-specific minimum clamping, and
- Skips the coordinator call when the target value hasn’t changed,
is efficient and avoids unnecessary bus traffic.
No changes needed here.
890-915: Resetting control state and sensor tracking look good
reset_control_statenow properly disables PWM and clears_calculated_setpoint, ensuring the next loop recomputes from fresh conditions.async_track_sensor_temperaturesensibly deduplicates sensors and usesasync_track_state_change_eventwith_async_temperature_changeas the callback.This is a clean separation of concerns.
936-993: Main heating loop integrates PWM, overshoot handling, and dynamic minimum setpoint correctly
async_control_heating_loopnow:
- Clears
_control_heating_loop_unsubat the start,- Validates availability of current/target/outside temperatures and HVAC mode,
- Delegates to the coordinator with the current
pwm.state,- Smooths
_calculated_setpointwith a low-pass filter,- Enables PWM when
BoilerStatus.OVERSHOOT_HANDLINGis detected,- Keeps PWM state in sync with
pulse_width_modulation_enabledand updatespwmwithflame/boilerstate,- Uses
minimum_setpoint.warming_upandminimum_setpoint.calculateonly when appropriate (heating up, flame active, no hot water),- Drives heater state via
async_set_heater_statebased on_setpointandCOLD_SETPOINT.The flow among coordinator, PWM controller, and minimum setpoint controller is coherent.
No functional issues spotted here.
995-1014: Heater on/off guardrails are appropriate
async_set_heater_statecorrectly:
- Avoids redundant on/off calls based on
device_active,- Prevents turning on the heater when
valves_openisFalse, and- Delegates the actual state change to the coordinator.
This is a good set of safety checks around heater control.
1015-1027: Set temperature/mode APIs now reset control and re-schedule the loop correctlyThe changes to:
async_set_temperature(short-circuit on missing temperature, preset auto-selection, delegating toasync_set_target_temperature),async_set_hvac_mode(PID reset +reset_control_state+ scheduling), andasync_set_target_temperature(PID reset,reset_control_state, andschedule_control_heating_loop(force=True)),ensure that major changes (setpoint or mode) both reset internal control state and trigger an immediate or prompt heating loop run.
This is the right coupling between user-facing services and internal control logic.
Also applies to: 1042-1046, 1123-1133
1096-1100: HOME preset per-room handling with_rooms.getis robustThe updated HOME preset logic:
if preset_mode != PRESET_HOME: target_temperature = self._presets[preset_mode] else: target_temperature = self._rooms.get(entity_id, self._presets[preset_mode])nicely avoids
KeyErrorwhen a room is missing from_roomswhile still providing a sensible fallback.This aligns with earlier defensive changes around
_rooms. No issues.
1134-1137: Notification helper is straightforward and useful
async_send_notificationis a thin, typed wrapper around the notify service with a defaultpersistent_notificationservice. It keeps notification calls inside the entity concise.Looks good as-is.
✅ Actions performedComments resolved. |
✅ Actions performedSummary regeneration triggered. |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
custom_components/sat/area.py (2)
96-103: Fix type mismatch in PID update call.The
errorproperty now returnsError | None(an Error object), but line 102 passesself.errordirectly topid.update_integral(), which likely expects afloat. This will raise aTypeErrorat runtime.🔎 Proposed fix
async def async_control_heating_loop(self, _time=None) -> None: """Asynchronously control the heating loop.""" - if self.error is None or self.heating_curve.value is None: + error = self.error + if error is None or self.heating_curve.value is None: return # Control the integral (if exceeded the time limit) - self.pid.update_integral(self.error, self.heating_curve.value) + self.pid.update_integral(error.value, self.heating_curve.value)
154-158: Fix type mismatch in PID update call.Line 156 passes
area.error(an Error object) topid.update(), which likely expects afloatas its first argument. This will raise aTypeErrorat runtime.🔎 Proposed fix
def update(self, boiler_temperature: float) -> None: for area in self.areas: if area.error is not None: - area.pid.update(area.error, area.heating_curve.value, boiler_temperature) + area.pid.update(area.error.value, area.heating_curve.value, boiler_temperature)custom_components/sat/esphome/__init__.py (1)
1-1: Remove duplicateannotationsimport.There's a duplicate import:
from __future__ import annotations, annotations. This should be:-from __future__ import annotations, annotations +from __future__ import annotations
♻️ Duplicate comments (12)
.github/workflows/hacs.yml (1)
5-6: LGTM! Branch filter appropriately limits master pushes.The addition of a branch filter to the push trigger is appropriate. The workflow still validates on pull requests (line 7) and runs on a daily schedule (lines 8-9), ensuring HACS compliance checks occur before merge.
.github/workflows/hassfest.yml (1)
5-6: Restorepull_requesttrigger for pre-merge validation.The hassfest workflow only runs on pushes to master, missing pre-merge validation. This creates inconsistency with your pytest workflow (which validates both pushes and pull requests) and risks merging invalid manifest changes.
For an integration with major API changes (as indicated by the PR summary), validating the manifest before merge is essential.
🔎 Proposed fix
on: push: branches: - master + pull_request:README.md (1)
263-263: Fix emphasis style inconsistency flagged by linter.The emphasis on "Automatic Gains Value" uses asterisks but markdownlint expects underscores per the repo's style rules.
🔎 Proposed fix
-*Automatic Gains Value*: Automatically tweaking the aggressiveness of the Kp, Ki and Kd gains when Classic or Improved Controller is in use. +_Automatic Gains Value_: Automatically tweaking the aggressiveness of the Kp, Ki and Kd gains when Classic or Improved Controller is in use.tests/test_climate.py (1)
51-51: Potential race condition: heating loop not awaited.The
schedule_control_heating_loop(force=True)creates an async task but returns immediately. The subsequent assertions may execute before the heating loop completes, potentially causing flaky tests.🔎 Proposed fix
await climate.async_set_hvac_mode(HVACMode.HEAT) climate.schedule_control_heating_loop(force=True) +await hass.async_block_till_done() assert climate.setpoint == 57Apply this pattern to all three test functions.
Also applies to lines 97 and 144.
custom_components/sat/simulator/__init__.py (1)
47-48: Correct the return type annotation.
supports_relative_modulationreturnsTrue(a boolean), but the signature advertisesfloat | None. Update the annotation to-> boolto match the actual return type.🔎 Proposed fix
- def supports_relative_modulation(self) -> float | None: + def supports_relative_modulation(self) -> bool: return Truecustom_components/sat/pwm.py (1)
135-146: Magic number thresholds should be extracted to constants.This was flagged in a previous review. The hardcoded values (6, 60, -3, +10) for flame timing and setpoint adjustments should be defined as named constants in
const.pyfor maintainability and clarity.custom_components/sat/esphome/__init__.py (1)
77-88: Critical:device_active,flame_active, andhot_water_activestill compare againstDeviceState.ONinstead of the string"on".This issue was flagged in a previous review and remains unresolved. The
SatEntityCoordinator.getmethod returns Home Assistant entity state as a string (e.g.,"on"/"off"), not aDeviceStateenum. These comparisons will always returnFalse.🔎 Proposed fix
@property def device_active(self) -> bool: - return self.get(switch.DOMAIN, DATA_CENTRAL_HEATING) == DeviceState.ON + return self.get(switch.DOMAIN, DATA_CENTRAL_HEATING) == "on" @property def flame_active(self) -> bool: - return self.get(binary_sensor.DOMAIN, DATA_FLAME_ACTIVE) == DeviceState.ON + return self.get(binary_sensor.DOMAIN, DATA_FLAME_ACTIVE) == "on" @property def hot_water_active(self) -> bool: - return self.get(binary_sensor.DOMAIN, DATA_DHW_ENABLE) == DeviceState.ON + return self.get(binary_sensor.DOMAIN, DATA_DHW_ENABLE) == "on"custom_components/sat/coordinator.py (2)
35-59: Critical: Class-level_is_dirtyattribute is shared across all instances.This was flagged in a previous review. Line 36 defines
_is_dirtyas a class attribute, causing dirty tracking to bleed between differentSatDatainstances.🔎 Proposed fix
class SatData(dict): - _is_dirty: bool = False + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._is_dirty = False def __setitem__(self, key, value):
357-361: Guardminimum_setpointagainst missing config data.This was flagged in a previous review.
self._config_data.get(CONF_MINIMUM_SETPOINT)can returnNonefor legacy configs, andfloat(None)will raiseTypeError.🔎 Proposed fix
@property def minimum_setpoint(self) -> float: """Return the minimum setpoint temperature before the device starts to overshoot.""" - return float(self._config_data.get(CONF_MINIMUM_SETPOINT)) + if (configured := self._config_data.get(CONF_MINIMUM_SETPOINT)) is None: + return MINIMUM_SETPOINT + return float(configured)custom_components/sat/flame.py (1)
324-335: Guard againstNoneduty_cycle whenPWMStatusisON.This was flagged in a previous review. Line 330 accesses
self._last_pulse_width_modulation_state.duty_cycle[0]assuming it's notNone, butduty_cycleis typed asOptional[Tuple[int, int]].🔎 Proposed fix
if self._last_pulse_width_modulation_state is not None and self._last_pulse_width_modulation_state.status == PWMStatus.ON: if cycles_per_hour > self.MAX_CYCLES_PER_HOUR_PWM: self._health_status = FlameStatus.SHORT_CYCLING return - if median_on_seconds is not None and median_on_seconds < self.MEDIAN_TOLERANCE * self._last_pulse_width_modulation_state.duty_cycle[0]: + if (median_on_seconds is not None + and self._last_pulse_width_modulation_state.duty_cycle is not None + and median_on_seconds < self.MEDIAN_TOLERANCE * self._last_pulse_width_modulation_state.duty_cycle[0]): self._health_status = FlameStatus.PWM_SHORT returncustom_components/sat/serial/__init__.py (1)
35-36: Fix subscribe/unsubscribe callback mismatch.This was flagged in a previous review. You subscribe with a lambda but unsubscribe using
self.async_set_updated_data, so the subscription will never actually be removed, leaving dangling callbacks.🔎 Proposed fix
- self._api.subscribe(lambda data: self.async_set_updated_data(data)) + self._api.subscribe(self.async_set_updated_data)custom_components/sat/climate.py (1)
916-933: CRITICAL: Force execution still has incorrect indentation (unfixed bug from previous review).Lines 925-926 are incorrectly indented inside the
if self._control_heating_loop_unsub is not None:block. Whenforce=Truebut there's no pending scheduled run (_control_heating_loop_unsub is None), the method falls through to normal scheduling logic instead of executing immediately.This bug was flagged in a previous review and marked as "addressed," but the fix was not correctly applied.
🔎 Required fix
def schedule_control_heating_loop(self, _time: Optional[datetime] = None, force: bool = False, ) -> None: """Schedule a debounced execution of the heating control loop.""" # Force immediate execution if force: # Cancel previous scheduled run, if any if self._control_heating_loop_unsub is not None: self._control_heating_loop_unsub() self._control_heating_loop_unsub = None - - self.hass.async_create_task(self.async_control_heating_loop()) - return + + self.hass.async_create_task(self.async_control_heating_loop()) + return # If a run is already scheduled, do nothing. if self._control_heating_loop_unsub is not None: return self._control_heating_loop_unsub = async_call_later(self.hass, 10, HassJob(self.async_control_heating_loop))
🧹 Nitpick comments (11)
custom_components/sat/simulator/sensor.py (1)
6-6: Replace star import with explicit imports.The star import
from ..const import *obscures which symbols are being used and triggers static analysis warnings (F405) forCONF_NAMEusage on lines 28 and 68. Explicit imports improve code clarity and maintainability.🔎 Proposed fix
-from ..const import * +from ..const import DOMAIN, COORDINATOR, CONF_NAMEcustom_components/sat/pid.py (1)
19-33: Consider refactoring to reduce constructor parameter count.The constructor accepts 12 parameters, which exceeds typical style guide recommendations (5-7 parameters). While the current implementation is functional, consider refactoring to accept a configuration object or dataclass to improve maintainability.
💡 Example refactoring approach
@dataclass class PIDConfig: heating_system: str automatic_gain_value: float heating_curve_coefficient: float derivative_time_weight: float kp: float ki: float kd: float deadband: float = DEADBAND automatic_gains: bool = False integral_time_limit: float = 300 sample_time_limit: Optional[float] = 10 version: int = 3 def __init__(self, config: PIDConfig) -> None: # Initialize from config objectcustom_components/sat/services.yaml (1)
12-15: Consider adding selector type for the enabled field.While the
enabledfield is correctly marked as required with an example, consider adding an explicit selector type for better UI representation in Home Assistant. This would provide a better user experience when calling the service from the UI.🔎 Suggested enhancement
fields: enabled: required: true example: true + selector: + boolean:custom_components/sat/__init__.py (1)
17-26: Remove unused import.The
get_climate_entitiesimport on line 26 is not used in this file—it's only used inservices.py. This import should be removed to keep the imports clean.🔎 Proposed fix
from .const import ( DOMAIN, CLIMATE, SENTRY, COORDINATOR, OPTIONS_DEFAULTS, CONF_MODE, CONF_DEVICE, CONF_ERROR_MONITORING, SERVICE_RESET_INTEGRAL, SERVICE_PULSE_WIDTH_MODULATION, ) from .coordinator import SatDataUpdateCoordinatorFactory from .services import async_register_services -from .util import get_climate_entitiesNote: The
SERVICE_RESET_INTEGRALandSERVICE_PULSE_WIDTH_MODULATIONimports are used indirectly through the const module exports, so they may be intentionally kept for re-export purposes.custom_components/sat/simulator/__init__.py (1)
87-87: Consider prefixing unused parameters with underscore.The
climateandpwm_stateparameters are unused (per static analysis). While this is acceptable for interface compliance, consider using underscore prefixes (_climate,_pwm_state) to signal intentional non-use, consistent with_time.🔎 Proposed fix
- async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMStatus] = None, _time=None) -> None: + async def async_control_heating_loop(self, _climate: Optional[SatClimate] = None, _pwm_state: Optional[PWMStatus] = None, _time=None) -> None:custom_components/sat/manufacturer.py (2)
28-34: Handle missing manufacturer class name inMANUFACTURERS.If a subclass name is not in
MANUFACTURERS,_member_idbecomesNone, and themember_idproperty will returnNoneinstead of anint. This could causeTypeErrorin code expecting an integer. Consider adding validation or documenting this behavior.🔎 Proposed fix with validation
class Manufacturer(ABC): def __init__(self): - self._member_id = MANUFACTURERS.get(type(self).__name__) + class_name = type(self).__name__ + if class_name not in MANUFACTURERS: + raise ValueError(f"Manufacturer '{class_name}' not registered in MANUFACTURERS") + self._member_id = MANUFACTURERS[class_name] @property def member_id(self) -> int: return self._member_id
60-63: Consider adding error handling for dynamic imports.The dynamic import could fail with
ImportError(missing module) orAttributeError(missing class). While callers may handle this, a more descriptive error would aid debugging.🔎 Proposed improvement
@staticmethod def _import_class(module_name: str, class_name: str) -> Type[Manufacturer]: """Dynamically import and return a Manufacturer class.""" - return getattr(__import__(f"custom_components.sat.manufacturers.{module_name}", fromlist=[class_name]), class_name) + try: + module = __import__(f"custom_components.sat.manufacturers.{module_name}", fromlist=[class_name]) + return getattr(module, class_name) + except (ImportError, AttributeError) as e: + raise ImportError(f"Failed to import manufacturer '{class_name}' from module '{module_name}': {e}") from ecustom_components/sat/serial/sensor.py (1)
112-114: Prefer f-string orENTITY_ID_FORMATfor entity_id generation.The current concatenation
sensor.DOMAIN + ".{}"works but is less idiomatic than usingf"{sensor.DOMAIN}.{{}}"or importingENTITY_ID_FORMATfrom the sensor component.🔎 Proposed fix
self.entity_id = async_generate_entity_id( - sensor.DOMAIN + ".{}", f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass + f"{sensor.DOMAIN}.{{}}", f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass )custom_components/sat/boiler.py (1)
15-31: Consider removing redundantis_inactivefield.The
is_inactivefield is always the logical negation ofis_active. Having both fields in an immutable dataclass is redundant and could lead to inconsistency if constructed incorrectly. Consider using onlyis_activeand derivingis_inactivevia a property.However, if this structure is intentionally mirroring an external API or used for serialization where both fields are expected, this is acceptable.
🔎 Alternative approach with derived property
@dataclass(frozen=True, slots=True, kw_only=True) class BoilerState: """Represents the operational state of a boiler.""" is_active: bool status: BoilerStatus # ... other fields ... @property def is_inactive(self) -> bool: return not self.is_activeNote: This requires removing
slots=Trueor using__slots__manually to include the property.custom_components/sat/mqtt/ems.py (1)
146-150: Uselogging.exceptionfor better error context.When logging an exception in an
exceptblock,_LOGGER.exception()automatically includes the full traceback, which is more helpful for debugging.🔎 Proposed fix
def _process_message_payload(self, key: str, value: str): try: self.async_set_updated_data(json.loads(value)) except json.JSONDecodeError as error: - _LOGGER.error("Failed to decode JSON payload: %s. Error: %s", value, error) + _LOGGER.exception("Failed to decode JSON payload: %s", value)custom_components/sat/climate.py (1)
505-507: Consider usingself.errors.max()for consistency.The
Errorsclass provides a.max()method that's purpose-built for this use case. While the current implementation using Python'smax()works, using the dedicated method would be more consistent with the Error/Errors abstraction.🔎 Proposed refactor
@property def max_error(self) -> Error: - return max(self.errors, key=lambda error: error.value) + # errors always contains at least one item, so max() is safe + result = self.errors.max() + return result if result is not None else Error(self.entity_id, self.error)
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 14
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
custom_components/sat/fake/__init__.py (1)
1-1: Fix duplicateannotationsimport.The
__future__import statement incorrectly importsannotationstwice, which is redundant and could cause issues.🔎 Proposed fix
-from __future__ import annotations, annotations +from __future__ import annotationscustom_components/sat/esphome/__init__.py (1)
1-1: Remove duplicateannotationsimport.
from __future__ import annotations, annotationshas a redundant duplicate.🔎 Proposed fix
-from __future__ import annotations, annotations +from __future__ import annotationscustom_components/sat/serial/__init__.py (1)
166-174: Fix the subscription callback mismatch in cleanup.The unsubscribe at line 170 cannot remove the callback registered at line 35 because they're different objects: a lambda wrapper is registered but a method reference is used for unsubscribe. Store the lambda in an instance variable during
__init__and pass that same reference tounsubscribe().Without this fix, the subscription callback persists after cleanup, causing potential memory leaks or stale callbacks firing after disconnect.
🤖 Fix all issues with AI Agents
In @custom_components/sat/__init__.py:
- Around line 62-63: async_register_services is being called from
async_setup_entry for each config entry which can cause duplicate registrations;
make service registration occur only once by moving the call to async_setup
(platform-level setup) or guarding it with a module-level flag. Update
async_setup_entry to remove or skip calling async_register_services and
implement a module-level boolean (e.g., SERVICES_REGISTERED) or change
async_register_services to first check hass.services.has_service for the
domain/service names before registering; reference async_setup_entry,
async_register_services, and async_setup when making the change.
In @custom_components/sat/climate.py:
- Around line 916-932: The force branch in schedule_control_heating_loop
incorrectly nests the immediate execution lines inside the check for
self._control_heating_loop_unsub, so when force=True and no existing
_control_heating_loop_unsub the method falls through to scheduling instead of
running immediately; fix by unindenting the
self.hass.async_create_task(self.async_control_heating_loop()) and its
accompanying return so they execute whenever force is True (while still
cancelling and clearing _control_heating_loop_unsub if it exists), i.e., in
schedule_control_heating_loop ensure cancellation occurs only if
_control_heating_loop_unsub is not None but always call
hass.async_create_task(self.async_control_heating_loop()) and return when force
is True.
In @custom_components/sat/coordinator.py:
- Around line 357-360: The minimum_setpoint property currently does
float(self._config_data.get(CONF_MINIMUM_SETPOINT)) which will raise TypeError
if the key is missing; update minimum_setpoint to first read val =
self._config_data.get(CONF_MINIMUM_SETPOINT), check if val is None, and return a
safe default (or raise a clear error) instead of calling float(None); reference
the CONF_MINIMUM_SETPOINT key and _config_data dict and ensure the property
always returns a float (e.g., float(val) when val is not None, otherwise return
a configured DEFAULT_MINIMUM_SETPOINT or 0.0).
- Around line 35-59: The _is_dirty boolean is declared at class level on SatData
so it is shared across all instances; make it an instance attribute by adding an
__init__(...) that calls dict.__init__(...) and sets self._is_dirty = False,
then keep the existing logic in __setitem__, update, __delitem__, reset_dirty
and is_dirty to operate on self._is_dirty; ensure update still iterates
other.items() and uses super().__setitem__ as before so per-instance dirty
tracking works correctly.
In @custom_components/sat/flame.py:
- Around line 324-335: The code assumes
self._last_pulse_width_modulation_state.duty_cycle is non-None when checking
PWMStatus.ON; add a defensive check that duty_cycle is not None (and has at
least one element) before indexing duty_cycle[0]; if duty_cycle is None or
empty, treat it as an invalid PWM reading and set self._health_status =
FlameStatus.PWM_SHORT (or another appropriate status) and return; update the
block handling PWMStatus.ON (referencing
self._last_pulse_width_modulation_state, PWMStatus.ON, duty_cycle,
median_on_seconds, MEDIAN_TOLERANCE, and FlameStatus.PWM_SHORT) to perform this
None/length guard before using duty_cycle[0].
In @custom_components/sat/minimum_setpoint.py:
- Around line 37-42: The warming_up method uses boiler_state.return_temperature
(Optional[float]) directly in update_state and logging; add a guard to check if
boiler_state.return_temperature is None and return early (or skip update) to
avoid passing None into update_state. Specifically, in warming_up, ensure you
only call update_state and _LOGGER.debug when boiler_state.return_temperature is
not None, and keep the existing short-circuit that compares
_base_return_temperature.value to the new float value.
In @custom_components/sat/pwm.py:
- Around line 63-70: The _setpoint_offset is declared as int but assigned a
float (0.5) and logged with an integer format specifier; change the annotation
of _setpoint_offset to float, ensure both branches assign floats (use 1.0
instead of 1 when supports_relative_modulation_management is False), and update
the _LOGGER.debug format specifier from %d to a float specifier like %.1f
(referencing _setpoint_offset, supports_relative_modulation_management, and the
_LOGGER.debug call).
In @custom_components/sat/serial/__init__.py:
- Around line 28-35: The subscription uses an anonymous lambda in __init__
(self._api.subscribe(lambda data: self.async_set_updated_data(data))) but
unsubscribe later calls self._api.unsubscribe(self.async_set_updated_data),
causing a mismatch and leaving a dangling callback; fix by creating and storing
a single callable instance (e.g., assign self._update_callback = lambda data:
self.async_set_updated_data(data) or just use self.async_set_updated_data
directly) and use that same symbol with OpenThermGateway.subscribe and
OpenThermGateway.unsubscribe so the same function object is registered and
removed.
In @custom_components/sat/setpoint_adjuster.py:
- Around line 24-28: The docstring for force() claims it forces the setpoint "to
at least the target value" but the method currently unconditionally assigns
target_setpoint to self._current; change the implementation of
force(target_setpoint: float) to enforce the "at least" semantics by setting
self._current = max(self._current, target_setpoint) (and keep the docstring), or
if you prefer to keep the unconditional assignment, update the docstring to say
it unconditionally sets the setpoint to target_setpoint; locate the change in
the force method and adjust self._current and the docstring accordingly.
In @hacs.json:
- Line 4: The hacs.json minimum Home Assistant version is set too high; update
the "homeassistant" value from "2025.2.0" down to "2024.5.0" (or "2024.6.0" if
you prefer) so users on HA 2024.5+ can install the integration, and verify the
code using EventStateChangedData and async_track_state_change_event (in
climate.py, sensor.py, and esphome/__init__.py) is compatible with that API
introduced in 2024.5 before committing the change.
In @README.md:
- Line 263: Replace the asterisk-based emphasis around the heading text
"*Automatic Gains Value*" with underscore-based emphasis "_Automatic Gains
Value_" to match the repository's markdown style rules; update the string
exactly where it appears so the line reads with underscores instead of
asterisks.
In @tests/test_climate.py:
- Line 144: The test calls climate.schedule_control_heating_loop(force=True)
which spawns an async task via hass.async_create_task() and returns immediately,
causing subsequent assertions to race; fix by awaiting task completion—after the
schedule_control_heating_loop(force=True) call add await
hass.async_block_till_done() so the heating loop task finishes before the
assertions run.
- Line 51: The test is creating the async heating task via
climate.schedule_control_heating_loop(force=True) (which uses
hass.async_create_task()) and immediately running assertions, causing a race;
after each call to schedule_control_heating_loop(force=True) in the tests, await
hass.async_block_till_done() to let the background heating loop finish before
making assertions (apply this to all three occurrences of
schedule_control_heating_loop(force=True) in tests/test_climate.py).
- Line 97: schedule_control_heating_loop(force=True) spawns an async task via
hass.async_create_task() and returns immediately, so add an await
hass.async_block_till_done() right after the
schedule_control_heating_loop(force=True) call to ensure the async heating loop
task completes before running the assertions that follow; reference
schedule_control_heating_loop, hass.async_create_task, and
hass.async_block_till_done in the change.
🧹 Nitpick comments (14)
custom_components/sat/services.yaml (2)
7-15: Consider adding type specification for the enabled field.The
enabledfield is correctly marked as required with an example, but it's missing an explicit type selector. While Home Assistant may infer this from the example, explicitly specifying the field type improves clarity and validation.🔎 Suggested enhancement
pulse_width_modulation: target: entity: domain: climate integration: "sat" fields: enabled: required: true + selector: + boolean: example: true
1-5: Service implementation is correct, but add migration guidance.The new
reset_integralservice is properly registered in bothservices.pyandclimate.pywith appropriate schema validation. However, if this service replaces a user-facing API from a previous version, migration or deprecation guidance should be provided to users (e.g., in CHANGELOG or documentation) to help them update their automations. No such guidance was found in the repository.custom_components/sat/fake/__init__.py (1)
92-94: Consider whether the aliasing is intentional.The
supports_relative_modulationproperty simply forwards tosupports_relative_modulation_management. In the main coordinator (per the relevant snippets), these properties have different semantics:
supports_relative_modulation: whether the device supports reading the modulation valuesupports_relative_modulation_management: whether the device supports setting the modulation valueFor a fake/test coordinator, aliasing them might be acceptable, but verify this matches the intended test behavior.
custom_components/sat/services.py (3)
24-29: Consider usingcv.entity_idsfor better validation.While
list[str]works, Home Assistant providescv.entity_idsfor entity ID validation, which offers better error messages and validation logic for users.🔎 Proposed refactor
hass.services.async_register( DOMAIN, service=SERVICE_RESET_INTEGRAL, service_func=reset_integral, - schema=vol.Schema({vol.Required("entity_id"): list[str]}) + schema=vol.Schema({vol.Required("entity_id"): cv.entity_ids}) )
44-49: Consider usingcv.entity_idsfor consistency.Same recommendation as the
reset_integralservice—usecv.entity_idsfor better validation.🔎 Proposed refactor
hass.services.async_register( DOMAIN, SERVICE_PULSE_WIDTH_MODULATION, service_func=pulse_width_modulation, - schema=vol.Schema({vol.Required("entity_id"): list[str], vol.Required("enabled"): cv.boolean}) + schema=vol.Schema({vol.Required("entity_id"): cv.entity_ids, vol.Required("enabled"): cv.boolean}) )
13-42: Consider adding error handling for robustness.The service handlers directly access attributes like
climate.pidandclimate.pwmwithout error handling. If a climate entity doesn't have these attributes, users will receive cryptic error messages. Consider adding try-except blocks or validation to provide better feedback.Example error handling pattern
async def reset_integral(call: ServiceCall): """Service to reset the integral part of the PID controller.""" target_entities = call.data.get("entity_id", []) for climate in get_climate_entities(hass, target_entities): try: _LOGGER.info("Reset Integral action called for %s", climate.entity_id) climate.pid.reset() climate.areas.pids.reset() except AttributeError as err: _LOGGER.error("Entity %s does not support PID reset: %s", climate.entity_id, err) except Exception as err: _LOGGER.error("Failed to reset integral for %s: %s", climate.entity_id, err)custom_components/sat/util.py (1)
85-102: Optimize registry retrieval.The
entity_registry.async_get(hass)call is inside the loop (Line 89), resulting in repeated lookups of the same registry. Move it outside the loop for better performance.🔎 Proposed optimization
def get_climate_entities(hass: "HomeAssistant", entity_ids: list[str]) -> list["SatClimate"]: """Retrieve climate entities for the given entity IDs.""" entities = [] + registry = entity_registry.async_get(hass) + for entity_id in entity_ids: - registry = entity_registry.async_get(hass) - if not (entry := registry.async_get(entity_id)): continue if not (config_entry := hass.data[DOMAIN].get(entry.config_entry_id)): continue if not (climate := config_entry.get(CLIMATE)): continue entities.append(climate) return entitiescustom_components/sat/area.py (1)
87-91: Simplify redundant multiplication by 1.0.
raw_weight = effective_delta * 1.0is equivalent toraw_weight = effective_delta. Consider simplifying or, if scaling is intended for future changes, add a comment explaining the placeholder.🔎 Proposed fix
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)custom_components/sat/simulator/__init__.py (1)
87-87: Prefix unused parameters to indicate intentional non-use.Static analysis flags
climateandpwm_stateas unused. If they're required for interface compatibility, prefix with underscore to clarify intent and silence the linter.- async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, pwm_state: Optional[PWMStatus] = None, _time=None) -> None: + async def async_control_heating_loop(self, _climate: Optional[SatClimate] = None, _pwm_state: Optional[PWMStatus] = None, _time=None) -> None:custom_components/sat/config_flow.py (1)
491-494: UseSelectOptionDictconsistently for manufacturer options.Other option lists use
SelectOptionDict(e.g., lines 309-311), but manufacturer options use a plain dict. Consider usingSelectOptionDictfor consistency.options = [] for name in MANUFACTURERS: manufacturer = ManufacturerFactory.resolve_by_name(name) - options.append({"value": name, "label": manufacturer.friendly_name}) + options.append(SelectOptionDict(value=name, label=manufacturer.friendly_name))custom_components/sat/coordinator.py (1)
44-49: Unusedkwargsparameter inupdatemethod.The
updatemethod accepts**kwargsbut never uses it. Either remove it or apply kwargs items to preserve dict.update() semantics.🔎 Proposed fix
- def update(self, other: dict, **kwargs): + def update(self, other: dict = None, **kwargs): + if other is None: + other = {} for key, value in other.items(): if self.get(key) != value: self._is_dirty = True - super().__setitem__(key, value) + for key, value in kwargs.items(): + if self.get(key) != value: + self._is_dirty = True + super().__setitem__(key, value)custom_components/sat/climate.py (1)
1015-1026: Minor:async_set_temperaturereturns None explicitly but is typed implicitly.Line 1018 returns
Noneexplicitly while line 1026 returns the result of an async call. Consider consistent return behavior.🔎 Proposed fix
async def async_set_temperature(self, **kwargs) -> None: """Set the target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - return None + return # Automatically select the preset for preset in self._presets: if float(self._presets[preset]) == float(temperature): - return await self.async_set_preset_mode(preset) + await self.async_set_preset_mode(preset) + return self._attr_preset_mode = PRESET_NONE - return await self.async_set_target_temperature(temperature) + await self.async_set_target_temperature(temperature)custom_components/sat/mqtt/ems.py (1)
146-150: Uselogging.exceptionand consider using thekeyparameter.
- The
keyparameter is unused - consider logging it for debugging context.- Use
logging.exceptioninstead oflogging.errorto include the stack trace.🔎 Proposed fix
- def _process_message_payload(self, key: str, value: str): + def _process_message_payload(self, key: str, value: str) -> None: try: self.async_set_updated_data(json.loads(value)) except json.JSONDecodeError as error: - _LOGGER.error("Failed to decode JSON payload: %s. Error: %s", value, error) + _LOGGER.exception("Failed to decode JSON payload for key '%s': %s", key, value)custom_components/sat/serial/__init__.py (1)
139-144: Consider updatingmember_idreturn type annotation for consistency.Other Optional properties use
Optional[int]but this usesint | None. While functionally equivalent, usingOptional[int]would be more consistent with the rest of the file.🔎 Proposed fix
@property - def member_id(self) -> int | None: + def member_id(self) -> Optional[int]: if (value := self.get(DATA_SLAVE_MEMBERID)) is not None: return int(value) return None
✅ Actions performedComments resolved. |
Summary by CodeRabbit
New Features
Refactor
Documentation
Chores
✏️ Tip: You can customize this high-level summary in your review settings.