transforming to integration

This commit is contained in:
Constantin Pascal
2026-02-12 11:25:12 +02:00
parent c8d1e968d7
commit b5fbca8453
14 changed files with 1847 additions and 0 deletions
@@ -0,0 +1,72 @@
"""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 service
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()
hass.services.async_register(
DOMAIN, "start_bath_boost", handle_start_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)
# Re-setup listeners with new entity IDs
await coordinator.async_shutdown()
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 service if no more entries
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "start_bath_boost")
return unload_ok
@@ -0,0 +1,122 @@
"""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,
},
}
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
return False
@property
def icon(self) -> str:
"""Return icon based on state."""
if self.is_on:
return self._defn["icon_on"]
return self._defn["icon_off"]
@@ -0,0 +1,48 @@
"""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]
# Only create bath boost button if circulation pump is configured
if coordinator.circulation_pump:
async_add_entities(
[BoilerBathBoostButton(coordinator, entry)]
)
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()
@@ -0,0 +1,162 @@
"""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_AWAY,
PRESET_BOOST,
PRESET_ECO,
PRESET_NORMAL,
PRESET_SLEEP,
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
)
_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
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."""
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
# Toggle bath boost based on preset
if preset_mode == PRESET_BOOST:
self.coordinator.bath_boost_active = True
else:
self.coordinator.bath_boost_active = False
self.async_write_ha_state()
self.coordinator.async_request_refresh()
@@ -0,0 +1,221 @@
"""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_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 - 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_CT1_POWER): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
vol.Required(CONF_CT2_POWER): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
vol.Required(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)
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.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:
return self.async_create_entry(title="", data=user_input)
current = {**self._config_entry.data, **self._config_entry.options}
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_CT1_POWER,
default=current.get(CONF_CT1_POWER),
): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
vol.Required(
CONF_CT2_POWER,
default=current.get(CONF_CT2_POWER),
): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
)
),
vol.Required(
CONF_CT3_POWER,
default=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.SENSOR)
),
vol.Optional(
CONF_CIRCULATION_PUMP,
description={
"suggested_value": current.get(CONF_CIRCULATION_PUMP)
},
): selector.EntitySelector(
selector.EntitySelectorConfig(domain=Platform.SWITCH)
),
vol.Optional(
CONF_PUMP_SCHEDULE,
description={
"suggested_value": current.get(CONF_PUMP_SCHEDULE)
},
): selector.EntitySelector(
selector.EntitySelectorConfig()
),
}
),
)
@@ -0,0 +1,203 @@
"""Constants for the Boiler Auxiliary Heater Control integration."""
DOMAIN = "boiler_aux_heater"
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"
# 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,
},
}
# 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
MIN_CYCLE_DURATION = 120 # seconds
# Coordinator debounce
DEBOUNCE_SECONDS = 1.0
@@ -0,0 +1,461 @@
"""Coordinator for Boiler Auxiliary Heater Control."""
from __future__ import annotations
import logging
import time
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,
)
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_PUMP_SCHEDULE,
DEBOUNCE_SECONDS,
DOMAIN,
MAIN_COLD_TOLERANCE,
MAIN_HOT_TOLERANCE,
MIN_CYCLE_DURATION,
PRESET_BOOST,
PV_COLD_TOLERANCE,
PV_HOT_TOLERANCE,
PV_STATE_ACTIVE,
PV_STATE_IDLE,
PV_STATE_WAITING_START,
PV_STATE_WAITING_STOP,
)
_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 = config[CONF_CT2_POWER]
self.ct3_power: str = config[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)
# 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._bath_boost_timer_handle: CALLBACK_TYPE | None = None
self._bath_boost_remaining: float = 0
self._bath_boost_end_time: float | None = None
self._pv_delay_handle: CALLBACK_TYPE | None = None
self._debounce_handle: CALLBACK_TYPE | None = None
self._min_cycle_handle: CALLBACK_TYPE | None = None
self._last_relay_change: float = 0
# Computed states (updated by recalculate)
self.decision: bool = False
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] = []
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,
self.ct2_power,
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
)
)
async def async_shutdown(self) -> None:
"""Clean up listeners and timers."""
for unsub in self._unsub_listeners:
unsub()
self._unsub_listeners.clear()
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
@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_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 _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)
ct3 = self._get_external_float(self.ct3_power, 0.0)
# HP aux heater interlock
hp_aux_on = False
if self.hp_aux_heater:
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
# PV sufficient calculation
phase_buffer = pv_buffer / 3.0
phase_heater = heater_power / 3.0
turn_on_threshold = -1.0 * (phase_buffer + phase_heater)
if relay_on:
self.pv_sufficient = ct1 <= 0 and ct2 <= 0 and ct3 <= 0
else:
self.pv_sufficient = (
ct1 < turn_on_threshold
and ct2 < turn_on_threshold
and ct3 < turn_on_threshold
)
# PV shadow thermostat logic
if not (pv_excess_mode and self.pv_state == PV_STATE_ACTIVE):
self.pv_shadow_heating = False
else:
# Shadow thermostat: is boiler temp below PV target (with tolerance)?
if boiler_temp < (pv_target_temp - PV_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)
# Main thermostat heating check (with 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 - MAIN_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
# Boost conditions
bath_boost_heating = (
self.bath_boost_active and main_thermostat_heating
)
all_modes_heating = boost_all_modes and main_thermostat_heating
# 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
# Main decision
old_decision = self.decision
self.decision = manual_override or (
allowed and (pv_heating or bath_boost_heating or all_modes_heating)
)
# Control relay if decision changed
if self.decision != old_decision:
self.hass.async_create_task(self._async_control_relay(self.decision))
self._notify_listeners()
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
# 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
)
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
# 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},
)
)
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[CONF_CT2_POWER]
self.ct3_power = new_config[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)
@@ -0,0 +1,10 @@
{
"domain": "boiler_aux_heater",
"name": "Boiler Auxiliary Heater Control",
"codeowners": [],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/your-repo/boiler-aux-heater",
"iot_class": "local_push",
"version": "1.0.0"
}
@@ -0,0 +1,81 @@
"""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
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():
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()
@@ -0,0 +1,219 @@
"""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),
]
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)
@@ -0,0 +1,5 @@
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.
@@ -0,0 +1,53 @@
{
"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 3-phase CT power sensors from your inverter. Negative values indicate export.",
"data": {
"ct1_power": "Phase 1 CT power sensor",
"ct2_power": "Phase 2 CT power sensor",
"ct3_power": "Phase 3 CT power sensor"
}
},
"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",
"ct1_power": "Phase 1 CT power sensor",
"ct2_power": "Phase 2 CT power sensor",
"ct3_power": "Phase 3 CT power sensor",
"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)"
}
}
}
}
}
@@ -0,0 +1,80 @@
"""Switch entities for Boiler Auxiliary Heater Control."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN, SWITCH_DEFINITIONS
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switch entities."""
coordinator = hass.data[DOMAIN][entry.entry_id]
entities = []
for key, defn in SWITCH_DEFINITIONS.items():
entity = BoilerAuxHeaterSwitch(coordinator, entry, key, defn)
entities.append(entity)
async_add_entities(entities)
class BoilerAuxHeaterSwitch(RestoreEntity, SwitchEntity):
"""A mode toggle switch for the boiler aux heater."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
coordinator,
entry: ConfigEntry,
key: str,
defn: dict,
) -> None:
"""Initialize the switch entity."""
self.coordinator = coordinator
self._key = key
self._defn = defn
self._attr_unique_id = f"{entry.entry_id}_{key}"
self._attr_translation_key = key
self._attr_icon = defn["icon"]
self._attr_is_on = defn["default"]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="Boiler Auxiliary Heater",
manufacturer="Custom",
)
async def async_added_to_hass(self) -> None:
"""Restore state on startup."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state and last_state.state not in ("unknown", "unavailable"):
self._attr_is_on = last_state.state == "on"
# Register with coordinator
self.coordinator.register_switch(self._key, self)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
self._attr_is_on = True
self.async_write_ha_state()
self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
self._attr_is_on = False
self.async_write_ha_state()
self.coordinator.async_request_refresh()
@@ -0,0 +1,110 @@
{
"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 3-phase CT power sensors from your inverter. Negative values indicate export.",
"data": {
"ct1_power": "Phase 1 CT power sensor",
"ct2_power": "Phase 2 CT power sensor",
"ct3_power": "Phase 3 CT power sensor"
}
},
"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",
"ct1_power": "Phase 1 CT power sensor",
"ct2_power": "Phase 2 CT power sensor",
"ct3_power": "Phase 3 CT power sensor",
"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 Aux Heater",
"state_attributes": {
"preset_mode": {
"state": {
"Normal": "Normal",
"Eco": "Eco",
"Boost": "Boost",
"Sleep": "Sleep",
"Away": "Away"
}
}
}
}
},
"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" }
},
"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" }
},
"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" }
},
"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" }
},
"button": {
"start_bath_boost": { "name": "Start Bath Boost" }
}
}
}