Fixing frequent switching

This commit is contained in:
Constantin Pascal
2026-02-16 17:41:35 +02:00
parent b5fbca8453
commit 6200e16bf2
3 changed files with 209 additions and 5 deletions
@@ -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
@@ -38,6 +38,12 @@ BINARY_SENSOR_DEFINITIONS = {
"icon_off": "mdi:transmission-tower-off", "icon_off": "mdi:transmission-tower-off",
"device_class": None, "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 return self.coordinator.relay_is_on and self.coordinator.pv_sufficient
if self._key == "running_on_grid": if self._key == "running_on_grid":
return self.coordinator.relay_is_on and not self.coordinator.pv_sufficient 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 return False
@property @property
@@ -26,7 +26,6 @@ from .const import (
DOMAIN, DOMAIN,
MAIN_COLD_TOLERANCE, MAIN_COLD_TOLERANCE,
MAIN_HOT_TOLERANCE, MAIN_HOT_TOLERANCE,
MIN_CYCLE_DURATION,
PRESET_BOOST, PRESET_BOOST,
PV_COLD_TOLERANCE, PV_COLD_TOLERANCE,
PV_HOT_TOLERANCE, PV_HOT_TOLERANCE,
@@ -71,8 +70,6 @@ class BoilerAuxHeaterCoordinator:
self._bath_boost_end_time: float | None = None self._bath_boost_end_time: float | None = None
self._pv_delay_handle: CALLBACK_TYPE | None = None self._pv_delay_handle: CALLBACK_TYPE | None = None
self._debounce_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) # Computed states (updated by recalculate)
self.decision: bool = False self.decision: bool = False
@@ -240,7 +237,7 @@ class BoilerAuxHeaterCoordinator:
) )
# PV shadow thermostat logic # 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 self.pv_shadow_heating = False
else: else:
# Shadow thermostat: is boiler temp below PV target (with tolerance)? # 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) allowed and (pv_heating or bath_boost_heating or all_modes_heating)
) )
# Control relay if decision changed
if self.decision != old_decision: if self.decision != old_decision:
self.hass.async_create_task(self._async_control_relay(self.decision)) self.hass.async_create_task(self._async_control_relay(self.decision))