Initial push
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
buy_me_a_coffee: costea32
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user