diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 6f90e30f..959624c0 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -1,13 +1,17 @@ import asyncio import logging -import sys +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant from . import mqtt, serial, switch from .config_store import SatConfigStore from .const import * +from .coordinator import SatDataUpdateCoordinatorFactory _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -23,32 +27,25 @@ async def async_setup(_hass: HomeAssistant, __config: Config): async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): """ - Set up this integration using UI. + Set up this integration using the UI. - This function is called by Home Assistant when the integration is set up using the UI. + This function is called by Home Assistant when the integration is set up with the UI. """ - - # Create a new dictionary for this entry if it doesn't exist - if _hass.data.get(DOMAIN) is None: - _hass.data.setdefault(DOMAIN, {}) + # Create a new dictionary + _hass.data[DOMAIN] = {_entry.entry_id: {}} # Create a new config store for this entry and initialize it - _hass.data[DOMAIN][_entry.entry_id] = {CONFIG_STORE: SatConfigStore(_hass, _entry)} + _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] = store = SatConfigStore(_hass, _entry) await _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE].async_initialize() - # Retrieve the defaults and override it with the user options - options = OPTIONS_DEFAULTS.copy() - options.update(_entry.data) - - # Get the module name from the config entry data and import it dynamically - module = getattr(sys.modules[__name__], options.get(CONF_MODE)) - - # Call the async_setup_entry function of the module - await _hass.async_add_job(module.async_setup_entry, _hass, _entry) + # Resolve the coordinator by using the factory according to the mode + _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = await SatDataUpdateCoordinatorFactory().resolve( + hass=_hass, store=store, mode=store.options.get(CONF_MODE), device=store.options.get(CONF_DEVICE) + ) # Forward entry setup for climate and other platforms - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR, NUMBER, BINARY_SENSOR])) + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE_DOMAIN)) + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN])) # Add an update listener for this entry _entry.async_on_unload(_entry.add_update_listener(async_reload_entry)) @@ -62,15 +59,11 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: This function is called by Home Assistant when the integration is being removed. """ - - # Retrieve the defaults and override it with the user options - options = OPTIONS_DEFAULTS.copy() - options.update(_entry.data) - # Unload the entry and its dependent components unloaded = all( await asyncio.gather( - _hass.config_entries.async_unload_platforms(_entry, [CLIMATE, SENSOR, NUMBER, BINARY_SENSOR]), + _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].async_will_remove_from_hass(_hass.data[DOMAIN][_entry.entry_id][CLIMATE]), + _hass.config_entries.async_unload_platforms(_entry, [CLIMATE, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN]), ) ) @@ -87,7 +80,6 @@ async def async_reload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> None: This function is called by Home Assistant when the integration configuration is updated. """ - # Unload the entry and its dependent components await async_unload_entry(_hass, _entry) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index d34b04ed..ddba0e5a 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import typing from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass from homeassistant.components.climate import HVACAction @@ -9,13 +8,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_MODE, MODE_SERIAL, OPTIONS_DEFAULTS, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE -from .entity import SatEntity +from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE +from .entity import SatClimateEntity from .serial import binary_sensor as serial_binary_sensor -if typing.TYPE_CHECKING: - from .climate import SatClimate - _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -26,26 +22,17 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a climate = _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] - # Retrieve the defaults and override it with the user options - options = OPTIONS_DEFAULTS.copy() - options.update(_config_entry.data) - # Check if integration is set to use the serial protocol - if options.get(CONF_MODE) == MODE_SERIAL: - # Call function to set up OpenTherm binary sensors + if coordinator.store.options.get(CONF_MODE) == MODE_SERIAL: await serial_binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) - _async_add_entities([ - SatControlSetpointSynchroSensor(coordinator, climate, _config_entry), - SatCentralHeatingSynchroSensor(coordinator, climate, _config_entry), - ]) + if coordinator.supports_setpoint_management: + _async_add_entities([SatControlSetpointSynchroSensor(coordinator, climate, _config_entry)]) + _async_add_entities([SatCentralHeatingSynchroSensor(coordinator, climate, _config_entry)]) -class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): - super().__init__(coordinator, config_entry) - self._climate = climate +class SatControlSetpointSynchroSensor(SatClimateEntity, BinarySensorEntity): @property def name(self): @@ -60,18 +47,12 @@ def device_class(self): @property def available(self): """Return availability of the sensor.""" - return self._climate is not None and self._coordinator.setpoint is not None + return self._climate.setpoint is not None and self._coordinator.setpoint is not None @property def is_on(self): """Return the state of the sensor.""" - coordinator_setpoint = self._coordinator.setpoint - climate_setpoint = float(self._climate.extra_state_attributes.get("setpoint") or coordinator_setpoint) - - return not ( - self._climate.state_attributes.get("hvac_action") != HVACAction.HEATING or - round(climate_setpoint, 1) == round(coordinator_setpoint, 1) - ) + return round(self._climate.setpoint, 1) != round(self._coordinator.setpoint, 1) @property def unique_id(self): @@ -79,11 +60,7 @@ def unique_id(self): return f"{self._config_entry.data.get(CONF_NAME).lower()}-control-setpoint-synchro" -class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: - """Initialize the Central Heating Synchro sensor.""" - super().__init__(coordinator, config_entry) - self._climate = climate +class SatCentralHeatingSynchroSensor(SatClimateEntity, BinarySensorEntity): @property def name(self) -> str: diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 7408249d..c7619708 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -197,7 +197,7 @@ async def async_added_to_hass(self) -> None: # Restore previous state if available, or set default values await self._restore_previous_state_or_set_defaults() - # Update heating curve if outside temperature is available + # Update a heating curve if outside temperature is available if self.current_outside_temperature is not None: self._heating_curve.update(self.target_temperature, self.current_outside_temperature) @@ -426,6 +426,10 @@ def hvac_action(self): def max_error(self) -> float: return max([self.error] + self.climate_errors) + @property + def setpoint(self) -> float | None: + return self._setpoint + @property def climate_errors(self) -> List[float]: """Calculate the temperature difference between the current temperature and target temperature for all connected climates.""" @@ -515,6 +519,9 @@ def pulse_width_modulation_enabled(self) -> bool: return False def _get_requested_setpoint(self): + if self._heating_curve.value is None: + return MINIMUM_SETPOINT + return max(self._heating_curve.value + self._pid.output, MINIMUM_SETPOINT) def _calculate_control_setpoint(self) -> float: @@ -525,7 +532,7 @@ def _calculate_control_setpoint(self) -> float: # Combine the heating curve value and the calculated output from the pid controller requested_setpoint = self._get_requested_setpoint() - # Make sure we are above the base setpoint when we are below target temperature + # Make sure we are above the base setpoint when we are below the target temperature if self.max_error > 0: requested_setpoint = max(requested_setpoint, self._heating_curve.value) @@ -598,7 +605,7 @@ async def _async_climate_changed(self, event: Event) -> None: elif new_attrs.get("temperature") != old_attrs.get("temperature"): await self._async_control_pid(True) - # If current temperature has changed, update the PID controller + # If the current temperature has changed, update the PID controller elif not hasattr(new_state.attributes, SENSOR_TEMPERATURE_ID) and new_attrs.get("current_temperature") != old_attrs.get("current_temperature"): await self._async_control_pid(False) @@ -658,7 +665,7 @@ async def _async_window_sensor_changed(self, event: Event) -> None: async def _async_control_heating_loop(self, _time=None) -> None: """Control the heating based on current temperature, target temperature, and outside temperature.""" - # If overshoot protection is active, we are not doing anything since we already have task running in async + # If overshoot protection is active, we are not doing anything since we already have a task running in async if self.overshoot_protection_calculate: return @@ -717,7 +724,7 @@ async def _async_control_pid(self, reset: bool = False): if not reset: _LOGGER.info(f"Updating error value to {max_error} (Reset: False)") - # Calculate optimal heating curve when we are in the deadband + # Calculate an optimal heating curve when we are in the deadband if -0.1 <= max_error <= 0.1: self._heating_curve.autotune( setpoint=self._get_requested_setpoint(), @@ -725,7 +732,7 @@ async def _async_control_pid(self, reset: bool = False): outside_temperature=self.current_outside_temperature ) - # Since we are in the deadband we can safely assume we are not warming up anymore + # Since we are in the deadband, we can safely assume we are not warming up anymore if self._warming_up and max_error <= 0.1: self._warming_up = False _LOGGER.info("Reached deadband, turning off warming up.") diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 11070dd1..c16ad3ba 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -111,7 +111,7 @@ async def async_step_switch(self, _user_input=None): self._data.update(_user_input) self._data[CONF_MODE] = MODE_SWITCH - await self.async_set_unique_id(self._data[CONF_SWITCH], raise_on_progress=False) + await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) self._abort_if_unique_id_configured() @@ -123,7 +123,7 @@ async def async_step_switch(self, _user_input=None): errors=self._errors, data_schema=vol.Schema({ vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_SWITCH): selector.EntitySelector( + vol.Required(CONF_DEVICE): selector.EntitySelector( selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN]) ) }), diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 6393ee8d..159b9bae 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -2,6 +2,7 @@ NAME = "Smart Autotune Thermostat" DOMAIN = "sat" VERSION = "2.1.0" +CLIMATE = "climate" COORDINATOR = "coordinator" CONFIG_STORE = "config_store" @@ -17,23 +18,10 @@ OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0 OVERSHOOT_PROTECTION_REQUIRED_DATASET = 40 -# Icons -ICON = "mdi:format-quote-close" - -# Device classes -BINARY_SENSOR_DEVICE_CLASS = "connectivity" - -# Platforms -SENSOR = "sensor" -NUMBER = "number" -CLIMATE = "climate" -BINARY_SENSOR = "binary_sensor" - # Configuration and options CONF_MODE = "mode" CONF_NAME = "name" CONF_DEVICE = "device" -CONF_SWITCH = "switch" CONF_SETPOINT = "setpoint" CONF_CLIMATES = "climates" CONF_MQTT_TOPIC = "mqtt_topic" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 1a68f590..76395b73 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -26,6 +26,24 @@ class DeviceState(Enum): OFF = "off" +class SatDataUpdateCoordinatorFactory: + @staticmethod + async def resolve(hass: HomeAssistant, store: SatConfigStore, mode: str, device: str) -> DataUpdateCoordinator: + if mode == MODE_MQTT: + from .mqtt import SatMqttCoordinator + return SatMqttCoordinator(hass, store, device) + + if mode == MODE_SERIAL: + from .serial import SatSerialCoordinator + return await SatSerialCoordinator(hass, store, device).async_connect() + + if mode == MODE_SWITCH: + from .switch import SatSwitchCoordinator + return SatSwitchCoordinator(hass, store, device) + + raise Exception(f'Invalid mode[{mode}]') + + class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: """Initialize.""" @@ -76,7 +94,7 @@ def device_active(self) -> bool: @property def flame_active(self) -> bool: - return True + return self.device_active @property def hot_water_active(self) -> bool: @@ -183,6 +201,10 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: partial(set_overshoot_protection_value, self) ) + async def async_will_remove_from_hass(self, climate: SatClimate) -> None: + """Run when entity will be removed from hass.""" + pass + async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: """Control the heating loop for the device.""" if climate.hvac_mode == HVACMode.OFF and self.device_active: diff --git a/custom_components/sat/entity.py b/custom_components/sat/entity.py index d5e2550e..b4cea2f1 100644 --- a/custom_components/sat/entity.py +++ b/custom_components/sat/entity.py @@ -1,14 +1,19 @@ -"""SatEntity class""" +from __future__ import annotations + import logging +import typing from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, NAME, VERSION, CONF_NAME -from .coordinator import SatDataUpdateCoordinator _LOGGER: logging.Logger = logging.getLogger(__name__) +if typing.TYPE_CHECKING: + from .climate import SatClimate + from .coordinator import SatDataUpdateCoordinator + class SatEntity(CoordinatorEntity): def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): @@ -25,3 +30,10 @@ def device_info(self): "manufacturer": NAME, "identifiers": {(DOMAIN, self._config_entry.data.get(CONF_NAME))}, } + + +class SatClimateEntity(SatEntity): + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._climate = climate diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 063848c4..97455386 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -1,17 +1,228 @@ +from __future__ import annotations, annotations + import logging +import typing from homeassistant.components import mqtt -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, Event, State +from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.event import async_track_state_change_event -from .coordinator import SatMqttCoordinator +from ..config_store import SatConfigStore from ..const import * +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +DATA_FLAME_ACTIVE = "flame" +DATA_DHW_SETPOINT = "TdhwSet" +DATA_CONTROL_SETPOINT = "TSet" +DATA_DHW_ENABLE = "dhw_enable" +DATA_REL_MOD_LEVEL = "RelModLevel" +DATA_BOILER_TEMPERATURE = "Tboiler" +DATA_CENTRAL_HEATING = "centralheating" +DATA_BOILER_CAPACITY = "MaxCapacityMinModLevell_hb_u8" +DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevell_lb_u8" +DATA_DHW_SETPOINT_MINIMUM = "TdhwSetUBTdhwSetLB_value_lb" +DATA_DHW_SETPOINT_MAXIMUM = "TdhwSetUBTdhwSetLB_value_hb" + +if typing.TYPE_CHECKING: + from ..climate import SatClimate _LOGGER: logging.Logger = logging.getLogger(__name__) -async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): - store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] - _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = SatMqttCoordinator(_hass, store, _entry.data.get(CONF_DEVICE)) +def entity_id_to_opentherm_key(hass: HomeAssistant, node_id: str, entity_id: str): + entities = entity_registry.async_get(hass) + entity = entities.async_get(entity_id) + + if entity.unique_id: + return entity.unique_id[len(node_id) + 1:] + + return None + + +class SatMqttCoordinator(SatDataUpdateCoordinator): + """Class to manage to fetch data from the OTGW Gateway using mqtt.""" + + def __init__(self, hass: HomeAssistant, store: SatConfigStore, device_id: str) -> None: + super().__init__(hass, store) + + self.data = {} + + self._device = device_registry.async_get(hass).async_get(device_id) + self._node_id = list(self._device.identifiers)[0][1] + self._topic = store.options.get(CONF_MQTT_TOPIC) + + self._entity_registry = entity_registry.async_get(hass) + self._entities = entity_registry.async_entries_for_device(self._entity_registry, self._device.id) + + @property + def supports_setpoint_management(self): + return True + + @property + def supports_hot_water_setpoint_management(self): + return True + + def supports_maximum_setpoint_management(self): + return True + + @property + def support_relative_modulation_management(self): + return True + + @property + def device_active(self) -> bool: + return bool(self.data.get(DATA_CENTRAL_HEATING)) + + @property + def flame_active(self) -> bool: + return bool(self.data.get(DATA_FLAME_ACTIVE)) + + @property + def hot_water_active(self) -> bool: + return bool(self.data.get(DATA_DHW_ENABLE)) + + @property + def setpoint(self) -> float | None: + if (setpoint := self.data.get(DATA_CONTROL_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def hot_water_setpoint(self) -> float | None: + if (setpoint := self.data.get(DATA_DHW_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def minimum_hot_water_setpoint(self) -> float: + if (setpoint := self.data.get(DATA_DHW_SETPOINT_MINIMUM)) is not None: + return float(setpoint) + + return super().minimum_hot_water_setpoint + + @property + def maximum_hot_water_setpoint(self) -> float | None: + if (setpoint := self.data.get(DATA_DHW_SETPOINT_MAXIMUM)) is not None: + return float(setpoint) + + return super().maximum_hot_water_setpoint + + @property + def boiler_temperature(self) -> float | None: + if (value := self.data.get(DATA_BOILER_TEMPERATURE)) is not None: + return float(value) + + return super().boiler_temperature + + @property + def relative_modulation_value(self) -> float | None: + if (value := self.data.get(DATA_REL_MOD_LEVEL)) is not None: + return float(value) + + return None + + @property + def boiler_capacity(self) -> float | None: + if (value := self.data.get(DATA_BOILER_CAPACITY)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_relative_modulation_value(self) -> float | None: + if (value := self.data.get(DATA_REL_MIN_MOD_LEVEL)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_setpoint(self): + return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) + + async def async_added_to_hass(self, climate: SatClimate) -> None: + await mqtt.async_wait_for_mqtt_client(self.hass) + + await self._send_command("PM=48") + + entities = list(filter(lambda entity: entity is not None, [ + self._get_entity_id(DATA_FLAME_ACTIVE), + self._get_entity_id(DATA_DHW_SETPOINT), + self._get_entity_id(DATA_CONTROL_SETPOINT), + self._get_entity_id(DATA_DHW_ENABLE), + self._get_entity_id(DATA_REL_MOD_LEVEL), + self._get_entity_id(DATA_BOILER_TEMPERATURE), + self._get_entity_id(DATA_CENTRAL_HEATING), + self._get_entity_id(DATA_BOILER_CAPACITY), + self._get_entity_id(DATA_REL_MIN_MOD_LEVEL), + self._get_entity_id(DATA_DHW_SETPOINT_MINIMUM), + self._get_entity_id(DATA_DHW_SETPOINT_MAXIMUM), + ])) + + for entity_id in entities: + if state := self.hass.states.get(entity_id): + await self._on_state_change(entity_id, state) + + async def async_coroutine(event: Event): + await self._on_state_change(event.data.get("entity_id"), event.data.get("new_state")) + + async_track_state_change_event(self.hass, entities, async_coroutine) + + async def async_set_control_setpoint(self, value: float) -> None: + await self._send_command(f"CS={value}") + + await super().async_set_control_setpoint(value) + + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + await self._send_command(f"SW={value}") + + await super().async_set_control_hot_water_setpoint(value) + + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + await self._send_command(f"TC={value}") + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + await self._send_command(f"CH={1 if state == DeviceState.ON else 0}") + + await super().async_set_heater_state(state) + + async def async_control_max_relative_mod(self, value: float) -> None: + await self._send_command("MM={value}") + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + await self._send_command(f"SH={value}") + + await super().async_set_control_max_setpoint(value) + + def _get_entity_id(self, key: str): + return self._entity_registry.async_get_entity_id(SENSOR_DOMAIN, MQTT_DOMAIN, f"{self._node_id}-{key}") + + async def _on_state_change(self, entity_id: str, state: State): + key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) + if key is None: + return + + if state.state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self.data[key] = None + else: + self.data[key] = state.state + + if self._listeners: + self._schedule_refresh() + + self.async_update_listeners() + + async def _send_command(self, payload: str): + if not self._simulation: + await mqtt.async_publish(self.hass, f"{self._topic}/set/{self._node_id}/command", payload) - await mqtt.async_wait_for_mqtt_client(_hass) + _LOGGER.debug(f"Publishing '{payload}' to MQTT.") diff --git a/custom_components/sat/mqtt/coordinator.py b/custom_components/sat/mqtt/coordinator.py deleted file mode 100644 index a27f59f7..00000000 --- a/custom_components/sat/mqtt/coordinator.py +++ /dev/null @@ -1,226 +0,0 @@ -from __future__ import annotations, annotations - -import logging -import typing - -from homeassistant.components import mqtt -from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN -from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant, Event, State -from homeassistant.helpers import device_registry, entity_registry -from homeassistant.helpers.event import async_track_state_change_event - -from ..config_store import SatConfigStore -from ..const import * -from ..coordinator import DeviceState, SatDataUpdateCoordinator - -DATA_FLAME_ACTIVE = "flame" -DATA_DHW_SETPOINT = "TdhwSet" -DATA_CONTROL_SETPOINT = "TSet" -DATA_DHW_ENABLE = "dhw_enable" -DATA_REL_MOD_LEVEL = "RelModLevel" -DATA_BOILER_TEMPERATURE = "Tboiler" -DATA_CENTRAL_HEATING = "centralheating" -DATA_BOILER_CAPACITY = "MaxCapacityMinModLevell_hb_u8" -DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevell_lb_u8" -DATA_DHW_SETPOINT_MINIMUM = "TdhwSetUBTdhwSetLB_value_lb" -DATA_DHW_SETPOINT_MAXIMUM = "TdhwSetUBTdhwSetLB_value_hb" - -if typing.TYPE_CHECKING: - from ..climate import SatClimate - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -def entity_id_to_opentherm_key(hass: HomeAssistant, node_id: str, entity_id: str): - entities = entity_registry.async_get(hass) - entity = entities.async_get(entity_id) - - if entity.unique_id: - return entity.unique_id[len(node_id) + 1:] - - return None - - -class SatMqttCoordinator(SatDataUpdateCoordinator): - """Class to manage fetching data from the OTGW Gateway using mqtt.""" - - def __init__(self, hass: HomeAssistant, store: SatConfigStore, device_id: str) -> None: - super().__init__(hass, store) - - self.data = {} - - self._device = device_registry.async_get(hass).async_get(device_id) - self._node_id = list(self._device.identifiers)[0][1] - self._topic = store.options.get(CONF_MQTT_TOPIC) - - self._entity_registry = entity_registry.async_get(hass) - self._entities = entity_registry.async_entries_for_device(self._entity_registry, self._device.id) - - @property - def supports_setpoint_management(self): - return True - - @property - def supports_hot_water_setpoint_management(self): - return True - - def supports_maximum_setpoint_management(self): - return True - - @property - def support_relative_modulation_management(self): - return True - - @property - def device_active(self) -> bool: - return bool(self.data.get(DATA_CENTRAL_HEATING)) - - @property - def flame_active(self) -> bool: - return bool(self.data.get(DATA_FLAME_ACTIVE)) - - @property - def hot_water_active(self) -> bool: - return bool(self.data.get(DATA_DHW_ENABLE)) - - @property - def setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_CONTROL_SETPOINT)) is not None: - return float(setpoint) - - return None - - @property - def hot_water_setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_DHW_SETPOINT)) is not None: - return float(setpoint) - - return None - - @property - def minimum_hot_water_setpoint(self) -> float: - if (setpoint := self.data.get(DATA_DHW_SETPOINT_MINIMUM)) is not None: - return float(setpoint) - - return super().minimum_hot_water_setpoint - - @property - def maximum_hot_water_setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_DHW_SETPOINT_MAXIMUM)) is not None: - return float(setpoint) - - return super().maximum_hot_water_setpoint - - @property - def boiler_temperature(self) -> float | None: - if (value := self.data.get(DATA_BOILER_TEMPERATURE)) is not None: - return float(value) - - return super().boiler_temperature - - @property - def relative_modulation_value(self) -> float | None: - if (value := self.data.get(DATA_REL_MOD_LEVEL)) is not None: - return float(value) - - return None - - @property - def boiler_capacity(self) -> float | None: - if (value := self.data.get(DATA_BOILER_CAPACITY)) is not None: - return float(value) - - return super().boiler_capacity - - @property - def minimum_relative_modulation_value(self) -> float | None: - if (value := self.data.get(DATA_REL_MIN_MOD_LEVEL)) is not None: - return float(value) - - return super().boiler_capacity - - @property - def minimum_setpoint(self): - return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) - - async def async_added_to_hass(self, climate: SatClimate) -> None: - await self._send_command("PM=48") - - entities = list(filter(lambda entity: entity is not None, [ - self._get_entity_id(DATA_FLAME_ACTIVE), - self._get_entity_id(DATA_DHW_SETPOINT), - self._get_entity_id(DATA_CONTROL_SETPOINT), - self._get_entity_id(DATA_DHW_ENABLE), - self._get_entity_id(DATA_REL_MOD_LEVEL), - self._get_entity_id(DATA_BOILER_TEMPERATURE), - self._get_entity_id(DATA_CENTRAL_HEATING), - self._get_entity_id(DATA_BOILER_CAPACITY), - self._get_entity_id(DATA_REL_MIN_MOD_LEVEL), - self._get_entity_id(DATA_DHW_SETPOINT_MINIMUM), - self._get_entity_id(DATA_DHW_SETPOINT_MAXIMUM), - ])) - - for entity_id in entities: - if state := self.hass.states.get(entity_id): - await self._on_state_change(entity_id, state) - - async def on_state_change(event: Event): - await self._on_state_change(event.data.get("entity_id"), event.data.get("new_state")) - - async_track_state_change_event(self.hass, entities, on_state_change) - - async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: - await super().async_control_heating_loop(climate, _time) - - async def async_set_control_setpoint(self, value: float) -> None: - await self._send_command(f"CS={value}") - - await super().async_set_control_setpoint(value) - - async def async_set_control_hot_water_setpoint(self, value: float) -> None: - await self._send_command(f"SW={value}") - - await super().async_set_control_hot_water_setpoint(value) - - async def async_set_control_thermostat_setpoint(self, value: float) -> None: - await self._send_command(f"TC={value}") - - await super().async_set_control_thermostat_setpoint(value) - - async def async_set_heater_state(self, state: DeviceState) -> None: - await self._send_command(f"CH={1 if state == DeviceState.ON else 0}") - - await super().async_set_heater_state(state) - - async def async_control_max_relative_mod(self, value: float) -> None: - await self._send_command("MM={value}") - - await super().async_set_control_max_relative_modulation(value) - - async def async_set_control_max_setpoint(self, value: float) -> None: - await self._send_command(f"SH={value}") - - await super().async_set_control_max_setpoint(value) - - def _get_entity_id(self, key: str): - return self._entity_registry.async_get_entity_id(SENSOR, MQTT_DOMAIN, f"{self._node_id}-{key}") - - async def _on_state_change(self, entity_id: str, state: State): - key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) - if key is None: - return - - if state.state is not None and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self.data[key] = state.state - else: - self.data[key] = None - - if self._listeners: - self._schedule_refresh() - - self.async_update_listeners() - - async def _send_command(self, command: str): - if not self._simulation: - await mqtt.async_publish(self.hass, f"{self._topic}/set/{self._node_id}/command", command) diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 6a133627..9a28b1f7 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -176,7 +176,7 @@ def update_derivative(self, error: float, alpha1: float = 0.8, alpha2: float = 0 def update_history_size(self, alpha: float = 0.8): """ - Update the size of the history of errors and times. + Update the history of errors and times. The size of the history is updated based on the frequency of updates to the sensor value. If the frequency of updates is high, the history size is increased, and if the frequency of updates is low, @@ -202,7 +202,7 @@ def update_history_size(self, alpha: float = 0.8): history_size = max(2, history_size) history_size = min(history_size, 100) - # Calculate a weighted average of the rate of updates and the previous history size + # Calculate an average weighted rate of updates and the previous history size self._history_size = alpha * history_size + (1 - alpha) * self._history_size # Update our lists with the new size diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 9b61624a..b1f0cfe0 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import typing from homeassistant.components.sensor import SensorEntity, SensorDeviceClass @@ -9,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_MODE, MODE_SERIAL, OPTIONS_DEFAULTS, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE +from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE from .coordinator import SatDataUpdateCoordinator from .entity import SatEntity from .serial import sensor as serial_sensor @@ -17,6 +18,8 @@ if typing.TYPE_CHECKING: from .climate import SatClimate +_LOGGER: logging.Logger = logging.getLogger(__name__) + async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): """ @@ -25,13 +28,8 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a climate = _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] - # Retrieve the defaults and override it with the user options - options = OPTIONS_DEFAULTS.copy() - options.update(_config_entry.data) - # Check if integration is set to use the serial protocol - if options.get(CONF_MODE) == MODE_SERIAL: - # Call function to set up OpenTherm sensors + if coordinator.store.options.get(CONF_MODE) == MODE_SERIAL: await serial_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) _async_add_entities([ diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index 810fa822..96940b55 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -1,14 +1,22 @@ +from __future__ import annotations + import asyncio import logging +import typing +from typing import Optional, Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from pyotgw import OpenThermGateway, vars as gw_vars +from pyotgw import vars as gw_vars, OpenThermGateway +from pyotgw.vars import * from serial import SerialException -from .coordinator import SatSerialCoordinator +from ..config_store import SatConfigStore from ..const import * +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +if typing.TYPE_CHECKING: + from ..climate import SatClimate _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -20,12 +28,165 @@ } -async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): - try: - client = OpenThermGateway() - await client.connect(port=_entry.data.get(CONF_DEVICE), timeout=5) - except (asyncio.TimeoutError, ConnectionError, SerialException) as exception: - raise ConfigEntryNotReady(f"Could not connect to gateway at {_entry.data.get(CONF_DEVICE)}: {exception}") from exception +class SatSerialCoordinator(SatDataUpdateCoordinator): + """Class to manage to fetch data from the OTGW Gateway using pyotgw.""" + + def __init__(self, hass: HomeAssistant, store: SatConfigStore, port: str) -> None: + """Initialize.""" + super().__init__(hass, store) + + self.data = DEFAULT_STATUS + + async def async_coroutine(data): + self.async_set_updated_data(data) + + self._port = port + self._api = OpenThermGateway() + self._api.subscribe(async_coroutine) + + @property + def device_active(self) -> bool: + return bool(self.get(DATA_MASTER_CH_ENABLED) or False) + + @property + def hot_water_active(self) -> bool: + return bool(self.get(DATA_SLAVE_DHW_ACTIVE) or False) + + @property + def supports_setpoint_management(self) -> bool: + return True + + @property + def supports_hot_water_setpoint_management(self): + return True + + @property + def supports_maximum_setpoint_management(self) -> bool: + return True + + @property + def support_relative_modulation_management(self) -> bool: + return self._overshoot_protection or not self._force_pulse_width_modulation + + @property + def setpoint(self) -> float | None: + if (setpoint := self.get(DATA_CONTROL_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def hot_water_setpoint(self) -> float | None: + if (setpoint := self.get(DATA_DHW_SETPOINT)) is not None: + return float(setpoint) + + return super().hot_water_setpoint + + @property + def boiler_temperature(self) -> float | None: + if (value := self.get(DATA_CH_WATER_TEMP)) is not None: + return float(value) + + return super().boiler_temperature + + @property + def minimum_hot_water_setpoint(self) -> float: + if (setpoint := self.get(DATA_SLAVE_DHW_MIN_SETP)) is not None: + return float(setpoint) + + return super().minimum_hot_water_setpoint + + @property + def maximum_hot_water_setpoint(self) -> float | None: + if (setpoint := self.get(DATA_SLAVE_DHW_MAX_SETP)) is not None: + return float(setpoint) + + return super().maximum_hot_water_setpoint + + @property + def relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_REL_MOD_LEVEL)) is not None: + return float(value) + + return super().relative_modulation_value + + @property + def boiler_capacity(self) -> float | None: + if (value := self.get(DATA_SLAVE_MAX_CAPACITY)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_SLAVE_MIN_MOD_LEVEL)) is not None: + return float(value) + + return super().minimum_relative_modulation_value + + @property + def flame_active(self) -> bool: + return bool(self.get(DATA_SLAVE_FLAME_ON)) + + @property + def minimum_setpoint(self): + return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) + + def get(self, key: str) -> Optional[Any]: + """Get the value for the given `key` from the boiler data. + + :param key: Key of the value to retrieve from the boiler data. + :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. + """ + return self.data[BOILER].get(key) + + async def async_connect(self) -> SatSerialCoordinator: + try: + await self._api.connect(port=self._port, timeout=5) + except (asyncio.TimeoutError, ConnectionError, SerialException) as exception: + raise ConfigEntryNotReady(f"Could not connect to gateway at {self._port}: {exception}") from exception + + return self + + async def async_will_remove_from_hass(self, climate: SatClimate) -> None: + self._api.unsubscribe(self.async_set_updated_data) + + await self._api.set_control_setpoint(0) + await self._api.set_max_relative_mod("-") + await self._api.disconnect() + + async def async_set_control_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_control_setpoint(value) + + await super().async_set_control_setpoint(value) + + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_dhw_setpoint(value) + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_target_temp(value) + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + if not self._simulation: + await self._api.set_ch_enable_bit(1 if state == DeviceState.ON else 0) + + await super().async_set_heater_state(state) + + async def async_set_control_max_relative_modulation(self, value: float) -> None: + if not self._simulation: + await self._api.set_max_relative_mod(value) + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_max_ch_setpoint(value) - store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] - _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = SatSerialCoordinator(_hass, store, client) + await super().async_set_control_max_setpoint(value) diff --git a/custom_components/sat/serial/binary_sensor.py b/custom_components/sat/serial/binary_sensor.py index 2a495ad7..6a7f54ae 100644 --- a/custom_components/sat/serial/binary_sensor.py +++ b/custom_components/sat/serial/binary_sensor.py @@ -10,14 +10,10 @@ from homeassistant.helpers.entity import async_generate_entity_id from pyotgw.vars import * -from . import TRANSLATE_SOURCE -from .coordinator import SatSerialCoordinator +from . import TRANSLATE_SOURCE, SatSerialCoordinator from ..const import * from ..entity import SatEntity -if typing.TYPE_CHECKING: - pass - _LOGGER = logging.getLogger(__name__) @@ -73,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" - # Create list of entities to be added + # Create a list of entities to be added entities = [] # Iterate through sensor information @@ -124,7 +120,7 @@ def device_class(self): @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[self._source] is not None + return self._coordinator.data[self._source].get(self._key) is not None @property def is_on(self): diff --git a/custom_components/sat/serial/coordinator.py b/custom_components/sat/serial/coordinator.py deleted file mode 100644 index 73159250..00000000 --- a/custom_components/sat/serial/coordinator.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Optional, Any - -from homeassistant.core import HomeAssistant -from pyotgw import OpenThermGateway -from pyotgw.vars import * - -from ..config_store import SatConfigStore -from ..const import * -from ..coordinator import DeviceState, SatDataUpdateCoordinator - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -class SatSerialCoordinator(SatDataUpdateCoordinator): - """Class to manage fetching data from the OTGW Gateway using pyotgw.""" - - def __init__(self, hass: HomeAssistant, store: SatConfigStore, client: OpenThermGateway) -> None: - """Initialize.""" - super().__init__(hass, store) - - self.api = client - self.api.subscribe(self.async_set_updated_data) - - @property - def device_active(self) -> bool: - return bool(self.get(DATA_MASTER_CH_ENABLED) or False) - - @property - def hot_water_active(self) -> bool: - return bool(self.get(DATA_SLAVE_DHW_ACTIVE) or False) - - @property - def supports_setpoint_management(self) -> bool: - return True - - @property - def supports_hot_water_setpoint_management(self): - return True - - @property - def supports_maximum_setpoint_management(self) -> bool: - return True - - @property - def support_relative_modulation_management(self) -> bool: - return self._overshoot_protection or not self._force_pulse_width_modulation - - @property - def setpoint(self) -> float | None: - if (setpoint := self.get(DATA_CONTROL_SETPOINT)) is not None: - return float(setpoint) - - return None - - @property - def hot_water_setpoint(self) -> float | None: - if (setpoint := self.get(DATA_DHW_SETPOINT)) is not None: - return float(setpoint) - - return super().hot_water_setpoint - - @property - def boiler_temperature(self) -> float | None: - if (value := self.get(DATA_CH_WATER_TEMP)) is not None: - return float(value) - - return super().boiler_temperature - - @property - def minimum_hot_water_setpoint(self) -> float: - if (setpoint := self.get(DATA_SLAVE_DHW_MIN_SETP)) is not None: - return float(setpoint) - - return super().minimum_hot_water_setpoint - - @property - def maximum_hot_water_setpoint(self) -> float | None: - if (setpoint := self.get(DATA_SLAVE_DHW_MAX_SETP)) is not None: - return float(setpoint) - - return super().maximum_hot_water_setpoint - - @property - def relative_modulation_value(self) -> float | None: - if (value := self.get(DATA_REL_MOD_LEVEL)) is not None: - return float(value) - - return super().relative_modulation_value - - @property - def boiler_capacity(self) -> float | None: - if (value := self.get(DATA_SLAVE_MAX_CAPACITY)) is not None: - return float(value) - - return super().boiler_capacity - - @property - def minimum_relative_modulation_value(self) -> float | None: - if (value := self.get(DATA_SLAVE_MIN_MOD_LEVEL)) is not None: - return float(value) - - return super().minimum_relative_modulation_value - - @property - def flame_active(self) -> bool: - return bool(self.get(DATA_SLAVE_FLAME_ON)) - - @property - def minimum_setpoint(self): - return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) - - def get(self, key: str) -> Optional[Any]: - """Get the value for the given `key` from the boiler data. - - :param key: Key of the value to retrieve from the boiler data. - :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. - """ - return self.data[BOILER].get(key) if self.data[BOILER] else None - - async def async_cleanup(self) -> None: - self.api.unsubscribe(self.async_set_updated_data) - - await self.api.set_control_setpoint(0) - await self.api.set_max_relative_mod("-") - await self.api.disconnect() - - async def async_set_control_setpoint(self, value: float) -> None: - if not self._simulation: - await self.api.set_control_setpoint(value) - - await super().async_set_control_setpoint(value) - - async def async_set_control_hot_water_setpoint(self, value: float) -> None: - if not self._simulation: - await self.api.set_dhw_setpoint(value) - - await super().async_set_control_thermostat_setpoint(value) - - async def async_set_control_thermostat_setpoint(self, value: float) -> None: - if not self._simulation: - await self.api.set_target_temp(value) - - await super().async_set_control_thermostat_setpoint(value) - - async def async_set_heater_state(self, state: DeviceState) -> None: - """Control the state of the central heating.""" - if not self._simulation: - await self.api.set_ch_enable_bit(1 if state == DeviceState.ON else 0) - - await super().async_set_heater_state(state) - - async def async_set_control_max_relative_modulation(self, value: float) -> None: - if not self._simulation: - await self.api.set_max_relative_mod(value) - - await super().async_set_control_max_relative_modulation(value) - - async def async_set_control_max_setpoint(self, value: float) -> None: - """Set a maximum temperature limit on the boiler.""" - if not self._simulation: - await self.api.set_max_ch_setpoint(value) - - await super().async_set_control_max_setpoint(value) diff --git a/custom_components/sat/serial/sensor.py b/custom_components/sat/serial/sensor.py index 583d1be6..a190ba3d 100644 --- a/custom_components/sat/serial/sensor.py +++ b/custom_components/sat/serial/sensor.py @@ -9,8 +9,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from pyotgw.vars import * -from . import TRANSLATE_SOURCE -from .coordinator import SatSerialCoordinator +from . import TRANSLATE_SOURCE, SatSerialCoordinator from ..const import * from ..entity import SatEntity @@ -89,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" - # Create list of entities to be added + # Create a list of entities to be added entities = [] # Iterate through sensor information @@ -144,7 +143,7 @@ def native_unit_of_measurement(self): @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[self._source] is not None + return self._coordinator.data[self._source].get(self._key) is not None @property def native_value(self): diff --git a/custom_components/sat/switch/__init__.py b/custom_components/sat/switch/__init__.py index f02977d6..af9c98d8 100644 --- a/custom_components/sat/switch/__init__.py +++ b/custom_components/sat/switch/__init__.py @@ -1,16 +1,37 @@ -import logging +from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant -from .coordinator import SatSwitchCoordinator -from ..const import * +from ..config_store import SatConfigStore +from ..coordinator import DeviceState, SatDataUpdateCoordinator -_LOGGER: logging.Logger = logging.getLogger(__name__) +class SatSwitchCoordinator(SatDataUpdateCoordinator): + """Class to manage the Switch.""" -async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): - _LOGGER.debug("Setting up Switch integration") + def __init__(self, hass: HomeAssistant, store: SatConfigStore, entity_id: str) -> None: + """Initialize.""" + super().__init__(hass, store) - store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] - _hass.data[DOMAIN][_entry.entry_id] = {COORDINATOR: SatSwitchCoordinator(_hass, store)} + self._entity_id = entity_id + + @property + def setpoint(self) -> float: + return self.minimum_setpoint + + @property + def maximum_setpoint(self) -> float: + return self.minimum_setpoint + + @property + def device_active(self) -> bool: + return self.hass.states.get(self._entity_id).state == STATE_ON + + async def async_set_heater_state(self, state: DeviceState) -> None: + if not self._simulation: + service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF + await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity_id}, blocking=True) + + await super().async_set_heater_state(state) diff --git a/custom_components/sat/switch/coordinator.py b/custom_components/sat/switch/coordinator.py deleted file mode 100644 index 1a37539b..00000000 --- a/custom_components/sat/switch/coordinator.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -import typing - -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant - -from ..config_store import SatConfigStore -from ..const import * -from ..coordinator import DeviceState, SatDataUpdateCoordinator - -if typing.TYPE_CHECKING: - pass - - -class SatSwitchCoordinator(SatDataUpdateCoordinator): - """Class to manage the Switch.""" - - def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: - """Initialize.""" - super().__init__(hass, store) - self._entity_id = self._store.options.get(CONF_SWITCH) - - async def async_set_heater_state(self, state: DeviceState) -> None: - """Control the state of the central heating.""" - if not self._simulation: - service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF - await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity_id}, blocking=True) - - await super().async_set_heater_state(state) - - @property - def setpoint(self) -> float: - return self.minimum_setpoint - - @property - def device_active(self) -> bool: - return self.hass.states.get(self._entity_id).state != "OFF" diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 3359597f..c366c704 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -32,7 +32,7 @@ "description": "Please fill in the following details to setup the switch:", "data": { "name": "Name", - "switch": "Switch" + "device": "Switch" } }, "sensors": {