Skip to content

[1.8] RC 1.8.0#1898

Open
KartoffelToby wants to merge 75 commits intomasterfrom
develop
Open

[1.8] RC 1.8.0#1898
KartoffelToby wants to merge 75 commits intomasterfrom
develop

Conversation

@KartoffelToby
Copy link
Owner

@KartoffelToby KartoffelToby commented Feb 9, 2026

Motivation:

This is the pre release branche

Summary by CodeRabbit

  • New Features

    • SELECT-type calibration support for more valve types.
    • TRV "ignore_trv_states" option to prevent unwanted state adoption.
    • New min_update_interval_s advanced configuration option.
  • Improvements

    • Dynamic algorithm-aware sensors that auto-create and clean up related entities.
    • Safer, more robust device handling, reduced redundant commands, and improved boost/safety valve behavior.
    • README/docs and UI text streamlined.
  • Bug Fixes

    • Aggressive calibration now skips inappropriate tolerance adjustments; improved calibration value parsing.
  • Tests

    • Expanded coverage for calibration modes, aggressive calibration, PID, valve resolution, boost mode, and TRV ignore-state behavior.
wtom and others added 14 commits January 28, 2026 09:56
* fix: ignore TRV target temp changes during active communication

When BT sends commands to a TRV, it sets `ignore_trv_states = True` to
prevent the TRV's response from being misinterpreted. However, in the
temperature change handler in events/trv.py, this flag was not checked.

This caused TRV responses (or internal TRV logic like Tado scheduling)
to override `bt_target_temp` even while BT was actively communicating
with the TRV.

This fix adds the missing check for `ignore_trv_states` in the condition
that decides whether to adopt a temperature change from the TRV.

Fixes #1733

* test: add tests for ignore_trv_states flag in temperature change logic

Tests verify that temperature changes from TRVs are correctly blocked
when ignore_trv_states is True during active BT communication.

---------

Co-authored-by: Tobias Haber <kontakt@t-haber.de>
Co-authored-by: Tobias Haber <kontakt@t-haber.de>
Co-authored-by: Tobias Haber <kontakt@t-haber.de>
)

Co-authored-by: Tobias Haber <kontakt@t-haber.de>
* [TASK] make sure the auto find entites are work with multilang

* [TASK] fix test
* [BUMP] version

* docs: Add AI development credits to feature request

- Clearly document that this feature was implemented with Claude AI assistance
- Credit AI collaboration for codebase analysis, architecture design, and implementation
- Highlight comprehensive nature of AI-assisted development process
- Provide transparency about AI involvement in the feature development

* fix markdown errors

* fix markdown errors

* fix: Resolve merge conflict in sensor.py - Integrate MpcKaSensor with dynamic entity management

- Integrated BetterThermostatMpcKaSensor from master branch
- Preserved complete dynamic entity management system from feature branch
- Updated MPC entity tracking to include new Ka sensor (5 total sensors)
- Removed redundant has_mpc logic in favor of universal algorithm detection
- All MPC entities (including Ka) now properly managed by cleanup system
- Seamless algorithm switching with automatic entity lifecycle management

Breaking: None
Compatibility: Full backward compatibility maintained
Testing: MPC algorithm diagnostics enhanced with thermal insulation monitoring

Resolves merge conflict between feature/dynamic-entity-cleanup and master
Addresses CodeRabbit merge conflict detection in sensor.py

* fix: Resolve merge conflict in sensor.py - Integrate MpcKaSensor with dynamic entity management

- Integrated BetterThermostatMpcKaSensor from master branch
- Preserved complete dynamic entity management system from feature branch
- Updated MPC entity tracking to include new Ka sensor (5 total sensors)
- Removed redundant has_mpc logic in favor of universal algorithm detection
- All MPC entities (including Ka) now properly managed by cleanup system
- Seamless algorithm switching with automatic entity lifecycle management

Breaking: None
Compatibility: Full backward compatibility maintained
Testing: MPC algorithm diagnostics enhanced with thermal insulation monitoring

Resolves merge conflict between feature/dynamic-entity-cleanup and master
Addresses CodeRabbit merge conflict detection in sensor.py

* fix: Address CodeRabbit review issues - Fix signal format, memory leaks, and grammar

- Fix signal format inconsistency: Use entry_id instead of entity_id in climate.py
- Fix memory leak: Store and use dispatcher unsubscribe functions properly
- Add _DISPATCHER_UNSUBSCRIBES tracking for proper cleanup on unload
- Fix grammar: Change 'Can be aggressive initially' to 'PID can be aggressive initially'

Issues resolved:
- Major: Signal format alignment between climate.py and sensor.py
- Major: Dispatcher callbacks now properly unsubscribed to prevent accumulation
- Minor: Grammar improvement in PID controller documentation

All CodeRabbit requested changes have been addressed.

* fix: Address CodeRabbit review issues - Fix signal format, memory leaks, and grammar

- Fix signal format inconsistency: Use entry_id instead of entity_id in climate.py
- Fix memory leak: Store and use dispatcher unsubscribe functions properly
- Add _DISPATCHER_UNSUBSCRIBES tracking for proper cleanup on unload
- Fix grammar: Change 'Can be aggressive initially' to 'PID can be aggressive initially'

Issues resolved:
- Major: Signal format alignment between climate.py and sensor.py
- Major: Dispatcher callbacks now properly unsubscribed to prevent accumulation
- Minor: Grammar improvement in PID controller documentation

All CodeRabbit requested changes have been addressed.

* fix: Resolve CodeRabbit review issues - Add missing MpcKaSensor class and fix signal handling

- Add missing BetterThermostatMpcKaSensor class implementation in sensor.py
  * Fixes critical NameError when MPC calibration mode is active
  * Monitors MPC thermal insulation coefficient (Ka parameter)
  * Follows established pattern of other MPC sensors (Gain, Loss)

- Fix signal format in climate.py _signal_config_change method
  * Change self._entry_id to self._config_entry_id (correct attribute)
  * Aligns with signal listener expectations in sensor.py
  * Ensures proper config change propagation to entity cleanup handlers

- Add .vscode/settings.json to .gitignore to exclude from public repo

Resolves all critical and major issues identified by CodeRabbit in PR #1887

* chore: Remove .vscode/settings.json from Git tracking

- File should remain local only per .gitignore configuration

* fix: Resolve critical async/await and data structure issues in sensor.py

- Fix async_get_entity_registry call: add 'await' keyword
  * Was being called as sync function but returns a coroutine
  * This prevented proper entity registry initialization

- Change bt_climate.all_trvs to bt_climate.real_trvs
  * all_trvs doesn't exist in the codebase
  * real_trvs is the correct dict of loaded TRV instances
  * Fixes AttributeError in _get_active_algorithms

- Update loop to handle dict.items() from real_trvs
  * Changed from 'for trv in all_trvs' to 'for trv_id, trv in real_trvs.items()'
  * Ensures proper unpacking of dictionary data structure

These fixes ensure:
- Entity registry operations complete successfully
- Algorithm detection works with actual TRV data
- No AttributeError at runtime when checking calibration modes

* fix: Use sync entity registry accessor in sensor cleanup

* fix: Log invalid calibration modes in sensor algorithm detection

* fix: Align MPC Ka sensor unit with changelog

* fix: Keep algorithm tracking when entity cleanup is partial

* feat: Add comprehensive cleanup for unused preset, PID number and switch entities

- Add automatic cleanup for unused preset input numbers (requested by @wtom)
- Extend cleanup system to PID number entities (Kp, Ki, Kd parameters)
- Add cleanup for PID auto-tune switch entities
- Implement entity tracking in number.py and switch.py platforms
- Enhance sensor.py with unified cleanup functions for all dynamic entities
- Add proper unload functions for tracking cleanup
- Maintain compatibility with existing algorithm sensor cleanup

Addresses code owner feedback in PR #1887 for complete entity management.

Co-authored-by: Claude AI <claude@anthropic.com>

* fix: Run entity cleanup on all config changes, not just algorithm changes

Addresses CodeRabbit feedback: preset cleanup was only triggered when algorithms
changed, causing stale preset number entities to remain when users only disabled
presets without changing calibration modes.

- Move _cleanup_unused_number_entities() outside algorithm-change conditional
- Ensure preset/PID number cleanup runs on every configuration change signal
- Maintains performance by only triggering on actual config changes via dispatcher

Fixes: #1887 (comment)

* fix: Use consistent TRV data source and key access in PID cleanup functions

- Both PID number and switch cleanup now use bt_climate.real_trvs (dict)
- Both use CONF_CALIBRATION_MODE constant instead of hardcoded string
- Ensures cleanup functions work on same TRV dataset consistently
- Addresses CodeRabbit feedback about inconsistent data sources

* docs: Add clarifying comment about TRV data source consistency

- Explicitly note that PID number cleanup uses same data source as switch cleanup
- Helps clarify the consistency fix from previous commit
- Addresses any remaining questions about TRV data source alignment

* fix: Normalize CalibrationMode values before PID comparison

- Add proper string-to-enum conversion in PID calibration checks
- Handle string-configured modes correctly in sensor.py cleanup functions
- Apply same normalization fix to switch.py PID creation logic
- Use CONF_CALIBRATION_MODE constant consistently in switch.py
- Prevents comparison issues between raw strings and enum values

* fix: Guard against None preset_modes in cleanup function

- Add null safety check for bt_climate.preset_modes before set conversion
- Use empty list as fallback when preset_modes is None or falsy
- Prevents TypeError when presets are unsupported by the climate entity
- Maintains existing logic for removing 'none' preset from cleanup scope

* feat: Extend switch cleanup to handle child lock switches

- Add cleanup logic for orphaned child lock switches when TRVs are removed
- Parse both _pid_auto_tune and _child_lock suffixes from switch unique IDs
- Remove child lock switches for TRVs that no longer exist in real_trvs
- Use same entity registry removal logic for consistent cleanup behavior
- Update logging to reflect cleanup of both switch types
- Addresses CodeRabbit feedback for comprehensive switch entity management

* feat: Add native SELECT entity support for HomeMatic temperature offset

- Extend find_local_calibration_entity() to discover SELECT temperature_offset entities
- Add fallback search for SELECT entities when device-based search fails
- Modify get_current_offset() to strip 'k' suffix from SELECT values (e.g., '1.5k' -> 1.5)
- Update set_offset() to detect entity domain and use appropriate service:
  * SELECT entities: Use select.select_option with 'k' suffix formatting
  * NUMBER entities: Continue using number.set_value (backward compatibility)
- Enhance get_min_offset() and get_max_offset() to parse options list for SELECT entities
- Support HomeMatic HM-CC-RT-DN thermostats via homematicip_local integration
- Eliminates need for template NUMBER entity workarounds
- Maintains full backward compatibility with existing NUMBER entity setups
- Resolves GitHub issue #1888

* Update custom_components/better_thermostat/adapters/generic.py

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Improve SELECT entity handling robustness in set_offset

- Make domain derivation null-safe using entity_id when entity_state is None
- Add comprehensive option validation for SELECT entities
- Handle missing entity_state gracefully by using empty options list
- Improve closest option matching with robust error handling
- Parse each option individually to handle malformed options
- Only call select_option with validated options from the entity
- Fallback to original option_value if parsing fails
- Enhances reliability for HomeMatic SELECT temperature offset entities

* docs: Fix markdown violations and add HomeMatic SELECT documentation

- Fix formatting in CHANGELOG_PRESET_NUMBER_CLEANUP.md and CLEANUP_REVIEW_SUMMARY.md
- Improve README.md structure and heading hierarchy
- Add comprehensive HomeMatic IP/CCU SELECT entity documentation
- Document temperature_offset activation steps for HomeMatic users
- Fix markdown compliance across all documentation files

* Update docs/Configuration/configuration.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update CHANGELOG_PRESET_NUMBER_CLEANUP.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Correct spelling and formatting in CLEANUP_REVIEW_SUMMARY.md

- Fix German compound hyphenation: Debug/Info Logging → Debug-/Info-Logging
- Remove trailing whitespace after 'activity' in test scenarios
- Address CodeRabbit spelling/typography issues

* docs: Translate CLEANUP_REVIEW_SUMMARY.md to English for consistency

- Translate German content to English to match rest of codebase
- Maintain consistent language across all documentation files
- Keep technical details and formatting intact
- Ensure professional documentation standards

* chore: rollback to stable state and update manifest/gitignore

- Rollback to commit 743ae5a before version 1.8.0-dev update
- Revert defensive NoneType fixes that caused integration instability
- Update manifest.json with correct version
- Adjust .gitignore for development workflow

* fix: Use lazy formatting in watcher logger call

Replace f-string with lazy %s formatting in DEBUG logger call to avoid
eager evaluation cost when log level is disabled. Aligns with all other
log calls in the module.

* remove individual unused files

* fix: improve exception handling specificity in climate.py

- Replace broad 'except Exception' catches with specific exception types
- Improves debuggability and prevents false exception masking
- Addresses 12 instances across multiple operation contexts:
  * Model/device detection: AttributeError, TypeError
  * Storage init/load (PID/MPC/TPI/Thermal): FileNotFoundError, PermissionError, RuntimeError
  * External temperature operations: OSError, RuntimeError, AttributeError, TypeError
  * Async task creation: RuntimeError
  * EMA initialization: ValueError, TypeError, ImportError
  * Heat loss calculations: ValueError, TypeError, KeyError

Changes maintain backward compatibility while improving error handling granularity.

* style: fix lazy logging and trailing whitespace

- Replace f-string with lazy % formatting in logger calls
- Remove trailing whitespace across 11 files
- Improves logging performance (lazy evaluation)
- No functional changes

Files: generic.py, config_flow.py, cooler.py, window.py, BTH-RM.py, TV02-Zigbee.py, number.py, sensor.py, switch.py, helpers.py, test_off_temperature_attribute.py

* fix: address CodeRabbit cleanup and add pylint config

* style: ignore unused-argument warnings in pylintrc

Callback handlers in Home Assistant often have unused arguments (e.g., event
parameter in dispatcher callbacks). This is intentional and not an error.

* style: fix unused variables in sensor.py and mpc.py

- Replace unused 'trv_id' loop variables with underscore (5 occurrences)
- Remove unused 'solar_gain_factor' variable assignment

* style: fix line length violations

* style: remove trailing whitespace and reorganize imports

* config: ignore import-outside-toplevel warnings (C0415) for tests

* config: ignore protected-access warnings (W0212)

* config: restrict pylint to custom_components directory only

* config: restrict linting to custom_components only (pylint + pyright)

* config: improve pyright ignore patterns for tmp and other directories

* fix: remove pyright include to preserve package structure

* fix: add missing __init__.py to adapters package

* fix: add missing __init__.py files to all Python packages

* fix: resolve pylint/pylance errors in helpers.py

- Add @staticmethod decorators to rounding class methods
- Fix round_by_step to properly default to rounding.nearest
- Add None checks for config_entry_id before API calls
- Add None check for entry in get_trv_intigration
- Fix device_id handling in find_valve_entity

* style: fix pylint/pylance infos and improve code quality

- Remove unnecessary pass statements in exception classes
- Initialize heating_cycles and heating_power_normalized in __init__ to prevent lazy initialization
- Replace inefficient .keys() iterations with direct dict iteration (Python 3 best practice)
- Change .items() iterations for direct access to dict values (performance improvement)
- Add debug logging to asyncio.CancelledError handler for better observability
- Move import statement to proper location after module aliases
- Add module docstrings to simulator files (pid_simulator, mpc_simulator)

All changes address pylint/pylance info messages while maintaining backwards compatibility and code stability.

* fix: add None-safety guards for thermal_store and attribute access

- Add explicit None checks before float() conversions for ATTR_TEMPERATURE, ATTR_STATE_HEATING_POWER, and ATTR_STATE_HEAT_LOSS
- Guard _thermal_store.async_load() calls to satisfy Pyright type checking
- Store .get() values in intermediate variables to improve type inference
- Resolves 6 Pyright None-safety errors while maintaining defensive programming patterns

* fix: add humidity state safety check and optimize type annotations

- Add None check for humidity_state.state access at line 862 (fixes .state type error)
- Import cached_property from functools for proper type compatibility
- Change device_info decorator from @Property to @cached_property to match Entity base class signature
- Improves defensive programming for Home Assistant state access patterns

All None-safety errors now resolved (7/7).

* chore: update .gitignore with comprehensive Python and development patterns

- Add Python bytecode patterns (*.pyc, *.pyo, *.py[cod])
- Add Python package build artifacts (dist/, build/, *.egg-info/)
- Expand virtual environment patterns (ENV/, env/, .env)
- Add testing artifacts (.pytest_cache/, .tox/, htmlcov/)
- Add IDE patterns (sublime, swap files)
- Add OS-specific files (Thumbs.db, Desktop.ini)
- Add Home Assistant database and log files
- Add temporary and backup file patterns
- Organize with category comments for better maintainability
- Keep manifest and spec files (needed for packaging)

* fix: restore broken target temperature state recovery

- Revert redundant double None-check in ATTR_TEMPERATURE restore logic
- The additional safety check in else branch caused state restore to fail
- This led to 'Undefined target temperature, falling back to 7.0' and
  HVAC mode defaulting to OFF, preventing MPC calibration from running
- Restores original working logic that was modified in previous commits

Fixes Better Thermostat state restore regression causing MPC sensors
to remain unknown after restart due to disabled calibration.

* fix: add missing exception handling for heating power state restore

- Add try/catch (TypeError, ValueError) around float() conversion for ATTR_STATE_HEATING_POWER
- Matches existing exception handling pattern used for heat loss restore
- Prevents startup crashes when corrupted heating power values are stored
- Ensures consistent error handling across all state restore operations

Fixes potential ValueError crash during startup sequence when non-numeric
heating power values (e.g. 'unknown') are encountered in saved state.

* [MERGE]

* [MERGE]

---------

Co-authored-by: Mulatz Florian (IT OS IC WA GO) <florian.mulatz@infineon.com>
Co-authored-by: Florian Mulatz <florian@mulatz.at>
Co-authored-by: Florian Mulatz <33655308+florianmulatz@users.noreply.github.com>
Co-authored-by: Claude AI <claude@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds SELECT-based calibration support, dispatcher-driven config-change signals, dynamic per-entry algorithm sensors with cleanup/unload hooks, translation_key-based entity discovery, refined valve/control helpers and boost handling, TRV ignore-state short-circuiting, logging hardening, tests, docs, and tooling configs.

Changes

Cohort / File(s) Summary
Project config & docs
/.gitignore, /.pylintrc, /.vscode/settings.json, /pyproject.toml, /README.md, docs/...
Normalize ignores; add pylint and pyright configs; remove VSCode settings; README/docs formatting/content tweaks.
Adapters
custom_components/better_thermostat/adapters/__init__.py, custom_components/better_thermostat/adapters/delegate.py, custom_components/better_thermostat/adapters/generic.py
Add adapters package; remove stray pass; ensure default step; add SELECT calibration support (strip suffix, infer min/max, snap/select options vs number.set_value).
Core: climate & config flow
custom_components/better_thermostat/climate.py, custom_components/better_thermostat/config_flow.py
Add SIGNAL_BT_CONFIG_CHANGED and _signal_config_change; cache device_info; detect calibration-algorithm changes in flows and dispatch config-change signals.
Dynamic entities & lifecycle
custom_components/better_thermostat/sensor.py, custom_components/better_thermostat/number.py, custom_components/better_thermostat/switch.py
Per-entry dynamic algorithm sensor setup; trackers for active algorithms/presets/PID/switches; async_unload_entry hooks; creation and cleanup logic for MPC/PID/preset entities.
Events & TRV handling
custom_components/better_thermostat/events/*, custom_components/better_thermostat/events/trv.py, custom_components/better_thermostat/events/*
Add events package; add None/attribute guards; adopt parameterized logging; short-circuit TRV processing when ignore_trv_states is set.
Model fixes & quirks
custom_components/better_thermostat/model_fixes/*
Add package init; null-check entity-registry entries; tighten exception handling in TRVZB; safer capability access and logging refinements.
Helpers & control utilities
custom_components/better_thermostat/utils/...
Add utils/calibration package; translation_key-first valve/calibration discovery; new rounding helpers; rework controlling.py (boost helpers, TaskManager, _get/_apply valve control, safety reset); remove solar_gain from MPC baseline calc.
Calibration logic & tests
custom_components/better_thermostat/calibration.py, tests/test_aggressive_calibration.py
Skip tolerance-based post-adjustment for AGGRESIVE_CALIBRATION; add comprehensive aggressive-calibration tests and hysteresis scenarios.
Entity discovery & unit tests
tests/test_helpers_valve.py, tests/unit/test_trv_ignore_states.py, tests/test_pid.py, many unit tests under tests/unit/*
Add translation_key-focused valve/calibration tests; TRV ignore_states tests; adjust PID test; add numerous unit tests for helpers and boost/control behavior.
Localization & strings
custom_components/better_thermostat/strings.json, translations/en.json
Shorten/help text strings; add min_update_interval_s entry; simplify calibration/help wording.
Misc / simulators & temp files
tmp/mpc_simulator.py, tmp/pid_simulator.py
Add module docstrings and minor formatting/import tweaks for simulators.

Sequence Diagram(s)

sequenceDiagram
    participant ConfigFlow as ConfigFlow/OptionsFlow
    participant Dispatcher as Dispatcher
    participant SensorMgr as sensor.py
    participant NumberMgr as number/switch managers

    ConfigFlow->>ConfigFlow: _check_calibration_changes(old, new)
    alt algorithms changed
        ConfigFlow->>Dispatcher: dispatcher_send(bt_config_changed_{entry_id})
        Dispatcher->>SensorMgr: bt_config_changed_{entry_id}
        SensorMgr->>SensorMgr: _handle_dynamic_entity_update(entry_id)
        SensorMgr->>NumberMgr: create/remove algorithm sensors, numbers, switches
        NumberMgr->>NumberMgr: _cleanup_stale_algorithm_entities(entry_id)
    else no change
        Note over ConfigFlow: no dispatch
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I hop where offsets hide in strings,

I trim the 'k' and wake the things,
I send a signal, sensors sprout,
Old numbers leave, new checks come out,
A cozy home with warmer wings.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description is largely incomplete. It only contains a single-line motivation ('This is the pre release branche') and is missing all other required template sections including Changes, Related issue, and Checklist. Complete the description by filling in the Changes section with a summary of modifications, selecting a Related issue option, and checking appropriate Checklist items. Include test hardware and device mapping information if applicable.
Title check ❓ Inconclusive The title '[1.8] RC 1.8.0' is vague and generic, using non-descriptive release notation that doesn't convey the main changes in the changeset. Provide a more descriptive title that highlights key improvements, such as 'Add boost mode support and improve robustness for 1.8.0 release' or 'Refactor calibration handling and valve control for 1.8.0'.
✅ Passed checks (1 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 94.04% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch develop

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

❤️ Share

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

@coderabbitai coderabbitai bot added the enhancement New feature or request label Feb 9, 2026
@KartoffelToby KartoffelToby self-assigned this Feb 9, 2026
@KartoffelToby KartoffelToby added this to the 1.8.0 milestone Feb 9, 2026
* Fix aggressive calibration not applied in hysteresis band

Change the condition for applying aggressive calibration from checking
hvac_action == HEATING to checking cur_temp < bt_target_temp.

The previous condition failed in the hysteresis band (between target-tolerance
and target) because:
- When temperature is in this band and system was previously IDLE
- The hysteresis logic keeps hvac_action as IDLE
- So aggressive calibration adjustment was not applied
- Result: TRV temperatures stayed "too close" to target

The fix ensures aggressive calibration is applied whenever the room actually
needs heating (cur_temp < target), regardless of the hysteresis-driven
hvac_action state.

Closes #1790

* Fix aggressive calibration by skipping tolerance delay

The real issue was that aggressive calibration mode was subject to the
same tolerance delay as DEFAULT mode. When hvac_action is IDLE and
heating should start, the tolerance delay (tolerance * 2.0) was added
to the calibration, effectively making aggressive mode LESS aggressive
when starting to heat.

Fix: Skip the tolerance delay for AGGRESIVE_CALIBRATION mode while
keeping the -2.5 offset behavior unchanged (only applies when HEATING).

This addresses the root cause identified in wtom's review comment.

---------

Co-authored-by: Tobias Haber <kontakt@t-haber.de>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

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

⚠️ Outside diff range comments (6)
custom_components/better_thermostat/utils/controlling.py (1)

386-448: ⚠️ Potential issue | 🟠 Major

Remove duplicate cooler control from control_trv().

Lines 386-448 duplicate cooler control logic that already executes at line 94 via the centralized control_cooler() call. Since control_cooler() is invoked before any control_trv() tasks and includes optimizations (state checking, conditional command sending), this block should be removed to avoid sending duplicate commands to the cooler.

tmp/mpc_simulator.py (1)

6-20: ⚠️ Potential issue | 🟡 Minor

sys.path.append is placed after the imports it's meant to enable.

Line 20 appends the workspace root to sys.path, but the custom_components import on lines 12–17 executes first. If this script is ever run from a directory where the module isn't already discoverable, the import will fail before the path fix takes effect.

Proposed fix
+import os
+import sys
+
+# Add workspace root to sys.path (must precede local imports)
+sys.path.append(os.getcwd())
+
 from dataclasses import dataclass
 import logging
-import os
-import sys
 from unittest.mock import patch
 
 from custom_components.better_thermostat.utils.calibration.mpc import (
     _MPC_STATES,
     MpcInput,
     MpcParams,
     compute_mpc,
 )
-
-# Add workspace root to sys.path
-sys.path.append(os.getcwd())
custom_components/better_thermostat/utils/retry.py (2)

44-48: ⚠️ Potential issue | 🟡 Minor

Off-by-one in entity_id extraction from positional args.

The comment says "self and entity_id are first two args" (indices 0 and 1), but the guard requires len(args) > 2 (i.e., at least 3 args). This means entity_id won't be extracted from calls with exactly 2 positional args (self, entity_id). Should be len(args) > 1.

Proposed fix
             entity_id = kwargs.get("entity_id", None)
-            if (
-                entity_id is None and len(args) > 2
-            ):  # Assuming self and entity_id are first two args
+            if (
+                entity_id is None and len(args) > 1
+            ):  # Assuming self is args[0] and entity_id is args[1]
                 entity_id = args[1]

89-92: ⚠️ Potential issue | 🟡 Minor

_LOGGER.exception outside an active except block won't capture a traceback.

At line 91, execution has left the except scope, so sys.exc_info() returns (None, None, None). Using _LOGGER.exception here logs the message but appends a useless NoneType: None instead of the actual traceback. Consider switching to _LOGGER.error or passing exc_info=last_exception.

Proposed fix
-            log_message = f"{log_prefix}{func.__name__} failed after {retries + 1} attempts: {last_exception}{entity_suffix}"
-            _LOGGER.exception(log_message)
+            log_message = f"{log_prefix}{func.__name__} failed after {retries + 1} attempts: {last_exception}{entity_suffix}"
+            _LOGGER.error(log_message, exc_info=last_exception)
custom_components/better_thermostat/events/trv.py (1)

301-309: ⚠️ Potential issue | 🟡 Minor

Pre-existing: both cooltemp guards always resolve to the same assignment.

The two conditions on lines 302 and 306 (<= and >=) together cover every possible relationship between bt_target_temp and bt_target_cooltemp, so bt_target_cooltemp is unconditionally set to bt_target_temp - bt_target_temp_step. If the intent was to only adjust when the heat/cool targets cross, the second branch likely needs a different assignment (e.g., bt_target_temp + step).

custom_components/better_thermostat/translations/en.json (1)

106-106: ⚠️ Potential issue | 🟡 Minor

Typo: "Aggresive" → "Aggressive" in the options calibration_mode description.

Also, the MPC description here says "hydraulic balance algorithm" while the config step (line 41) says "predictive controller" — these should be consistent. The strings.json file (line 39) uses "Aggressive" (correct) and a different MPC phrasing as well.

🤖 Fix all issues with AI agents
In `@custom_components/better_thermostat/events/cooler.py`:
- Around line 29-36: The check builds a tuple including new_state.attributes
before testing for None, which can raise when new_state is None; change the
guard in the Cooler event handler to first check new_state and old_state (e.g.,
if new_state is None or old_state is None) and only then check
new_state.attributes (e.g., if new_state.attributes is None) before logging and
returning; update the condition around the _LOGGER.debug/return so you never
access new_state.attributes unless new_state is not None.

In `@custom_components/better_thermostat/model_fixes/BTH-RM.py`:
- Around line 49-52: entity_reg.async_get(entity_id) can return None so change
the handling around entry in the function that reads entry.capabilities (lookup
using entity_reg and async_get) to guard against None before accessing
attributes: check if entry is None and if so set hvac_modes = [] (or an
appropriate default) or early-return, otherwise read
entry.capabilities.get("hvac_modes", []); apply the same None-guard pattern to
the analogous code in BTH-RM230Z.py to avoid AttributeError when the entity is
not registered.

In `@custom_components/better_thermostat/number.py`:
- Around line 64-71: The code directly compares
advanced.get(CONF_CALIBRATION_MODE) to CalibrationMode.PID_CALIBRATION which
fails when the stored value is a string; mirror the normalization used in
switch.py by first normalizing/mapping the raw value to the CalibrationMode enum
(e.g. read calib = advanced.get(CONF_CALIBRATION_MODE), if calib is a str
attempt to map it to CalibrationMode via CalibrationMode(calib) or
CalibrationMode[calib.upper()] in a try/except) and then compare that enum to
CalibrationMode.PID_CALIBRATION before creating BetterThermostatPIDNumber
instances (refer to CONF_CALIBRATION_MODE, CalibrationMode,
BetterThermostatPIDNumber and trv_conf to locate the change).

In `@custom_components/better_thermostat/sensor.py`:
- Around line 160-182: The code is recreating sensors for all active algorithms
even if already registered, causing duplicate unique_id collisions; modify the
logic so that only algorithms that are newly added are passed to or created by
_setup_algorithm_sensors (or add a guard inside _setup_algorithm_sensors) by
using algorithms_added (and filtering out any algorithm keys present in
_ACTIVE_ALGORITHM_ENTITIES) before creating sensor objects for bt_climate;
ensure _setup_algorithm_sensors either accepts a list/set of
algorithms_to_create or checks _ACTIVE_ALGORITHM_ENTITIES and skips creation for
algorithms already tracked so no duplicate entities with the same unique_id are
instantiated.

In `@custom_components/better_thermostat/utils/controlling.py`:
- Around line 177-192: The current check uses "if current_temp != desired_temp"
which treats current_temp=None as always different; change it to explicitly
handle unknown current_temp by sending the command when current_temp is None or
different (e.g., "if current_temp is None or current_temp != desired_temp"), and
add a debug log via _LOGGER.debug when current_temp is None to state that
temperature is unknown for self.cooler_entity_id so the set_temperature command
(the async_call to "climate.set_temperature" using self.cooler_entity_id and
desired_temp with context self.context) is being sent.
- Around line 167-175: The conditional that sets desired_mode uses
self.cur_temp, self.bt_target_cooltemp, self.tolerance (and compares to
self.bt_target_temp) without None-safety; add an early guard before that block
to check if any of self.cur_temp, self.bt_target_cooltemp, self.tolerance, or
self.bt_target_temp is None and if so return or set desired_mode = HVACMode.OFF
(i.e. bail out early) to avoid TypeError; update the method containing this
conditional to perform this None-check before evaluating the comparison.

In `@docs/Configuration/configuration.md`:
- Line 82: The docs callout uses a single device-specific parameter name
TEMPERATURE_OFFSET:MASTER@HM-CC-RT-DN which may not apply to all HomeMatic
models; update the text to clarify that parameter paths can vary by model (e.g.,
HmIP-eTRV, HM-CC-RT-DN-BoM) and instruct users to check their device-specific
parameter name in the device manual or interface, and give
TEMPERATURE_OFFSET:MASTER@HM-CC-RT-DN as an example rather than the only valid
key.

In `@pyproject.toml`:
- Around line 64-83: Ruff's target-version setting ("py313") and Pyright's
pythonVersion ("3.12") are inconsistent; update one so both match (e.g., set
Ruff's target-version to "py312" or Pyright's pythonVersion to "3.13") to ensure
consistent linting/type-checking; edit the Ruff config entry that defines
target-version and/or the [tool.pyright] pythonVersion entry so they use the
same Python version string.
🧹 Nitpick comments (12)
tmp/pid_simulator.py (1)

17-18: sys.path.append is executed after the import it's meant to support.

The custom_components import on lines 11–15 runs before sys.path.append(os.getcwd()) on line 18. This works only if the working directory is already on sys.path (e.g., via PYTHONPATH or IDE config). If this script is ever invoked standalone from a different directory, the import will fail. Consider moving the path manipulation above the custom_components import.

Proposed reorder
+import os
+import sys
+
+# Add workspace root to path so custom_components is importable
+sys.path.append(os.getcwd())
+
 """PID controller simulator for testing the PID calibration logic.
 ...
 """
 
 import logging
-import os
-import sys
 from unittest.mock import patch
 
 from custom_components.better_thermostat.utils.calibration.pid import (
     PIDParams,
     compute_pid,
     reset_pid_state,
 )
-
-# Add workspace root to path
-sys.path.append(os.getcwd())
tmp/mpc_simulator.py (1)

1-4: Consider whether tmp/mpc_simulator.py should be committed to the repository.

This file lives under a tmp/ directory, which conventionally holds throwaway/scratch files. If it's meant as a development utility, consider moving it to a proper location (e.g., scripts/ or tools/) or adding tmp/ to .gitignore to avoid cluttering the repo.

.pylintrc (2)

27-29: max-line-length inconsistent with Ruff.

Ruff enforces line-length = 88 (pyproject.toml line 3) while pylint allows 150. If Ruff is the primary linter/formatter, consider aligning pylint's limit or disabling its line-length check entirely (disable=C0301) to avoid confusion.


18-25: Disabling W0718 while also listing overgeneral exceptions is redundant.

W0718 (broad-exception-caught) is disabled in [MESSAGES CONTROL], but [EXCEPTIONS] still defines overgeneral-exceptions. The disable takes precedence, so the [EXCEPTIONS] block is effectively dead config for this warning. This is fine functionally but may confuse future maintainers.

custom_components/better_thermostat/utils/calibration/mpc.py (1)

1373-1401: Remove unused solar_gain_est field from state, export list, and initialization.

The solar gain logic is fully disabled (lines 1375–1400 all commented out), yet solar_gain_est continues to be initialized at line 914–915 and exported/imported via _STATE_EXPORT_FIELDS (line 161). This dead field wastes cycles on initialization and persists across state save/load cycles without ever being consumed. Remove the field definition (line 121), the export entry (line 161), and the initialization block (lines 914–915).

custom_components/better_thermostat/events/trv.py (2)

459-460: Bare _LOGGER.error(e) loses the traceback.

In convert_outbound_states, the catch-all logs only the exception message. Use _LOGGER.error("...: %s", e, exc_info=True) or _LOGGER.exception(...) to preserve the stack trace for debugging.

Proposed fix
     except Exception as e:
-        _LOGGER.error(e)
+        _LOGGER.exception(
+            "better_thermostat %s: convert_outbound_states failed for %s: %s",
+            self.device_name,
+            entity_id,
+            e,
+        )
         return None

201-202: Silent except Exception: pass swallows errors in hvac_action / valve_position handling.

If an unexpected error occurs while caching hvac_action or valve_position, it will be silently ignored, making debugging difficult. Consider at minimum logging at debug level.

Proposed fix
-    except Exception:
-        pass
+    except Exception:
+        _LOGGER.debug(
+            "better_thermostat %s: failed to update hvac_action/valve_position for %s",
+            self.device_name,
+            entity_id,
+            exc_info=True,
+        )
.gitignore (1)

78-82: Duplicate entries: *.swp and *~ already appear in the IDEs section (lines 42, 44).

Lines 81–82 duplicate rules from lines 42 and 44. While git handles duplicates gracefully, removing them keeps the file tidy.

Proposed fix
 # Temporary files
 *.tmp
 *.bak
-*.swp
-*~
custom_components/better_thermostat/model_fixes/TRVZB.py (1)

25-31: Tightened exception handling in _cancel_pending_valve_bump — minor note.

task.cancel() itself doesn't raise CancelledError (it only schedules cancellation on the task). The CancelledError catch here is harmless but effectively dead code. RuntimeError (e.g., closed event loop) is the realistic exception to guard against.

custom_components/better_thermostat/utils/helpers.py (1)

697-729: find_local_calibration_entity doesn't break after finding the first translation_key match — last match wins silently.

In the first pass (lines 699-710), if multiple entities on the same device have a matching translation_key, the loop keeps overwriting calibration_entity without breaking. The same applies in the string-match fallback (lines 714-729). This means if two calibration-like entities exist (e.g., one for local temp calibration and one for external offset), the last one iterated wins non-deterministically.

Consider adding a break after finding the first match in each pass, or implementing a scoring/priority mechanism similar to find_valve_entity.

♻️ Proposed fix — break after first match in each pass
             if tk and tk in _CALIBRATION_TRANSLATION_KEYS:
                 _LOGGER.debug(
                     "better thermostat: Found local calibration entity %s for %s (translation_key=%s)",
                     entity.entity_id,
                     entity_id,
                     tk,
                 )
                 calibration_entity = entity.entity_id
+                break

     # Second pass: fallback to string matching on unique_id / entity_id / original_name
     if calibration_entity is None:
         for entity in entity_entries:
             if entity.device_id != reg_entity.device_id:
                 continue
             descriptor = f"{entity.unique_id} {entity.entity_id} {getattr(entity, 'original_name', '') or ''}".lower()
             if (
                 "temperature_calibration" in descriptor
                 or "temperature_offset" in descriptor
                 or "temperatur_offset" in descriptor
                 or "local_temperature" in descriptor
             ):
                 _LOGGER.debug(
                     "better thermostat: Found local calibration entity %s for %s (string match)",
                     entity.entity_id,
                     entity_id,
                 )
                 calibration_entity = entity.entity_id
+                break
tests/test_helpers_valve.py (1)

453-495: Test may not catch the "last match wins" bug in production code.

This test (line 491) passes [ent_str, ent_tk] — since ent_str has translation_key=None, it won't match in the first pass, so ent_tk (with translation_key="temperature_calibration") will be the only first-pass match and calibration_entity will be set correctly. However, if two entities both had valid translation keys, the test wouldn't catch the non-deterministic "last wins" behavior noted in the find_local_calibration_entity review. Consider adding a test case with two entities that both have valid translation keys to verify which one is selected.

tests/unit/test_trv_ignore_states.py (1)

64-88: Tests duplicate production condition logic instead of exercising the actual function — reduces regression coverage.

All four tests inline-reconstruct the temperature adoption condition instead of calling the real trigger_trv_change() function from events/trv.py. The condition at lines 73–86 (and repeated in subsequent tests) mirrors the production check at events/trv.py:271–282 exactly. If the production condition changes—e.g., a clause is reordered, a new check is added, or a field is renamed—these tests will still pass while silently diverging from the actual behavior.

Consider extracting the condition into a helper method in the BetterThermostat class and having tests call it directly (passing the necessary temperature and TRV state parameters). This would provide genuine regression coverage.

Copy link

@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 (2)
custom_components/better_thermostat/calibration.py (2)

92-120: ⚠️ Potential issue | 🟠 Major

Copy-paste bug: cloud coverage check duplicated, UV index block shadowed.

Lines 102–110 are an exact duplicate of lines 92–100 (both check cloud_coverage with identical logic). Since the first block returns on a match, the duplicate on lines 102–110 is unreachable dead code. The real UV index check on lines 112–120 is fine but the duplicate block above it should be removed.

🐛 Proposed fix: remove duplicate block
             except (ValueError, TypeError):
                 pass
 
-    # 2. UV Index (0-10+) -> Higher is better
-    for source in sources:
-        cc = _get_val(source, "cloud_coverage")
-        if cc is not None:
-            try:
-                # 0% clouds = 1.0 intensity, 100% clouds = 0.0 intensity
-                return max(0.0, min(1.0, (100.0 - float(cc)) / 100.0))
-            except (ValueError, TypeError):
-                pass
-
     # 2. UV Index (0-10+) -> Higher is better
     for source in sources:
         uv = _get_val(source, "uv_index")

575-578: ⚠️ Potential issue | 🟠 Major

Aggressive calibration condition mismatch with commit message — code is intentional but documentation is misleading.

The commit message describes two changes, but they contradict each other and the code. The first part claims: "Change the condition for applying aggressive calibration from checking hvac_action == HEATING to checking cur_temp < bt_target_temp." The second part states: "Skip the tolerance delay for AGGRESIVE_CALIBRATION mode while keeping the -2.5 offset behavior unchanged (only applies when HEATING)."

The actual code on line 576 still checks self.attr_hvac_action == HVACAction.HEATING, not cur_temp < bt_target_temp. The promised condition change was never applied. The real fix (evident from the code diff) was skipping the tolerance delay elsewhere, not changing this condition.

The code is intentional—the -2.5 offset applies only when actively HEATING—but the commit message is inaccurate about what was changed. Update the commit message to reflect that the fix skipped the tolerance delay, not the heating condition.

🤖 Fix all issues with AI agents
In `@tests/test_aggressive_calibration.py`:
- Around line 37-51: The test incorrectly computes _skip_post_adjustments
compared to production: align the test with calculate_calibration_local() by
including CalibrationMode.DEFAULT in the tuple used to set
_skip_post_adjustments (so DEFAULT skips post-adjustments), or alternatively
update the failing assertions in tests test_default_idle_has_delay,
test_setpoint_default_has_tolerance_delay, and
test_scenario_temperature_drops_below_tolerance to expect no tolerance delay for
DEFAULT; locate the test code using the variable _skip_post_adjustments and the
test functions named above and make the change so the test behavior matches the
production logic in calculate_calibration_local().
- Around line 23-340: Replace the duplicated inline calibration logic in these
tests with integration-style calls to the real production functions: call
calculate_calibration_local() and calculate_calibration_setpoint() instead of
reimplementing branches; construct a minimal/mock "self" object (or fixture)
with the required attributes (cur_temp, bt_target_temp, real_trvs, tolerance,
attr_hvac_action, base_setpoint, etc.) to pass into those functions, invoke them
for each calibration_mode (CalibrationMode.DEFAULT, AGGRESIVE_CALIBRATION,
MPC_CALIBRATION, etc.), and assert on the returned calibration values (or
mutated state) matching the expected outcomes; update tests like
test_default_mode_has_tolerance_delay_when_idle,
test_aggressive_mode_skips_tolerance_delay_when_idle,
test_setpoint_default_has_tolerance_delay, etc., to call
calculate_calibration_local() / calculate_calibration_setpoint() and assert
results rather than duplicating the arithmetic.
🧹 Nitpick comments (1)
tests/test_aggressive_calibration.py (1)

305-340: Hysteresis scenario test is a good illustration but uses abs() comparisons unnecessarily.

Since all values are computed from exact floating-point arithmetic (no division or irrational operations), exact equality checks would work fine here (as used in all other tests). Using abs(... - ...) < 0.001 is fine but inconsistent with the rest of the file. Minor nit — not blocking.

Copy link

@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

Caution

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

⚠️ Outside diff range comments (1)
custom_components/better_thermostat/utils/controlling.py (1)

416-477: ⚠️ Potential issue | 🟠 Major

Stale cooler control path in the TRV-unavailable branch lacks new guards and duplicates work.

control_cooler is already called at line 94 (before any control_trv call). Yet when a TRV is unavailable, this block unconditionally issues set_temperature and set_hvac_mode to the cooler again, without any of the new safeguards:

  1. TypeError riskself.cur_temp, self.bt_target_cooltemp, and self.tolerance are used at lines 418/440 without None checks (the new control_cooler guards against this at line 167).
  2. Redundant / conflicting commands – The cooler has already been set to desired_mode by control_cooler; this block may flip it back to a different mode because it uses _new_hvac_mode (which factors in window-open state but not the same decision logic).
  3. No state-diffing – Commands are always sent even if the cooler is already in the desired state.

Consider removing this block entirely (consistent with the available-TRV path at line 714, which has already been cleaned up) and relying on the centralized control_cooler.

Proposed fix – remove the duplicate cooler section
         _new_hvac_mode = handle_window_open(self, _remapped_states)
 
-        # New cooler section
-        if self.cooler_entity_id is not None:
-            if (
-                self.cur_temp >= self.bt_target_cooltemp - self.tolerance
-                and _new_hvac_mode is not HVACMode.OFF
-                and self.cur_temp > self.bt_target_temp
-            ):
-                await self.hass.services.async_call(
-                    "climate",
-                    "set_temperature",
-                    {
-                        "entity_id": self.cooler_entity_id,
-                        "temperature": self.bt_target_cooltemp,
-                    },
-                    blocking=True,
-                    context=self.context,
-                )
-                await self.hass.services.async_call(
-                    "climate",
-                    "set_hvac_mode",
-                    {"entity_id": self.cooler_entity_id, "hvac_mode": HVACMode.COOL},
-                    blocking=True,
-                    context=self.context,
-                )
-            elif (
-                self.cur_temp <= (self.bt_target_cooltemp - self.tolerance)
-                and _new_hvac_mode is not HVACMode.OFF
-            ):
-                await self.hass.services.async_call(
-                    "climate",
-                    "set_temperature",
-                    {
-                        "entity_id": self.cooler_entity_id,
-                        "temperature": self.bt_target_cooltemp,
-                    },
-                    blocking=True,
-                    context=self.context,
-                )
-                await self.hass.services.async_call(
-                    "climate",
-                    "set_hvac_mode",
-                    {"entity_id": self.cooler_entity_id, "hvac_mode": HVACMode.OFF},
-                    blocking=True,
-                    context=self.context,
-                )
-            else:
-                await self.hass.services.async_call(
-                    "climate",
-                    "set_temperature",
-                    {
-                        "entity_id": self.cooler_entity_id,
-                        "temperature": self.bt_target_cooltemp,
-                    },
-                    blocking=True,
-                    context=self.context,
-                )
-                await self.hass.services.async_call(
-                    "climate",
-                    "set_hvac_mode",
-                    {"entity_id": self.cooler_entity_id, "hvac_mode": HVACMode.OFF},
-                    blocking=True,
-                    context=self.context,
-                )
-
         # if we don't need to heat, we force HVACMode to be off
🤖 Fix all issues with AI agents
In `@custom_components/better_thermostat/events/cooler.py`:
- Around line 38-52: The code accesses new_state.attributes before verifying
types; move the isinstance checks so that both new_state and old_state are
confirmed to be instances of State (using the existing isinstance(new_state,
State) and isinstance(old_state, State) guard) before any access to
new_state.attributes, and only then check if new_state.attributes is None and
log/return as currently done (preserve the existing debug messages referencing
self.device_name and entity_id).

In `@custom_components/better_thermostat/sensor.py`:
- Around line 404-411: The current cleanup logic (looping tracked_pid_numbers
and splitting on "_pid_") is fragile because it reverse-parses unique_id
strings; change the tracking structure (_ACTIVE_PID_NUMBERS and related PID
tracking maps) from a list of unique_id strings to a map/dict keyed by unique_id
with stored metadata { "trv": trv_entity_id, "param": parameter } and update all
places that create PID entities to populate that metadata; then modify cleanup
routines (the loop using tracked_pid_numbers, _cleanup_pid_switch_entities, and
_cleanup_preset_number_entities) to read the stored "trv" and "param" values
directly (e.g., replace the split/replace logic that uses bt_climate.unique_id
and parts = pid_unique_id.split("_pid_")) so you no longer reconstruct
trv_entity_id from the unique_id string.

In `@docs/Configuration/configuration.md`:
- Line 11: Replace the typo "Goto" with "Go to" in the markdown heading text
that currently reads "**Goto: `Settings` -> `Devices & Services` ->
`Integrations` -> `+ Add Integration` -> `Better Thermostat`**" so it becomes
"**Go to: `Settings` -> `Devices & Services` -> `Integrations` -> `+ Add
Integration` -> `Better Thermostat`**"; update the string in configuration.md
accordingly to preserve existing formatting and punctuation.
🧹 Nitpick comments (8)
custom_components/better_thermostat/model_fixes/BTH-RM.py (1)

44-47: Good: parameterized logging replaces f-strings.

Note that the sibling file BTH-RM230Z.py (lines 45 and 71) still uses f-string logging for the equivalent messages. Consider aligning both files for consistency.

Also applies to: 72-77

custom_components/better_thermostat/model_fixes/BTH-RM230Z.py (1)

44-45: Inconsistent logging style: f-strings here vs. parameterized logging in BTH-RM.py.

BTH-RM.py was updated to use %s placeholders (lines 45-47, 73-76), but these two log calls still use f-strings. Parameterized logging defers string interpolation until the message is actually emitted, which is the recommended practice.

Proposed fix
         _LOGGER.debug(
-            f"better_thermostat {self.device_name}: TRV {entity_id} device quirk bth-rm230z for set_temperature active"
+            "better_thermostat %s: TRV %s device quirk bth-rm230z for set_temperature active",
+            self.device_name,
+            entity_id,
         )
         _LOGGER.debug(
-            f"better_thermostat {self.device_name}: TRV {entity_id} device quirk bth-rm230z found hvac_modes {hvac_modes}"
+            "better_thermostat %s: TRV %s device quirk bth-rm230z found hvac_modes %s",
+            self.device_name,
+            entity_id,
+            hvac_modes,
         )

Also applies to: 70-72

custom_components/better_thermostat/utils/controlling.py (1)

320-405: Duplicated valve-control logic across TRV-unavailable and TRV-available paths.

The CalibrationMode-based valve-percent selection (lines 320-405 and 630-706) is nearly identical copy-paste. Any future calibration-mode addition or bug fix must be applied in both places. Consider extracting a helper like _resolve_valve_balance(self, heater_entity_id, calibration_mode, calibration_type).

custom_components/better_thermostat/number.py (2)

96-104: Duplicate cleanup of tracking dicts across number.py and sensor.py.

Both number.py (lines 101-102) and sensor.py (lines 566-567) pop the same _ACTIVE_PRESET_NUMBERS[entry_id] and _ACTIVE_PID_NUMBERS[entry_id]. The double-pop is safe but redundant — whichever platform unloads second operates on already-removed keys.

This is a consequence of the shared mutable state noted above. If ownership is moved to a shared module, cleanup should be consolidated in one place (e.g., __init__.py's async_unload_entry).


13-13: Cross-module shared mutable state creates tight coupling.

number.py imports and mutates _ACTIVE_PRESET_NUMBERS and _ACTIVE_PID_NUMBERS from sensor.py. While functionally safe (both modules' async_unload_entry methods safely call .pop with default), the ownership of these tracking structures is unclear and creates cross-module dependency. Consider moving these dicts to utils/const.py to make the dependency direction explicit and improve code clarity.

custom_components/better_thermostat/sensor.py (3)

28-36: German comments mixed with English codebase.

Several comments throughout this file are in German (e.g., "Globale Tracking-Variablen für aktive algorithmus-spezifische Entitäten", "Konvertiere String zu Enum falls nötig", "Prüfe auf Änderungen bei den Algorithmen", etc.). For consistency with the rest of the codebase (which uses English), consider translating these to English.


134-158: _ENTITY_CLEANUP_CALLBACKS is stored but never invoked — dead state.

Line 151 stores the _on_config_change callback in _ENTITY_CLEANUP_CALLBACKS, and line 565 pops it during unload, but nothing in the codebase ever reads or invokes these stored callbacks. The actual cleanup is handled by _DISPATCHER_UNSUBSCRIBES. Consider removing _ENTITY_CLEANUP_CALLBACKS to avoid confusion.

Proposed cleanup
-# Store callback für späteren Cleanup
-_ENTITY_CLEANUP_CALLBACKS[entry.entry_id] = _on_config_change
-
 # Listen to configuration change signals

And in async_unload_entry:

 _ACTIVE_ALGORITHM_ENTITIES.pop(entry_id, None)
-_ENTITY_CLEANUP_CALLBACKS.pop(entry_id, None)
 _ACTIVE_PRESET_NUMBERS.pop(entry_id, None)

And remove the module-level dict:

 _ACTIVE_ALGORITHM_ENTITIES = {}
-_ENTITY_CLEANUP_CALLBACKS = {}
 _DISPATCHER_UNSUBSCRIBES = {}

269-293: CalibrationMode string-to-enum normalization is repeated in 5+ places.

The same isinstance(calibration_mode, str) → CalibrationMode(calibration_mode) try/except pattern appears in _get_active_algorithms (line 280), _cleanup_pid_number_entities (line 392), _cleanup_pid_switch_entities (line 466), number.py (line 68), and config_flow.py. Extract a small helper to DRY this up.

Suggested helper

In utils/const.py alongside the CalibrationMode enum:

def normalize_calibration_mode(value) -> CalibrationMode | None:
    """Normalize a raw calibration mode value to a CalibrationMode enum, or None."""
    if isinstance(value, CalibrationMode):
        return value
    if isinstance(value, str):
        try:
            return CalibrationMode(value)
        except ValueError:
            return None
    return None
wtom and others added 7 commits February 10, 2026 13:16
- Add @Property available() to all MPC sensor classes
- Return False when thermostat is OFF, window is open, or climate not available
- This ensures sensors show 'unavailable' instead of 'unknown' per HA best practices
- Affected sensors: virtual_temperature, mpc_gain, mpc_loss, mpc_insulation_ka

Resolves issue where MPC sensors showed 'unknown' state inappropriately.
)

* fix: Prevent complex number crash in heating_power_valve_position

Fixes crash when heating_power_valve_position is called with negative
temperature difference (cur_temp > target_temp).

This occurs in rare TRV override edge case:
1. System heating normally (cur_temp < target_temp)
2. Fast temperature rise (sun, external heat)
3. TRV reports "heating" with delay
4. _compute_hvac_action() overrides to HEATING
5. Function called with negative temp_diff
6. Formula produces complex number: (-x) ** 0.946
7. TypeError on comparison

Changes:
- Add guard for temp_diff <= 0 at function start
- Return 0.0 (no heating needed) when room warmer than target
- Add debug logging for this edge case
- Add comprehensive tests (extracted from #1868)

Related to #1868 (test coverage PR)

Affects: HEATING_POWER_CALIBRATION mode only

* test: Remove meta-comments from test docstrings

* style: fix ruff formatting in test_helpers_valve_position

* refactor: Clean up test docstrings and remove meta-communication

Remove bug-hunting language ("FIXED:", "BUG:", severity ratings),
try/except pytest.fail patterns, and unused pytest import. Rename
test methods to describe expected behavior instead of referencing bugs.

---------

Co-authored-by: Tobias Haber <kontakt@t-haber.de>
* fix: Add boost mode support for available TRVs

**Problem:**
Boost mode logic (lines 296-313) only existed in the unavailable TRV path.
When a TRV was available (normal case), boost mode had no effect.

**Root Cause:**
Code duplication between unavailable (lines 262-582) and available (584-829)
TRV paths led to missing boost mode implementation in the available path.

**Fix:**
Added boost mode logic to available TRV path (after line 611):
1. Set temperature to max_temp when boost mode is active (lines 613-619)
2. Set valve to 100% when boost mode is active (lines 625-632)

This mirrors the existing boost mode logic from the unavailable path.

**Related Issues:**
- Fixes #1817 - Support native operation modes (Boost/Vacation/Profiles)
- Users reported boost mode not working - this explains why it only worked
  when TRV was unavailable (which is the exceptional case, not the norm)

**Testing:**
Added test_boost_mode_bug.py with tests documenting the bug and verifying
that boost mode now works for both unavailable and available TRVs.

* test: Clean up test and add missing bt_hvac_mode

- Remove meta-comments from docstrings (BUG, After the fix)
- Add missing bt_hvac_mode attribute to mock
- Rename test to describe behavior, not bug
- Clean up assertions

* fix: Move set_valve call outside elif to fix boost mode

- Critical bug: set_valve was inside elif block, never executed for boost mode
- Moved set_valve logic after if/elif/else so it executes for both boost and calibration
- Fixed in both unavailable TRV path (lines 307-397) and available TRV path (lines 625-710)
- Updated tests with proper mocks (adapter, model_quirks, cooler_entity_id, etc.)
- All 3 boost mode tests now pass

* test: Fix import ordering in test_boost_mode_bug.py

* fix: Reset valve when safety overrides force HVAC OFF in boost mode

When boost mode sets valve to 100% but safety checks (window open or
call_for_heat=False) force HVAC mode to OFF, the valve was left at 100%.
This created conflicting commands that could cause unpredictable TRV behavior.

Now explicitly reset valve to 0% when safety overrides activate during boost.

Addresses CodeRabbit review feedback on PR #1873.

* refactor: Extract valve control helpers and fix safety override

- Add _is_boost_heating_active() to check boost condition
- Add _get_valve_control() to determine valve settings from boost or calibration
- Add _apply_valve_control() to apply valve settings to TRV
- Add _reset_valve_on_safety_override() to reset valve when HVAC forced OFF
- Remove redundant cooler section from unavailable TRV path (handled by control_cooler)
- Reduce code duplication between unavailable and available TRV paths
- Add tests for safety override scenarios (window open, no heat call)
- Rename test file from test_boost_mode_bug.py to test_boost_mode.py

* fix: Remove unused solar_gain_factor variable

---------

Co-authored-by: Tobias Haber <kontakt@t-haber.de>
* fix: Handle TypeError in check_float for None and invalid types

Fixes bug where check_float() raised TypeError instead of returning
False when passed None or invalid types (list, dict, etc.).

The function only caught ValueError but not TypeError, which is raised
when float() is called with incompatible types.

Changes:
- Add TypeError to exception handling in check_float()
- Add comprehensive tests (extracted from #1868)

Related to #1868 (test coverage PR)
Fixes bug #2 documented in #1868

Tests:
- test_returns_false_for_none ✅
- test_returns_false_for_invalid_types ✅
- All 6 tests pass

* test: Remove unused pytest import

* refactor: Remove meta-communication from test module docstring

* style: Fix ruff format

---------

Co-authored-by: Tobias Haber <kontakt@t-haber.de>
cl445 and others added 4 commits February 20, 2026 18:08
* refactor: Add unified StateManager for runtime state persistence

Introduce a single versioned HA Store per config entry that replaces
the four separate Store files for MPC, PID, TPI, and thermal data.

- Add state_manager.py with RuntimeState, MpcState, PidState, TpiState,
  ThermalStats dataclasses and typed serialization/deserialization
- Add StateManager class with dirty-flag tracking, get-or-create
  accessors, and async load/save/flush lifecycle
- Add schema migration path (_migrate_v0_to_v1) for future versioning
- Full mypy --strict compliance (zero type: ignore)
- 58 unit tests covering roundtrip, type coercion, edge cases, and
  StateManager lifecycle

* refactor: Make MPC computation stateless (caller-owned persistence)

compute_mpc() now accepts an optional `state` parameter and returns
(MpcOutput | None, MpcState) so the caller owns persistence.
When state is None, falls back to module-level dict for backward compat.

* chore: fix ruff lint and formatting from develop rebase
…1916)

* refactor: Make PID and TPI computation stateless (caller-owned persistence)

compute_pid() now returns (percent, debug, PIDState) and compute_tpi()
returns (TpiOutput | None, TpiState). Both accept an optional state
parameter; when None they fall back to module-level dicts.

* chore: fix ruff lint and formatting from develop rebase
KartoffelToby and others added 16 commits February 20, 2026 19:17
)

Replace four separate Store files (MPC, PID, TPI, thermal) with the
unified StateManager introduced in the previous commit.

Key changes:
- async_added_to_hass: init StateManager, run one-time legacy migration,
  hydrate module-level controller caches from loaded state
- schedule_save_state: cancelable debounce via async_call_later (replaces
  four non-cancelable asyncio.sleep schedulers)
- on_remove: cancel pending timer, sync state, flush to disk
- _sync_controllers_to_state: export module-level caches back to
  StateManager before each save
- Inline _migrate_legacy_stores() reads old Store files on first run
…1944)

* refactor: DRY sensor.py with base class hierarchy

Extract 3 base classes to eliminate massive code duplication:
- _BtSensorBase: shared __init__, async_added_to_hass, _on_climate_update
- _BtMpcSensorBase: shared available property + debug key lookup (was 4x copy-paste)
- _BtSimpleAttributeSensor: shared attribute read + optional rounding (was 3x copy-paste)
- _get_filtered_temp helper: shared fallback logic for external temp sensors

Reduces sensor.py by ~315 lines. Bug fixes now only need 1 change instead of 4.
No behavioral changes — all existing functionality preserved.

* typing: Make sensor.py fully mypy --strict compliant

- Add `from __future__ import annotations` and TYPE_CHECKING block
- Import BetterThermostat type for all bt_climate parameters
- Type all base class methods: __init__, async_added_to_hass, _on_climate_update, _update_state
- Type helper: _get_filtered_temp(bt_climate) -> float | None
- Type EMA sensor: _ema_value: float | None, _update_ema(new_value: float) -> None
- Type all module-level functions: bt_climate, async_add_entities, generic params
- Fix generic type params in globals (dict, set, list, Callable)
- Add assert for _ema_value narrowing after _update_ema call

Result: 0 mypy --strict errors in sensor.py

* cleanup: Remove redundant hasattr checks and extract _get_pid_trvs helper

- Extract duplicated PID TRV detection logic into _get_pid_trvs() helper
  (was copy-pasted in _cleanup_pid_number_entities and _cleanup_pid_switch_entities)
- Remove unnecessary hasattr() checks now that bt_climate is typed as BetterThermostat
- Simplify algorithm.value patterns (CalibrationMode always has .value)
- Remove unused total_removed variable
- Simplify had_algorithm_entities / previous_algorithms logic
- Guard real_trvs iteration with truthiness check instead of hasattr

* style: Restore meaningful code comments removed during refactoring

Re-add comments that explain non-obvious behavior:
- "Also update initially" in async_added_to_hass
- HA guideline note in available property docstring
- Why SolarIntensitySensor needs polling
- 0.0-1.0 to % conversion note
- String-to-enum normalization in _get_pid_trvs

* typing: Replace Any with specific types in sensor.py

- Event[Any] → Event[EventStateChangedData]
- data: Any → data: object (value unused)
- Remove Any import
* refactor: DRY sensor.py with base class hierarchy

Extract 3 base classes to eliminate massive code duplication:
- _BtSensorBase: shared __init__, async_added_to_hass, _on_climate_update
- _BtMpcSensorBase: shared available property + debug key lookup (was 4x copy-paste)
- _BtSimpleAttributeSensor: shared attribute read + optional rounding (was 3x copy-paste)
- _get_filtered_temp helper: shared fallback logic for external temp sensors

Reduces sensor.py by ~315 lines. Bug fixes now only need 1 change instead of 4.
No behavioral changes — all existing functionality preserved.

* typing: Make sensor.py fully mypy --strict compliant

- Add `from __future__ import annotations` and TYPE_CHECKING block
- Import BetterThermostat type for all bt_climate parameters
- Type all base class methods: __init__, async_added_to_hass, _on_climate_update, _update_state
- Type helper: _get_filtered_temp(bt_climate) -> float | None
- Type EMA sensor: _ema_value: float | None, _update_ema(new_value: float) -> None
- Type all module-level functions: bt_climate, async_add_entities, generic params
- Fix generic type params in globals (dict, set, list, Callable)
- Add assert for _ema_value narrowing after _update_ema call

Result: 0 mypy --strict errors in sensor.py

* cleanup: Remove redundant hasattr checks and extract _get_pid_trvs helper

- Extract duplicated PID TRV detection logic into _get_pid_trvs() helper
  (was copy-pasted in _cleanup_pid_number_entities and _cleanup_pid_switch_entities)
- Remove unnecessary hasattr() checks now that bt_climate is typed as BetterThermostat
- Simplify algorithm.value patterns (CalibrationMode always has .value)
- Remove unused total_removed variable
- Simplify had_algorithm_entities / previous_algorithms logic
- Guard real_trvs iteration with truthiness check instead of hasattr

* style: Restore meaningful code comments removed during refactoring

Re-add comments that explain non-obvious behavior:
- "Also update initially" in async_added_to_hass
- HA guideline note in available property docstring
- Why SolarIntensitySensor needs polling
- 0.0-1.0 to % conversion note
- String-to-enum normalization in _get_pid_trvs

* typing: Replace Any with specific types in sensor.py

- Event[Any] → Event[EventStateChangedData]
- data: Any → data: object (value unused)
- Remove Any import

* test: Add comprehensive test suite for sensor platform

108 tests covering all 10 sensor classes, setup/teardown, algorithm
detection, and entity cleanup functions in sensor.py.

1 test FAILS, documenting a bug:
- real_trvs=None crashes MPC sensor _update_state with AttributeError
  (hasattr check passes but .items() called on None)

107 tests pass, covering:
- External temp EMA sensor (state, fallback, invalid values)
- 1h EMA sensor (math correctness, convergence, edge cases)
- Simple attribute sensors (TempSlope, HeatingPower, HeatLoss)
- MPC sensor availability (4 sensors x 5 conditions)
- MPC sensor state from calibration_balance debug data
- Solar intensity sensor (percent conversion, exceptions)
- _get_active_algorithms (enum conversion, invalid modes)
- _setup_algorithm_sensors (MPC creation, filtering, tracking)
- async_setup_entry (no climate guard, core sensor count)
- async_unload_entry (dispatcher cleanup, tracking dicts)
- Stale algorithm entity cleanup (removal, partial, exceptions)
- Preset/PID/switch entity cleanup (removal, tracking, failures)
- Edge cases (NaN, inf, negative solar, list vs dict)

* test: Update sensor tests for base class hierarchy

Adapt test imports and add tests for the new base classes:
- _BtSensorBase: inheritance checks, __init__ verification
- _BtMpcSensorBase: inheritance checks for MPC sensors
- _BtSimpleAttributeSensor: rounding and attribute read logic
- _get_filtered_temp: fallback helper tests

122 tests total (121 pass, 1 expected fail for known real_trvs=None bug)

* test: Update tests for hasattr removal in sensor base classes

Adapt tests that used `del bt.attr` to simulate missing attributes.
Since bt_climate is now typed as BetterThermostat (all attributes always
present), use None/False values instead of deleting attributes.
The `except KeyError` handler in the HomematicIP TRV detection loop
(temperature.py:209) does not catch TypeError, which occurs when:
- `self.all_trvs` is None (TypeError: 'NoneType' not iterable)
- `trv["advanced"]` is None (TypeError: 'NoneType' not subscriptable)

Widen the exception handler to `except (KeyError, TypeError)` so that
malformed TRV data degrades gracefully to the default 5s debounce
instead of crashing the temperature event handler.
The _main_key for reading the cooling setpoint was determined from
old_state.attributes only, then applied to both old and new state.
If the cooler entity switches between "temperature" and
"target_temp_high" attributes across events, the new setpoint was
silently lost (convert_to_float("None") → None → no adoption).

Fix: extract a helper that checks both keys per state independently.
When the cooler setpoint is clamped to min_temp, the heat-target sync
(line 107) pushed bt_target_temp to cooltemp - step, which could go
below bt_min_temp. Also, a zero-valued step made heat == cool,
breaking the heat < cool invariant.

Fix: use max(step, 0.5) to guarantee separation, and clamp the
result to bt_min_temp.
…ler (#1937)

Add a None check after `hass.states.get(entity_id)` in
`trigger_trv_change()`. Without this guard, any of 9 subsequent
accesses to `_org_trv_state.attributes` / `.state` crash with
`AttributeError` when the entity is temporarily missing from the
state registry (e.g. during removal or unavailability).

Follows the same early-return-with-debug-log pattern already used
for the new_state/old_state guard clause above.
@KartoffelToby
Copy link
Owner Author

-> This state is in Beta 13

@KartoffelToby KartoffelToby changed the title RC 1.8.0 Feb 23, 2026
Move the inline _migrate_legacy_stores() method from climate.py to
utils/migrate_v0_stores.py with full test coverage (33 tests).

The module reads the four legacy Store files (MPC, PID, TPI, thermal),
filters entries by entity prefix, and imports them into the unified
StateManager. Runs once on startup when the unified store is empty.
Remove Store import from climate.py (no longer needed directly).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

5 participants