Initial push

This commit is contained in:
Constantin Pascal
2026-02-26 09:58:44 +02:00
commit 73d5b8aeca
20 changed files with 3558 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
buy_me_a_coffee: costea32
+46
View File
@@ -0,0 +1,46 @@
name: Create Release
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., 1.1.5)'
required: true
type: string
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Update manifest.json
run: |
sed -i 's/"version": ".*"/"version": "${{ github.event.inputs.version }}"/' custom_components/boiler_aux_heater_3phase/manifest.json
- name: Commit manifest change
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add custom_components/boiler_aux_heater_3phase/manifest.json
git commit -m "Bump version to ${{ github.event.inputs.version }}"
git push
- name: Create tag
run: |
git tag v${{ github.event.inputs.version }}
git push origin v${{ github.event.inputs.version }}
- name: Create GitHub Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ github.event.inputs.version }}
release_name: v${{ github.event.inputs.version }}
draft: true
prerelease: false
+215
View File
@@ -0,0 +1,215 @@
# CLAUDE.md - Boiler Auxiliary Heater Custom Integration
## Purpose
Home Assistant custom integration (`boiler_aux_heater_3phase`) 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 optional circulation pump control
- **Preset-based temperature control** - Normal, Eco, Boost, Sleep, Away presets with adjustable target temperatures
- **All-modes boost** - allow the aux heater to run whenever the thermostat demands heat (not just PV/bath boost)
- **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 x1-3, 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 all-modes heating demand (separate larger hysteresis + recovery offset)
+-- 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`, `stop_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), bath boost remaining timer, decision_reason, pv_state |
| `switch.py` | 4 config switches: manual_override, allow_usage, pv_excess_mode, boost_all_modes |
| `number.py` | 15 configurable number entities (temperatures, thresholds, delays, power rating, etc.) |
| `button.py` | Bath boost start + cancel buttons (only created if circulation pump is configured) |
| `services.yaml` | Service definitions for `start_bath_boost` and `stop_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_3phase`, version=1.0.0, iot_class=local_push |
| `dashboard.yaml` | Ready-to-import Lovelace dashboard for monitoring and control |
## 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
- `decision_reason` - human-readable string explaining the current decision
- `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, 1-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()` stores pre-boost preset, turns on circulation pump, sets climate to Boost preset, starts countdown timer
2. `async_stop_bath_boost()` can be called manually (cancel button / service) to end early
3. `_bath_boost_finished()` callback fires when timer expires - turns off boost, restores pump schedule, restores previous preset, fires HA event
**Boost State Persistence:** Bath boost state (active, end_time, pre_boost_preset) is persisted to HA storage so it survives HA restarts.
### `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 (calls `async_stop_bath_boost`)
### 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.
A periodic 60-second energy tick (`async_track_time_interval`) notifies listeners to accumulate energy during steady state when no external events fire.
### `PvTargetTempNumber` (number.py)
Special subclass of `BoilerAuxHeaterNumber` whose `native_max_value` is dynamically capped at `cut_off_temp - 1`, preventing the PV target from exceeding the safety cut-off.
## Decision Logic (coordinator.py:_recalculate)
The heater relay is turned ON when (evaluated in priority order):
```
1. manual_override → ON (bypasses everything)
2. NOT allow_usage → OFF
3. hp_aux_heater sensor unavailable → OFF (safety)
4. hp_aux_on → OFF (interlock)
5. boiler_temp >= cut_off_temp → OFF (safety)
6. hvac_mode == "off" → OFF
7. pv_excess_mode AND pv_shadow_heating → ON (PV excess)
8. bath_boost_active AND main_thermostat_heating → ON (bath boost)
9. boost_all_modes AND all_modes_heating_demand → ON (all-modes boost)
10. otherwise → OFF
```
**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 ON (heater disabled) if unavailable - SAFETY interlock
**PV sufficient calculation (supports 1 or 3 phases):**
- When relay is OFF: each active CT phase must export more than `(buffer + heater_power) / phase_count`
- When relay is ON (hysteresis): each active phase must still be exporting (value <= 0)
**Main thermostat heating (hysteresis):**
- Turns ON when `boiler_temp < target - temp_hysteresis`
- Turns OFF when `boiler_temp >= target + MAIN_HOT_TOLERANCE (0.0)`
**All-modes heating demand (separate, larger hysteresis):**
- Turns ON when `boiler_temp < target - boost_all_modes_hysteresis`
- Turns OFF when `boiler_temp >= target - boost_recovery_offset`
## Config Flow (config_flow.py)
3-step wizard:
1. **Core Hardware**: boiler temp sensor (temperature device class) + heater relay switch
2. **Solar Monitoring**: phase count (1 or 3) + CT power sensors (power device class) for 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 | Hysteresis for main thermostat and PV shadow thermostat |
| `pv_target_temp` | 55 | 40-70 °C | Target temp for PV shadow thermostat (max capped at cut_off - 1) |
| `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 |
| `boost_all_modes_hysteresis` | 10 | 3-20 °C | How far below target before all-modes boost activates |
| `boost_recovery_offset` | 3 | 0-15 °C | How close to target before all-modes boost deactivates |
## 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 to trigger relay outside PV/bath boost modes |
## Known Issues / Design Notes
1. **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.
2. **Energy sensors use `time.monotonic()`** - Energy integration accuracy depends on how frequently `_recalculate` fires (debounced to 1s on external state changes) plus the 60s periodic tick. Long periods without either would not accumulate energy.
3. **Single instance only** - Config flow enforces one instance via unique_id. Multiple boiler setups would need code changes.
4. **3-phase assumption for full accuracy** - PV calculation with 3 phases requires all 3 phases to independently meet the threshold. Single-phase mode supported (only CT1 used).
5. **Bath boost button conditional** - `button.py` only creates the bath boost buttons if `circulation_pump` is configured. The `start_bath_boost`/`stop_bath_boost` services are always registered regardless.
6. **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.
7. **`_enable_turn_on_off_backwards_compat = False`** in climate - opts out of HA 2024.x backwards compatibility for `turn_on`/`turn_off`.
8. **No `async_setup` (YAML config)** - Integration only supports config entries (UI setup), not YAML configuration.
## Patterns Used
- **RestoreEntity** on climate, switch, number, and energy sensors for state persistence across restarts
- **Persistent storage** (`homeassistant.helpers.storage.Store`) for bath boost state 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
+338
View File
@@ -0,0 +1,338 @@
# Boiler Auxiliary Heater Control
A Home Assistant custom integration for intelligently controlling an auxiliary electric heating element inside a DHW (Domestic Hot Water) boiler. Designed for systems where a heat pump is the primary heat source and an electric resistance heater is the secondary element.
The integration maximises PV self-consumption by diverting surplus solar energy to the boiler, while also supporting on-demand bath boost heating and fully configurable temperature presets.
---
## Features
- **PV Excess Diversion** — monitors up to 3 CT clamps (single-phase or 3-phase) and activates the heater only when there is enough surplus export, using configurable start/stop delays to avoid rapid cycling
- **Bath Boost** — one-tap hot water on demand; activates the heater and optionally runs the DHW circulation pump for a configurable duration, then automatically restores the previous state
- **5 Temperature Presets** — Normal, Eco, Boost, Sleep, Away, each with individually adjustable target temperatures
- **All-Modes Boost** — optional mode that lets the aux heater top up the boiler whenever the thermostat demands heat, regardless of PV availability
- **Safety Interlocks** — hard cut-off temperature, HP aux heater interlock (disables aux heater while the heat pump's own electric element is running), and safe defaults for unavailable sensors
- **Energy Tracking** — separate kWh counters for total, PV-sourced, and grid-sourced energy, compatible with HA's Energy Dashboard
- **Decision Transparency** — a `Decision Reason` sensor tells you exactly why the heater is on or off at any moment
- **Persistent State** — all settings and an active bath boost survive HA restarts
- **Ready-to-Use Dashboard** — an importable Lovelace dashboard is included
---
## Requirements
- Home Assistant 2024.1.0 or newer
- A temperature sensor measuring the boiler water temperature (device class: `temperature`)
- A switch entity controlling the heater relay
- One or more power sensors measuring grid import/export at each phase (device class: `power`, **negative = export**)
Optional:
- A binary sensor indicating whether the heat pump's own auxiliary heater is active (for interlock)
- A switch entity for a DHW circulation pump (enables the bath boost button)
- A scheduler entity for the pump (its schedule is restored after bath boost)
---
## Installation
### Via HACS (recommended)
1. Open HACS in Home Assistant
2. Go to **Integrations** → click the three-dot menu → **Custom repositories**
3. Add this repository URL and select category **Integration**
4. Search for **Boiler Auxiliary Heater Control** and install
5. Restart Home Assistant
### Manual
1. Copy the `custom_components/boiler_aux_heater_3phase` folder into your HA `config/custom_components/` directory
2. Restart Home Assistant
---
## Configuration
Navigate to **Settings → Devices & Services → Add Integration** and search for **Boiler Auxiliary Heater Control**.
The setup wizard has three steps:
### Step 1 — Core Hardware
| Field | Description |
|-------|-------------|
| Boiler temperature sensor | Sensor measuring the DHW tank temperature (must have device class `temperature`) |
| Heater relay switch | The switch entity that controls the heating element |
### Step 2 — Solar Monitoring
| Field | Description |
|-------|-------------|
| Number of phases | `1 Phase` or `3 Phases` |
| Phase 1 CT power sensor | Grid power sensor for phase 1 (negative = exporting) |
| Phase 2 CT power sensor | Phase 2 sensor (3-phase only, optional) |
| Phase 3 CT power sensor | Phase 3 sensor (3-phase only, optional) |
> **Important:** the CT sensors must report **negative values for export** and positive values for import. This is the convention used by most inverter integrations (Fronius, SolarEdge, Huawei, etc.).
### Step 3 — Optional Devices
| Field | Description |
|-------|-------------|
| HP aux heater sensor | Binary sensor that is `on` when the heat pump's own electric element is active. If provided, the aux heater is disabled while this sensor is `on`. If the sensor becomes **unavailable**, the aux heater is disabled as a safety measure. |
| Circulation pump switch | Switch controlling a DHW circulation pump. When provided, the **Bath Boost** button entities are created. |
| Pump schedule entity | A scheduler entity whose schedule is re-applied after bath boost finishes. |
After initial setup, all entity references can be changed via **Configure** on the integration card.
---
## Entities
### Climate
| Entity | Description |
|--------|-------------|
| `climate.boiler_auxiliary_heater` | Main thermostat. Set HVAC mode (`heat`/`off`) and choose a preset. |
**Presets:** Normal · Eco · Boost · Sleep · Away
Selecting the **Boost** preset starts the bath boost timer. Selecting any other preset while boost is active cancels the boost.
---
### Switches (Configuration)
| Entity | Default | Description |
|--------|---------|-------------|
| `switch…allow_aux_heater_usage` | ON | Master enable. Turn off to disable the aux heater entirely. |
| `switch…pv_excess_mode` | ON | Enable PV excess diversion. |
| `switch…boost_in_all_modes` | OFF | Allow the heater to run whenever the thermostat demands heat, regardless of PV. Uses the larger all-modes hysteresis to avoid short cycling. |
| `switch…manual_override` | OFF | Force the relay ON, bypassing all logic. For testing or emergencies only. |
---
### Numbers (Configuration)
**Temperatures**
| Entity | Default | Range | Description |
|--------|---------|-------|-------------|
| `number…cut_off_temperature` | 60 °C | 40–85 | Hard safety limit. Heater is always off above this temperature. |
| `number…temperature_hysteresis` | 4 °C | 1–15 | How far below the target temperature heating must be demanded before activating. Also used as PV shadow thermostat cold tolerance. |
| `number…pv_target_temperature` | 55 °C | 40–70 | Target temperature for PV excess mode. Max is automatically capped at `cut_off_temp - 1`. |
| `number…normal_preset_temperature` | 44 °C | 30–65 | Target for Normal preset. |
| `number…eco_preset_temperature` | 40 °C | 30–60 | Target for Eco preset. |
| `number…boost_preset_temperature` | 50 °C | 40–70 | Target for Boost preset. |
| `number…sleep_preset_temperature` | 38 °C | 25–55 | Target for Sleep preset. |
| `number…away_preset_temperature` | 35 °C | 20–50 | Target for Away preset. |
**PV Excess Settings**
| Entity | Default | Range | Description |
|--------|---------|-------|-------------|
| `number…heater_power_rating` | 4500 W | 1500–9000 | Rated wattage of the heating element. Used for PV surplus threshold and energy calculations. |
| `number…pv_excess_buffer` | 100 W | 0–1000 | Additional margin required above heater power before PV mode activates. Prevents marginal activation. |
| `number…pv_excess_start_delay` | 60 s | 1–300 | How long PV surplus must be sustained before activating. Prevents cloud transients from triggering the heater. |
| `number…pv_excess_stop_delay` | 60 s | 1–300 | How long PV deficit must be sustained before deactivating. Allows short shading events without turning off. |
**Bath Boost & All-Modes Settings**
| Entity | Default | Range | Description |
|--------|---------|-------|-------------|
| `number…bath_boost_duration` | 30 min | 5–120 | How long bath boost runs. |
| `number…all_modes_boost_hysteresis` | 10 °C | 3–20 | Temperature must be this far below target for all-modes boost to activate. Larger value → heater activates less often. |
| `number…boost_recovery_offset` | 3 °C | 0–15 | All-modes boost turns off when temperature reaches `target - boost_recovery_offset`. |
---
### Binary Sensors (Status)
| Entity | Description |
|--------|-------------|
| `binary_sensor…heater_decision` | `on` = heater relay should be on right now. |
| `binary_sensor…pv_power_sufficient` | `on` = current PV export is enough to run the heater. |
| `binary_sensor…running_on_pv` | `on` = heater is running and powered by PV surplus. |
| `binary_sensor…running_on_grid` | `on` = heater is running on grid power (bath boost or all-modes boost). |
| `binary_sensor…bath_boost_active` | `on` = bath boost timer is running. |
---
### Sensors (Status)
| Entity | Description |
|--------|-------------|
| `sensor…heater_power` | Current total power consumption of the heater (W). |
| `sensor…heater_pv_power` | Current power drawn from PV surplus (W). |
| `sensor…heater_grid_power` | Current power drawn from the grid (W). |
| `sensor…heater_energy` | Total energy consumed (kWh) — suitable for HA Energy Dashboard. |
| `sensor…heater_pv_energy` | Energy consumed from PV (kWh). |
| `sensor…heater_grid_energy` | Energy consumed from grid (kWh). |
| `sensor…bath_boost_remaining` | Seconds remaining in the current bath boost (0 when inactive). |
| `sensor…decision_reason` | Human-readable text explaining the current heater decision, e.g. *"PV excess heating"* or *"OFF: HP aux heater active"*. |
| `sensor…pv_excess_state` | PV state machine state: `idle`, `waiting_to_start`, `active`, or `waiting_to_stop`. |
---
### Buttons (optional — only if circulation pump is configured)
| Entity | Description |
|--------|-------------|
| `button…start_bath_boost` | Start the bath boost timer immediately. |
| `button…cancel_bath_boost` | Cancel an active bath boost and restore previous state. |
---
## Services
Both services are always available, regardless of whether a circulation pump is configured.
### `boiler_aux_heater_3phase.start_bath_boost`
Starts the bath boost. Saves the current climate preset, activates the Boost preset, starts the countdown timer, and (if configured) turns on the circulation pump.
```yaml
service: boiler_aux_heater_3phase.start_bath_boost
```
### `boiler_aux_heater_3phase.stop_bath_boost`
Cancels an active bath boost, restores the previous preset, and (if configured) turns off the circulation pump and re-applies the pump schedule.
```yaml
service: boiler_aux_heater_3phase.stop_bath_boost
```
---
## Events
When a bath boost expires naturally (timer runs out), the integration fires:
```
event: boiler_aux_heater_3phase_boost_finished
data:
duration_minutes: 30
pre_boost_preset: "Normal"
restored_preset: "Normal"
```
You can use this event in automations to notify occupants or take further action.
---
## How the PV Excess Logic Works
```
For each active CT phase:
Turn-on threshold: CT_value < -(heater_power / phases + pv_buffer / phases)
Stay-on threshold: CT_value <= 0 (any export at all)
State machine:
IDLE ──[all phases meet turn-on threshold]──► WAITING_TO_START
WAITING_TO_START ──[pv_start_delay elapsed]──► ACTIVE
WAITING_TO_START ──[PV drops]──► IDLE
ACTIVE ──[any phase stops exporting]──► WAITING_TO_STOP
WAITING_TO_STOP ──[pv_stop_delay elapsed]──► IDLE
WAITING_TO_STOP ──[PV recovers]──► ACTIVE
```
While in `ACTIVE` or `WAITING_TO_STOP`, the PV shadow thermostat checks whether the boiler temperature is below `pv_target_temp`. This prevents unnecessarily overheating the tank on very sunny days.
---
## Dashboard
A ready-to-use Lovelace dashboard is included in `custom_components/boiler_aux_heater_3phase/dashboard.yaml`.
To import it:
1. Go to **Settings → Dashboards → Add Dashboard**
2. Or use the **Raw Configuration Editor** in an existing dashboard and paste the YAML
The dashboard includes sections for:
- Thermostat control with preset dropdown
- Bath boost controls and timer
- Mode switches
- Preset temperature configuration
- Safety and heater settings
- PV excess settings
- Live status (decision, PV state, power & energy)
---
## Automation Examples
### Notify when bath boost finishes
```yaml
automation:
- alias: "Notify bath boost done"
trigger:
- platform: event
event_type: boiler_aux_heater_3phase_boost_finished
action:
- service: notify.mobile_app_my_phone
data:
message: "Hot water ready! Tank heated for {{ trigger.event.data.duration_minutes }} minutes."
```
### Switch to Away preset when leaving home
```yaml
automation:
- alias: "Boiler away mode"
trigger:
- platform: state
entity_id: person.resident
to: not_home
action:
- service: climate.set_preset_mode
target:
entity_id: climate.boiler_auxiliary_heater
data:
preset_mode: Away
```
### Start bath boost on a schedule
```yaml
automation:
- alias: "Morning bath boost"
trigger:
- platform: time
at: "07:00:00"
condition:
- condition: state
entity_id: binary_sensor.workday_sensor
state: "on"
action:
- service: boiler_aux_heater_3phase.start_bath_boost
```
---
## Troubleshooting
**Heater never activates in PV mode**
- Check `sensor…pv_excess_state` — if it stays `idle`, the CT sensors may not be reporting negative values when exporting. Confirm the sign convention of your inverter integration.
- Check that `switch…pv_excess_mode` is on and `switch…allow_aux_heater_usage` is on.
- Check `sensor…decision_reason` for the exact reason the heater is off.
- Increase `pv_excess_buffer` to 0 temporarily to rule out threshold issues.
**HP aux heater interlock keeps the heater off**
- If the HP aux heater binary sensor reads `unavailable`, the integration disables the aux heater as a safety measure. Check the sensor entity or remove it from the configuration if not needed.
**Bath boost buttons are missing**
- The bath boost buttons are only created when a circulation pump switch is configured. Go to **Configure** on the integration card to add one. The `start_bath_boost` and `stop_bath_boost` services are always available regardless.
**Energy counters reset after restart**
- Energy sensors use `RestoreEntity` to persist their values. If values are lost, check that the HA recorder is running and that the entities are not excluded from recording.
**PV mode activates but heater stays off**
- Check `sensor…decision_reason`. Common causes: boiler temperature above `cut_off_temperature`, HVAC mode set to `off`, or `allow_aux_heater_usage` switch is off.
---
## License
MIT License — see [LICENSE](LICENSE) for details.
@@ -0,0 +1,82 @@
"""Boiler Auxiliary Heater Control integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall
from .const import DOMAIN, PLATFORMS
from .coordinator import BoilerAuxHeaterCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Boiler Auxiliary Heater from a config entry."""
config = {**entry.data, **entry.options}
coordinator = BoilerAuxHeaterCoordinator(hass, config)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Start listening to external entities after platforms are set up
await coordinator.async_setup()
# Register services
async def handle_start_bath_boost(call: ServiceCall) -> None:
"""Handle the start_bath_boost service call."""
for coord in hass.data[DOMAIN].values():
if isinstance(coord, BoilerAuxHeaterCoordinator):
await coord.async_start_bath_boost()
async def handle_stop_bath_boost(call: ServiceCall) -> None:
"""Handle the stop_bath_boost service call."""
for coord in hass.data[DOMAIN].values():
if isinstance(coord, BoilerAuxHeaterCoordinator):
await coord.async_stop_bath_boost()
hass.services.async_register(
DOMAIN, "start_bath_boost", handle_start_bath_boost
)
hass.services.async_register(
DOMAIN, "stop_bath_boost", handle_stop_bath_boost
)
# Listen for options updates
entry.async_on_unload(entry.add_update_listener(async_options_updated))
return True
async def async_options_updated(
hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Handle options update."""
coordinator: BoilerAuxHeaterCoordinator = hass.data[DOMAIN][entry.entry_id]
new_config = {**entry.data, **entry.options}
coordinator.update_config(new_config)
# B8: Only shut down external listeners, preserve boost timer and PV delays
coordinator._async_shutdown_listeners()
await coordinator.async_setup()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
coordinator: BoilerAuxHeaterCoordinator = hass.data[DOMAIN][entry.entry_id]
await coordinator.async_shutdown()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Remove services if no more entries
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "start_bath_boost")
hass.services.async_remove(DOMAIN, "stop_bath_boost")
return unload_ok
@@ -0,0 +1,130 @@
"""Binary sensor entities for Boiler Auxiliary Heater Control."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
BINARY_SENSOR_DEFINITIONS = {
"decision": {
"name": "Heater Decision",
"icon_on": "mdi:water-boiler",
"icon_off": "mdi:water-boiler-off",
"device_class": None,
},
"pv_sufficient": {
"name": "PV Power Sufficient",
"icon_on": "mdi:solar-power",
"icon_off": "mdi:solar-power-variant-outline",
"device_class": BinarySensorDeviceClass.POWER,
},
"running_on_pv": {
"name": "Running on PV",
"icon_on": "mdi:solar-power",
"icon_off": "mdi:solar-power-variant-outline",
"device_class": None,
},
"running_on_grid": {
"name": "Running on Grid",
"icon_on": "mdi:transmission-tower",
"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,
},
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
coordinator = hass.data[DOMAIN][entry.entry_id]
entities = []
for key, defn in BINARY_SENSOR_DEFINITIONS.items():
entities.append(
BoilerAuxHeaterBinarySensor(coordinator, entry, key, defn)
)
async_add_entities(entities)
class BoilerAuxHeaterBinarySensor(BinarySensorEntity):
"""A computed binary sensor for the boiler aux heater."""
_attr_has_entity_name = True
def __init__(
self,
coordinator,
entry: ConfigEntry,
key: str,
defn: dict,
) -> None:
"""Initialize the binary sensor."""
self.coordinator = coordinator
self._key = key
self._defn = defn
self._attr_unique_id = f"{entry.entry_id}_{key}"
self._attr_translation_key = key
if defn["device_class"]:
self._attr_device_class = defn["device_class"]
self._unsub_listener = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Register with coordinator for updates."""
self._unsub_listener = self.coordinator.async_add_listener(
self._handle_coordinator_update
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up listener."""
if self._unsub_listener:
self._unsub_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Update state from coordinator."""
self.async_write_ha_state()
@property
def is_on(self) -> bool:
"""Return computed state from coordinator."""
if self._key == "decision":
return self.coordinator.decision
if self._key == "pv_sufficient":
return self.coordinator.pv_sufficient
if self._key == "running_on_pv":
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
def icon(self) -> str:
"""Return icon based on state."""
if self.is_on:
return self._defn["icon_on"]
return self._defn["icon_off"]
@@ -0,0 +1,74 @@
"""Button entity for Boiler Auxiliary Heater Control."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button entities."""
coordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[ButtonEntity] = []
# Only create bath boost buttons if circulation pump is configured
if coordinator.circulation_pump:
entities.append(BoilerBathBoostButton(coordinator, entry))
entities.append(BoilerCancelBathBoostButton(coordinator, entry))
if entities:
async_add_entities(entities)
class BoilerBathBoostButton(ButtonEntity):
"""Button to start the bath boost timer."""
_attr_has_entity_name = True
_attr_translation_key = "start_bath_boost"
_attr_icon = "mdi:bath"
def __init__(self, coordinator, entry: ConfigEntry) -> None:
"""Initialize the button."""
self.coordinator = coordinator
self._attr_unique_id = f"{entry.entry_id}_start_bath_boost"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_press(self) -> None:
"""Handle button press - start bath boost."""
await self.coordinator.async_start_bath_boost()
class BoilerCancelBathBoostButton(ButtonEntity):
"""Button to cancel an active bath boost (U1)."""
_attr_has_entity_name = True
_attr_translation_key = "cancel_bath_boost"
_attr_icon = "mdi:bath"
def __init__(self, coordinator, entry: ConfigEntry) -> None:
"""Initialize the button."""
self.coordinator = coordinator
self._attr_unique_id = f"{entry.entry_id}_cancel_bath_boost"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_press(self) -> None:
"""Handle button press - cancel bath boost."""
await self.coordinator.async_stop_bath_boost()
@@ -0,0 +1,184 @@
"""Climate entity for Boiler Auxiliary Heater Control."""
from __future__ import annotations
from typing import Any
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
DOMAIN,
PRESET_BOOST,
PRESET_NORMAL,
PRESET_TEMP_MAP,
PRESETS,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the climate entity."""
coordinator = hass.data[DOMAIN][entry.entry_id]
entity = BoilerAuxHeaterClimate(coordinator, entry)
async_add_entities([entity])
class BoilerAuxHeaterClimate(RestoreEntity, ClimateEntity):
"""Unified climate entity with native presets and dual thermostat logic."""
_attr_has_entity_name = True
_attr_translation_key = "boiler_aux_heater"
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_min_temp = 10
_attr_max_temp = 70
_attr_target_temperature_step = 1
_attr_precision = 0.1
_enable_turn_on_off_backwards_compat = False
def __init__(self, coordinator, entry: ConfigEntry) -> None:
"""Initialize the climate entity."""
self.coordinator = coordinator
self._attr_unique_id = f"{entry.entry_id}_climate"
self._attr_preset_modes = list(PRESETS)
self._attr_preset_mode = PRESET_NORMAL
self._attr_hvac_mode = HVACMode.HEAT
self._attr_target_temperature = 50.0
self._unsub_listener = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Restore state and register with coordinator."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state:
if last_state.state in ("heat", "off"):
self._attr_hvac_mode = HVACMode(last_state.state)
attrs = last_state.attributes
if "temperature" in attrs:
try:
self._attr_target_temperature = float(attrs["temperature"])
except (ValueError, TypeError):
pass
if "preset_mode" in attrs and attrs["preset_mode"] in PRESETS:
self._attr_preset_mode = attrs["preset_mode"]
# Register with coordinator
self.coordinator.register_climate(self)
self._unsub_listener = self.coordinator.async_add_listener(
self._handle_coordinator_update
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up listener."""
if self._unsub_listener:
self._unsub_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Update entity when coordinator state changes."""
self.async_write_ha_state()
@property
def current_temperature(self) -> float | None:
"""Return current boiler temperature from external sensor."""
state = self.hass.states.get(self.coordinator.boiler_temp_sensor)
if state is None or state.state in ("unknown", "unavailable"):
return None
try:
return float(state.state)
except (ValueError, TypeError):
return None
@property
def hvac_action(self) -> HVACAction:
"""Return current action based on relay state."""
if self._attr_hvac_mode == HVACMode.OFF:
return HVACAction.OFF
if self.coordinator.relay_is_on:
return HVACAction.HEATING
return HVACAction.IDLE
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVAC mode."""
self._attr_hvac_mode = hvac_mode
if hvac_mode == HVACMode.OFF:
self._attr_preset_mode = None
# Stop boost if turning off
if self.coordinator.bath_boost_active:
await self.coordinator.async_stop_bath_boost()
self.async_write_ha_state()
self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set target temperature."""
if ATTR_TEMPERATURE in kwargs:
self._attr_target_temperature = kwargs[ATTR_TEMPERATURE]
self.async_write_ha_state()
self.coordinator.async_request_refresh()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode and update target temperature from number entity."""
# Capture pre-boost preset BEFORE overwriting _attr_preset_mode
if preset_mode == PRESET_BOOST and not self.coordinator.bath_boost_active:
self.coordinator._pre_boost_preset = self._attr_preset_mode
self._attr_preset_mode = preset_mode
self._attr_hvac_mode = HVACMode.HEAT
# Get target temperature from the corresponding number entity
temp_key = PRESET_TEMP_MAP.get(preset_mode)
if temp_key:
temp = self.coordinator.get_number_value(temp_key)
if temp is not None:
self._attr_target_temperature = temp
# B2: Toggle bath boost via coordinator for proper lifecycle
if preset_mode == PRESET_BOOST:
if not self.coordinator.bath_boost_active:
self.async_write_ha_state()
await self.coordinator.async_start_bath_boost()
return
else:
if self.coordinator.bath_boost_active:
self.async_write_ha_state()
# restore_preset=False: _attr_preset_mode already holds the user's desired preset
await self.coordinator.async_stop_bath_boost(restore_preset=False)
return
self.async_write_ha_state()
self.coordinator.async_request_refresh()
async def async_turn_on(self) -> None:
"""Turn the climate entity on (U4: voice assistant support)."""
await self.async_set_hvac_mode(HVACMode.HEAT)
async def async_turn_off(self) -> None:
"""Turn the climate entity off (U4: voice assistant support)."""
await self.async_set_hvac_mode(HVACMode.OFF)
@@ -0,0 +1,254 @@
"""Config flow for Boiler Auxiliary Heater Control."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import Platform
from homeassistant.core import callback
from homeassistant.helpers import selector
from .const import (
CONF_BOILER_TEMP_SENSOR,
CONF_CIRCULATION_PUMP,
CONF_CT1_POWER,
CONF_CT2_POWER,
CONF_CT3_POWER,
CONF_HEATER_RELAY,
CONF_HP_AUX_HEATER,
CONF_PHASE_COUNT,
CONF_PUMP_SCHEDULE,
DOMAIN,
)
class BoilerAuxHeaterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Boiler Auxiliary Heater Control."""
VERSION = 1
def __init__(self) -> None:
"""Initialize flow."""
self._data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> dict:
"""Step 1: Core hardware - boiler temp sensor and heater relay."""
if user_input is not None:
self._data.update(user_input)
return await self.async_step_solar()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_BOILER_TEMP_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.TEMPERATURE,
)
),
vol.Required(CONF_HEATER_RELAY): selector.EntitySelector(
selector.EntitySelectorConfig(domain=Platform.SWITCH)
),
}
),
)
async def async_step_solar(
self, user_input: dict[str, Any] | None = None
) -> dict:
"""Step 2: Solar monitoring - phase count and CT power sensors."""
if user_input is not None:
self._data.update(user_input)
return await self.async_step_optional()
return self.async_show_form(
step_id="solar",
data_schema=vol.Schema(
{
vol.Required(CONF_PHASE_COUNT, default=3): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="1", label="1 Phase"),
selector.SelectOptionDict(value="3", label="3 Phases"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Required(CONF_CT1_POWER): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
vol.Optional(CONF_CT2_POWER): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
vol.Optional(CONF_CT3_POWER): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
}
),
)
async def async_step_optional(
self, user_input: dict[str, Any] | None = None
) -> dict:
"""Step 3: Optional devices."""
if user_input is not None:
self._data.update(user_input)
# Convert phase_count from string to int
if CONF_PHASE_COUNT in self._data:
self._data[CONF_PHASE_COUNT] = int(self._data[CONF_PHASE_COUNT])
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Boiler Auxiliary Heater",
data=self._data,
)
return self.async_show_form(
step_id="optional",
data_schema=vol.Schema(
{
vol.Optional(CONF_HP_AUX_HEATER): selector.EntitySelector(
selector.EntitySelectorConfig(domain=Platform.BINARY_SENSOR)
),
vol.Optional(CONF_CIRCULATION_PUMP): selector.EntitySelector(
selector.EntitySelectorConfig(domain=Platform.SWITCH)
),
vol.Optional(CONF_PUMP_SCHEDULE): selector.EntitySelector(
selector.EntitySelectorConfig()
),
}
),
)
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow handler."""
return BoilerAuxHeaterOptionsFlow(config_entry)
class BoilerAuxHeaterOptionsFlow(OptionsFlow):
"""Handle options flow for Boiler Auxiliary Heater Control."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> dict:
"""Manage entity references."""
if user_input is not None:
# Convert phase_count from string to int
if CONF_PHASE_COUNT in user_input:
user_input[CONF_PHASE_COUNT] = int(user_input[CONF_PHASE_COUNT])
return self.async_create_entry(title="", data=user_input)
current = {**self._config_entry.data, **self._config_entry.options}
current_phase_count = str(current.get(CONF_PHASE_COUNT, 3))
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_BOILER_TEMP_SENSOR,
default=current.get(CONF_BOILER_TEMP_SENSOR),
): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.TEMPERATURE,
)
),
vol.Required(
CONF_HEATER_RELAY,
default=current.get(CONF_HEATER_RELAY),
): selector.EntitySelector(
selector.EntitySelectorConfig(domain=Platform.SWITCH)
),
vol.Required(
CONF_PHASE_COUNT,
default=current_phase_count,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value="1", label="1 Phase"),
selector.SelectOptionDict(value="3", label="3 Phases"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Required(
CONF_CT1_POWER,
default=current.get(CONF_CT1_POWER),
): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
vol.Optional(
CONF_CT2_POWER,
description={
"suggested_value": current.get(CONF_CT2_POWER)
},
): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
vol.Optional(
CONF_CT3_POWER,
description={
"suggested_value": current.get(CONF_CT3_POWER)
},
): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
vol.Optional(
CONF_HP_AUX_HEATER,
description={
"suggested_value": current.get(CONF_HP_AUX_HEATER)
},
): selector.EntitySelector(
selector.EntitySelectorConfig(domain=Platform.BINARY_SENSOR)
),
vol.Optional(
CONF_CIRCULATION_PUMP,
description={
"suggested_value": current.get(CONF_CIRCULATION_PUMP)
},
): selector.EntitySelector(
selector.EntitySelectorConfig(domain=Platform.SWITCH)
),
vol.Optional(
CONF_PUMP_SCHEDULE,
description={
"suggested_value": current.get(CONF_PUMP_SCHEDULE)
},
): selector.EntitySelector(
selector.EntitySelectorConfig()
),
}
),
)
@@ -0,0 +1,228 @@
"""Constants for the Boiler Auxiliary Heater Control integration."""
DOMAIN = "boiler_aux_heater_3phase"
MANUFACTURER = "Custom"
# Config entry keys
CONF_BOILER_TEMP_SENSOR = "boiler_temp_sensor"
CONF_HEATER_RELAY = "heater_relay"
CONF_CT1_POWER = "ct1_power"
CONF_CT2_POWER = "ct2_power"
CONF_CT3_POWER = "ct3_power"
CONF_HP_AUX_HEATER = "hp_aux_heater"
CONF_CIRCULATION_PUMP = "circulation_pump"
CONF_PUMP_SCHEDULE = "pump_schedule"
CONF_PHASE_COUNT = "phase_count"
# Platforms
PLATFORMS = [
"climate",
"switch",
"number",
"binary_sensor",
"sensor",
"button",
]
# Presets (order matters for UI)
PRESET_NORMAL = "Normal"
PRESET_ECO = "Eco"
PRESET_BOOST = "Boost"
PRESET_SLEEP = "Sleep"
PRESET_AWAY = "Away"
PRESETS = [PRESET_NORMAL, PRESET_ECO, PRESET_BOOST, PRESET_SLEEP, PRESET_AWAY]
# PV state machine states
PV_STATE_IDLE = "idle"
PV_STATE_WAITING_START = "waiting_to_start"
PV_STATE_ACTIVE = "active"
PV_STATE_WAITING_STOP = "waiting_to_stop"
# Number entity definitions: (key, name, min, max, step, unit, icon, default)
NUMBER_DEFINITIONS = {
"cut_off_temp": {
"name": "Cut-off Temperature",
"min": 40,
"max": 85,
"step": 1,
"unit": "°C",
"icon": "mdi:thermometer-high",
"default": 60,
},
"temp_hysteresis": {
"name": "Temperature Hysteresis",
"min": 1,
"max": 15,
"step": 0.5,
"unit": "°C",
"icon": "mdi:thermometer-lines",
"default": 4,
},
"pv_target_temp": {
"name": "PV Target Temperature",
"min": 40,
"max": 70,
"step": 1,
"unit": "°C",
"icon": "mdi:solar-power",
"default": 55,
},
"pv_buffer": {
"name": "PV Excess Buffer",
"min": 0,
"max": 1000,
"step": 50,
"unit": "W",
"icon": "mdi:buffer",
"default": 100,
},
"heater_power": {
"name": "Heater Power Rating",
"min": 1500,
"max": 9000,
"step": 1500,
"unit": "W",
"icon": "mdi:lightning-bolt",
"default": 4500,
},
"pv_start_delay": {
"name": "PV Excess Start Delay",
"min": 1,
"max": 300,
"step": 1,
"unit": "s",
"icon": "mdi:timer-play",
"default": 60,
},
"pv_stop_delay": {
"name": "PV Excess Stop Delay",
"min": 1,
"max": 300,
"step": 1,
"unit": "s",
"icon": "mdi:timer-stop",
"default": 60,
},
"temp_normal": {
"name": "Normal Preset Temperature",
"min": 30,
"max": 65,
"step": 1,
"unit": "°C",
"icon": "mdi:thermometer",
"default": 44,
},
"temp_eco": {
"name": "Eco Preset Temperature",
"min": 30,
"max": 60,
"step": 1,
"unit": "°C",
"icon": "mdi:leaf",
"default": 40,
},
"temp_boost": {
"name": "Boost Preset Temperature",
"min": 40,
"max": 70,
"step": 1,
"unit": "°C",
"icon": "mdi:flash",
"default": 50,
},
"temp_sleep": {
"name": "Sleep Preset Temperature",
"min": 25,
"max": 55,
"step": 1,
"unit": "°C",
"icon": "mdi:sleep",
"default": 38,
},
"temp_away": {
"name": "Away Preset Temperature",
"min": 20,
"max": 50,
"step": 1,
"unit": "°C",
"icon": "mdi:home-export-outline",
"default": 35,
},
"bath_boost_duration": {
"name": "Bath Boost Duration",
"min": 5,
"max": 120,
"step": 5,
"unit": "min",
"icon": "mdi:timer-sand",
"default": 30,
},
"boost_all_modes_hysteresis": {
"name": "All Modes Boost Hysteresis",
"min": 3,
"max": 20,
"step": 1,
"unit": "°C",
"icon": "mdi:thermometer-alert",
"default": 10,
},
"boost_recovery_offset": {
"name": "Boost Recovery Offset",
"min": 0,
"max": 15,
"step": 1,
"unit": "°C",
"icon": "mdi:thermometer-minus",
"default": 3,
},
}
# Map preset name to number entity key
PRESET_TEMP_MAP = {
PRESET_NORMAL: "temp_normal",
PRESET_ECO: "temp_eco",
PRESET_BOOST: "temp_boost",
PRESET_SLEEP: "temp_sleep",
PRESET_AWAY: "temp_away",
}
# Switch entity definitions: (key, name, icon, default)
SWITCH_DEFINITIONS = {
"manual_override": {
"name": "Manual Override",
"icon": "mdi:water-boiler",
"default": False,
},
"allow_usage": {
"name": "Allow Aux Heater Usage",
"icon": "mdi:check-circle",
"default": True,
},
"pv_excess_mode": {
"name": "PV Excess Mode",
"icon": "mdi:solar-power",
"default": True,
},
"boost_all_modes": {
"name": "Boost in All Modes",
"icon": "mdi:thermostat",
"default": False,
},
}
# Thermostat tolerances (matching YAML)
MAIN_COLD_TOLERANCE = 4.0
MAIN_HOT_TOLERANCE = 0.0
PV_COLD_TOLERANCE = 1.0
PV_HOT_TOLERANCE = 0.5
# Coordinator debounce
DEBOUNCE_SECONDS = 1.0
# Energy update interval (seconds) for steady-state accumulation
ENERGY_UPDATE_INTERVAL = 60
# Storage
STORAGE_KEY = f"{DOMAIN}_boost_state"
STORAGE_VERSION = 1
@@ -0,0 +1,713 @@
"""Coordinator for Boiler Auxiliary Heater Control."""
from __future__ import annotations
import logging
import time
from datetime import datetime, timedelta, timezone
from typing import Any
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.storage import Store
from .const import (
CONF_BOILER_TEMP_SENSOR,
CONF_CIRCULATION_PUMP,
CONF_CT1_POWER,
CONF_CT2_POWER,
CONF_CT3_POWER,
CONF_HEATER_RELAY,
CONF_HP_AUX_HEATER,
CONF_PHASE_COUNT,
CONF_PUMP_SCHEDULE,
DEBOUNCE_SECONDS,
DOMAIN,
ENERGY_UPDATE_INTERVAL,
MAIN_COLD_TOLERANCE,
MAIN_HOT_TOLERANCE,
PRESET_BOOST,
PRESET_NORMAL,
PV_COLD_TOLERANCE,
PV_HOT_TOLERANCE,
PV_STATE_ACTIVE,
PV_STATE_IDLE,
PV_STATE_WAITING_START,
PV_STATE_WAITING_STOP,
STORAGE_KEY,
STORAGE_VERSION,
)
_LOGGER = logging.getLogger(__name__)
class BoilerAuxHeaterCoordinator:
"""Central coordinator for all boiler aux heater logic."""
def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
"""Initialize the coordinator."""
self.hass = hass
self.config = config
# External entity IDs
self.boiler_temp_sensor: str = config[CONF_BOILER_TEMP_SENSOR]
self.heater_relay: str = config[CONF_HEATER_RELAY]
self.ct1_power: str = config[CONF_CT1_POWER]
self.ct2_power: str | None = config.get(CONF_CT2_POWER)
self.ct3_power: str | None = config.get(CONF_CT3_POWER)
self.hp_aux_heater: str | None = config.get(CONF_HP_AUX_HEATER)
self.circulation_pump: str | None = config.get(CONF_CIRCULATION_PUMP)
self.pump_schedule: str | None = config.get(CONF_PUMP_SCHEDULE)
self.phase_count: int = config.get(CONF_PHASE_COUNT, 3)
# Internal entity references (set during platform setup)
self._number_entities: dict[str, Any] = {}
self._switch_entities: dict[str, Any] = {}
self._climate_entity: Any = None
# State
self.pv_state: str = PV_STATE_IDLE
self.bath_boost_active: bool = False
self._main_thermostat_heating: bool = False
self._all_modes_heating_demand: bool = False
self._bath_boost_timer_handle: CALLBACK_TYPE | None = None
self._bath_boost_remaining: float = 0
self._bath_boost_end_time: float | None = None
self._pre_boost_preset: str | None = None
self._pv_delay_handle: CALLBACK_TYPE | None = None
self._debounce_handle: CALLBACK_TYPE | None = None
self._energy_interval_unsub: CALLBACK_TYPE | None = None
self._min_cycle_deferred_handle: CALLBACK_TYPE | None = None
# Relay cycle protection (B6)
self._last_relay_change_time: float = 0.0
# Computed states (updated by recalculate)
self.decision: bool = False
self.decision_reason: str = "OFF: Initializing"
self.pv_sufficient: bool = False
self.relay_is_on: bool = False
self.pv_shadow_heating: bool = False
# Listeners
self._unsub_listeners: list[CALLBACK_TYPE] = []
self._update_callbacks: list[callback] = []
# Persistent storage (B4)
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
def register_number(self, key: str, entity: Any) -> None:
"""Register a number entity for value lookups."""
self._number_entities[key] = entity
def register_switch(self, key: str, entity: Any) -> None:
"""Register a switch entity for state lookups."""
self._switch_entities[key] = entity
def register_climate(self, entity: Any) -> None:
"""Register the climate entity."""
self._climate_entity = entity
def get_number_value(self, key: str) -> float | None:
"""Get current value of a number entity."""
entity = self._number_entities.get(key)
if entity is not None and entity.native_value is not None:
return float(entity.native_value)
return None
def get_switch_state(self, key: str) -> bool:
"""Get current state of a switch entity."""
entity = self._switch_entities.get(key)
if entity is not None:
return entity.is_on
return False
@callback
def async_add_listener(self, update_callback: callback) -> CALLBACK_TYPE:
"""Add a listener for coordinator updates."""
self._update_callbacks.append(update_callback)
@callback
def remove_listener() -> None:
self._update_callbacks.remove(update_callback)
return remove_listener
@callback
def _notify_listeners(self) -> None:
"""Notify all registered listeners."""
for cb in self._update_callbacks:
cb()
async def async_setup(self) -> None:
"""Set up state listeners on external entities."""
tracked = [
self.boiler_temp_sensor,
self.heater_relay,
self.ct1_power,
]
if self.ct2_power:
tracked.append(self.ct2_power)
if self.ct3_power:
tracked.append(self.ct3_power)
if self.hp_aux_heater:
tracked.append(self.hp_aux_heater)
self._unsub_listeners.append(
async_track_state_change_event(
self.hass, tracked, self._async_state_changed
)
)
# B5: Periodic energy update every 60s for steady-state accumulation
self._energy_interval_unsub = async_track_time_interval(
self.hass,
self._async_energy_tick,
timedelta(seconds=ENERGY_UPDATE_INTERVAL),
)
# B4: Restore persisted boost state
await self._async_restore_boost_state()
async def _async_restore_boost_state(self) -> None:
"""Restore bath boost state from storage after HA restart."""
data = await self._store.async_load()
if not data or not data.get("bath_boost_active"):
return
end_time_str = data.get("bath_boost_end_time")
if not end_time_str:
return
try:
end_time = datetime.fromisoformat(end_time_str)
except (ValueError, TypeError):
_LOGGER.warning("Invalid stored boost end time, cleaning up")
await self._async_clear_boost_storage()
return
now = datetime.now(timezone.utc)
remaining = (end_time - now).total_seconds()
self._pre_boost_preset = data.get("pre_boost_preset")
if remaining <= 0:
# Boost expired while HA was down - run cleanup
_LOGGER.info("Bath boost expired during downtime, running cleanup")
self._bath_boost_finished()
else:
# Resume boost with remaining time
_LOGGER.info(
"Resuming bath boost with %.0f seconds remaining", remaining
)
self.bath_boost_active = True
self._bath_boost_end_time = time.monotonic() + remaining
self._bath_boost_remaining = remaining
self._bath_boost_timer_handle = async_call_later(
self.hass, remaining, self._bath_boost_finished
)
async def _async_persist_boost_state(self) -> None:
"""Save bath boost state to storage."""
if self.bath_boost_active and self._bath_boost_end_time is not None:
remaining = self._bath_boost_end_time - time.monotonic()
end_time_utc = datetime.now(timezone.utc) + timedelta(seconds=remaining)
await self._store.async_save(
{
"bath_boost_active": True,
"bath_boost_end_time": end_time_utc.isoformat(),
"pre_boost_preset": self._pre_boost_preset,
}
)
else:
await self._async_clear_boost_storage()
async def _async_clear_boost_storage(self) -> None:
"""Clear persisted boost state."""
await self._store.async_save(
{
"bath_boost_active": False,
"bath_boost_end_time": None,
"pre_boost_preset": None,
}
)
async def async_shutdown(self) -> None:
"""Clean up all listeners and timers (full shutdown for unload)."""
self._async_shutdown_listeners()
if self._pv_delay_handle:
self._pv_delay_handle()
self._pv_delay_handle = None
if self._bath_boost_timer_handle:
self._bath_boost_timer_handle()
self._bath_boost_timer_handle = None
if self._debounce_handle:
self._debounce_handle()
self._debounce_handle = None
if self._min_cycle_deferred_handle:
self._min_cycle_deferred_handle()
self._min_cycle_deferred_handle = None
@callback
def _async_shutdown_listeners(self) -> None:
"""Shut down only external entity listeners (B8: safe for options update)."""
for unsub in self._unsub_listeners:
unsub()
self._unsub_listeners.clear()
if self._energy_interval_unsub:
self._energy_interval_unsub()
self._energy_interval_unsub = None
@callback
def _async_state_changed(self, event: Event) -> None:
"""Handle external state changes with debounce."""
if self._debounce_handle:
self._debounce_handle()
self._debounce_handle = async_call_later(
self.hass, DEBOUNCE_SECONDS, self._async_debounced_update
)
@callback
def _async_debounced_update(self, _now: Any = None) -> None:
"""Run the full recalculation after debounce."""
self._debounce_handle = None
self._recalculate()
@callback
def _async_energy_tick(self, _now: Any = None) -> None:
"""Periodic tick to ensure energy sensors accumulate during steady state (B5)."""
self._notify_listeners()
@callback
def async_request_refresh(self) -> None:
"""Request an immediate recalculation (called by internal entities)."""
self._recalculate()
def _get_external_state(self, entity_id: str, default: str = "") -> str:
"""Get external entity state safely."""
state = self.hass.states.get(entity_id)
if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return default
return state.state
def _is_external_unavailable(self, entity_id: str) -> bool:
"""Check if an external entity is unavailable or unknown."""
state = self.hass.states.get(entity_id)
return state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN)
def _get_external_float(self, entity_id: str, default: float) -> float:
"""Get external entity state as float with fallback."""
raw = self._get_external_state(entity_id)
if not raw:
return default
try:
return float(raw)
except (ValueError, TypeError):
return default
def _recalculate(self) -> None:
"""Recalculate all derived states."""
# Read external states
boiler_temp = self._get_external_float(self.boiler_temp_sensor, 100.0)
relay_on = self._get_external_state(self.heater_relay) == STATE_ON
ct1 = self._get_external_float(self.ct1_power, 0.0)
ct2 = self._get_external_float(self.ct2_power, 0.0) if self.ct2_power else 0.0
ct3 = self._get_external_float(self.ct3_power, 0.0) if self.ct3_power else 0.0
# B1: HP aux heater interlock - default to True (ON) when unavailable (SAFETY)
hp_aux_on = False
hp_aux_unavailable = False
if self.hp_aux_heater:
if self._is_external_unavailable(self.hp_aux_heater):
hp_aux_on = True
hp_aux_unavailable = True
_LOGGER.warning(
"HP aux heater sensor %s is unavailable - assuming ON for safety interlock",
self.hp_aux_heater,
)
else:
hp_aux_on = self._get_external_state(self.hp_aux_heater) == STATE_ON
# Read internal settings
cut_off_temp = self.get_number_value("cut_off_temp") or 60.0
pv_buffer = self.get_number_value("pv_buffer") or 300.0
heater_power = self.get_number_value("heater_power") or 3000.0
pv_target_temp = self.get_number_value("pv_target_temp") or 55.0
manual_override = self.get_switch_state("manual_override")
allow_usage = self.get_switch_state("allow_usage")
pv_excess_mode = self.get_switch_state("pv_excess_mode")
boost_all_modes = self.get_switch_state("boost_all_modes")
self.relay_is_on = relay_on
# U2: PV sufficient calculation with configurable phase count
phases = self.phase_count
phase_buffer = pv_buffer / float(phases)
phase_heater = heater_power / float(phases)
turn_on_threshold = -1.0 * (phase_buffer + phase_heater)
if relay_on:
if phases == 1:
self.pv_sufficient = ct1 <= 0
else:
self.pv_sufficient = ct1 <= 0 and ct2 <= 0 and ct3 <= 0
else:
if phases == 1:
self.pv_sufficient = ct1 < turn_on_threshold
else:
self.pv_sufficient = (
ct1 < turn_on_threshold
and ct2 < turn_on_threshold
and ct3 < turn_on_threshold
)
cold_tolerance = self.get_number_value("temp_hysteresis") or MAIN_COLD_TOLERANCE
# PV shadow thermostat logic
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)?
if boiler_temp < (pv_target_temp - cold_tolerance):
self.pv_shadow_heating = True
elif boiler_temp >= (pv_target_temp + PV_HOT_TOLERANCE):
self.pv_shadow_heating = False
# else: in the tolerance band - maintain previous state
# PV state machine transitions
self._update_pv_state_machine(pv_excess_mode)
# B7: Main thermostat heating check - use configurable hysteresis
if self._climate_entity is not None:
target = self._climate_entity.target_temperature
if target is not None and self._climate_entity.hvac_mode == "heat":
if boiler_temp < (target - cold_tolerance):
self._main_thermostat_heating = True
elif boiler_temp >= (target + MAIN_HOT_TOLERANCE):
self._main_thermostat_heating = False
# else: in tolerance band, maintain previous state
else:
self._main_thermostat_heating = False
else:
self._main_thermostat_heating = False
main_thermostat_heating = self._main_thermostat_heating
# All-modes heating demand: separate, larger hysteresis + recovery offset
boost_am_hysteresis = self.get_number_value("boost_all_modes_hysteresis") or 10.0
boost_recovery = self.get_number_value("boost_recovery_offset") or 3.0
if self._climate_entity is not None:
target_am = self._climate_entity.target_temperature
if target_am is not None and self._climate_entity.hvac_mode == "heat":
if boiler_temp < (target_am - boost_am_hysteresis):
self._all_modes_heating_demand = True
elif boiler_temp >= (target_am - boost_recovery):
self._all_modes_heating_demand = False
# else: in band, maintain previous state
else:
self._all_modes_heating_demand = False
else:
self._all_modes_heating_demand = False
# Boost conditions
bath_boost_heating = (
self.bath_boost_active and main_thermostat_heating
)
all_modes_heating = boost_all_modes and self._all_modes_heating_demand
# PV excess condition
pv_heating = pv_excess_mode and self.pv_shadow_heating
# Safety checks
boiler_temp_ok = boiler_temp < cut_off_temp
hp_aux_off = not hp_aux_on
allowed = hp_aux_off and allow_usage and boiler_temp_ok
# F1: Compute decision reason
old_decision = self.decision
if manual_override:
self.decision = True
self.decision_reason = "Manual override"
elif not allow_usage:
self.decision = False
self.decision_reason = "OFF: Disabled by user"
elif hp_aux_unavailable:
self.decision = False
self.decision_reason = "OFF: HP aux heater sensor unavailable (safety)"
elif hp_aux_on:
self.decision = False
self.decision_reason = "OFF: HP aux heater active"
elif not boiler_temp_ok:
self.decision = False
self.decision_reason = f"OFF: Temperature above cut-off ({cut_off_temp:.0f}\u00b0C)"
elif self._climate_entity is not None and self._climate_entity.hvac_mode == "off":
self.decision = False
self.decision_reason = "OFF: HVAC mode off"
elif pv_heating:
self.decision = True
self.decision_reason = "PV excess heating"
elif bath_boost_heating:
self.decision = True
self.decision_reason = "Bath boost heating"
elif all_modes_heating:
self.decision = True
self.decision_reason = "All modes boost heating"
else:
self.decision = False
self.decision_reason = "OFF: No heating demand"
if self.decision != old_decision:
self.hass.async_create_task(self._async_control_relay(self.decision))
self._notify_listeners()
@callback
def _async_min_cycle_recheck(self, _now: Any = None) -> None:
"""Re-check decision after min cycle duration elapsed."""
self._min_cycle_deferred_handle = None
self._recalculate()
def _update_pv_state_machine(self, pv_enabled: bool) -> None:
"""Update PV excess state machine based on pv_sufficient."""
if not pv_enabled:
if self.pv_state != PV_STATE_IDLE:
self._cancel_pv_delay()
self.pv_state = PV_STATE_IDLE
self.pv_shadow_heating = False
return
if self.pv_sufficient and self.pv_state == PV_STATE_IDLE:
# Start waiting to activate
self.pv_state = PV_STATE_WAITING_START
start_delay = self.get_number_value("pv_start_delay") or 60.0
self._cancel_pv_delay()
self._pv_delay_handle = async_call_later(
self.hass, start_delay, self._pv_start_callback
)
elif not self.pv_sufficient and self.pv_state == PV_STATE_WAITING_START:
# PV dropped before delay elapsed, cancel
self._cancel_pv_delay()
self.pv_state = PV_STATE_IDLE
elif not self.pv_sufficient and self.pv_state == PV_STATE_ACTIVE:
# Start waiting to deactivate
self.pv_state = PV_STATE_WAITING_STOP
stop_delay = self.get_number_value("pv_stop_delay") or 60.0
self._cancel_pv_delay()
self._pv_delay_handle = async_call_later(
self.hass, stop_delay, self._pv_stop_callback
)
elif self.pv_sufficient and self.pv_state == PV_STATE_WAITING_STOP:
# PV recovered before stop delay, cancel stop
self._cancel_pv_delay()
self.pv_state = PV_STATE_ACTIVE
@callback
def _pv_start_callback(self, _now: Any = None) -> None:
"""PV start delay elapsed - activate PV mode."""
self._pv_delay_handle = None
if self.pv_sufficient:
self.pv_state = PV_STATE_ACTIVE
self._recalculate()
else:
self.pv_state = PV_STATE_IDLE
@callback
def _pv_stop_callback(self, _now: Any = None) -> None:
"""PV stop delay elapsed - deactivate PV mode."""
self._pv_delay_handle = None
self.pv_state = PV_STATE_IDLE
self.pv_shadow_heating = False
self._recalculate()
def _cancel_pv_delay(self) -> None:
"""Cancel any pending PV delay timer."""
if self._pv_delay_handle:
self._pv_delay_handle()
self._pv_delay_handle = None
async def _async_control_relay(self, turn_on: bool) -> None:
"""Control the physical heater relay."""
try:
await self.hass.services.async_call(
"switch",
"turn_on" if turn_on else "turn_off",
{"entity_id": self.heater_relay},
)
except Exception:
_LOGGER.exception("Failed to control heater relay %s", self.heater_relay)
# Bath boost management
async def async_start_bath_boost(self) -> None:
"""Start the bath boost timer."""
duration_min = self.get_number_value("bath_boost_duration") or 30.0
duration_sec = duration_min * 60
# B3: Store pre-boost preset for later restoration.
# Only capture if not already pre-set by climate entity to avoid reading
# the already-changed "Boost" value when called from async_set_preset_mode.
if self._climate_entity is not None and self._pre_boost_preset is None:
self._pre_boost_preset = self._climate_entity.preset_mode
# Turn on circulation pump if configured
if self.circulation_pump:
try:
await self.hass.services.async_call(
"switch",
"turn_on",
{"entity_id": self.circulation_pump},
)
except Exception:
_LOGGER.exception("Failed to turn on circulation pump")
# Activate bath boost
self.bath_boost_active = True
# Set preset to Boost on climate entity
if self._climate_entity is not None:
await self._climate_entity.async_set_preset_mode(PRESET_BOOST)
# Start countdown timer
self._bath_boost_end_time = time.monotonic() + duration_sec
self._bath_boost_remaining = duration_sec
if self._bath_boost_timer_handle:
self._bath_boost_timer_handle()
self._bath_boost_timer_handle = async_call_later(
self.hass, duration_sec, self._bath_boost_finished
)
# B4: Persist boost state
await self._async_persist_boost_state()
self._recalculate()
async def async_stop_bath_boost(self, restore_preset: bool = True) -> None:
"""Stop the bath boost (cancel button / preset change / cleanup)."""
if self._bath_boost_timer_handle:
self._bath_boost_timer_handle()
self._bath_boost_timer_handle = None
self._bath_boost_end_time = None
self._bath_boost_remaining = 0
self.bath_boost_active = False
# Restore pump schedule if configured
if self.pump_schedule:
await self._async_restore_pump_schedule()
# Turn off circulation pump if configured
if self.circulation_pump:
try:
await self.hass.services.async_call(
"switch",
"turn_off",
{"entity_id": self.circulation_pump},
)
except Exception:
_LOGGER.exception("Failed to turn off circulation pump")
# B3: Restore pre-boost preset (only when not called from a user preset change;
# when restore_preset=False the climate entity already shows the correct new preset)
preset_to_restore = self._pre_boost_preset or PRESET_NORMAL
self._pre_boost_preset = None
if restore_preset and self._climate_entity is not None:
await self._climate_entity.async_set_preset_mode(preset_to_restore)
# B4: Clear persisted state
await self._async_clear_boost_storage()
self._recalculate()
@callback
def _bath_boost_finished(self, _now: Any = None) -> None:
"""Bath boost timer expired."""
self._bath_boost_timer_handle = None
self._bath_boost_end_time = None
self._bath_boost_remaining = 0
self.bath_boost_active = False
# Capture duration for event before clearing state
duration_min = self.get_number_value("bath_boost_duration") or 30.0
pre_preset = self._pre_boost_preset
# Restore pump schedule if configured
if self.pump_schedule:
self.hass.async_create_task(self._async_restore_pump_schedule())
# Turn off circulation pump if configured
if self.circulation_pump:
self.hass.async_create_task(
self.hass.services.async_call(
"switch",
"turn_off",
{"entity_id": self.circulation_pump},
)
)
# B3: Restore pre-boost preset
restore_preset = self._pre_boost_preset or PRESET_NORMAL
self._pre_boost_preset = None
if self._climate_entity is not None:
self.hass.async_create_task(
self._climate_entity.async_set_preset_mode(restore_preset)
)
# B4: Clear persisted state
self.hass.async_create_task(self._async_clear_boost_storage())
# U3: Fire HA event on boost finish
self.hass.bus.async_fire(
f"{DOMAIN}_boost_finished",
{
"duration_minutes": duration_min,
"pre_boost_preset": pre_preset,
"restored_preset": restore_preset,
},
)
self._recalculate()
async def _async_restore_pump_schedule(self) -> None:
"""Restore the pump schedule after bath boost."""
if not self.pump_schedule:
return
try:
await self.hass.services.async_call(
"scheduler",
"run_action",
{"entity_id": self.pump_schedule},
)
except Exception:
_LOGGER.warning("Failed to restore pump schedule %s", self.pump_schedule)
@property
def bath_boost_remaining_seconds(self) -> float:
"""Get remaining bath boost time in seconds."""
if not self.bath_boost_active or self._bath_boost_end_time is None:
return 0
remaining = self._bath_boost_end_time - time.monotonic()
return max(0, remaining)
def update_config(self, new_config: dict[str, Any]) -> None:
"""Update configuration (called from options flow)."""
self.config = new_config
self.boiler_temp_sensor = new_config[CONF_BOILER_TEMP_SENSOR]
self.heater_relay = new_config[CONF_HEATER_RELAY]
self.ct1_power = new_config[CONF_CT1_POWER]
self.ct2_power = new_config.get(CONF_CT2_POWER)
self.ct3_power = new_config.get(CONF_CT3_POWER)
self.hp_aux_heater = new_config.get(CONF_HP_AUX_HEATER)
self.circulation_pump = new_config.get(CONF_CIRCULATION_PUMP)
self.pump_schedule = new_config.get(CONF_PUMP_SCHEDULE)
self.phase_count = new_config.get(CONF_PHASE_COUNT, 3)
@@ -0,0 +1,389 @@
views:
- title: Aux Heater Control
type: sections
max_columns: 4
icon: mdi:heating-coil
subview: false
path: aux-heater-control
header:
card:
type: markdown
content: |-
# Auxiliary Heater Control
Manage the boiler auxiliary heater, presets, PV excess, and bath boost
text_only: true
sections:
# ── Thermostat ──
- type: grid
column_span: 2
cards:
- type: heading
heading: Thermostat
heading_style: title
icon: mdi:thermometer
- type: thermostat
entity: climate.boiler_auxiliary_heater
features:
- type: climate-preset-modes
style: dropdown
show_current_as_primary: true
grid_options:
columns: full
# ── Boost ──
- type: grid
column_span: 2
cards:
- type: heading
heading: Bath Boost
heading_style: title
icon: mdi:rocket-launch
- type: tile
entity: button.boiler_auxiliary_heater_start_bath_boost
vertical: false
state_content: last_changed
tap_action:
action: call-service
service: button.press
target:
entity_id: button.boiler_auxiliary_heater_start_bath_boost
grid_options:
columns: full
rows: 2
- type: tile
entity: button.boiler_auxiliary_heater_cancel_bath_boost
vertical: false
state_content: last_changed
tap_action:
action: call-service
service: button.press
target:
entity_id: button.boiler_auxiliary_heater_cancel_bath_boost
grid_options:
columns: full
rows: 2
- type: tile
entity: binary_sensor.boiler_auxiliary_heater_bath_boost_active
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: sensor.boiler_auxiliary_heater_bath_boost_remaining
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_bath_boost_duration
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
# ── Configuration: Switches ──
- type: grid
column_span: 2
cards:
- type: heading
heading: Mode Switches
heading_style: title
icon: mdi:toggle-switch
- features:
- type: toggle
type: tile
entity: switch.boiler_auxiliary_heater_allow_aux_heater_usage
features_position: bottom
vertical: false
state_content: last_changed
grid_options:
columns: full
- features:
- type: toggle
type: tile
entity: switch.boiler_auxiliary_heater_manual_override
features_position: bottom
vertical: false
state_content: last_changed
grid_options:
columns: full
- features:
- type: toggle
type: tile
entity: switch.boiler_auxiliary_heater_pv_excess_mode
features_position: bottom
vertical: false
state_content: last_changed
grid_options:
columns: full
- features:
- type: toggle
type: tile
entity: switch.boiler_auxiliary_heater_boost_in_all_modes
features_position: bottom
vertical: false
state_content: last_changed
grid_options:
columns: full
# ── Configuration: Temperature Presets ──
- type: grid
column_span: 2
cards:
- type: heading
heading: Preset Temperatures
heading_style: title
icon: mdi:thermometer-lines
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_normal_preset_temperature
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_eco_preset_temperature
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_boost_preset_temperature
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_sleep_preset_temperature
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_away_preset_temperature
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
# ── Configuration: Safety & Heater Settings ──
- type: grid
column_span: 2
cards:
- type: heading
heading: Safety & Heater Settings
heading_style: title
icon: mdi:shield-check
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_cut_off_temperature
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_temperature_hysteresis
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_heater_power_rating
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_all_modes_boost_hysteresis
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_boost_recovery_offset
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
# ── Configuration: PV Excess Settings ──
- type: grid
column_span: 2
cards:
- type: heading
heading: PV Excess Settings
heading_style: title
icon: mdi:solar-power-variant
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_pv_target_temperature
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_pv_excess_buffer
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_pv_excess_start_delay
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
- features:
- type: numeric-input
style: buttons
type: tile
entity: number.boiler_auxiliary_heater_pv_excess_stop_delay
features_position: bottom
vertical: false
state_content: state
grid_options:
columns: full
# ── Status: Decision & PV State ──
- type: grid
column_span: 2
cards:
- type: heading
heading: Heater Status
heading_style: title
icon: mdi:information
- type: tile
entity: binary_sensor.boiler_auxiliary_heater_heater_decision
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: sensor.boiler_auxiliary_heater_decision_reason
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: sensor.boiler_auxiliary_heater_pv_state
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: binary_sensor.boiler_auxiliary_heater_pv_power_sufficient
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: binary_sensor.boiler_auxiliary_heater_running_on_pv
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: binary_sensor.boiler_auxiliary_heater_running_on_grid
vertical: false
state_content: state
grid_options:
columns: full
# ── Status: Power & Energy ──
- type: grid
column_span: 2
cards:
- type: heading
heading: Power & Energy
heading_style: title
icon: mdi:lightning-bolt
- type: tile
entity: sensor.boiler_auxiliary_heater_heater_power
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: sensor.boiler_auxiliary_heater_heater_pv_power
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: sensor.boiler_auxiliary_heater_heater_grid_power
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: sensor.boiler_auxiliary_heater_heater_energy
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: sensor.boiler_auxiliary_heater_heater_pv_energy
vertical: false
state_content: state
grid_options:
columns: full
- type: tile
entity: sensor.boiler_auxiliary_heater_heater_grid_energy
vertical: false
state_content: state
grid_options:
columns: full
cards: []
@@ -0,0 +1,12 @@
{
"domain": "boiler_aux_heater_3phase",
"name": "Boiler Auxiliary Heater Control",
"codeowners": ["@costea32"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/costea32/ha-boiler-aux-heater-3phase",
"iot_class": "local_push",
"version": "1.0.0",
"requirements": [],
"homeassistant": "2024.1.0"
}
@@ -0,0 +1,121 @@
"""Number entities for Boiler Auxiliary Heater Control."""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN, NUMBER_DEFINITIONS
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up number entities."""
coordinator = hass.data[DOMAIN][entry.entry_id]
entities = []
for key, defn in NUMBER_DEFINITIONS.items():
if key == "pv_target_temp":
entity = PvTargetTempNumber(coordinator, entry, key, defn)
else:
entity = BoilerAuxHeaterNumber(coordinator, entry, key, defn)
entities.append(entity)
async_add_entities(entities)
class BoilerAuxHeaterNumber(RestoreEntity, NumberEntity):
"""A configurable number parameter for the boiler aux heater."""
_attr_has_entity_name = True
_attr_mode = NumberMode.SLIDER
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
coordinator,
entry: ConfigEntry,
key: str,
defn: dict,
) -> None:
"""Initialize the number entity."""
self.coordinator = coordinator
self._key = key
self._defn = defn
self._attr_unique_id = f"{entry.entry_id}_{key}"
self._attr_translation_key = key
self._attr_native_min_value = defn["min"]
self._attr_native_max_value = defn["max"]
self._attr_native_step = defn["step"]
self._attr_native_unit_of_measurement = defn["unit"]
self._attr_icon = defn["icon"]
self._attr_native_value = defn["default"]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Restore state on startup."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state and last_state.state not in ("unknown", "unavailable"):
try:
self._attr_native_value = float(last_state.state)
except (ValueError, TypeError):
pass
# Register with coordinator
self.coordinator.register_number(self._key, self)
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
self._attr_native_value = value
self.async_write_ha_state()
# Trigger coordinator recalculation
self.coordinator.async_request_refresh()
class PvTargetTempNumber(BoilerAuxHeaterNumber):
"""PV Target Temperature with dynamic max capped at cut_off_temp - 1."""
def __init__(self, coordinator, entry: ConfigEntry, key: str, defn: dict) -> None:
"""Initialize."""
super().__init__(coordinator, entry, key, defn)
self._unsub_coordinator = None
async def async_added_to_hass(self) -> None:
"""Restore state and subscribe to coordinator updates."""
await super().async_added_to_hass()
self._unsub_coordinator = self.coordinator.async_add_listener(
self._handle_coordinator_update
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up coordinator listener."""
if self._unsub_coordinator:
self._unsub_coordinator()
@callback
def _handle_coordinator_update(self) -> None:
"""Clamp value to new max and update HA state."""
max_val = self.native_max_value
if self._attr_native_value is not None and self._attr_native_value > max_val:
self._attr_native_value = max_val
self.async_write_ha_state()
@property
def native_max_value(self) -> float:
"""Return max value capped at cut_off_temp - 1."""
cut_off = self.coordinator.get_number_value("cut_off_temp")
if cut_off is not None:
return min(self._defn["max"], cut_off - 1)
return self._defn["max"]
@@ -0,0 +1,301 @@
"""Sensor entities for Boiler Auxiliary Heater Control."""
from __future__ import annotations
import time
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensor entities."""
coordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[SensorEntity] = [
BoilerPowerSensor(coordinator, entry, "total_power", "Heater Power"),
BoilerPowerSensor(coordinator, entry, "pv_power", "Heater PV Power"),
BoilerPowerSensor(coordinator, entry, "grid_power", "Heater Grid Power"),
BoilerEnergySensor(coordinator, entry, "total_energy", "Heater Energy", "total_power"),
BoilerEnergySensor(coordinator, entry, "pv_energy", "Heater PV Energy", "pv_power"),
BoilerEnergySensor(coordinator, entry, "grid_energy", "Heater Grid Energy", "grid_power"),
BathBoostRemainingSensor(coordinator, entry),
DecisionReasonSensor(coordinator, entry),
PvStateSensor(coordinator, entry),
]
async_add_entities(entities)
class BoilerPowerSensor(SensorEntity):
"""Power sensor that reports heater wattage based on relay/PV state."""
_attr_has_entity_name = True
_attr_device_class = SensorDeviceClass.POWER
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = UnitOfPower.WATT
def __init__(
self, coordinator, entry: ConfigEntry, key: str, name: str
) -> None:
"""Initialize the power sensor."""
self.coordinator = coordinator
self._key = key
self._attr_unique_id = f"{entry.entry_id}_{key}"
self._attr_translation_key = key
self._unsub_listener = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Register with coordinator."""
self._unsub_listener = self.coordinator.async_add_listener(
self._handle_coordinator_update
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up."""
if self._unsub_listener:
self._unsub_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Update on coordinator changes."""
self.async_write_ha_state()
@property
def native_value(self) -> float:
"""Return power value."""
heater_power = self.coordinator.get_number_value("heater_power") or 0.0
relay_on = self.coordinator.relay_is_on
pv_sufficient = self.coordinator.pv_sufficient
if self._key == "total_power":
return heater_power if relay_on else 0.0
if self._key == "pv_power":
return heater_power if (relay_on and pv_sufficient) else 0.0
if self._key == "grid_power":
return heater_power if (relay_on and not pv_sufficient) else 0.0
return 0.0
class BoilerEnergySensor(RestoreEntity, SensorEntity):
"""Energy sensor using internal Riemann sum integration."""
_attr_has_entity_name = True
_attr_device_class = SensorDeviceClass.ENERGY
_attr_state_class = SensorStateClass.TOTAL_INCREASING
_attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
def __init__(
self,
coordinator,
entry: ConfigEntry,
key: str,
name: str,
power_key: str,
) -> None:
"""Initialize the energy sensor."""
self.coordinator = coordinator
self._key = key
self._power_key = power_key
self._attr_unique_id = f"{entry.entry_id}_{key}"
self._attr_translation_key = key
self._accumulated_energy: float = 0.0
self._last_update_time: float | None = None
self._unsub_listener = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Restore accumulated energy and start tracking."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state and last_state.state not in ("unknown", "unavailable"):
try:
self._accumulated_energy = float(last_state.state)
except (ValueError, TypeError):
pass
self._last_update_time = time.monotonic()
self._unsub_listener = self.coordinator.async_add_listener(
self._handle_coordinator_update
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up."""
if self._unsub_listener:
self._unsub_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Integrate power over time on each update."""
now = time.monotonic()
if self._last_update_time is not None:
elapsed_hours = (now - self._last_update_time) / 3600.0
power_w = self._get_power_value()
self._accumulated_energy += (power_w / 1000.0) * elapsed_hours
self._last_update_time = now
self.async_write_ha_state()
def _get_power_value(self) -> float:
"""Get the current power for integration."""
heater_power = self.coordinator.get_number_value("heater_power") or 0.0
relay_on = self.coordinator.relay_is_on
pv_sufficient = self.coordinator.pv_sufficient
if self._power_key == "total_power":
return heater_power if relay_on else 0.0
if self._power_key == "pv_power":
return heater_power if (relay_on and pv_sufficient) else 0.0
if self._power_key == "grid_power":
return heater_power if (relay_on and not pv_sufficient) else 0.0
return 0.0
@property
def native_value(self) -> float:
"""Return accumulated energy in kWh."""
return round(self._accumulated_energy, 3)
class BathBoostRemainingSensor(SensorEntity):
"""Sensor showing remaining bath boost time."""
_attr_has_entity_name = True
_attr_translation_key = "bath_boost_remaining"
_attr_device_class = SensorDeviceClass.DURATION
_attr_native_unit_of_measurement = UnitOfTime.SECONDS
_attr_icon = "mdi:timer-sand"
def __init__(self, coordinator, entry: ConfigEntry) -> None:
"""Initialize the bath boost remaining sensor."""
self.coordinator = coordinator
self._attr_unique_id = f"{entry.entry_id}_bath_boost_remaining"
self._unsub_listener = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Register with coordinator."""
self._unsub_listener = self.coordinator.async_add_listener(
self._handle_coordinator_update
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up."""
if self._unsub_listener:
self._unsub_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Update state."""
self.async_write_ha_state()
@property
def native_value(self) -> float:
"""Return remaining seconds."""
return round(self.coordinator.bath_boost_remaining_seconds, 0)
class DecisionReasonSensor(SensorEntity):
"""Sensor reporting why the heater is ON or OFF (F1)."""
_attr_has_entity_name = True
_attr_translation_key = "decision_reason"
_attr_icon = "mdi:information-outline"
def __init__(self, coordinator, entry: ConfigEntry) -> None:
"""Initialize the decision reason sensor."""
self.coordinator = coordinator
self._attr_unique_id = f"{entry.entry_id}_decision_reason"
self._unsub_listener = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Register with coordinator."""
self._unsub_listener = self.coordinator.async_add_listener(
self._handle_coordinator_update
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up."""
if self._unsub_listener:
self._unsub_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Update state."""
self.async_write_ha_state()
@property
def native_value(self) -> str:
"""Return the decision reason text."""
return self.coordinator.decision_reason
class PvStateSensor(SensorEntity):
"""Sensor exposing the PV excess state machine state (F2)."""
_attr_has_entity_name = True
_attr_translation_key = "pv_state"
_attr_icon = "mdi:solar-power-variant"
def __init__(self, coordinator, entry: ConfigEntry) -> None:
"""Initialize the PV state sensor."""
self.coordinator = coordinator
self._attr_unique_id = f"{entry.entry_id}_pv_state"
self._unsub_listener = None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Register with coordinator."""
self._unsub_listener = self.coordinator.async_add_listener(
self._handle_coordinator_update
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up."""
if self._unsub_listener:
self._unsub_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Update state."""
self.async_write_ha_state()
@property
def native_value(self) -> str:
"""Return the PV state machine state."""
return self.coordinator.pv_state
@@ -0,0 +1,11 @@
start_bath_boost:
name: Start Bath Boost
description: >-
Starts the bath boost timer with the configured duration.
Turns on the circulation pump and sets the thermostat to Boost preset.
stop_bath_boost:
name: Stop Bath Boost
description: >-
Stops an active bath boost. Cancels the timer, turns off the circulation
pump, restores the pump schedule, and restores the previous preset.
@@ -0,0 +1,187 @@
{
"config": {
"step": {
"user": {
"title": "Core Hardware",
"description": "Select the boiler temperature sensor and heater relay switch.",
"data": {
"boiler_temp_sensor": "Boiler temperature sensor",
"heater_relay": "Heater relay switch"
}
},
"solar": {
"title": "Solar Monitoring",
"description": "Select the phase count and CT power sensors from your inverter. Negative values indicate export. For single-phase setups, only Phase 1 CT is required.",
"data": {
"phase_count": "Number of phases",
"ct1_power": "Phase 1 CT power sensor",
"ct2_power": "Phase 2 CT power sensor (3-phase only)",
"ct3_power": "Phase 3 CT power sensor (3-phase only)"
}
},
"optional": {
"title": "Optional Devices",
"description": "Configure optional devices. Leave empty to skip.",
"data": {
"hp_aux_heater": "Heat pump auxiliary heater sensor (interlock)",
"circulation_pump": "DHW circulation pump switch (enables bath boost)",
"pump_schedule": "Pump schedule entity (restored after bath boost)"
}
}
},
"abort": {
"already_configured": "Already configured. Only one instance is supported."
}
},
"options": {
"step": {
"init": {
"title": "Reconfigure Entities",
"description": "Update entity references for the integration.",
"data": {
"boiler_temp_sensor": "Boiler temperature sensor",
"heater_relay": "Heater relay switch",
"phase_count": "Number of phases",
"ct1_power": "Phase 1 CT power sensor",
"ct2_power": "Phase 2 CT power sensor (3-phase only)",
"ct3_power": "Phase 3 CT power sensor (3-phase only)",
"hp_aux_heater": "Heat pump auxiliary heater sensor (interlock)",
"circulation_pump": "DHW circulation pump switch (enables bath boost)",
"pump_schedule": "Pump schedule entity (restored after bath boost)"
}
}
}
},
"entity": {
"climate": {
"boiler_aux_heater": {
"name": "Boiler Auxiliary Heater"
}
},
"sensor": {
"total_power": {
"name": "Heater Power"
},
"pv_power": {
"name": "Heater PV Power"
},
"grid_power": {
"name": "Heater Grid Power"
},
"total_energy": {
"name": "Heater Energy"
},
"pv_energy": {
"name": "Heater PV Energy"
},
"grid_energy": {
"name": "Heater Grid Energy"
},
"bath_boost_remaining": {
"name": "Bath Boost Remaining"
},
"decision_reason": {
"name": "Decision Reason"
},
"pv_state": {
"name": "PV Excess State"
}
},
"binary_sensor": {
"decision": {
"name": "Heater Decision"
},
"pv_sufficient": {
"name": "PV Power Sufficient"
},
"running_on_pv": {
"name": "Running on PV"
},
"running_on_grid": {
"name": "Running on Grid"
},
"bath_boost_active": {
"name": "Bath Boost Active"
}
},
"switch": {
"manual_override": {
"name": "Manual Override"
},
"allow_usage": {
"name": "Allow Aux Heater Usage"
},
"pv_excess_mode": {
"name": "PV Excess Mode"
},
"boost_all_modes": {
"name": "Boost in All Modes"
}
},
"number": {
"cut_off_temp": {
"name": "Cut-off Temperature"
},
"temp_hysteresis": {
"name": "Temperature Hysteresis"
},
"pv_target_temp": {
"name": "PV Target Temperature"
},
"pv_buffer": {
"name": "PV Excess Buffer"
},
"heater_power": {
"name": "Heater Power Rating"
},
"pv_start_delay": {
"name": "PV Excess Start Delay"
},
"pv_stop_delay": {
"name": "PV Excess Stop Delay"
},
"temp_normal": {
"name": "Normal Preset Temperature"
},
"temp_eco": {
"name": "Eco Preset Temperature"
},
"temp_boost": {
"name": "Boost Preset Temperature"
},
"temp_sleep": {
"name": "Sleep Preset Temperature"
},
"temp_away": {
"name": "Away Preset Temperature"
},
"bath_boost_duration": {
"name": "Bath Boost Duration"
},
"boost_all_modes_hysteresis": {
"name": "All Modes Boost Hysteresis"
},
"boost_recovery_offset": {
"name": "Boost Recovery Offset"
}
},
"button": {
"start_bath_boost": {
"name": "Start Bath Boost"
},
"cancel_bath_boost": {
"name": "Cancel Bath Boost"
}
}
},
"services": {
"start_bath_boost": {
"name": "Start Bath Boost",
"description": "Start the bath boost timer with pump activation."
},
"stop_bath_boost": {
"name": "Stop Bath Boost",
"description": "Stop an active bath boost, restore pump schedule and preset."
}
}
}
@@ -0,0 +1,80 @@
"""Switch entities for Boiler Auxiliary Heater Control."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN, SWITCH_DEFINITIONS
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switch entities."""
coordinator = hass.data[DOMAIN][entry.entry_id]
entities = []
for key, defn in SWITCH_DEFINITIONS.items():
entity = BoilerAuxHeaterSwitch(coordinator, entry, key, defn)
entities.append(entity)
async_add_entities(entities)
class BoilerAuxHeaterSwitch(RestoreEntity, SwitchEntity):
"""A mode toggle switch for the boiler aux heater."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
coordinator,
entry: ConfigEntry,
key: str,
defn: dict,
) -> None:
"""Initialize the switch entity."""
self.coordinator = coordinator
self._key = key
self._defn = defn
self._attr_unique_id = f"{entry.entry_id}_{key}"
self._attr_translation_key = key
self._attr_icon = defn["icon"]
self._attr_is_on = defn["default"]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Restore state on startup."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state and last_state.state not in ("unknown", "unavailable"):
self._attr_is_on = last_state.state == "on"
# Register with coordinator
self.coordinator.register_switch(self._key, self)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
self._attr_is_on = True
self.async_write_ha_state()
self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
self._attr_is_on = False
self.async_write_ha_state()
self.coordinator.async_request_refresh()
@@ -0,0 +1,187 @@
{
"config": {
"step": {
"user": {
"title": "Core Hardware",
"description": "Select the boiler temperature sensor and heater relay switch.",
"data": {
"boiler_temp_sensor": "Boiler temperature sensor",
"heater_relay": "Heater relay switch"
}
},
"solar": {
"title": "Solar Monitoring",
"description": "Select the phase count and CT power sensors from your inverter. Negative values indicate export. For single-phase setups, only Phase 1 CT is required.",
"data": {
"phase_count": "Number of phases",
"ct1_power": "Phase 1 CT power sensor",
"ct2_power": "Phase 2 CT power sensor (3-phase only)",
"ct3_power": "Phase 3 CT power sensor (3-phase only)"
}
},
"optional": {
"title": "Optional Devices",
"description": "Configure optional devices. Leave empty to skip.",
"data": {
"hp_aux_heater": "Heat pump auxiliary heater sensor (interlock)",
"circulation_pump": "DHW circulation pump switch (enables bath boost)",
"pump_schedule": "Pump schedule entity (restored after bath boost)"
}
}
},
"abort": {
"already_configured": "Already configured. Only one instance is supported."
}
},
"options": {
"step": {
"init": {
"title": "Reconfigure Entities",
"description": "Update entity references for the integration.",
"data": {
"boiler_temp_sensor": "Boiler temperature sensor",
"heater_relay": "Heater relay switch",
"phase_count": "Number of phases",
"ct1_power": "Phase 1 CT power sensor",
"ct2_power": "Phase 2 CT power sensor (3-phase only)",
"ct3_power": "Phase 3 CT power sensor (3-phase only)",
"hp_aux_heater": "Heat pump auxiliary heater sensor (interlock)",
"circulation_pump": "DHW circulation pump switch (enables bath boost)",
"pump_schedule": "Pump schedule entity (restored after bath boost)"
}
}
}
},
"entity": {
"climate": {
"boiler_aux_heater": {
"name": "Boiler Auxiliary Heater"
}
},
"sensor": {
"total_power": {
"name": "Heater Power"
},
"pv_power": {
"name": "Heater PV Power"
},
"grid_power": {
"name": "Heater Grid Power"
},
"total_energy": {
"name": "Heater Energy"
},
"pv_energy": {
"name": "Heater PV Energy"
},
"grid_energy": {
"name": "Heater Grid Energy"
},
"bath_boost_remaining": {
"name": "Bath Boost Remaining"
},
"decision_reason": {
"name": "Decision Reason"
},
"pv_state": {
"name": "PV Excess State"
}
},
"binary_sensor": {
"decision": {
"name": "Heater Decision"
},
"pv_sufficient": {
"name": "PV Power Sufficient"
},
"running_on_pv": {
"name": "Running on PV"
},
"running_on_grid": {
"name": "Running on Grid"
},
"bath_boost_active": {
"name": "Bath Boost Active"
}
},
"switch": {
"manual_override": {
"name": "Manual Override"
},
"allow_usage": {
"name": "Allow Aux Heater Usage"
},
"pv_excess_mode": {
"name": "PV Excess Mode"
},
"boost_all_modes": {
"name": "Boost in All Modes"
}
},
"number": {
"cut_off_temp": {
"name": "Cut-off Temperature"
},
"temp_hysteresis": {
"name": "Temperature Hysteresis"
},
"pv_target_temp": {
"name": "PV Target Temperature"
},
"pv_buffer": {
"name": "PV Excess Buffer"
},
"heater_power": {
"name": "Heater Power Rating"
},
"pv_start_delay": {
"name": "PV Excess Start Delay"
},
"pv_stop_delay": {
"name": "PV Excess Stop Delay"
},
"temp_normal": {
"name": "Normal Preset Temperature"
},
"temp_eco": {
"name": "Eco Preset Temperature"
},
"temp_boost": {
"name": "Boost Preset Temperature"
},
"temp_sleep": {
"name": "Sleep Preset Temperature"
},
"temp_away": {
"name": "Away Preset Temperature"
},
"bath_boost_duration": {
"name": "Bath Boost Duration"
},
"boost_all_modes_hysteresis": {
"name": "All Modes Boost Hysteresis"
},
"boost_recovery_offset": {
"name": "Boost Recovery Offset"
}
},
"button": {
"start_bath_boost": {
"name": "Start Bath Boost"
},
"cancel_bath_boost": {
"name": "Cancel Bath Boost"
}
}
},
"services": {
"start_bath_boost": {
"name": "Start Bath Boost",
"description": "Start the bath boost timer with pump activation."
},
"stop_bath_boost": {
"name": "Stop Bath Boost",
"description": "Stop an active bath boost, restore pump schedule and preset."
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Boiler Auxiliary Heater Control",
"content_in_root": false,
"homeassistant": "2024.1.0"
}