From 330c3323d19d516fc4207d227ce6ae5b4160b355 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 26 Feb 2023 23:34:37 +0100 Subject: [PATCH] Add binary_sensors and it's ok --- .../versatile_thermostat/__init__.py | 9 +- .../versatile_thermostat/binary_sensor.py | 174 ++++++++++++++++++ .../versatile_thermostat/climate.py | 27 ++- .../versatile_thermostat/commons.py | 100 ++++++++++ .../versatile_thermostat/const.py | 9 +- .../versatile_thermostat/manifest.json | 4 +- 6 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 custom_components/versatile_thermostat/binary_sensor.py create mode 100644 custom_components/versatile_thermostat/commons.py diff --git a/custom_components/versatile_thermostat/__init__.py b/custom_components/versatile_thermostat/__init__.py index 461c29f..f28bc7e 100644 --- a/custom_components/versatile_thermostat/__init__.py +++ b/custom_components/versatile_thermostat/__init__.py @@ -15,7 +15,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -58,13 +58,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class VersatileThermostatAPI(Dict): +class VersatileThermostatAPI(dict): """The VersatileThermostatAPI""" _hass: HomeAssistant # _entries: Dict(str, ConfigEntry) - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: _LOGGER.debug("building a VersatileThermostatAPI") super().__init__() self._hass = hass @@ -96,12 +96,11 @@ class VersatileThermostatAPI(Dict): # Example migration function -async def async_migrate_entry(hass, config_entry: ConfigEntry): +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) if config_entry.version == 1: - new = {**config_entry.data} # TODO: modify Config Entry data diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py new file mode 100644 index 0000000..d367273 --- /dev/null +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -0,0 +1,174 @@ +""" Implements the VersatileThermostat binary sensors component """ +import logging + +from homeassistant.core import HomeAssistant, callback, Event + +from homeassistant.const import STATE_ON + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry + +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .commons import VersatileThermostatBaseEntity +from .const import CONF_NAME + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VersatileThermostat binary sensors with config flow.""" + _LOGGER.debug( + "Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data + ) + + unique_id = entry.entry_id + name = entry.data.get(CONF_NAME) + + async_add_entities( + [ + SecurityBinarySensor(hass, unique_id, name, entry.data), + OverpoweringBinarySensor(hass, unique_id, name, entry.data), + WindowBinarySensor(hass, unique_id, name, entry.data), + MotionBinarySensor(hass, unique_id, name, entry.data), + PresenceBinarySensor(hass, unique_id, name, entry.data), + ], + True, + ) + + +class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the security state""" + + _security_state: bool + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the SecurityState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Security state" + self._attr_unique_id = f"{self._device_name}_security_state" + + @callback + async def async_my_climate_changed(self, event: Event): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", event.origin.name) + old_state = self._attr_is_on + self._attr_is_on = self.my_climate.security_state + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + if self._attr_is_on: + return "mdi:shield-alert" + else: + return "mdi:shield-check-outline" + + +class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the overpowering state""" + + _security_state: bool + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the OverpoweringState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Overpowering state" + self._attr_unique_id = f"{self._device_name}_overpowering_state" + + @callback + async def async_my_climate_changed(self, event: Event): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", event.origin.name) + old_state = self._attr_is_on + self._attr_is_on = self.my_climate.overpowering_state + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:flash-alert" + + +class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the window state""" + + _security_state: bool + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the WindowState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Window state" + self._attr_unique_id = f"{self._device_name}_window_state" + + @callback + async def async_my_climate_changed(self, event: Event): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", event.origin.name) + old_state = self._attr_is_on + self._attr_is_on = self.my_climate.window_state == STATE_ON + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:window-open-variant" + + +class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the motion state""" + + _security_state: bool + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the MotionState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Motion state" + self._attr_unique_id = f"{self._device_name}_motion_state" + + @callback + async def async_my_climate_changed(self, event: Event): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", event.origin.name) + old_state = self._attr_is_on + self._attr_is_on = self.my_climate.motion_state == STATE_ON + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:motion-sensor" + + +class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the presence state""" + + _security_state: bool + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the PresenceState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Presence state" + self._attr_unique_id = f"{self._device_name}_presence_state" + + @callback + async def async_my_climate_changed(self, event: Event): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", event.origin.name) + old_state = self._attr_is_on + self._attr_is_on = self.my_climate.presence_state == STATE_ON + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:home-account" diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 3d9c859..9f0c4b1 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -21,6 +21,7 @@ from homeassistant.core import ( from homeassistant.components.climate import ClimateEntity from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo, DeviceEntryType from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_component import EntityComponent import homeassistant.helpers.config_validation as cv @@ -91,7 +92,8 @@ from homeassistant.const import ( ) from .const import ( - # DOMAIN, + DOMAIN, + DEVICE_MANUFACTURER, CONF_HEATER, CONF_POWER_SENSOR, CONF_TEMP_SENSOR, @@ -268,6 +270,17 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self.post_init(entry_infos) + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._unique_id)}, + name=self._name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + def post_init(self, entry_infos): """Finish the initialization of the thermostast""" @@ -1022,11 +1035,21 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): """Get the window_state""" return self._window_state + @property + def security_state(self) -> bool | None: + """Get the security_state""" + return self._security_state + @property def motion_state(self) -> bool | None: """Get the motion_state""" return self._motion_state + @property + def presence_state(self) -> bool | None: + """Get the presence_state""" + return self._presence_state + def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" if self._is_over_climate and self._underlying_climate: @@ -1467,7 +1490,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def _async_climate_changed(self, event): """Handle unerdlying climate state changes.""" new_state = event.data.get("new_state") - _LOGGER.warning("%s - _async_climate_changed new_state is %s", self, new_state) + _LOGGER.debug("%s - _async_climate_changed new_state is %s", self, new_state) old_state = event.data.get("old_state") old_hvac_action = ( old_state.attributes.get("hvac_action") diff --git a/custom_components/versatile_thermostat/commons.py b/custom_components/versatile_thermostat/commons.py new file mode 100644 index 0000000..45f5758 --- /dev/null +++ b/custom_components/versatile_thermostat/commons.py @@ -0,0 +1,100 @@ +""" Some usefull commons class """ +import logging +from datetime import timedelta +from homeassistant.core import HomeAssistant, callback, Event +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity, DeviceInfo, DeviceEntryType +from homeassistant.helpers.event import async_track_state_change_event, async_call_later + +from .climate import VersatileThermostat +from .const import DOMAIN, DEVICE_MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +class VersatileThermostatBaseEntity(Entity): + """A base class for all entities""" + + _my_climate: VersatileThermostat + hass: HomeAssistant + _config_id: str + _devince_name: str + + def __init__(self, hass: HomeAssistant, config_id, device_name) -> None: + """The CTOR""" + self.hass = hass + self._config_id = config_id + self._device_name = device_name + self._my_climate = None + self._cancel_call = None + self._attr_has_entity_name = True + + @property + def should_poll(self) -> bool: + """Do not poll for those entities""" + return False + + @property + def my_climate(self) -> VersatileThermostat | None: + """Returns my climate if found""" + if not self._my_climate: + self._my_climate = self.find_my_versatile_thermostat() + return self._my_climate + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + def find_my_versatile_thermostat(self) -> VersatileThermostat: + """Find the underlying climate entity""" + component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + _LOGGER.debug("Device_info is %s", entity.device_info) + if entity.device_info == self.device_info: + _LOGGER.debug("Found %s!", entity) + return entity + return None + + @callback + async def async_added_to_hass(self): + """Listen to my climate state change""" + + # Check delay condition + async def try_find_climate(_): + _LOGGER.debug( + "%s - Calling VersatileThermostatBaseEntity.async_added_to_hass", self + ) + mcl = self.my_climate + if mcl: + if self._cancel_call: + self._cancel_call() + self._cancel_call = None + self.async_on_remove( + async_track_state_change_event( + self.hass, + [mcl.entity_id], + self.async_my_climate_changed, + ) + ) + else: + _LOGGER.warning("%s - no entity to listen. Try later", self) + self._cancel_call = async_call_later( + self.hass, timedelta(seconds=1), try_find_climate + ) + + await try_find_climate(None) + + @callback + async def async_my_climate_changed(self, event: Event): + """Called when my climate have change + This method aims to be overriden to take the status change + """ + return diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 2abdffe..dbeed4a 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -2,16 +2,19 @@ from enum import Enum from homeassistant.const import CONF_NAME -from homeassistant.components.climate.const import ( +from homeassistant.components.climate import ( # PRESET_ACTIVITY, PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, - SUPPORT_TARGET_TEMPERATURE, + ClimateEntityFeature, ) from homeassistant.exceptions import HomeAssistantError +DEVICE_MANUFACTURER = "JMCOLLIN" +DEVICE_MODEL = "Versatile Thermostat" + from .prop_algorithm import ( PROPORTIONAL_FUNCTION_TPI, ) @@ -126,7 +129,7 @@ CONF_FUNCTIONS = [ CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE] -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE +SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE SERVICE_SET_PRESENCE = "set_presence" SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature" diff --git a/custom_components/versatile_thermostat/manifest.json b/custom_components/versatile_thermostat/manifest.json index 4ac19b2..24fa9a6 100644 --- a/custom_components/versatile_thermostat/manifest.json +++ b/custom_components/versatile_thermostat/manifest.json @@ -14,6 +14,6 @@ "quality_scale": "silver", "requirements": [], "ssdp": [], - "version": "0.0.1", + "version": "3.0.0", "zeroconf": [] -} +} \ No newline at end of file