Skip to content

Boiler and PWM improvements#97

Merged
Alexwijn merged 87 commits intomasterfrom
develop
Jan 6, 2026
Merged

Boiler and PWM improvements#97
Alexwijn merged 87 commits intomasterfrom
develop

Conversation

@Alexwijn
Copy link
Copy Markdown
Owner

@Alexwijn Alexwijn commented Feb 16, 2025

Summary by CodeRabbit

  • New Features

    • Services to reset PID integral and to force enable/disable PWM.
    • New sensors: Flame Status, Flame Health and Boiler Health.
    • Added manufacturers: Itho, Remeha, Worcester, Other.
    • Funding/sponsorship links added.
  • Refactor

    • Large internal rework: improved flame/boiler health, PWM, error and setpoint handling; public API adjustments; integration version bumped to 4.2.0.
  • Documentation

    • README and translations updated for new services and strings.
  • Chores

    • CI workflows and test matrix updated.

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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 16, 2025

Walkthrough

Refactors 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

Cohort / File(s) Summary
Funding
​.github/FUNDING.yml
Added GitHub sponsor Alexwijn and BuyMeACoffee alexwijn.
Integration bootstrap & manifest
custom_components/sat/__init__.py, custom_components/sat/manifest.json
Consolidated domain imports, added OPTIONS_DEFAULTS and SERVICE_PULSE_WIDTH_MODULATION, register services during setup, bumped manifest version.
Services & translations
custom_components/sat/services.py, custom_components/sat/services.yaml, custom_components/sat/translations/en.json, custom_components/sat/translations/sk.json
Added async_register_services registering reset_integral and pulse_width_modulation; removed clear_integral; added translation entries.
Errors & State utilities
custom_components/sat/errors.py, custom_components/sat/state.py
New Error dataclass and Errors container; new immutable State dataclass and update_state helper.
Boiler, Flame & coordinator
custom_components/sat/boiler.py, custom_components/sat/flame.py, custom_components/sat/coordinator.py
Added BoilerState and Flame/FlameState; coordinator now accepts config_data, exposes boiler/flame properties, debounced updates and async_set_updated_data/async_notify_listeners; device status mapped to BoilerStatus.
PWM & util
custom_components/sat/pwm.py, custom_components/sat/util.py
Introduced CycleConfig and PWMState; PWM API now cycle-based and takes BoilerState+FlameState; create_pwm_controller signature changed; added get_climate_entities helper.
PID, MinimumSetpoint, SetpointAdjuster
custom_components/sat/pid.py, custom_components/sat/minimum_setpoint.py, custom_components/sat/setpoint_adjuster.py
PID now consumes Error objects and uses time-based integral/derivative; MinimumSetpoint uses State wrappers and accepts BoilerState/PWMStatus; SetpointAdjuster.current → Optional and new adjust() method.
Heating curve
custom_components/sat/heating_curve.py
Removed version branching; added static HeatingCurve.calculate(target_temperature, outside_temperature).
Area / Relative modulation / Overshoot
custom_components/sat/area.py, custom_components/sat/relative_modulation.py, custom_components/sat/overshoot_protection.py
Area.errorError, Areas.errorsErrors; removed per-area PWM controller; reordered relative modulation logic; renamed MAXIMUM_RELATIVE_MOD → MAXIMUM_RELATIVE_MODULATION.
Manufacturer refactor & classes
custom_components/sat/manufacturer.py, custom_components/sat/manufacturers/*, tests/test_manufacturer.py
Flattened MANUFACTURERS to numeric IDs; Manufacturer becomes ABC with member_id and friendly_name; many manufacturer classes renamed/added; tests updated for new API.
Coordinators / transports
custom_components/sat/mqtt/*.py, custom_components/sat/serial/*.py, custom_components/sat/esphome/__init__.py, custom_components/sat/fake/__init__.py, custom_components/sat/simulator/__init__.py
Constructors now accept config_data; capability name supports_relative_modulation_managementsupports_relative_modulation; unified payload handling via async_set_updated_data; many properties now return Optional types.
Sensors & entities
custom_components/sat/sensor.py, custom_components/sat/binary_sensor.py, custom_components/sat/entity.py, custom_components/sat/serial/sensor.py, custom_components/sat/simulator/sensor.py
Added SatFlameSensor and boiler/flame health binary sensors; sensors use manufacturer.friendly_name; migrated to namespaced sensor/binary_sensor imports.
Helpers & numeric utilities
custom_components/sat/helpers.py
Added robust parsing helpers: float_value, to_float, int_value, to_int, and clamp.
CI, docs & misc
.github/workflows/*.yml, README.md, hacs.json, compose.yml, tests/test_climate.py, custom_components/sat/summer_simmer.py
Workflow trigger and Python matrix tweaks, README updates, HACS HA version bump, removed compose version line, tests call schedule_control_heating_loop(force=True) in setups, minor comment fixes.

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

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.29% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Boiler and PWM improvements' clearly summarizes the main changes: extensive boiler state refactoring and PWM controller enhancements are present throughout the changeset.

📜 Recent review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1e832bf and 13831c4.

📒 Files selected for processing (1)
  • custom_components/sat/manifest.json
🔇 Additional comments (2)
custom_components/sat/manifest.json (2)

28-28: LGTM! Version bump is appropriate.

The minor version increment from 4.1.2 to 4.2.0 aligns with semantic versioning principles for a release that introduces new features (services, BoilerState, PWM improvements) alongside architectural refactoring.


24-27: Update sentry-sdk to the latest version.

pyotgw==2.2.2 is the latest release and is secure. However, sentry-sdk==2.19.2 is significantly outdated; the latest version is 2.48.0. While 2.19.2 has no known security vulnerabilities, updating to the latest stable version is recommended for bug fixes, performance improvements, and ongoing maintenance.


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

❤️ Share

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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 16, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai bot changed the title @coderabbitai Update SAT integration: endpoints, boiler, PWM, and simulator support Feb 16, 2025
@Alexwijn Alexwijn changed the title Update SAT integration: endpoints, boiler, PWM, and simulator support Boiler and PWM improvements Feb 16, 2025
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 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 1
custom_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:

  1. Try-catch blocks to handle potential errors during enable/disable operations
  2. 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_modulation simply returns supports_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 use supports_relative_modulation directly.

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


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: 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/services.yaml (1)

1-6: Service 'reset_integral' Definition

The new reset_integral service 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' Service

The translation entry for pulse_width_modulation is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 3d42ebe and 48443fd.

📒 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 in update method.

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, _setpoint is 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_SETPOINT ensures a valid setpoint even if _setpoint is None.
This is a reasonable fallback, but confirm any edge cases where an uninitialized _setpoint might mask a bug.
Overall, the logic appears appropriate.

custom_components/sat/coordinator.py (2)

103-143: Comprehensive expansion of BoilerStatus.

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


298-302: Potential undefined import for CONF_MINIMUM_SETPOINT.

Referencing self._data.get(CONF_MINIMUM_SETPOINT) relies on the star import from .const.
Ensure that CONF_MINIMUM_SETPOINT is 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_SETPOINT may be undefined, or defined from star imports

(F405)

custom_components/sat/climate.py (3)

563-565: Check definition of MINIMUM_RELATIVE_MODULATION.

Static analysis indicates that MINIMUM_RELATIVE_MODULATION may come from a star import.
Verify that it is indeed defined in .const or elsewhere to prevent runtime NameError.

🧰 Tools
🪛 Ruff (0.8.2)

564-564: MINIMUM_RELATIVE_MODULATION may 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_DOWN and the calculated setpoint is higher than pwm.setpoint helps 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 == 1 and 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_SETPOINT may 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 name to friendly_name is 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_name in manufacturer classes is correctly applied across the board. Our checks confirm that no manufacturer file still declares a name property, and all manufacturer classes (including ferroli.py) consistently implement friendly_name.

  • All manufacturer files now define and use friendly_name (as seen in multiple files including custom_components/sat/manufacturers/ferroli.py).
  • The only remaining reference to .name is in custom_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 name to friendly_name aligns 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 name to friendly_name aligns 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 name property to friendly_name is 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 Remeha manufacturer class follows the established pattern and correctly implements the friendly_name property.

custom_components/sat/manufacturers/worcester.py (1)

4-7: LGTM!

The new Worcester manufacturer class follows the established pattern and correctly implements the friendly_name property.

custom_components/sat/entity.py (1)

30-30: LGTM!

The update to use friendly_name instead of name is 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:

  1. Correctly building the member_id_to_names dictionary using the new MANUFACTURERS structure
  2. Adding an additional assertion to verify member_id
  3. 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:

  1. Making it an abstract base class
  2. Adding member_id property with proper initialization
  3. Renaming name to friendly_name for better clarity
custom_components/sat/relative_modulation.py (1)

37-43: LGTM!

The changes improve the code by:

  1. Simplifying the control flow
  2. Reducing nested conditions
  3. 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 Cycles class 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 *_MOD to *_MODULATION improves 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_MARGIN has been increased from 0.1 to 1.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 BoilerStatus enum provides a comprehensive and well-named set of boiler states. Good use of str inheritance for string representation.


35-44: LGTM! Good enhancement of boiler state tracking.

The BoilerState class has been enhanced with proper typing and additional state tracking capabilities through the device_status and flame_on_since parameters.


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 name to friendly_name is 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_MODE may be undefined, or defined from star imports

(F405)


357-357: MODE_SIMULATOR may be undefined, or defined from star imports

(F405)


492-492: LGTM! Manufacturer resolution improved.

The changes improve manufacturer resolution by:

  1. Using a default value of -1 when no manufacturers are found
  2. Consistently using friendly_name for manufacturer display

Also applies to: 497-499

custom_components/sat/services.yaml (1)

7-15: Service 'pulse_width_modulation' Definition

The pulse_width_modulation service is defined with the necessary target details and includes a required field enabled with 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' Service

The JSON entry for reset_integral includes 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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d4715f1 and 2e34920.

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (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 since float_value already returns float and default is typed as float. The expression result if result is not None else default already 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 explicit int() casts on line 87 are redundant since int_value already returns int and default is typed as int.

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

497-506: Consider using Errors.max() method.

The current implementation works correctly, but the Errors class provides a max() 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

📥 Commits

Reviewing files that changed from the base of the PR and between 2e34920 and 3f28df0.

📒 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 errors property correctly returns an Errors collection, properly filtering out None values 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 Error class is decorated with @dataclass(frozen=True, slots=True) and has fields entity_id: str and value: 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 raise TypeError. 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 accept Error objects as parameters. These methods properly extract the numeric value via error.value throughout 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.isfinite guards against inf and nan values, and the explicit Optional[float] return type improves type safety.


78-82: LGTM!

The function correctly delegates to float_value for validation and finite checks, then converts to int.


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 SatFlameHealthSensor class 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 State instance, preserving type consistency.


37-45: LGTM!

The warming up logic correctly uses State.value for comparisons and update_state for 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 current property now correctly returns a float value, and _data_to_save properly 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_unsub field correctly tracks the scheduled heating loop callback for proper cleanup.


202-208: LGTM!

Switching to schedule_control_heating_loop implements 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_value property.


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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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_setpoint currently delegates to super().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 duplicate annotations in __future__ import

from __future__ import annotations, annotations repeats the same future feature and is redundant (and may confuse linters). Keep a single occurrence:

-from __future__ import annotations, annotations
+from __future__ import annotations
custom_components/sat/config_flow.py (2)

232-237: Fix simulator device-id string construction

Line 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.format incorrectly 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 option value, not friendly_name

Here:

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 value is the MANUFACTURERS key (name).
  • default_manufacturer is currently set to friendly_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 value field used in options.

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

1-1: Remove duplicate annotations in __future__ import

Line 1:

from __future__ import annotations, annotations

annotations is 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 of supports_relative_modulation

This property always returns True (a boolean), but the annotation is float | 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 linters

The 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 on from 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_*/BOILER constants 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 unused climate/pwm_state parameters in simulator loop

async_control_heating_loop accepts climate and pwm_state for 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: ARG002

Optionally, you could also forward pwm_state into 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 for supports_relative_modulation

For 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_management
custom_components/sat/area.py (1)

75-91: Minor cleanups for weight and Areas.errors

Two small polish points:

  • Line 114: Errors([area.error for area in self._areas if area.error is not None]) recomputes area.error twice; consider binding once in the comprehension.
  • Line 113: The docstring still says “list of all the error values” but the method now returns an Errors collection of Error objects, 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: Use SelectOptionDict consistently (optional)

You’ve imported SelectOptionDict, but here options are still simple dicts wrapped by selector.SelectSelectorConfig. For consistency with other parts of the file and HA style, you can use SelectOptionDict directly:

-                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 from from .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] = 10

This 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 SatData class
  • Debounced listener notifications (5-second delay)
  • Consistent update handling across all coordinators

As an optional improvement, consider using logging.exception instead of logging.error on 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

📥 Commits

Reviewing files that changed from the base of the PR and between 3f28df0 and 96f7260.

📒 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 to config_data looks consistent

The coordinator now takes config_data and passes it through to SatDataUpdateCoordinator like the other coordinators; no functional issues spotted here.

custom_components/sat/helpers.py (1)

60-68: Numeric helper utilities look solid and safe

The new float_value/to_float/int_value/to_int helpers handle None, bad input, and non‑finite floats cleanly, and clamp correctly 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 accepts Error objects instead of raw floats

Area.error now returns an Error instance, but both async_control_heating_loop and _PIDs.update pass area.error directly 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.update still expect a numeric error, this will raise or silently misbehave; they would need self.error.value / area.error.value instead. If you already updated PID to accept Error instances, 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 downstream

The CONF_OUTSIDE_SENSOR_ENTITY_ID selector 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 keep multiple=False if only a single entity is expected.


345-361: Simulator short-circuiting looks good

The new early returns for simulator mode in async_step_automatic_gains, async_step_overshoot_protection, and async_step_pid_controller correctly 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 correct

The refactor to use climate.DOMAIN, climate.SERVICE_SET_HVAC_MODE and climate.HVACMode.* when turning the climate off / on and restoring the mode is consistent with current HA APIs and avoids stringly-typed service names.

The data payloads on lines 397–404 and 423–424 look correct.

Also applies to: 421-425


629-643: Window sensor selector refactor looks correct

The changes to:

  • Use binary_sensor.DOMAIN when resolving window_id, and
  • Filter CONF_WINDOW_SENSORS by binary_sensor.DOMAIN and device classes DOOR, WINDOW, GARAGE_DOOR

are consistent with HA’s binary_sensor device-class usage and should behave as intended.


676-705: Typed schema: dict[Marker, Any] annotations are fine but optional

The new schema: dict[Marker, Any] annotations in async_step_system_configuration and async_step_advanced are 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: Confirm SatData is JSON-serializable for Store persistence

The coordinator now uses SatDataUpdateCoordinator’s self.data and persists it via:

await self._store.async_save(self.data)

Given self.data is a SatData instance (not a plain dict), Store will expect it to be JSON-serializable. If SatData isn’t directly serializable, you may need to persist self.data.as_dict() or similar instead.

Likewise, _load_stored_data assumes stored_data is a dict, which is correct if what you save is a mapping.

Please verify that:

  • SatData is JSON-serializable when passed to async_save, and
  • The schema of stored_data matches what async_set_updated_data expects.

Also applies to: 54-62, 98-101

custom_components/sat/mqtt/opentherm.py (3)

70-83: Typing and Optional handling look correct

The 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-check supports_relative_modulation override vs base manufacturer logic

Here supports_relative_modulation is overridden to always return True, while the base SatDataUpdateCoordinator.supports_relative_modulation appears 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 reasonable

The extra await self._publish_command("PM=15") and expanded MI=500 handling for (Ideal, Intergas, Nefit) are straightforward and fit the existing pattern in boot(). No issues spotted here.

custom_components/sat/esphome/__init__.py (3)

90-136: Numeric parsing via float_value / int_value looks good

The refactor to use float_value / int_value for 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 direct float()/int() calls.

Also applies to: 118-132


137-157: Confirm domains for tracked ESPHome entities are correct

In async_added_to_hass and _get_entity_id:

  • Tracked IDs for DATA_FLAME_ACTIVE, DATA_REL_MOD_LEVEL, DATA_SLAVE_MEMBERID, DATA_BOILER_TEMPERATURE, DATA_RETURN_TEMPERATURE use sensor.DOMAIN.
  • device_active / flame_active / hot_water_active use switch.DOMAIN and binary_sensor.DOMAIN when calling get.

If the underlying ESPHome entities are actually binary_sensor/switch (common for on/off flags), mixing domains here will cause _get_entity_id to return None for 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.DOMAIN vs binary_sensor.DOMAIN / switch.DOMAIN as needed to match the actual ESPHome entities.

Also applies to: 192-213


204-213: Command helpers look correct

The _send_command_state and _send_command_value helpers 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 _simulation flag 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, and kd properties properly handle cases where automatic gains are enabled but _last_heating_curve_value is 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:

  • proportional returns 0.0 when kp is None
  • derivative returns 0.0 when kd is None

This 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 Error objects 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 using error.value where 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 restore method 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_management to supports_relative_modulation and the typing updates to use Optional[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_alpha within [0.0, 1.0] and initializes all tracking state, rolling windows, and timing anchors. The initial health status of INSUFFICIENT_DATA is 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 update method 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_health method implements a decision tree that systematically handles different boiler operating scenarios:

  1. Insufficient data for reliable health assessment
  2. Idle state (no demand)
  3. Domestic hot water mode (continuous flame allowed)
  4. Space heating with PWM control
  5. 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.median

These methods provide the foundation for accurate health assessments.

custom_components/sat/coordinator.py (6)

34-58: LGTM: SatData dirty tracking is cleanly implemented.

The SatData class extends dict to provide automatic dirty tracking when values change. This enables efficient listener notifications (only when data actually changes) as used in async_set_updated_data on lines 502-517.

The implementation correctly:

  • Sets _is_dirty only when values differ
  • Handles both __setitem__ and update() paths
  • Provides reset and query methods

The unused kwargs parameter in update() flagged by static analysis maintains signature compatibility with dict.update() and can be ignored.


177-207: LGTM: Boiler and flame state aggregation provides clean API.

The boiler and flame properties aggregate multiple coordinator attributes into structured BoilerState and FlameState objects. 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_data and async_notify_listeners methods 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_temperature correctly 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_loop properly:

  • Accepts optional pwm_state parameter for flame health monitoring
  • Passes PWM state to flame.update() for accurate health classification
  • Includes device_status in 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 Errors and Error types 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_loop method implements proper debouncing:

  • Cancels any previously scheduled execution
  • Supports immediate execution via force=True parameter (used when temperature setpoint changes)
  • Default 10-second delay for batching multiple rapid state changes
  • Uses HassJob for efficient async task scheduling

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

This 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_value property properly implements three strategies:

  1. Version 1: Uses MinimumSetpoint controller with dynamic calculation based on modulation
  2. Version 2: Uses PWM controller's setpoint
  3. 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_setpoint method correctly manages setpoint based on:

  1. HVAC OFF: Sets to MINIMUM_SETPOINT
  2. Normal heating (no PWM): Uses calculated setpoint with COLD_SETPOINT safety check
  3. PWM ON: Uses minimum_setpoint_value to maintain minimum flow
  4. PWM OFF/IDLE: Uses MINIMUM_SETPOINT to allow cooldown

The final application correctly caps the setpoint at maximum_setpoint to respect device limits. The safety check against COLD_SETPOINT prevents sending unrealistically low setpoints to the boiler.


929-988: LGTM: Main control loop orchestrates all heating subsystems correctly.

The async_control_heating_loop properly coordinates:

  1. Coordinator updates: Flame tracking, device state, temperature tracking
  2. Setpoint calculation: Low-pass filtered to avoid rapid changes
  3. Overshoot detection: Automatically enables PWM when needed
  4. PWM control: Updates with structured flame/boiler state
  5. Setpoint application: Respects PWM state and device limits
  6. PID integral: Periodic updates independent of main PID updates
  7. Area control: Updates multi-zone heating
  8. Dynamic minimum: Calculates based on boiler state and modulation
  9. 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.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (2)
custom_components/sat/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.py for consistency with the codebase pattern (similar to HEATER_STARTUP_TIMEFRAME):

  • Line 137: 6 → define FLAME_ON_TIME_THRESHOLD_SECONDS
  • Line 141: 60 → define FLAME_AVERAGE_ON_TIME_THRESHOLD_SECONDS
  • Line 138: -3 → define SETPOINT_ADJUST_DOWN_DEGREES
  • Line 142: +10 → define SETPOINT_ADJUST_UP_DEGREES

Then 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 be None for legacy or incomplete configs, and float(None) will raise TypeError, 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 unused kwargs parameter.

The update() method's kwargs parameter 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

📥 Commits

Reviewing files that changed from the base of the PR and between 96f7260 and da5d6f4.

📒 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 Cycles and PWMState dataclasses 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_offset based on supports_relative_modulation_management is 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 new Cycles dataclass and BoilerState type hints. The duty cycle calculation logic remains sound.


265-294: LGTM!

The property methods provide a clean public API. The state property correctly encapsulates the PWM state, and the setpoint property appropriately falls back to MINIMUM_SETPOINT when no adjusted setpoint is set.

custom_components/sat/coordinator.py (6)

97-120: LGTM!

The constructor properly initializes the new SatData instance, stores config_data, and registers a listener to keep flame state synchronized with boiler state changes.


133-176: LGTM!

The device_status property 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 boiler and flame properties provide a clean, immutable public API by constructing BoilerState and FlameState from internal tracking. This design improves encapsulation and prevents external mutation.


375-385: LGTM!

The supports_relative_modulation property 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 FlameState dataclass is well-designed with comprehensive flame tracking metrics and proper immutability (frozen=True).


35-98: LGTM!

The Flame class initialization is well-structured with clear constants, proper validation of smoothing_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_attributes correctly exposes the new error model fields (error, error_pid, error_source), flame timing metrics, and updated property references.


498-507: LGTM!

The errors and max_error properties correctly implement the new error model, properly handling eco mode vs. area errors and using max() with the appropriate key function.


590-602: LGTM!

The minimum_setpoint_value property 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 the requested_setpoint property, 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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
custom_components/sat/climate.py (1)

813-846: Add explicit None handling for maximum_setpoint property.

The verification confirms the property could theoretically fail if CONF_MAXIMUM_SETPOINT is explicitly set to None in 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.py should ensure the value is never None before calling float():

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 valid float, 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_error property directly uses the built-in max() function on self.errors rather than calling self.errors.max(). While this works (errors always has at least one element), using the Errors.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 error

Note: The current implementation is functionally correct since errors is 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_temperature has an async_ prefix but is not an async def function. 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

📥 Commits

Reviewing files that changed from the base of the PR and between da5d6f4 and 849cf28.

📒 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 Error and Errors types improves type safety and code clarity. The logic correctly:

  • Creates an Error for the main climate entity
  • Merges with area errors when not in ECO mode
  • Extracts max_error.value and max_error.entity_id for 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 for Geminox manufacturer 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 potential KeyError. 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 imported notify module (line 10) properly aligns with Home Assistant's service architecture. The default service notify.SERVICE_PERSISTENT_NOTIFICATION is appropriate for integration notifications.

…djusted `BoilerStatus` transitions with `BOILER_DEADBAND`, and incorporated `ANTI_CYCLING` state.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
custom_components/sat/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 → define FLAME_ON_TIME_THRESHOLD_SECONDS
  • Line 139: 60 → define FLAME_AVERAGE_ON_TIME_THRESHOLD_SECONDS
  • Line 136: -3 → define SETPOINT_ADJUST_DOWN_DEGREES
  • Line 140: +10 → define SETPOINT_ADJUST_UP_DEGREES

Add these constants to custom_components/sat/const.py (similar to HEATER_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 return None for legacy or incomplete configurations, causing float(None) to raise TypeError at 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_DEADBAND and COLD_SETPOINT lack 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.

PWMStatus includes a docstring, but FlameStatus and BoilerStatus lack 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

📥 Commits

Reviewing files that changed from the base of the PR and between 849cf28 and 2eabbb2.

📒 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_MOD and MAXIMUM_RELATIVE_MOD have been updated to use the new names MINIMUM_RELATIVE_MODULATION and MAXIMUM_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 CycleConfig and PWMState follow best practices with frozen=True for immutability, slots=True for memory efficiency, and kw_only=True for explicit parameter passing. The encapsulation improves the API surface.


42-71: LGTM! Improved parameter encapsulation.

The refactored constructor using CycleConfig for cycle parameters provides better encapsulation and clearer semantics compared to separate max_cycle_time/max_cycles parameters. The supports_relative_modulation_management parameter 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 accepting BoilerState and FlameState objects 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_cycle method correctly adapts to the new BoilerState parameter and uses cycles.maximum_time from the CycleConfig object. The current cycle count logging aids debugging.


268-292: LGTM! Well-designed property accessors.

The new status, state, and setpoint properties provide clean access to PWM state:

  • state returns an immutable PWMState snapshot
  • setpoint has a safe fallback to MINIMUM_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 CycleConfig usage and the get_climate_entities helper while avoiding circular import issues.


60-67: LGTM! HeatingCurve API simplified.

The removal of the version parameter from HeatingCurve instantiation aligns with the broader refactor removing CONF_HEATING_CURVE_VERSION. The simplified API is cleaner.


70-82: LGTM! Proper CycleConfig encapsulation.

The refactored create_pwm_controller correctly:

  • Accepts the new supports_relative_modulation_management parameter
  • Encapsulates cycle parameters in a CycleConfig object
  • 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_data instead of the generic data parameter, improving clarity and aligning with the constructor signature change.


97-121: LGTM with caveat on SatData.

The constructor properly:

  • Stores config_data for use in properties like minimum_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_dirty bug flagged in the previous comment.


133-186: LGTM! Comprehensive boiler status determination.

The expanded device_status property provides detailed status classification using the new BoilerStatus enum and BOILER_DEADBAND constant. 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 boiler and flame properties effectively encapsulate coordinator state into immutable BoilerState and FlameState dataclasses, providing a clean, consistent API for consumers while preventing external state mutations.


385-395: LGTM! Manufacturer-specific behavior centralized.

The supports_relative_modulation property appropriately centralizes manufacturer-specific behavior, explicitly returning False for 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_data tracks changes via dirty flag and schedules a 5-second delayed notification
  • Cancels previous pending notifications to prevent spam
  • async_notify_listeners performs immediate notification after cleanup

The pattern effectively batches rapid state changes.


530-541: LGTM! Correct cold temperature determination logic.

The _get_latest_boiler_cold_temperature method properly:

  • Finds the maximum temperature recorded when both device and flame were off
  • Handles optional timestamps with proper None checks
  • Returns None when no valid cold temperature samples exist

The caller (lines 472-473) appropriately handles the None return with a fallback.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
custom_components/sat/pwm.py (1)

42-71: Fix _setpoint_offset type/logging and consider its actual usage

_setpoint_offset is declared as int but initialized with 0.5 when relative modulation is supported, and logged with %d, which will truncate 0.5 to 0. Also, beyond logging, _setpoint_offset doesn’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.0 and Offset: %.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 in flame.py for health_status

Your concern was valid—there is a type mismatch. However, it's in flame.py, not the sensor code. At runtime, health_status actually returns FlameStatus enum members, not plain strings. The type annotations falsely claim str:

  • custom_components/sat/flame.py, line 23: health_status: str should be health_status: FlameStatus
  • custom_components/sat/flame.py, line 97: self._health_status: str = ... should be self._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 in flame.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_pid is defined as an async method but is called throughout the file without await (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‑verify supports_relative_modulation_management vs new coordinator API

This still relies on coordinator.supports_relative_modulation_management; earlier review mentioned a rename to supports_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/sat
custom_components/sat/pwm.py (1)

134-145: Setpoint adjustment logic is reasonable; extract magic thresholds as constants

The 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 (> 6 seconds, < 60 seconds, -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_dirty causes shared state across instances.

Line 36 defines _is_dirty as a class attribute, meaning all SatData instances 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_dirty an 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: Guard minimum_setpoint against missing config data.

self._config_data.get(CONF_MINIMUM_SETPOINT) can be absent (e.g., legacy configs or incomplete options), and float(None) will raise TypeError. Add a fallback to the integration's MINIMUM_SETPOINT constant 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=True but 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 the if 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 in SatSynchroSensor constructor

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

The new implementation safely handles None and uses friendly_name, which fixes the previous AttributeError risk. Given it can now return None, you might want to update the return annotation from -> str to -> str | None (or Optional[str]) for accuracy.

custom_components/sat/pwm.py (1)

289-292: Clarify setpoint fallback expression

return self._setpoint or MINIMUM_SETPOINT works because _setpoint is expected to be either None or a positive float; however, it relies on truthiness and would behave oddly if _setpoint were ever 0.0. For clarity and robustness, consider switching to an explicit None check:

-        return self._setpoint or MINIMUM_SETPOINT
+        return self._setpoint if self._setpoint is not None else MINIMUM_SETPOINT
custom_components/sat/flame.py (1)

19-33: Consider using FlameStatus enum type for health_status.

The health_status field is currently typed as str, but based on the context it should be FlameStatus (from const.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

📥 Commits

Reviewing files that changed from the base of the PR and between 2eabbb2 and 46ced16.

📒 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, and SatFlameHealthSensor are 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

SatBoilerHealthSensor and SatFlameHealthSensor correctly use BoilerStatus/FlameStatus with BinarySensorDeviceClass.PROBLEM and 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 correct

Using if not self._coordinator.device_active / if not self._coordinator.flame_active is 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 == False checks.


147-155: Event typing updates improve callback signatures

Annotating on_state_change as Event[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 format

Returning self._coordinator.device_status.name exposes values like COOLING_DOWN instead of the enum’s raw value. That’s consistent with how SatFlameSensor is intended to work and with the new BoilerStatus enum, 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 appropriate

Enabling future annotations and importing Enum here 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, and PWMStatus as str, Enum subclasses 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_MODULATION names verified as complete

All callers have been successfully migrated to the new constant names. No remaining references to the old MINIMUM_RELATIVE_MOD or MAXIMUM_RELATIVE_MOD constants were found in the codebase.

custom_components/sat/boiler.py (2)

1-8: BoilerState dataclass looks well‑structured and aligns with coordinator usage

The frozen, slotted BoilerState with BoilerStatus and the various temperatures/modulation fields matches the coordinator’s boiler() 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 updated

Verification found only one call site at custom_components/sat/coordinator.py:450, which already passes the flame_active argument 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 improvement

Encapsulating 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, and disable() calls reset() plus self._setpoint_adjuster.reset(). That keeps PWM state and the adjuster in sync when PWM is turned off.


147-155: Overshoot stabilization guard looks correct

The overshoot handling branch that disables PWM when boiler.status == BoilerStatus.COOLING_DOWN and the adjusted setpoint is below the requested setpoint matches the intent of “stabilization detected, stop PWM”. The interplay between requested_setpoint, self._setpoint_adjuster.current, and self._setpoint is consistent with the adjustment paths above.


157-176: Duty‑cycle state transitions are coherent

The ON/OFF transitions based on HEATER_STARTUP_TIMEFRAME, elapsed time, and _cycles.maximum_count form a consistent state machine:

  • ON only starts when on_time >= HEATER_STARTUP_TIMEFRAME, we’ve waited at least off_time (or we’re IDLE), and we haven’t exceeded maximum_count.
  • OFF triggers when on_time is too short or we’ve been ON for at least on_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_cycle now uses CycleConfig.maximum_time and _current_cycle in 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 includes self._current_cycle is helpful for diagnostics.


263-277: status/state accessors provide a clean public surface

The status property and the new state property that returns a PWMState snapshot (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:

  1. 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 both None (uninitialized) and invalid states.

  2. PWM update without _enabled check – Not an issue. The only call to pwm.update() (climate.py:966) is already guarded by if self.pulse_width_modulation_enabled:, with reset called on the False branch. PWM correctly uses _status (ON/OFF/IDLE) for operational state transitions within update(), while _enabled is 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_alpha and 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_seconds property properly guards against returning values before the first cycle completes.


158-264: LGTM: State transition logic is comprehensive.

The update method 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 for average_on_time_seconds is 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_data to 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_data and 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_status property 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 boiler and flame properties correctly construct immutable state objects (BoilerState and FlameState) 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_modulation property 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_loop method properly updates flame state, tracks device state, manages temperature history, and updates cold temperature with appropriate fallback logic.

Note: The climate parameter 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_data and async_notify_listeners methods 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_temperature method 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, and minimum_setpoint_value properties properly utilize the new Error/Errors types 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 through schedule_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_setpoint method 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_loop and 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. The async_send_notification method properly uses notify.DOMAIN.

Note: The missing await on _async_control_pid calls was already flagged in a separate comment.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
custom_components/sat/climate.py (1)

916-933: Regression: force=True does not always execute the heating loop immediately

The schedule_control_heating_loop implementation 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())
        return

When force=True and _control_heating_loop_unsub is None`** (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())
+        return

The rest of the debounced path (early return if _control_heating_loop_unsub is not None, then async_call_later) can remain as-is.

🧹 Nitpick comments (6)
custom_components/sat/climate.py (6)

38-40: Star import from .const keeps Ruff warnings and obscures dependencies

from .const import * combined with specific imports like PWMStatus makes 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 possible

The initialization flow (config->config_options->conf_presets/_presets, and creation of pid, 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_presets clause is redundant because you iterate over conf_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_defaults correctly 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 states

The current_outside_temperature implementation now distinguishes sensor vs weather domains and prefers sensors, which makes sense.

One thing to keep in mind: float(state.state) or float(state.attributes.get("temperature")) will raise if the upstream entity exposes non-numeric or None values. If that is possible in your deployment, you may want an extra guard or try/except around the conversions.

Not a blocker, just a robustness consideration.


579-588: Relative modulation control: behavior OK, possible clarification of “disabled” value

The relative modulation pipeline:

  • await self.relative_modulation.update(self.pulse_width_modulation_enabled),
  • Use of relative_modulation_value plus Geminox lower bound,
  • No-op if maximum_relative_modulation_value is unchanged,

is coherent.

One nuance: relative_modulation_value currently returns _maximum_relative_modulation whenever self.relative_modulation.enabled is True or relative-modulation management is unsupported; the “disabled” case only yields MINIMUM_RELATIVE_MODULATION when management is supported and enabled is False. 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. 0 or None) 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 possible

The new _async_control_setpoint:

  • Forces _setpoint = MINIMUM_SETPOINT and clears _calculated_setpoint when HVAC is not HEAT,
  • Uses _calculated_setpoint for the non-PWM/IDLE path with a “too cold” guard using COLD_SETPOINT,
  • Switches between minimum_setpoint_value and absolute MINIMUM_SETPOINT based on PWMStatus.

Both _setpoint and _calculated_setpoint are guaranteed to be set before use by the surrounding async_control_heating_loop, so this is functionally safe.

If you want extra defensive coding, you could assert or early-return if _calculated_setpoint is None in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 46ced16 and 1e832bf.

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

Switching async_track_coordinator_data and the time-interval listener to call schedule_control_heating_loop() instead of invoking async_control_heating_loop directly provides natural debouncing and centralizes scheduling via _control_heating_loop_unsub. The callback signature also matches async_track_time_interval’s expectations.

No issues here.


300-304: Room tracking via climates and restore path are consistent

The 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_changed when _rooms is not None (lines 715–717)

ensures _rooms is 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 via Errors/Error and extra attributes is well-structured

Using errors: Errors and max_error: Error and then exposing:

  • "error" / "error_pid" / "error_source",
  • PID internals,
  • PWM and boiler status,

in extra_state_attributes is a clean way to surface diagnostics. The errors implementation always includes at least this climate’s error, so max(self.errors, ...) is safe and avoids empty-iterable issues.

No functional problems spotted here.

Also applies to: 498-507


527-559: Valve-open heuristic for valves_open is sensible

The 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 for valves_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_enabled and minimum_setpoint_value correctly 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 using pwm.setpoint).

This matches the later use in _async_control_setpoint and the minimum-setpoint controller integration. I don’t see obvious logical gaps here.

Looks good.


611-721: Event handlers now correctly debounce through PID + scheduler

The 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 around max_error are sound

_async_control_pid now:

  • Resets PID/area PIDs if sensor data is stale,
  • Uses max_error = self.max_error based on the new Errors/Error model,
  • 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 calls

The logic that:

  • Updates the RelativeModulation controller 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_state now properly disables PWM and clears _calculated_setpoint, ensuring the next loop recomputes from fresh conditions. async_track_sensor_temperature sensibly deduplicates sensors and uses async_track_state_change_event with _async_temperature_change as 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_loop now:

  • Clears _control_heating_loop_unsub at the start,
  • Validates availability of current/target/outside temperatures and HVAC mode,
  • Delegates to the coordinator with the current pwm.state,
  • Smooths _calculated_setpoint with a low-pass filter,
  • Enables PWM when BoilerStatus.OVERSHOOT_HANDLING is detected,
  • Keeps PWM state in sync with pulse_width_modulation_enabled and updates pwm with flame/boiler state,
  • Uses minimum_setpoint.warming_up and minimum_setpoint.calculate only when appropriate (heating up, flame active, no hot water),
  • Drives heater state via async_set_heater_state based on _setpoint and COLD_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_state correctly:

  • Avoids redundant on/off calls based on device_active,
  • Prevents turning on the heater when valves_open is False, 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 correctly

The changes to:

  • async_set_temperature (short-circuit on missing temperature, preset auto-selection, delegating to async_set_target_temperature),
  • async_set_hvac_mode (PID reset + reset_control_state + scheduling), and
  • async_set_target_temperature (PID reset, reset_control_state, and schedule_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.get is robust

The 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 KeyError when a room is missing from _rooms while 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_notification is a thin, typed wrapper around the notify service with a default persistent_notification service. It keeps notification calls inside the entity concise.

Looks good as-is.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Dec 27, 2025

✅ Actions performed

Comments resolved.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Dec 27, 2025

✅ Actions performed

Summary regeneration triggered.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Dec 27, 2025

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
custom_components/sat/area.py (2)

96-103: Fix type mismatch in PID update call.

The error property now returns Error | None (an Error object), but line 102 passes self.error directly to pid.update_integral(), which likely expects a float. This will raise a TypeError at 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) to pid.update(), which likely expects a float as its first argument. This will raise a TypeError at 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 duplicate annotations import.

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: Restore pull_request trigger 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 == 57

Apply 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_modulation returns True (a boolean), but the signature advertises float | None. Update the annotation to -> bool to match the actual return type.

🔎 Proposed fix
-    def supports_relative_modulation(self) -> float | None:
+    def supports_relative_modulation(self) -> bool:
         return True
custom_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.py for maintainability and clarity.

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

77-88: Critical: device_active, flame_active, and hot_water_active still compare against DeviceState.ON instead of the string "on".

This issue was flagged in a previous review and remains unresolved. The SatEntityCoordinator.get method returns Home Assistant entity state as a string (e.g., "on" / "off"), not a DeviceState enum. These comparisons will always return False.

🔎 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_dirty attribute is shared across all instances.

This was flagged in a previous review. Line 36 defines _is_dirty as a class attribute, causing dirty tracking to bleed between different SatData instances.

🔎 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: Guard minimum_setpoint against missing config data.

This was flagged in a previous review. self._config_data.get(CONF_MINIMUM_SETPOINT) can return None for legacy configs, and float(None) will raise TypeError.

🔎 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 against None duty_cycle when PWMStatus is ON.

This was flagged in a previous review. Line 330 accesses self._last_pulse_width_modulation_state.duty_cycle[0] assuming it's not None, but duty_cycle is typed as Optional[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
                 return
custom_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. When force=True but 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) for CONF_NAME usage on lines 28 and 68. Explicit imports improve code clarity and maintainability.

🔎 Proposed fix
-from ..const import *
+from ..const import DOMAIN, COORDINATOR, CONF_NAME
custom_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 object
custom_components/sat/services.yaml (1)

12-15: Consider adding selector type for the enabled field.

While the enabled field 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_entities import on line 26 is not used in this file—it's only used in services.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_entities

Note: The SERVICE_RESET_INTEGRAL and SERVICE_PULSE_WIDTH_MODULATION imports 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 climate and pwm_state parameters 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 in MANUFACTURERS.

If a subclass name is not in MANUFACTURERS, _member_id becomes None, and the member_id property will return None instead of an int. This could cause TypeError in 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) or AttributeError (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 e
custom_components/sat/serial/sensor.py (1)

112-114: Prefer f-string or ENTITY_ID_FORMAT for entity_id generation.

The current concatenation sensor.DOMAIN + ".{}" works but is less idiomatic than using f"{sensor.DOMAIN}.{{}}" or importing ENTITY_ID_FORMAT from 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 redundant is_inactive field.

The is_inactive field is always the logical negation of is_active. Having both fields in an immutable dataclass is redundant and could lead to inconsistency if constructed incorrectly. Consider using only is_active and deriving is_inactive via 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_active

Note: This requires removing slots=True or using __slots__ manually to include the property.

custom_components/sat/mqtt/ems.py (1)

146-150: Use logging.exception for better error context.

When logging an exception in an except block, _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 using self.errors.max() for consistency.

The Errors class provides a .max() method that's purpose-built for this use case. While the current implementation using Python's max() 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)

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Jan 6, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 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 duplicate annotations import.

The __future__ import statement incorrectly imports annotations twice, which is redundant and could cause issues.

🔎 Proposed fix
-from __future__ import annotations, annotations
+from __future__ import annotations
custom_components/sat/esphome/__init__.py (1)

1-1: Remove duplicate annotations import.

from __future__ import annotations, annotations has a redundant duplicate.

🔎 Proposed fix
-from __future__ import annotations, annotations
+from __future__ import annotations
custom_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 to unsubscribe().

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 enabled field 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_integral service is properly registered in both services.py and climate.py with 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_modulation property simply forwards to supports_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 value
  • supports_relative_modulation_management: whether the device supports setting the modulation value

For 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 using cv.entity_ids for better validation.

While list[str] works, Home Assistant provides cv.entity_ids for 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 using cv.entity_ids for consistency.

Same recommendation as the reset_integral service—use cv.entity_ids for 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.pid and climate.pwm without 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 entities
custom_components/sat/area.py (1)

87-91: Simplify redundant multiplication by 1.0.

raw_weight = effective_delta * 1.0 is equivalent to raw_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 climate and pwm_state as 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: Use SelectOptionDict consistently for manufacturer options.

Other option lists use SelectOptionDict (e.g., lines 309-311), but manufacturer options use a plain dict. Consider using SelectOptionDict for 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: Unused kwargs parameter in update method.

The update method accepts **kwargs but 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_temperature returns None explicitly but is typed implicitly.

Line 1018 returns None explicitly 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: Use logging.exception and consider using the key parameter.

  1. The key parameter is unused - consider logging it for debugging context.
  2. Use logging.exception instead of logging.error to 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 updating member_id return type annotation for consistency.

Other Optional properties use Optional[int] but this uses int | None. While functionally equivalent, using Optional[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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Jan 6, 2026

✅ Actions performed

Comments resolved.

@Alexwijn Alexwijn merged commit 8a25c12 into master Jan 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants