From b5fbca8453a3c046fdc863c63f480d18d2426d3d Mon Sep 17 00:00:00 2001 From: Constantin Pascal Date: Thu, 12 Feb 2026 11:25:12 +0200 Subject: [PATCH] transforming to integration --- .../boiler_aux_heater/__init__.py | 72 +++ .../boiler_aux_heater/binary_sensor.py | 122 +++++ custom_components/boiler_aux_heater/button.py | 48 ++ .../boiler_aux_heater/climate.py | 162 ++++++ .../boiler_aux_heater/config_flow.py | 221 +++++++++ custom_components/boiler_aux_heater/const.py | 203 ++++++++ .../boiler_aux_heater/coordinator.py | 461 ++++++++++++++++++ .../boiler_aux_heater/manifest.json | 10 + custom_components/boiler_aux_heater/number.py | 81 +++ custom_components/boiler_aux_heater/sensor.py | 219 +++++++++ .../boiler_aux_heater/services.yaml | 5 + .../boiler_aux_heater/strings.json | 53 ++ custom_components/boiler_aux_heater/switch.py | 80 +++ .../boiler_aux_heater/translations/en.json | 110 +++++ 14 files changed, 1847 insertions(+) create mode 100644 custom_components/boiler_aux_heater/__init__.py create mode 100644 custom_components/boiler_aux_heater/binary_sensor.py create mode 100644 custom_components/boiler_aux_heater/button.py create mode 100644 custom_components/boiler_aux_heater/climate.py create mode 100644 custom_components/boiler_aux_heater/config_flow.py create mode 100644 custom_components/boiler_aux_heater/const.py create mode 100644 custom_components/boiler_aux_heater/coordinator.py create mode 100644 custom_components/boiler_aux_heater/manifest.json create mode 100644 custom_components/boiler_aux_heater/number.py create mode 100644 custom_components/boiler_aux_heater/sensor.py create mode 100644 custom_components/boiler_aux_heater/services.yaml create mode 100644 custom_components/boiler_aux_heater/strings.json create mode 100644 custom_components/boiler_aux_heater/switch.py create mode 100644 custom_components/boiler_aux_heater/translations/en.json diff --git a/custom_components/boiler_aux_heater/__init__.py b/custom_components/boiler_aux_heater/__init__.py new file mode 100644 index 0000000..9c33394 --- /dev/null +++ b/custom_components/boiler_aux_heater/__init__.py @@ -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 diff --git a/custom_components/boiler_aux_heater/binary_sensor.py b/custom_components/boiler_aux_heater/binary_sensor.py new file mode 100644 index 0000000..b5d2bed --- /dev/null +++ b/custom_components/boiler_aux_heater/binary_sensor.py @@ -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"] diff --git a/custom_components/boiler_aux_heater/button.py b/custom_components/boiler_aux_heater/button.py new file mode 100644 index 0000000..62ff05f --- /dev/null +++ b/custom_components/boiler_aux_heater/button.py @@ -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() diff --git a/custom_components/boiler_aux_heater/climate.py b/custom_components/boiler_aux_heater/climate.py new file mode 100644 index 0000000..f3ecc46 --- /dev/null +++ b/custom_components/boiler_aux_heater/climate.py @@ -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() diff --git a/custom_components/boiler_aux_heater/config_flow.py b/custom_components/boiler_aux_heater/config_flow.py new file mode 100644 index 0000000..19c4422 --- /dev/null +++ b/custom_components/boiler_aux_heater/config_flow.py @@ -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() + ), + } + ), + ) diff --git a/custom_components/boiler_aux_heater/const.py b/custom_components/boiler_aux_heater/const.py new file mode 100644 index 0000000..d767fcd --- /dev/null +++ b/custom_components/boiler_aux_heater/const.py @@ -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 diff --git a/custom_components/boiler_aux_heater/coordinator.py b/custom_components/boiler_aux_heater/coordinator.py new file mode 100644 index 0000000..4d680b9 --- /dev/null +++ b/custom_components/boiler_aux_heater/coordinator.py @@ -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) diff --git a/custom_components/boiler_aux_heater/manifest.json b/custom_components/boiler_aux_heater/manifest.json new file mode 100644 index 0000000..dc1c7bd --- /dev/null +++ b/custom_components/boiler_aux_heater/manifest.json @@ -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" +} diff --git a/custom_components/boiler_aux_heater/number.py b/custom_components/boiler_aux_heater/number.py new file mode 100644 index 0000000..dfcaf39 --- /dev/null +++ b/custom_components/boiler_aux_heater/number.py @@ -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() diff --git a/custom_components/boiler_aux_heater/sensor.py b/custom_components/boiler_aux_heater/sensor.py new file mode 100644 index 0000000..0d38631 --- /dev/null +++ b/custom_components/boiler_aux_heater/sensor.py @@ -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) diff --git a/custom_components/boiler_aux_heater/services.yaml b/custom_components/boiler_aux_heater/services.yaml new file mode 100644 index 0000000..eff2bf6 --- /dev/null +++ b/custom_components/boiler_aux_heater/services.yaml @@ -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. diff --git a/custom_components/boiler_aux_heater/strings.json b/custom_components/boiler_aux_heater/strings.json new file mode 100644 index 0000000..8bdb6d3 --- /dev/null +++ b/custom_components/boiler_aux_heater/strings.json @@ -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)" + } + } + } + } +} diff --git a/custom_components/boiler_aux_heater/switch.py b/custom_components/boiler_aux_heater/switch.py new file mode 100644 index 0000000..4d956a9 --- /dev/null +++ b/custom_components/boiler_aux_heater/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/translations/en.json b/custom_components/boiler_aux_heater/translations/en.json new file mode 100644 index 0000000..5fd1f35 --- /dev/null +++ b/custom_components/boiler_aux_heater/translations/en.json @@ -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" } + } + } +}