From 6200e16bf2bdedf27b3efbc8e4ca11f36f89fbda Mon Sep 17 00:00:00 2001 From: Constantin Pascal Date: Mon, 16 Feb 2026 17:41:35 +0200 Subject: [PATCH] Fixing frequent switching --- custom_components/boiler_aux_heater/CLAUDE.md | 200 ++++++++++++++++++ .../boiler_aux_heater/binary_sensor.py | 8 + .../boiler_aux_heater/coordinator.py | 6 +- 3 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 custom_components/boiler_aux_heater/CLAUDE.md diff --git a/custom_components/boiler_aux_heater/CLAUDE.md b/custom_components/boiler_aux_heater/CLAUDE.md new file mode 100644 index 0000000..3e33390 --- /dev/null +++ b/custom_components/boiler_aux_heater/CLAUDE.md @@ -0,0 +1,200 @@ +# CLAUDE.md - Boiler Auxiliary Heater Custom Integration + +## Purpose + +Home Assistant custom integration (`boiler_aux_heater`) that manages an auxiliary electric heater mounted inside a DHW (Domestic Hot Water) boiler. The boiler's primary heat source is a heat pump; this integration controls a secondary resistive heater for: + +- **PV excess utilization** - diverts surplus solar export to the heater instead of feeding back to the grid +- **Boost heating** - rapid hot water via bath boost timer with circulation pump control +- **Preset-based temperature control** - Normal, Eco, Boost, Sleep, Away presets with adjustable target temperatures +- **Manual override** - force heater ON for testing/emergency + +## Architecture Overview + +The integration follows a coordinator pattern. `BoilerAuxHeaterCoordinator` is the central brain that: +1. Listens to external entity state changes (boiler temp, grid power, relay state, heat pump aux heater) +2. Reads internal entity states (switches, numbers) via direct references +3. Runs decision logic to determine whether the heater relay should be ON or OFF +4. Controls the physical relay via `switch.turn_on`/`switch.turn_off` service calls + +All entities register themselves with the coordinator on `async_added_to_hass` and receive updates via a listener callback pattern (not HA's `DataUpdateCoordinator`). + +``` +External sensors (boiler temp, CT power x3, HP aux heater, relay state) + | + v (async_track_state_change_event + debounce) +BoilerAuxHeaterCoordinator._recalculate() + | + +-- reads switch states (manual_override, allow_usage, pv_excess_mode, boost_all_modes) + +-- reads number values (cut_off_temp, heater_power, pv_buffer, pv_target_temp, etc.) + +-- runs PV state machine (idle -> waiting_start -> active -> waiting_stop) + +-- computes main thermostat heating (hysteresis against climate target temp) + +-- computes final decision = manual_override OR (safety_ok AND (pv_heating OR bath_boost OR all_modes_boost)) + | + v +Controls physical relay + notifies all entity listeners to update HA state +``` + +## File Structure + +| File | Purpose | +|------|---------| +| `__init__.py` | Entry setup/unload, service registration (`start_bath_boost`), options update listener | +| `const.py` | All constants: domain, config keys, presets, number/switch definitions, tolerances | +| `coordinator.py` | `BoilerAuxHeaterCoordinator` - central logic, PV state machine, bath boost timer, relay control | +| `config_flow.py` | 3-step config flow (core hardware, solar sensors, optional devices) + options flow | +| `climate.py` | `BoilerAuxHeaterClimate` - climate entity with presets, reads boiler temp from external sensor | +| `binary_sensor.py` | 5 binary sensors: decision, pv_sufficient, running_on_pv, running_on_grid, bath_boost_active | +| `sensor.py` | Power sensors (total/pv/grid), energy sensors (Riemann sum integration), bath boost remaining timer | +| `switch.py` | 4 config switches: manual_override, allow_usage, pv_excess_mode, boost_all_modes | +| `number.py` | 13 configurable number entities (temperatures, thresholds, delays, power rating, etc.) | +| `button.py` | Bath boost start button (only created if circulation pump is configured) | +| `services.yaml` | Service definition for `start_bath_boost` | +| `strings.json` | UI strings for config flow and entity names | +| `translations/en.json` | English translations (mirrors strings.json + entity translations) | +| `manifest.json` | Integration manifest: domain=`boiler_aux_heater`, version=1.0.0, iot_class=local_push | + +## Key Classes + +### `BoilerAuxHeaterCoordinator` (coordinator.py) + +Central state manager. **Not** a subclass of HA's `DataUpdateCoordinator` - uses a custom listener pattern instead. + +**State tracked:** +- `pv_state` - PV state machine: `idle` / `waiting_to_start` / `active` / `waiting_to_stop` +- `bath_boost_active` - whether bath boost is running +- `decision` - final ON/OFF decision for the relay +- `pv_sufficient` - whether PV export exceeds heater power needs +- `relay_is_on` - current physical relay state (read from external entity) +- `pv_shadow_heating` - whether PV shadow thermostat logic wants heating + +**Entity registration:** Internal entities call `register_number(key, entity)`, `register_switch(key, entity)`, `register_climate(entity)` during setup. The coordinator reads their values directly via `get_number_value(key)` and `get_switch_state(key)`. + +**External state listening:** Tracks state changes on configured external entities (boiler temp sensor, 3x CT power sensors, heater relay, optionally HP aux heater). Changes are debounced (1s) before triggering `_recalculate()`. + +**PV State Machine:** +- `idle` -> `waiting_to_start` when PV is sufficient (starts delay timer) +- `waiting_to_start` -> `active` when start delay elapses and PV still sufficient +- `waiting_to_start` -> `idle` if PV drops before delay +- `active` -> `waiting_to_stop` when PV drops (starts stop delay timer) +- `waiting_to_stop` -> `active` if PV recovers before stop delay +- `waiting_to_stop` -> `idle` when stop delay elapses + +**Bath Boost Flow:** +1. `async_start_bath_boost()` turns on circulation pump, sets climate to Boost preset, starts countdown timer +2. `_bath_boost_finished()` callback turns off boost, restores pump schedule, turns off pump + +### `BoilerAuxHeaterClimate` (climate.py) + +Single climate entity with: +- HVAC modes: `heat`, `off` +- Presets: Normal, Eco, Boost, Sleep, Away +- Uses `RestoreEntity` to persist state across restarts +- `current_temperature` reads from external boiler temp sensor +- `hvac_action` reports HEATING when relay is on, IDLE when off +- Setting a preset reads the target temp from the corresponding number entity +- Setting Boost preset activates `bath_boost_active` on coordinator; any other preset deactivates it + +### Energy Sensors (sensor.py) + +`BoilerEnergySensor` uses internal Riemann sum integration (not HA's `integration` platform). It accumulates energy by multiplying the heater power rating by elapsed time on each coordinator update. Uses `RestoreEntity` + `SensorStateClass.TOTAL_INCREASING` for long-term tracking. Tracks total, PV-only, and grid-only energy separately. + +## Decision Logic (coordinator.py:_recalculate) + +The heater relay is turned ON when: + +``` +decision = manual_override OR ( + hp_aux_heater_off AND + allow_usage AND + boiler_temp < cut_off_temp AND + ( + (pv_excess_mode AND pv_shadow_heating) OR + (bath_boost_active AND main_thermostat_heating) OR + (boost_all_modes AND main_thermostat_heating) + ) +) +``` + +**Safety defaults for unavailable sensors:** +- Boiler temperature: defaults to `100.0` (heater stays OFF - above any cut-off) +- CT power sensors: default to `0.0` (no export detected - PV won't trigger) +- HP aux heater: treated as OFF if unavailable + +**PV sufficient calculation:** +- When relay is OFF: each of the 3 CT phases must export more than `(buffer + heater_power) / 3` +- When relay is ON (hysteresis): each phase must still be exporting (value <= 0) + +**Main thermostat heating (hysteresis):** +- Turns ON when `boiler_temp < target - MAIN_COLD_TOLERANCE (4.0)` +- Turns OFF when `boiler_temp >= target + MAIN_HOT_TOLERANCE (0.0)` +- In tolerance band: maintains previous state + +## Config Flow (config_flow.py) + +3-step wizard: +1. **Core Hardware**: boiler temp sensor (temperature device class) + heater relay switch +2. **Solar Monitoring**: 3x CT power sensors (power device class) for 3-phase grid monitoring +3. **Optional Devices**: HP aux heater sensor (interlock), circulation pump switch (enables bath boost), pump schedule entity + +Uses `async_set_unique_id(DOMAIN)` + `_abort_if_unique_id_configured()` - only one instance allowed. + +Options flow allows reconfiguring all entity references post-setup. + +## Configurable Parameters (number entities, defined in const.py) + +| Key | Default | Range | Purpose | +|-----|---------|-------|---------| +| `cut_off_temp` | 60 | 40-85 C | Hard safety limit - heater stops above this | +| `temp_hysteresis` | 4 | 1-15 C | **Defined but not used in coordinator** | +| `pv_target_temp` | 55 | 40-70 C | Target temp for PV shadow thermostat | +| `pv_buffer` | 100 | 0-1000 W | Extra export required before PV triggers | +| `heater_power` | 4500 | 1500-9000 W | Heater wattage (used for PV threshold + energy calc) | +| `pv_start_delay` | 60 | 1-300 s | Delay before activating PV mode | +| `pv_stop_delay` | 60 | 1-300 s | Delay before deactivating PV mode | +| `temp_normal` | 44 | 30-65 C | Normal preset target | +| `temp_eco` | 40 | 30-60 C | Eco preset target | +| `temp_boost` | 50 | 40-70 C | Boost preset target | +| `temp_sleep` | 38 | 25-55 C | Sleep preset target | +| `temp_away` | 35 | 20-50 C | Away preset target | +| `bath_boost_duration` | 30 | 5-120 min | How long bath boost runs | + +## Mode Switches (switch entities, defined in const.py) + +| Key | Default | Purpose | +|-----|---------|---------| +| `manual_override` | OFF | Bypass all logic, force relay ON | +| `allow_usage` | ON | Master enable for aux heater | +| `pv_excess_mode` | ON | Enable PV excess diversion | +| `boost_all_modes` | OFF | Allow main thermostat heating to trigger relay (not just PV/bath boost) | + +## Known Issues / Design Notes + +1. **`temp_hysteresis` number entity is defined but unused** - The coordinator uses hardcoded `MAIN_COLD_TOLERANCE` (4.0) and `MAIN_HOT_TOLERANCE` (0.0) instead of reading from this entity. + +2. **Custom coordinator pattern** - Does not extend `homeassistant.helpers.update_coordinator.DataUpdateCoordinator`. Uses its own listener list and manual `_notify_listeners()`. This means no built-in polling fallback. + +3. **Energy sensors use `time.monotonic()`** - Energy integration accuracy depends on how frequently `_recalculate` fires (debounced to 1s on external state changes). Long periods without state changes won't accumulate energy even if the relay is on. + +4. **Single instance only** - Config flow enforces one instance via unique_id. Multiple boiler setups would need code changes. + +5. **3-phase assumption** - PV calculation assumes 3-phase power with per-phase CTs. All 3 phases must meet threshold independently (no sum/average). + +6. **Bath boost button conditional** - `button.py` only creates the bath boost button if `circulation_pump` is configured. The `start_bath_boost` service is always registered regardless. + +7. **Relay control via service calls** - The coordinator calls `switch.turn_on`/`turn_off` services to control the relay rather than directly setting state. Failure is caught and logged but doesn't retry. + +8. **`bath_boost_active` is toggled from two places** - Both `climate.async_set_preset_mode()` (when selecting/deselecting Boost preset) and `coordinator.async_start_bath_boost()` / `_bath_boost_finished()` modify `bath_boost_active`. The bath boost timer in the coordinator and the preset selection in the climate entity are loosely coupled. + +9. **No `async_setup` (YAML config)** - Integration only supports config entries (UI setup), not YAML configuration. + +10. **`_enable_turn_on_off_backwards_compat = False`** in climate - opts out of HA 2024.x backwards compatibility for `turn_on`/`turn_off` but doesn't declare `ClimateEntityFeature.TURN_ON | TURN_OFF`. + +## Patterns Used + +- **RestoreEntity** on climate, switch, number, and energy sensors for state persistence across restarts +- **Entity registration with coordinator** via `register_*()` methods during `async_added_to_hass` +- **Data-driven entity creation** from `NUMBER_DEFINITIONS`, `SWITCH_DEFINITIONS`, `BINARY_SENSOR_DEFINITIONS` dicts +- **Debounced state change handling** with `async_call_later` (1s debounce) +- **Translation keys** (`_attr_translation_key`) for all entities with translations in `translations/en.json` +- **DeviceInfo** with shared identifiers groups all entities under one device in HA \ No newline at end of file diff --git a/custom_components/boiler_aux_heater/binary_sensor.py b/custom_components/boiler_aux_heater/binary_sensor.py index b5d2bed..15fbc9f 100644 --- a/custom_components/boiler_aux_heater/binary_sensor.py +++ b/custom_components/boiler_aux_heater/binary_sensor.py @@ -38,6 +38,12 @@ BINARY_SENSOR_DEFINITIONS = { "icon_off": "mdi:transmission-tower-off", "device_class": None, }, + "bath_boost_active": { + "name": "Bath Boost Active", + "icon_on": "mdi:flash", + "icon_off": "mdi:flash-off", + "device_class": None, + }, } @@ -112,6 +118,8 @@ class BoilerAuxHeaterBinarySensor(BinarySensorEntity): return self.coordinator.relay_is_on and self.coordinator.pv_sufficient if self._key == "running_on_grid": return self.coordinator.relay_is_on and not self.coordinator.pv_sufficient + if self._key == "bath_boost_active": + return self.coordinator.bath_boost_active return False @property diff --git a/custom_components/boiler_aux_heater/coordinator.py b/custom_components/boiler_aux_heater/coordinator.py index 4d680b9..f28b560 100644 --- a/custom_components/boiler_aux_heater/coordinator.py +++ b/custom_components/boiler_aux_heater/coordinator.py @@ -26,7 +26,6 @@ from .const import ( DOMAIN, MAIN_COLD_TOLERANCE, MAIN_HOT_TOLERANCE, - MIN_CYCLE_DURATION, PRESET_BOOST, PV_COLD_TOLERANCE, PV_HOT_TOLERANCE, @@ -71,8 +70,6 @@ class BoilerAuxHeaterCoordinator: self._bath_boost_end_time: float | None = None self._pv_delay_handle: CALLBACK_TYPE | None = None self._debounce_handle: CALLBACK_TYPE | None = None - self._min_cycle_handle: CALLBACK_TYPE | None = None - self._last_relay_change: float = 0 # Computed states (updated by recalculate) self.decision: bool = False @@ -240,7 +237,7 @@ class BoilerAuxHeaterCoordinator: ) # PV shadow thermostat logic - if not (pv_excess_mode and self.pv_state == PV_STATE_ACTIVE): + if not (pv_excess_mode and self.pv_state in (PV_STATE_ACTIVE, PV_STATE_WAITING_STOP)): self.pv_shadow_heating = False else: # Shadow thermostat: is boiler temp below PV target (with tolerance)? @@ -288,7 +285,6 @@ class BoilerAuxHeaterCoordinator: allowed and (pv_heating or bath_boost_heating or all_modes_heating) ) - # Control relay if decision changed if self.decision != old_decision: self.hass.async_create_task(self._async_control_relay(self.decision))