Archived
transforming to integration
This commit is contained in:
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user