commit 73d5b8aeca6efe29b60e60edcff45be3053eb594 Author: Constantin Pascal Date: Thu Feb 26 09:58:44 2026 +0200 Initial push diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e0115a7 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: costea32 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..246aeac --- /dev/null +++ b/.github/workflows/release.yml @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f5c73b --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b8593a --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/custom_components/boiler_aux_heater_3phase/__init__.py b/custom_components/boiler_aux_heater_3phase/__init__.py new file mode 100644 index 0000000..25b5483 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/__init__.py @@ -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 diff --git a/custom_components/boiler_aux_heater_3phase/binary_sensor.py b/custom_components/boiler_aux_heater_3phase/binary_sensor.py new file mode 100644 index 0000000..15fbc9f --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/binary_sensor.py @@ -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"] diff --git a/custom_components/boiler_aux_heater_3phase/button.py b/custom_components/boiler_aux_heater_3phase/button.py new file mode 100644 index 0000000..188ffb0 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/button.py @@ -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() diff --git a/custom_components/boiler_aux_heater_3phase/climate.py b/custom_components/boiler_aux_heater_3phase/climate.py new file mode 100644 index 0000000..6ddbfe5 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/climate.py @@ -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) diff --git a/custom_components/boiler_aux_heater_3phase/config_flow.py b/custom_components/boiler_aux_heater_3phase/config_flow.py new file mode 100644 index 0000000..ce9f759 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/config_flow.py @@ -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() + ), + } + ), + ) diff --git a/custom_components/boiler_aux_heater_3phase/const.py b/custom_components/boiler_aux_heater_3phase/const.py new file mode 100644 index 0000000..163bce9 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/const.py @@ -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 diff --git a/custom_components/boiler_aux_heater_3phase/coordinator.py b/custom_components/boiler_aux_heater_3phase/coordinator.py new file mode 100644 index 0000000..3eefea5 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/coordinator.py @@ -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) diff --git a/custom_components/boiler_aux_heater_3phase/dashboard.yaml b/custom_components/boiler_aux_heater_3phase/dashboard.yaml new file mode 100644 index 0000000..6539d35 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/dashboard.yaml @@ -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: [] diff --git a/custom_components/boiler_aux_heater_3phase/manifest.json b/custom_components/boiler_aux_heater_3phase/manifest.json new file mode 100644 index 0000000..5defbe2 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/manifest.json @@ -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" +} diff --git a/custom_components/boiler_aux_heater_3phase/number.py b/custom_components/boiler_aux_heater_3phase/number.py new file mode 100644 index 0000000..efc00ea --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/number.py @@ -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"] diff --git a/custom_components/boiler_aux_heater_3phase/sensor.py b/custom_components/boiler_aux_heater_3phase/sensor.py new file mode 100644 index 0000000..f97d7c0 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/sensor.py @@ -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 diff --git a/custom_components/boiler_aux_heater_3phase/services.yaml b/custom_components/boiler_aux_heater_3phase/services.yaml new file mode 100644 index 0000000..6894c4d --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/services.yaml @@ -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. diff --git a/custom_components/boiler_aux_heater_3phase/strings.json b/custom_components/boiler_aux_heater_3phase/strings.json new file mode 100644 index 0000000..44cab83 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/strings.json @@ -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." + } + } +} diff --git a/custom_components/boiler_aux_heater_3phase/switch.py b/custom_components/boiler_aux_heater_3phase/switch.py new file mode 100644 index 0000000..4d956a9 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/switch.py @@ -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() diff --git a/custom_components/boiler_aux_heater_3phase/translations/en.json b/custom_components/boiler_aux_heater_3phase/translations/en.json new file mode 100644 index 0000000..44cab83 --- /dev/null +++ b/custom_components/boiler_aux_heater_3phase/translations/en.json @@ -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." + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..cc78a56 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Boiler Auxiliary Heater Control", + "content_in_root": false, + "homeassistant": "2024.1.0" +} \ No newline at end of file