From 3988da6ce68080efafa9967714272b2f88dd01c4 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Tue, 16 Jan 2024 23:35:54 +0100 Subject: [PATCH 01/20] refactor: various quality improvements --- custom_components/eq3btsmart/__init__.py | 82 +++++-- custom_components/eq3btsmart/binary_sensor.py | 124 ++++++---- custom_components/eq3btsmart/button.py | 125 ++++------ custom_components/eq3btsmart/climate.py | 170 ++++++-------- custom_components/eq3btsmart/config_flow.py | 210 +++++------------ custom_components/eq3btsmart/const.py | 42 +++- custom_components/eq3btsmart/eq3_entity.py | 10 + custom_components/eq3btsmart/lock.py | 43 ++-- custom_components/eq3btsmart/models.py | 28 +++ custom_components/eq3btsmart/number.py | 159 +++++++------ custom_components/eq3btsmart/schemas.py | 213 ++++++++++++++++++ custom_components/eq3btsmart/sensor.py | 157 ++++++++----- custom_components/eq3btsmart/switch.py | 107 +++++---- 13 files changed, 861 insertions(+), 609 deletions(-) create mode 100644 custom_components/eq3btsmart/eq3_entity.py create mode 100644 custom_components/eq3btsmart/models.py create mode 100644 custom_components/eq3btsmart/schemas.py diff --git a/custom_components/eq3btsmart/__init__.py b/custom_components/eq3btsmart/__init__.py index 79bbb3e..983c5e4 100644 --- a/custom_components/eq3btsmart/__init__.py +++ b/custom_components/eq3btsmart/__init__.py @@ -1,20 +1,29 @@ """Support for EQ3 devices.""" -from __future__ import annotations -import logging +from typing import Any from eq3btsmart import Thermostat from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant from .const import ( CONF_ADAPTER, + CONF_CURRENT_TEMP_SELECTOR, + CONF_DEBUG_MODE, + CONF_EXTERNAL_TEMP_SENSOR, CONF_STAY_CONNECTED, + CONF_TARGET_TEMP_SELECTOR, DEFAULT_ADAPTER, + DEFAULT_CURRENT_TEMP_SELECTOR, + DEFAULT_DEBUG_MODE, + DEFAULT_SCAN_INTERVAL, DEFAULT_STAY_CONNECTED, + DEFAULT_TARGET_TEMP_SELECTOR, DOMAIN, + Adapter, ) +from .models import Eq3Config, Eq3ConfigEntry PLATFORMS = [ Platform.CLIMATE, @@ -26,47 +35,70 @@ Platform.NUMBER, ] -_LOGGER = logging.getLogger(__name__) - -# based on https://github.com/home-assistant/example-custom-config/tree/master/custom_components/detailed_hello_world_push - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Hello World from a config entry.""" + """Called when an entry is setup.""" - # Store an instance of the "connecting" class that does the work of speaking - # with your actual devices. + mac_address: str = entry.data[CONF_MAC] + name: str = entry.data[CONF_NAME] + adapter: Adapter = entry.options.get(CONF_ADAPTER, DEFAULT_ADAPTER) + stay_connected: bool = entry.options.get( + CONF_STAY_CONNECTED, DEFAULT_STAY_CONNECTED + ) + current_temp_selector = entry.options.get( + CONF_CURRENT_TEMP_SELECTOR, DEFAULT_CURRENT_TEMP_SELECTOR + ) + target_temp_selector = entry.options.get( + CONF_TARGET_TEMP_SELECTOR, DEFAULT_TARGET_TEMP_SELECTOR + ) + external_temp_sensor = entry.options.get(CONF_EXTERNAL_TEMP_SENSOR) + debug_mode = entry.options.get(CONF_DEBUG_MODE, DEFAULT_DEBUG_MODE) + scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + eq3_config = Eq3Config( + mac_address=mac_address, + name=name, + adapter=adapter, + stay_connected=stay_connected, + current_temp_selector=current_temp_selector, + target_temp_selector=target_temp_selector, + external_temp_sensor=external_temp_sensor, + debug_mode=debug_mode, + scan_interval=scan_interval, + ) thermostat = Thermostat( - mac=entry.data["mac"], - name=entry.data["name"], - adapter=entry.options.get(CONF_ADAPTER, DEFAULT_ADAPTER), - stay_connected=entry.options.get(CONF_STAY_CONNECTED, DEFAULT_STAY_CONNECTED), + mac=mac_address, + name=name, + adapter=adapter, + stay_connected=stay_connected, hass=hass, ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = thermostat - entry.async_on_unload(entry.add_update_listener(update_listener)) + eq3_config_entry = Eq3ConfigEntry(eq3_config=eq3_config, thermostat=thermostat) - # This creates each HA object for each platform your device requires. - # It's done by calling the `async_setup_entry` function in each platform module. + domain_data: dict[str, Any] = hass.data.setdefault(DOMAIN, {}) + domain_data[entry.entry_id] = eq3_config_entry + + entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - # This is called when an entry/configured device is to be removed. The class - # needs to unload itself, and remove callbacks. See the classes for further - # details + """Called when an entry is unloaded.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: - thermostat = hass.data[DOMAIN].pop(entry.entry_id) - thermostat.shutdown() + eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN].pop(entry.entry_id) + eq3_config_entry.thermostat.shutdown() + return unload_ok async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener. Called when integration options are changed""" + """Called when an entry is updated.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/eq3btsmart/binary_sensor.py b/custom_components/eq3btsmart/binary_sensor.py index 0285b16..9bfbad1 100644 --- a/custom_components/eq3btsmart/binary_sensor.py +++ b/custom_components/eq3btsmart/binary_sensor.py @@ -1,6 +1,9 @@ +"""Platform for eQ-3 binary sensor entities.""" + import json -import logging +from custom_components.eq3btsmart.eq3_entity import Eq3Entity +from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -9,12 +12,17 @@ from homeassistant.config_entries import ConfigEntry, UndefinedType from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_DEBUG_MODE, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import ( + DOMAIN, + ENTITY_NAME_BATTERY, + ENTITY_NAME_BUSY, + ENTITY_NAME_CONNECTED, + ENTITY_NAME_DST, + ENTITY_NAME_WINDOW_OPEN, +) async def async_setup_entry( @@ -22,26 +30,33 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Add sensors for passed config_entry in HA.""" - eq3 = hass.data[DOMAIN][config_entry.entry_id] - debug_mode = config_entry.options.get(CONF_DEBUG_MODE, False) - new_devices = [ - BatterySensor(eq3), - WindowOpenSensor(eq3), - DSTSensor(eq3), + """Called when an entry is setup.""" + + eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN][config_entry.entry_id] + thermostat = eq3_config_entry.thermostat + eq3_config = eq3_config_entry.eq3_config + + entities_to_add: list[Entity] = [ + BatterySensor(eq3_config, thermostat), + WindowOpenSensor(eq3_config, thermostat), + DSTSensor(eq3_config, thermostat), ] - async_add_entities(new_devices) - if debug_mode: - new_devices = [ - BusySensor(eq3), - ConnectedSensor(eq3), + + if eq3_config.debug_mode: + entities_to_add += [ + BusySensor(eq3_config, thermostat), + ConnectedSensor(eq3_config, thermostat), ] - async_add_entities(new_devices) + async_add_entities(entities_to_add) + + +class Base(Eq3Entity, BinarySensorEntity): + """Base class for all eQ-3 binary sensors.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) -class Base(BinarySensorEntity): - def __init__(self, _thermostat: Thermostat): - self._thermostat = _thermostat self._attr_has_entity_name = True @property @@ -59,34 +74,37 @@ def device_info(self) -> DeviceInfo: class BusySensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat._conn.register_connection_callback(self.schedule_update_ha_state) + """Binary sensor that reports if the thermostat connection is busy.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat._conn.register_connection_callback( + self.schedule_update_ha_state + ) self._attr_entity_category = EntityCategory.DIAGNOSTIC - self._attr_name = "Busy" + self._attr_name = ENTITY_NAME_BUSY @property def is_on(self) -> bool: return self._thermostat._conn._lock.locked() -def json_serial(obj): - """JSON serializer for objects not serializable by default json code""" - # raise TypeError ("Type %s not serializable" % type(obj)) - return None +class ConnectedSensor(Base): + """Binary sensor that reports if the thermostat is connected.""" + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) -class ConnectedSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat._conn.register_connection_callback(self.schedule_update_ha_state) + self._thermostat._conn.register_connection_callback( + self.schedule_update_ha_state + ) self._attr_entity_category = EntityCategory.DIAGNOSTIC - self._attr_name = "Connected" + self._attr_name = ENTITY_NAME_CONNECTED self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY @property def extra_state_attributes(self) -> dict[str, str] | None: - """Return the device specific state attributes.""" if (device := self._thermostat._conn._ble_device) is None: return None if (details := device.details) is None: @@ -94,20 +112,24 @@ def extra_state_attributes(self) -> dict[str, str] | None: if "props" not in details: return None - return json.loads(json.dumps(details["props"], default=json_serial)) + return json.loads(json.dumps(details["props"], default=lambda obj: None)) @property def is_on(self) -> bool: if self._thermostat._conn._conn is None: return False + return self._thermostat._conn._conn.is_connected class BatterySensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Battery" + """Binary sensor that reports if the thermostat battery is low.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_BATTERY self._attr_device_class = BinarySensorDeviceClass.BATTERY self._attr_entity_category = EntityCategory.DIAGNOSTIC @@ -117,10 +139,13 @@ def is_on(self): class WindowOpenSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Window Open" + """Binary sensor that reports if the thermostat thinks a window is open.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_WINDOW_OPEN self._attr_device_class = BinarySensorDeviceClass.WINDOW @property @@ -129,10 +154,13 @@ def is_on(self): class DSTSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "dSt" + """Binary sensor that reports if the thermostat is in daylight savings time mode.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_DST self._attr_entity_category = EntityCategory.DIAGNOSTIC @property diff --git a/custom_components/eq3btsmart/button.py b/custom_components/eq3btsmart/button.py index d3879bc..8411c69 100644 --- a/custom_components/eq3btsmart/button.py +++ b/custom_components/eq3btsmart/button.py @@ -1,107 +1,59 @@ +"""Platform for eQ-3 button entities.""" + import datetime import logging -import voluptuous as vol +from custom_components.eq3btsmart.eq3_entity import Eq3Entity +from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat -from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_MIN_TEMP, HOUR_24_PLACEHOLDER +from eq3btsmart.const import HOUR_24_PLACEHOLDER from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry, UndefinedType from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_DEBUG_MODE, DOMAIN +from .const import DOMAIN, ENTITY_NAME_FETCH, ENTITY_NAME_FETCH_SCHEDULE +from .schemas import SCHEMA_SCHEDULE_SET _LOGGER = logging.getLogger(__name__) -def times_and_temps_schema(value): - """Validate times.""" - - def v_assert(bool, error): - if not bool: - raise vol.Invalid(error) - - def time(i): - return value.get(f"next_change_at_{i}") - - def temp(i): - return value.get(f"target_temp_{i}") - - v_assert(temp(0), f"Missing target_temp_{0}") - if time(0): - v_assert(temp(1), f"Missing target_temp_{1} after: {time(0)}") - for i in range(1, 7): - if time(i): - v_assert(time(i - 1), f"Missing next_change_at_{i-1} before: {time(i)}") - v_assert( - time(i - 1) < time(i), - f"Times not in order at next_change_at_{i}: {time(i-1)}≥{time(i)}", - ) - v_assert(temp(i + 1), f"Missing target_temp_{i+1} after: {time(i)}") - if temp(i): - v_assert(temp(i - 1), f"Missing target_temp_{i-1} before: {time(i-1)}") - v_assert(time(i - 1), f"Missing next_change_at_{i-1} after: {time(i-2)}") - return value - - -EQ3_TEMPERATURE = vol.Range(min=EQ3BT_MIN_TEMP, max=EQ3BT_MAX_TEMP) - -SCHEDULE_SCHEMA = { - vol.Required("days"): cv.weekdays, - vol.Required("target_temp_0"): EQ3_TEMPERATURE, - vol.Optional("next_change_at_0"): cv.time, - vol.Optional("target_temp_1"): EQ3_TEMPERATURE, - vol.Optional("next_change_at_1"): cv.time, - vol.Optional("target_temp_2"): EQ3_TEMPERATURE, - vol.Optional("next_change_at_2"): cv.time, - vol.Optional("target_temp_3"): EQ3_TEMPERATURE, - vol.Optional("next_change_at_3"): cv.time, - vol.Optional("target_temp_4"): EQ3_TEMPERATURE, - vol.Optional("next_change_at_4"): cv.time, - vol.Optional("target_temp_5"): EQ3_TEMPERATURE, - vol.Optional("next_change_at_5"): cv.time, - vol.Optional("target_temp_6"): EQ3_TEMPERATURE, -} - -SET_SCHEDULE_SCHEMA = vol.All( - cv.make_entity_service_schema(SCHEDULE_SCHEMA), - times_and_temps_schema, -) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Add sensors for passed config_entry in HA.""" - eq3 = hass.data[DOMAIN][config_entry.entry_id] - debug_mode = config_entry.options.get(CONF_DEBUG_MODE, False) - new_devices: list[Entity] = [FetchScheduleButton(eq3)] - async_add_entities(new_devices) + """Called when an entry is setup.""" - if debug_mode: - new_devices = [FetchButton(eq3)] - async_add_entities(new_devices) + eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN][config_entry.entry_id] + thermostat = eq3_config_entry.thermostat + eq3_config = eq3_config_entry.eq3_config - platform = entity_platform.async_get_current_platform() + entities_to_add: list[Entity] = [FetchScheduleButton(eq3_config, thermostat)] + if eq3_config.debug_mode: + entities_to_add += [ + FetchButton(eq3_config, thermostat), + ] + async_add_entities(entities_to_add) + + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( "set_schedule", - SET_SCHEDULE_SCHEMA, # type: ignore + SCHEMA_SCHEDULE_SET, "set_schedule", ) -class Base(ButtonEntity): - """Representation of an eQ-3 Bluetooth Smart thermostat.""" +class Base(Eq3Entity, ButtonEntity): + """Base class for all eQ-3 buttons.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) - def __init__(self, _thermostat: Thermostat): - self._thermostat = _thermostat self._attr_has_entity_name = True @property @@ -119,17 +71,18 @@ def device_info(self) -> DeviceInfo: class FetchScheduleButton(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Fetch Schedule" + """Button to fetch the schedule from the thermostat.""" - async def async_press(self) -> None: - await self.fetch_schedule() + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) - async def fetch_schedule(self): + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_FETCH_SCHEDULE + + async def async_press(self) -> None: for x in range(0, 7): await self._thermostat.async_query_schedule(x) + _LOGGER.debug( "[%s] schedule (day %s): %s", self._thermostat.name, @@ -137,7 +90,10 @@ async def fetch_schedule(self): ) async def set_schedule(self, **kwargs) -> None: + """Called when the set_schedule service is invoked.""" + _LOGGER.debug("[%s] set_schedule (day %s)", self._thermostat.name, kwargs) + for day in kwargs["days"]: times = [ kwargs.get(f"next_change_at_{i}", datetime.time(0, 0)) for i in range(6) @@ -171,9 +127,12 @@ def extra_state_attributes(self): class FetchButton(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "Fetch" + """Button to fetch the current state from the thermostat.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._attr_name = ENTITY_NAME_FETCH self._attr_entity_category = EntityCategory.DIAGNOSTIC async def async_press(self) -> None: diff --git a/custom_components/eq3btsmart/climate.py b/custom_components/eq3btsmart/climate.py index 9237d92..db12813 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -1,13 +1,12 @@ -"""Support for eQ-3 Bluetooth Smart thermostats.""" - -from __future__ import annotations +"""Platform for eQ-3 climate entities.""" import asyncio import logging from datetime import timedelta from typing import Callable -import voluptuous as vol +from custom_components.eq3btsmart.eq3_entity import Eq3Entity +from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Mode from homeassistant.components.climate import ClimateEntity, HVACMode @@ -20,35 +19,27 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, - CONF_MAC, - CONF_SCAN_INTERVAL, PRECISION_TENTHS, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, format_mac -from homeassistant.helpers.entity import DeviceInfo, EntityPlatformState +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityPlatformState from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from .const import ( - CONF_CURRENT_TEMP_SELECTOR, - CONF_EXTERNAL_TEMP_SENSOR, - CONF_TARGET_TEMP_SELECTOR, - DEFAULT_CURRENT_TEMP_SELECTOR, - DEFAULT_SCAN_INTERVAL, - DEFAULT_TARGET_TEMP_SELECTOR, + DEVICE_MODEL, DOMAIN, EQ_TO_HA_HVAC, HA_TO_EQ_HVAC, + MANUFACTURER, CurrentTemperatureSelector, Preset, TargetTemperatureSelector, ) _LOGGER = logging.getLogger(__name__) -DEVICE_SCHEMA = vol.Schema({vol.Required(CONF_MAC): cv.string}) async def async_setup_entry( @@ -56,58 +47,31 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Add cover for passed entry in HA.""" - eq3 = hass.data[DOMAIN][config_entry.entry_id] - - new_entities = [ - EQ3Climate( - thermostat=eq3, - scan_interval=config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - conf_current_temp_selector=config_entry.options.get( - CONF_CURRENT_TEMP_SELECTOR, DEFAULT_CURRENT_TEMP_SELECTOR - ), - conf_target_temp_selector=config_entry.options.get( - CONF_TARGET_TEMP_SELECTOR, DEFAULT_TARGET_TEMP_SELECTOR - ), - conf_external_temp_sensor=config_entry.options.get( - CONF_EXTERNAL_TEMP_SENSOR, "" - ), - ) - ] - _LOGGER.debug("[%s] created climate entity", eq3.name) + """Called when an entry is setup.""" + + eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN][config_entry.entry_id] + thermostat = eq3_config_entry.thermostat + eq3_config = eq3_config_entry.eq3_config + + entities_to_add: list[Entity] = [Eq3Climate(eq3_config, thermostat)] async_add_entities( - new_entities, + entities_to_add, update_before_add=False, ) -class EQ3Climate(ClimateEntity): - """Representation of an eQ-3 Bluetooth Smart thermostat.""" +class Eq3Climate(Eq3Entity, ClimateEntity): + """Climate entity to represent a eQ-3 thermostat.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) - def __init__( - self, - thermostat: Thermostat, - scan_interval: float, - conf_current_temp_selector: CurrentTemperatureSelector, - conf_target_temp_selector: TargetTemperatureSelector, - conf_external_temp_sensor: str, - ): - """Initialize the thermostat.""" - self._thermostat = thermostat self._thermostat.register_update_callback(self._on_updated) - self._scan_interval = scan_interval - self._conf_current_temp_selector = conf_current_temp_selector - self._conf_target_temp_selector = conf_target_temp_selector - self._conf_external_temp_sensor = conf_external_temp_sensor self._target_temperature_to_set: float | None = None self._is_setting_temperature = False self._is_available = False self._cancel_timer: Callable[[], None] | None = None - # This is the main entity of the device and should use the device name. - # See https://developers.home-assistant.io/docs/core/entity#has_entity_name-true-mandatory-for-new-integrations self._attr_has_entity_name = True self._attr_name = None self._attr_supported_features = ( @@ -115,17 +79,17 @@ def __init__( ) self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_precision = PRECISION_TENTHS - self._attr_hvac_modes = list(HA_TO_EQ_HVAC) + self._attr_hvac_modes = list(HA_TO_EQ_HVAC.keys()) self._attr_min_temp = EQ3BT_OFF_TEMP self._attr_max_temp = EQ3BT_MAX_TEMP self._attr_preset_modes = list(Preset) - self._attr_unique_id = format_mac(self._thermostat.mac) + self._attr_unique_id = format_mac(self._eq3_config.mac_address) self._attr_should_poll = False _LOGGER.debug( "[%s] created climate entity %s, %s, %s", self.name, - conf_external_temp_sensor, + self._eq3_config.external_temp_sensor, ) async def async_added_to_hass(self) -> None: @@ -137,8 +101,9 @@ async def async_will_remove_from_hass(self) -> None: async def _async_scan_loop(self, now=None) -> None: await self.async_scan() + if self._platform_state != EntityPlatformState.REMOVED: - delay = timedelta(minutes=self._scan_interval) + delay = timedelta(minutes=self._eq3_config.scan_interval) self._cancel_timer = async_call_later( self.hass, delay, self._async_scan_loop ) @@ -146,66 +111,71 @@ async def _async_scan_loop(self, now=None) -> None: @callback def _on_updated(self): self._is_available = True + if self._target_temperature_to_set == self._thermostat.target_temperature: self._is_setting_temperature = False + if not self._is_setting_temperature: # temperature may have been updated from the thermostat self._target_temperature_to_set = self._thermostat.target_temperature + if self.entity_id is None: _LOGGER.warn( "[%s] Updated but the entity is not loaded", self._thermostat.name ) return + self.schedule_update_ha_state() @property def available(self) -> bool: - """Return if thermostat is available.""" return self._is_available @property def hvac_action(self) -> HVACAction | None: - """Return the current running hvac operation.""" if self._thermostat.mode == Mode.Off: return HVACAction.OFF + if self._thermostat.valve_state == 0: return HVACAction.IDLE + return HVACAction.HEATING @property def current_temperature(self) -> float | None: - """Can not report temperature, so return target_temperature.""" - if self._conf_current_temp_selector == CurrentTemperatureSelector.NOTHING: - return None - if self._conf_current_temp_selector == CurrentTemperatureSelector.VALVE: - if self._thermostat.valve_state is None: + match self._eq3_config.current_temp_selector: + case CurrentTemperatureSelector.NOTHING: return None - valve: int = self._thermostat.valve_state - return (1 - valve / 100) * 2 + self._thermostat.target_temperature - 2 - if self._conf_current_temp_selector == CurrentTemperatureSelector.UI: - return self._target_temperature_to_set - if self._conf_current_temp_selector == CurrentTemperatureSelector.DEVICE: - return self._thermostat.target_temperature - if self._conf_current_temp_selector == CurrentTemperatureSelector.ENTITY: - state = self.hass.states.get(self._conf_external_temp_sensor) - if state is not None: - try: - return float(state.state) - except ValueError: - pass + case CurrentTemperatureSelector.VALVE: + if self._thermostat.valve_state is None: + return None + valve: int = self._thermostat.valve_state + return (1 - valve / 100) * 2 + self._thermostat.target_temperature - 2 + case CurrentTemperatureSelector.UI: + return self._target_temperature_to_set + case CurrentTemperatureSelector.DEVICE: + return self._thermostat.target_temperature + case CurrentTemperatureSelector.ENTITY: + state = self.hass.states.get(self._eq3_config.external_temp_sensor) + if state is not None: + try: + return float(state.state) + except ValueError: + pass + return None @property def target_temperature(self) -> float | None: - """Return the temperature we try to reach.""" - match self._conf_target_temp_selector: + match self._eq3_config.target_temp_selector: case TargetTemperatureSelector.TARGET: return self._target_temperature_to_set case TargetTemperatureSelector.LAST_REPORTED: return self._thermostat.target_temperature + return None + async def async_set_temperature(self, **kwargs) -> None: - """Set new target temperature.""" # We can also set the HVAC mode when setting the temperature. # This needs to be done before changing the temperature because # changing the mode might change the temperature. @@ -223,8 +193,10 @@ async def async_set_temperature(self, **kwargs) -> None: ) temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: return + temperature = round(temperature * 2) / 2 # increments of 0.5 temperature = min(temperature, self.max_temp) temperature = max(temperature, self.min_temp) @@ -250,35 +222,31 @@ async def async_set_temperature_now(self) -> None: @property def hvac_mode(self) -> HVACMode | None: - """Return the current operation mode.""" if self._thermostat.mode is None: return None return EQ_TO_HA_HVAC[self._thermostat.mode] async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set operation mode.""" - if hvac_mode == HVACMode.OFF: - self._target_temperature_to_set = EQ3BT_OFF_TEMP - self._is_setting_temperature = True - else: # auto or manual/heat - self._target_temperature_to_set = self._thermostat.target_temperature - self._is_setting_temperature = False - self.async_schedule_update_ha_state() + match hvac_mode: + case HVACMode.OFF: + self._target_temperature_to_set = EQ3BT_OFF_TEMP + self._is_setting_temperature = True + case _: + self._target_temperature_to_set = self._thermostat.target_temperature + self._is_setting_temperature = False + self.async_schedule_update_ha_state() await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode]) @property def preset_mode(self) -> str | None: - """Return the current preset mode, e.g., home, away, temp. - Requires SUPPORT_PRESET_MODE. - """ if self._thermostat.window_open: - return "Window" + return Preset.WINDOW_OPEN if self._thermostat.boost: return Preset.BOOST if self._thermostat.low_battery: - return "Low Battery" + return Preset.LOW_BATTERY if self._thermostat.away: return Preset.AWAY if self._thermostat.target_temperature == self._thermostat.eco_temperature: @@ -290,7 +258,6 @@ def preset_mode(self) -> str | None: return PRESET_NONE async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new preset mode.""" match preset_mode: case Preset.BOOST: await self._thermostat.async_set_boost(True) @@ -326,15 +293,16 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: def device_info(self) -> DeviceInfo: return DeviceInfo( name=self._thermostat.name, - manufacturer="eQ-3 AG", - model="CC-RT-BLE-EQ", - identifiers={(DOMAIN, self._thermostat.mac)}, + manufacturer=MANUFACTURER, + model=DEVICE_MODEL, + identifiers={(DOMAIN, self._eq3_config.mac_address)}, sw_version=self._thermostat.firmware_version, - connections={(CONNECTION_BLUETOOTH, self._thermostat.mac)}, + connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, ) async def async_scan(self) -> None: """Update the data from the thermostat.""" + try: await self._thermostat.async_update() if self._is_setting_temperature: diff --git a/custom_components/eq3btsmart/config_flow.py b/custom_components/eq3btsmart/config_flow.py index e0fc745..a943aff 100644 --- a/custom_components/eq3btsmart/config_flow.py +++ b/custom_components/eq3btsmart/config_flow.py @@ -1,63 +1,61 @@ +"""Config flow for eQ-3 Bluetooth Smart thermostats.""" + import logging from typing import Any -import voluptuous as vol +from custom_components.eq3btsmart.schemas import ( + SCHEMA_NAME, + SCHEMA_NAME_MAC, + SCHEMA_OPTIONS, +) from homeassistant import config_entries from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import CONF_MAC, CONF_NAME, CONF_SCAN_INTERVAL from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.selector import selector from .const import ( CONF_ADAPTER, CONF_CURRENT_TEMP_SELECTOR, CONF_DEBUG_MODE, CONF_EXTERNAL_TEMP_SENSOR, + CONF_RSSI, CONF_STAY_CONNECTED, CONF_TARGET_TEMP_SELECTOR, DEFAULT_ADAPTER, DEFAULT_CURRENT_TEMP_SELECTOR, + DEFAULT_DEBUG_MODE, DEFAULT_SCAN_INTERVAL, DEFAULT_STAY_CONNECTED, DEFAULT_TARGET_TEMP_SELECTOR, DOMAIN, - Adapter, - CurrentTemperatureSelector, - TargetTemperatureSelector, ) _LOGGER = logging.getLogger(__name__) class EQ3ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Config flow for EQ3 One""" + """Config flow for eQ-3 Bluetooth Smart thermostats.""" VERSION = 1 def __init__(self): - """Initialize the EQ3One flow.""" self.discovery_info = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" + _LOGGER.debug("async_step_user: %s", user_input) errors: dict[str, str] | None = {} if user_input is None: return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME): str, - vol.Required(CONF_MAC): str, - } - ), + data_schema=SCHEMA_NAME_MAC, errors=errors, ) @@ -69,6 +67,7 @@ async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> FlowResult: """Handle bluetooth discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.address)) self._abort_if_unique_id_configured() @@ -77,6 +76,7 @@ async def async_step_bluetooth( discovery_info, discovery_info.device.name, ) + self.discovery_info = discovery_info name = self.discovery_info.device.name or self.discovery_info.name self.context.update( @@ -84,7 +84,7 @@ async def async_step_bluetooth( "title_placeholders": { CONF_NAME: name, CONF_MAC: discovery_info.address, - "rssi": discovery_info.rssi, + CONF_RSSI: discovery_info.rssi, } } ) @@ -92,29 +92,30 @@ async def async_step_bluetooth( async def async_step_init(self, user_input: dict[str, Any] | None = None): """Handle a flow start.""" + if self.discovery_info is None: - # mainly to shut up the type checker return self.async_abort(reason="not_supported") + self._async_abort_entries_match({CONF_MAC: self.discovery_info.address}) + if user_input is None: name = self.discovery_info.device.name or self.discovery_info.name return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME, default=name): str, # type: ignore - } - ), + data_schema=SCHEMA_NAME(default_name=name), description_placeholders={ CONF_NAME: name, CONF_MAC: self.discovery_info.address, - "rssi": str(self.discovery_info.rssi), + CONF_RSSI: str(self.discovery_info.rssi), }, ) await self.async_set_unique_id(format_mac(self.discovery_info.address)) return self.async_create_entry( title=user_input[CONF_NAME], - data={"name": user_input["name"], "mac": self.discovery_info.address}, + data={ + CONF_NAME: user_input[CONF_NAME], + CONF_MAC: self.discovery_info.address, + }, ) @staticmethod @@ -123,156 +124,49 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: """Create the options flow.""" + return OptionsFlowHandler(config_entry) class OptionsFlowHandler(OptionsFlow): + """Options flow for eQ-3 Bluetooth Smart thermostats.""" + 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 ) -> FlowResult: """Manage the options.""" + if user_input is not None: return self.async_create_entry(title="", data=user_input) return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Required( - CONF_SCAN_INTERVAL, - description={ - "suggested_value": self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - }, - ): cv.positive_float, - vol.Required( - CONF_CURRENT_TEMP_SELECTOR, - description={ - "suggested_value": self.config_entry.options.get( - CONF_CURRENT_TEMP_SELECTOR, - DEFAULT_CURRENT_TEMP_SELECTOR, - ) - }, - ): selector( - { - "select": { - "options": [ - { - "label": "nothing", - "value": CurrentTemperatureSelector.NOTHING, - }, - { - "label": "target temperature to be set (fast)", - "value": CurrentTemperatureSelector.UI, - }, - { - "label": "target temperature in device", - "value": CurrentTemperatureSelector.DEVICE, - }, - { - "label": "valve based calculation", - "value": CurrentTemperatureSelector.VALVE, - }, - { - "label": "external entity", - "value": CurrentTemperatureSelector.ENTITY, - }, - ], - } - } - ), - vol.Required( - CONF_TARGET_TEMP_SELECTOR, - description={ - "suggested_value": self.config_entry.options.get( - CONF_TARGET_TEMP_SELECTOR, - DEFAULT_TARGET_TEMP_SELECTOR, - ) - }, - ): selector( - { - "select": { - "options": [ - { - "label": "target temperature to be set (fast)", - "value": TargetTemperatureSelector.TARGET, - }, - { - "label": "target temperature in device", - "value": TargetTemperatureSelector.LAST_REPORTED, - }, - ], - } - } - ), - vol.Optional( - CONF_EXTERNAL_TEMP_SENSOR, - description={ - "suggested_value": self.config_entry.options.get( - CONF_EXTERNAL_TEMP_SENSOR, "" - ) - }, - ): selector( - {"entity": {"domain": "sensor", "device_class": "temperature"}} - ), - vol.Required( - CONF_ADAPTER, - description={ - "suggested_value": self.config_entry.options.get( - CONF_ADAPTER, DEFAULT_ADAPTER - ) - }, - ): selector( - { - "select": { - "options": [ - {"label": "Automatic", "value": Adapter.AUTO}, - { - "label": "Local adapters only", - "value": Adapter.LOCAL, - }, - { - "label": "/org/bluez/hci0", - "value": "/org/bluez/hci0", - }, - { - "label": "/org/bluez/hci1", - "value": "/org/bluez/hci1", - }, - { - "label": "/org/bluez/hci2", - "value": "/org/bluez/hci2", - }, - { - "label": "/org/bluez/hci3", - "value": "/org/bluez/hci3", - }, - ], - "custom_value": True, - } - } - ), - vol.Required( - CONF_STAY_CONNECTED, - description={ - "suggested_value": self.config_entry.options.get( - CONF_STAY_CONNECTED, DEFAULT_STAY_CONNECTED - ) - }, - ): cv.boolean, - vol.Required( - CONF_DEBUG_MODE, - description={ - "suggested_value": self.config_entry.options.get( - CONF_DEBUG_MODE, False - ) - }, - ): cv.boolean, - } + data_schema=SCHEMA_OPTIONS( + suggested_scan_interval=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + suggested_current_temp_selector=self.config_entry.options.get( + CONF_CURRENT_TEMP_SELECTOR, + DEFAULT_CURRENT_TEMP_SELECTOR, + ), + suggested_target_temp_selector=self.config_entry.options.get( + CONF_TARGET_TEMP_SELECTOR, + DEFAULT_TARGET_TEMP_SELECTOR, + ), + suggested_external_temp_sensor=self.config_entry.options.get( + CONF_EXTERNAL_TEMP_SENSOR, "" + ), + suggested_adapter=self.config_entry.options.get( + CONF_ADAPTER, DEFAULT_ADAPTER + ), + suggested_stay_connected=self.config_entry.options.get( + CONF_STAY_CONNECTED, DEFAULT_STAY_CONNECTED + ), + suggested_debug_mode=self.config_entry.options.get( + CONF_DEBUG_MODE, DEFAULT_DEBUG_MODE + ), ), ) diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index d4037f0..44d6ebc 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -12,6 +12,9 @@ DOMAIN = "eq3btsmart" +MANUFACTURER = "eQ-3 AG" +DEVICE_MODEL = "CC-RT-BLE-EQ" + EQ_TO_HA_HVAC: dict[Mode, HVACMode] = { Mode.Unknown: HVACMode.HEAT, Mode.Off: HVACMode.OFF, @@ -33,6 +36,8 @@ class Preset(str, Enum): BOOST = PRESET_BOOST AWAY = PRESET_AWAY OPEN = "Open" + LOW_BATTERY = "Low Battery" + WINDOW_OPEN = "Window" CONF_ADAPTER = "conf_adapter" @@ -41,8 +46,39 @@ class Preset(str, Enum): CONF_EXTERNAL_TEMP_SENSOR = "conf_external_temp_sensor" CONF_STAY_CONNECTED = "conf_stay_connected" CONF_DEBUG_MODE = "conf_debug_mode" - -DEFAULT_SCAN_INTERVAL = 1 # minutes +CONF_RSSI = "rssi" + +ENTITY_NAME_BUSY = "Busy" +ENTITY_NAME_CONNECTED = "Connected" +ENTITY_NAME_BATTERY = "Battery" +ENTITY_NAME_WINDOW_OPEN = "Window Open" +ENTITY_NAME_DST = "dSt" +ENTITY_NAME_FETCH_SCHEDULE = "Fetch Schedule" +ENTITY_NAME_FETCH = "Fetch" +ENTITY_NAME_LOCKED = "Locked" +ENTITY_NAME_COMFORT = "Comfort" +ENTITY_NAME_ECO = "Eco" +ENTITY_NAME_OFFSET = "Offset" +ENTITY_NAME_WINDOW_OPEN_TEMPERATURE = "Window Open" +ENTITY_NAME_WINDOW_OPEN_TIMEOUT = "Window Open Timeout" +ENTITY_NAME_AWAY_HOURS = "Away Hours" +ENTITY_NAME_AWAY_TEMPERATURE = "Away" +ENTITY_NAME_VALVE = "Valve" +ENTITY_NAME_AWAY_END = "Away until" +ENTITY_NAME_RSSI = "Rssi" +ENTITY_NAME_SERIAL_NUMBER = "Serial" +ENTITY_NAME_FIRMWARE_VERSION = "Firmware Version" +ENTITY_NAME_MAC = "MAC" +ENTITY_NAME_RETRIES = "Retries" +ENTITY_NAME_PATH = "Path" +ENTITY_NAME_AWAY_SWITCH = "Away" +ENTITY_NAME_BOOST_SWITCH = "Boost" +ENTITY_NAME_CONNECTION = "Connection" + +ENTITY_ICON_VALVE = "mdi:pipe-valve" +ENTITY_ICON_AWAY_SWITCH = "mdi:lock" +ENTITY_ICON_BOOST_SWITCH = "mdi:speedometer" +ENTITY_ICON_CONNECTION = "mdi:bluetooth" class Adapter(str, Enum): @@ -67,3 +103,5 @@ class TargetTemperatureSelector(str, Enum): DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.UI DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET DEFAULT_STAY_CONNECTED = True +DEFAULT_DEBUG_MODE = False +DEFAULT_SCAN_INTERVAL = 1 # minutes diff --git a/custom_components/eq3btsmart/eq3_entity.py b/custom_components/eq3btsmart/eq3_entity.py new file mode 100644 index 0000000..c42768b --- /dev/null +++ b/custom_components/eq3btsmart/eq3_entity.py @@ -0,0 +1,10 @@ +from custom_components.eq3btsmart.models import Eq3Config +from eq3btsmart.thermostat import Thermostat + + +class Eq3Entity: + """Base class for all eQ-3 entities.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + self._eq3_config = eq3_config + self._thermostat = thermostat diff --git a/custom_components/eq3btsmart/lock.py b/custom_components/eq3btsmart/lock.py index 9f79c87..e4a408c 100644 --- a/custom_components/eq3btsmart/lock.py +++ b/custom_components/eq3btsmart/lock.py @@ -1,5 +1,8 @@ -import logging +"""Platform for eQ-3 lock entities.""" + +from custom_components.eq3btsmart.eq3_entity import Eq3Entity +from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry, UndefinedType @@ -8,9 +11,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, ENTITY_NAME_LOCKED async def async_setup_entry( @@ -18,17 +19,24 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - eq3 = hass.data[DOMAIN][config_entry.entry_id] + """Called when an entry is setup.""" - new_devices = [ - LockedSwitch(eq3), + eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN][config_entry.entry_id] + thermostat = eq3_config_entry.thermostat + eq3_config = eq3_config_entry.eq3_config + + entities_to_add = [ + LockedSwitch(eq3_config, thermostat), ] - async_add_entities(new_devices) + async_add_entities(entities_to_add) + +class Base(Eq3Entity, LockEntity): + """Base class for all eQ-3 lock entities.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) -class Base(LockEntity): - def __init__(self, _thermostat: Thermostat): - self._thermostat = _thermostat self._attr_has_entity_name = True @property @@ -36,7 +44,7 @@ def unique_id(self) -> str | None: if self.name is None or isinstance(self.name, UndefinedType): return None - return format_mac(self._thermostat.mac) + "_" + self.name + return format_mac(self._eq3_config.mac_address) + "_" + self.name @property def device_info(self) -> DeviceInfo: @@ -46,10 +54,13 @@ def device_info(self) -> DeviceInfo: class LockedSwitch(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Locked" + """Lock to prevent manual changes to the thermostat.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_LOCKED async def async_lock(self, **kwargs) -> None: await self._thermostat.async_set_locked(True) diff --git a/custom_components/eq3btsmart/models.py b/custom_components/eq3btsmart/models.py new file mode 100644 index 0000000..45c9420 --- /dev/null +++ b/custom_components/eq3btsmart/models.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass + +from eq3btsmart.thermostat import Thermostat + +from .const import ( + Adapter, + CurrentTemperatureSelector, + TargetTemperatureSelector, +) + + +@dataclass +class Eq3Config: + mac_address: str + name: str + adapter: Adapter + stay_connected: bool + current_temp_selector: CurrentTemperatureSelector + target_temp_selector: TargetTemperatureSelector + external_temp_sensor: str + debug_mode: bool + scan_interval: int + + +@dataclass +class Eq3ConfigEntry: + eq3_config: Eq3Config + thermostat: Thermostat diff --git a/custom_components/eq3btsmart/number.py b/custom_components/eq3btsmart/number.py index 2de4aa2..77b539a 100644 --- a/custom_components/eq3btsmart/number.py +++ b/custom_components/eq3btsmart/number.py @@ -1,6 +1,9 @@ -import logging +"""Platform for eQ-3 number entities.""" + from datetime import timedelta +from custom_components.eq3btsmart.eq3_entity import Eq3Entity +from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat from eq3btsmart.const import ( EQ3BT_MAX_OFFSET, @@ -21,9 +24,16 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import ( + DOMAIN, + ENTITY_NAME_AWAY_HOURS, + ENTITY_NAME_AWAY_TEMPERATURE, + ENTITY_NAME_COMFORT, + ENTITY_NAME_ECO, + ENTITY_NAME_OFFSET, + ENTITY_NAME_WINDOW_OPEN_TEMPERATURE, + ENTITY_NAME_WINDOW_OPEN_TIMEOUT, +) async def async_setup_entry( @@ -31,25 +41,31 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Add sensors for passed config_entry in HA.""" - eq3 = hass.data[DOMAIN][config_entry.entry_id] + """Called when an entry is setup.""" + + eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN][config_entry.entry_id] + thermostat = eq3_config_entry.thermostat + eq3_config = eq3_config_entry.eq3_config new_devices = [ - ComfortTemperature(eq3), - EcoTemperature(eq3), - OffsetTemperature(eq3), - WindowOpenTemperature(eq3), - WindowOpenTimeout(eq3), - AwayForHours(eq3), - AwayTemperature(eq3), + ComfortTemperature(eq3_config, thermostat), + EcoTemperature(eq3_config, thermostat), + OffsetTemperature(eq3_config, thermostat), + WindowOpenTemperature(eq3_config, thermostat), + WindowOpenTimeout(eq3_config, thermostat), + AwayForHours(eq3_config, thermostat), + AwayTemperature(eq3_config, thermostat), ] async_add_entities(new_devices) -class Base(NumberEntity): - def __init__(self, _thermostat: Thermostat): - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._thermostat = _thermostat +class Base(Eq3Entity, NumberEntity): + """Base class for all eQ-3 number entities.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) self._attr_has_entity_name = True self._attr_device_class = NumberDeviceClass.TEMPERATURE self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @@ -64,19 +80,22 @@ def unique_id(self) -> str | None: if self.name is None or isinstance(self.name, UndefinedType): return None - return format_mac(self._thermostat.mac) + "_" + self.name + return format_mac(self._eq3_config.mac_address) + "_" + self.name @property def device_info(self) -> DeviceInfo: return DeviceInfo( - identifiers={(DOMAIN, self._thermostat.mac)}, + identifiers={(DOMAIN, self._eq3_config.mac_address)}, ) class ComfortTemperature(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "Comfort" + """Number entity for the comfort temperature.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._attr_name = ENTITY_NAME_COMFORT @property def native_value(self) -> float | None: @@ -93,9 +112,12 @@ async def async_set_native_value(self, value: float) -> None: class EcoTemperature(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "Eco" + """Number entity for the eco temperature.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._attr_name = ENTITY_NAME_ECO @property def native_value(self) -> float | None: @@ -112,9 +134,12 @@ async def async_set_native_value(self, value: float) -> None: class OffsetTemperature(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "Offset" + """Number entity for the temperature offset.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._attr_name = ENTITY_NAME_OFFSET self._attr_native_min_value = EQ3BT_MIN_OFFSET self._attr_native_max_value = EQ3BT_MAX_OFFSET @@ -127,9 +152,12 @@ async def async_set_native_value(self, value: float) -> None: class WindowOpenTemperature(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "Window Open" + """Number entity for the window open temperature.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._attr_name = ENTITY_NAME_WINDOW_OPEN_TEMPERATURE @property def native_value(self) -> float | None: @@ -146,31 +174,21 @@ async def async_set_native_value(self, value: float) -> None: ) -class WindowOpenTimeout(NumberEntity): - def __init__(self, _thermostat: Thermostat): - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._thermostat = _thermostat +class WindowOpenTimeout(Base): + """Number entity for the window open timeout.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) self._attr_has_entity_name = True self._attr_mode = NumberMode.BOX - self._attr_name = "Window Open Timeout" + self._attr_name = ENTITY_NAME_WINDOW_OPEN_TIMEOUT self._attr_native_min_value = 0 self._attr_native_max_value = 60 self._attr_native_step = 5 self._attr_native_unit_of_measurement = UnitOfTime.MINUTES - @property - def unique_id(self) -> str | None: - if self.name is None or isinstance(self.name, UndefinedType): - return None - - return format_mac(self._thermostat.mac) + "_" + self.name - - @property - def device_info(self) -> DeviceInfo: - return DeviceInfo( - identifiers={(DOMAIN, self._thermostat.mac)}, - ) - @property def native_value(self) -> float | None: if self._thermostat.window_open_time is None: @@ -190,30 +208,20 @@ async def async_set_native_value(self, value: float) -> None: ) -class AwayForHours(RestoreNumber): - def __init__(self, _thermostat: Thermostat): - self._thermostat = _thermostat +class AwayForHours(Base, RestoreNumber): + """Number entity for the away hours.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + self._attr_has_entity_name = True self._attr_mode = NumberMode.BOX - self._attr_name = "Away Hours" + self._attr_name = ENTITY_NAME_AWAY_HOURS self._attr_native_min_value = 0.5 self._attr_native_max_value = 1000000 self._attr_native_step = 0.5 self._attr_native_unit_of_measurement = UnitOfTime.HOURS - @property - def unique_id(self) -> str | None: - if self.name is None or isinstance(self.name, UndefinedType): - return None - - return format_mac(self._thermostat.mac) + "_" + self.name - - @property - def device_info(self) -> DeviceInfo: - return DeviceInfo( - identifiers={(DOMAIN, self._thermostat.mac)}, - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" @@ -230,25 +238,16 @@ def native_value(self) -> float | None: class AwayTemperature(Base, RestoreNumber): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "Away" - - @property - def unique_id(self) -> str | None: - if self.name is None or isinstance(self.name, UndefinedType): - return None + """Number entity for the away temperature.""" - return format_mac(self._thermostat.mac) + "_" + self.name + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) - @property - def device_info(self) -> DeviceInfo: - return DeviceInfo( - identifiers={(DOMAIN, self._thermostat.mac)}, - ) + self._attr_name = ENTITY_NAME_AWAY_TEMPERATURE async def async_added_to_hass(self) -> None: """Restore last state.""" + data = await self.async_get_last_number_data() if data and data.native_value is not None: self._thermostat.default_away_temp = data.native_value diff --git a/custom_components/eq3btsmart/schemas.py b/custom_components/eq3btsmart/schemas.py new file mode 100644 index 0000000..150b136 --- /dev/null +++ b/custom_components/eq3btsmart/schemas.py @@ -0,0 +1,213 @@ +"""Voluptuous schemas for eq3btsmart.""" + +import voluptuous as vol +from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_MIN_TEMP, EQ3BT_OFF_TEMP +from homeassistant.const import CONF_MAC, CONF_NAME, CONF_SCAN_INTERVAL +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import selector + +from .const import ( + CONF_ADAPTER, + CONF_CURRENT_TEMP_SELECTOR, + CONF_DEBUG_MODE, + CONF_EXTERNAL_TEMP_SENSOR, + CONF_STAY_CONNECTED, + CONF_TARGET_TEMP_SELECTOR, + Adapter, + CurrentTemperatureSelector, + TargetTemperatureSelector, +) + + +def times_and_temps_schema(value): + """Validate times.""" + + def v_assert(bool, error): + if not bool: + raise vol.Invalid(error) + + def time(i): + return value.get(f"next_change_at_{i}") + + def temp(i): + return value.get(f"target_temp_{i}") + + v_assert(temp(0), f"Missing target_temp_{0}") + if time(0): + v_assert(temp(1), f"Missing target_temp_{1} after: {time(0)}") + for i in range(1, 7): + if time(i): + v_assert(time(i - 1), f"Missing next_change_at_{i-1} before: {time(i)}") + v_assert( + time(i - 1) < time(i), + f"Times not in order at next_change_at_{i}: {time(i-1)}≥{time(i)}", + ) + v_assert(temp(i + 1), f"Missing target_temp_{i+1} after: {time(i)}") + if temp(i): + v_assert(temp(i - 1), f"Missing target_temp_{i-1} before: {time(i-1)}") + v_assert(time(i - 1), f"Missing next_change_at_{i-1} after: {time(i-2)}") + return value + + +SCHEMA_TEMPERATURE = vol.Range(min=EQ3BT_MIN_TEMP, max=EQ3BT_MAX_TEMP) +SCHEMA_TIMES_AND_TEMPS = times_and_temps_schema +SCHEMA_SCHEDULE = { + vol.Required("days"): cv.weekdays, + vol.Required("target_temp_0"): SCHEMA_TEMPERATURE, + vol.Optional("next_change_at_0"): cv.time, + vol.Optional("target_temp_1"): SCHEMA_TEMPERATURE, + vol.Optional("next_change_at_1"): cv.time, + vol.Optional("target_temp_2"): SCHEMA_TEMPERATURE, + vol.Optional("next_change_at_2"): cv.time, + vol.Optional("target_temp_3"): SCHEMA_TEMPERATURE, + vol.Optional("next_change_at_3"): cv.time, + vol.Optional("target_temp_4"): SCHEMA_TEMPERATURE, + vol.Optional("next_change_at_4"): cv.time, + vol.Optional("target_temp_5"): SCHEMA_TEMPERATURE, + vol.Optional("next_change_at_5"): cv.time, + vol.Optional("target_temp_6"): SCHEMA_TEMPERATURE, +} +SCHEMA_SCHEDULE_SET = vol.Schema( + vol.All( + cv.make_entity_service_schema(SCHEMA_SCHEDULE), + times_and_temps_schema, + ) +) +SCHEMA_DEVICE = vol.Schema({vol.Required(CONF_MAC): cv.string}) +SCHEMA_NAME_MAC = vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_MAC): str, + } +) + + +def schema_name(default_name: str): + return vol.Schema({vol.Required(CONF_NAME, default=default_name): str}) + + +SCHEMA_NAME = schema_name + + +def schema_options( + suggested_scan_interval: int, + suggested_current_temp_selector: CurrentTemperatureSelector, + suggested_target_temp_selector: TargetTemperatureSelector, + suggested_external_temp_sensor: str, + suggested_adapter: Adapter, + suggested_stay_connected: bool, + suggested_debug_mode: bool, +) -> vol.Schema: + return vol.Schema( + { + vol.Required( + CONF_SCAN_INTERVAL, + description={"suggested_value": suggested_scan_interval}, + ): cv.positive_float, + vol.Required( + CONF_CURRENT_TEMP_SELECTOR, + description={"suggested_value": suggested_current_temp_selector}, + ): selector( + { + "select": { + "options": [ + { + "label": "nothing", + "value": CurrentTemperatureSelector.NOTHING, + }, + { + "label": "target temperature to be set (fast)", + "value": CurrentTemperatureSelector.UI, + }, + { + "label": "target temperature in device", + "value": CurrentTemperatureSelector.DEVICE, + }, + { + "label": "valve based calculation", + "value": CurrentTemperatureSelector.VALVE, + }, + { + "label": "external entity", + "value": CurrentTemperatureSelector.ENTITY, + }, + ], + } + } + ), + vol.Required( + CONF_TARGET_TEMP_SELECTOR, + description={"suggested_value": suggested_target_temp_selector}, + ): selector( + { + "select": { + "options": [ + { + "label": "target temperature to be set (fast)", + "value": TargetTemperatureSelector.TARGET, + }, + { + "label": "target temperature in device", + "value": TargetTemperatureSelector.LAST_REPORTED, + }, + ], + } + } + ), + vol.Optional( + CONF_EXTERNAL_TEMP_SENSOR, + description={"suggested_value": suggested_external_temp_sensor}, + ): selector( + {"entity": {"domain": "sensor", "device_class": "temperature"}} + ), + vol.Required( + CONF_ADAPTER, + description={"suggested_value": suggested_adapter}, + ): selector( + { + "select": { + "options": [ + {"label": "Automatic", "value": Adapter.AUTO}, + { + "label": "Local adapters only", + "value": Adapter.LOCAL, + }, + { + "label": "/org/bluez/hci0", + "value": "/org/bluez/hci0", + }, + { + "label": "/org/bluez/hci1", + "value": "/org/bluez/hci1", + }, + { + "label": "/org/bluez/hci2", + "value": "/org/bluez/hci2", + }, + { + "label": "/org/bluez/hci3", + "value": "/org/bluez/hci3", + }, + ], + "custom_value": True, + } + } + ), + vol.Required( + CONF_STAY_CONNECTED, + description={"suggested_value": suggested_stay_connected}, + ): cv.boolean, + vol.Required( + CONF_DEBUG_MODE, + description={"suggested_value": suggested_debug_mode}, + ): cv.boolean, + } + ) + + +SCHEMA_OPTIONS = schema_options + +SCHEMA_SET_AWAY_UNTIL = { + vol.Required("away_until"): cv.datetime, + vol.Required("temperature"): vol.Range(min=EQ3BT_OFF_TEMP, max=EQ3BT_MAX_TEMP), +} diff --git a/custom_components/eq3btsmart/sensor.py b/custom_components/eq3btsmart/sensor.py index 1ce8fb8..5ce33bc 100644 --- a/custom_components/eq3btsmart/sensor.py +++ b/custom_components/eq3btsmart/sensor.py @@ -1,7 +1,11 @@ +"""Platform for eQ-3 sensor entities.""" + import asyncio import logging from datetime import datetime +from custom_components.eq3btsmart.eq3_entity import Eq3Entity +from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat from homeassistant.components.homekit import SensorDeviceClass from homeassistant.components.sensor import SensorEntity @@ -13,7 +17,18 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_DEBUG_MODE, DOMAIN +from .const import ( + DOMAIN, + ENTITY_ICON_VALVE, + ENTITY_NAME_AWAY_END, + ENTITY_NAME_FIRMWARE_VERSION, + ENTITY_NAME_MAC, + ENTITY_NAME_PATH, + ENTITY_NAME_RETRIES, + ENTITY_NAME_RSSI, + ENTITY_NAME_SERIAL_NUMBER, + ENTITY_NAME_VALVE, +) _LOGGER = logging.getLogger(__name__) @@ -23,30 +38,36 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Add sensors for passed config_entry in HA.""" - eq3 = hass.data[DOMAIN][config_entry.entry_id] - debug_mode = config_entry.options.get(CONF_DEBUG_MODE, False) + """Called when an entry is setup.""" + + eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN][config_entry.entry_id] + thermostat = eq3_config_entry.thermostat + eq3_config = eq3_config_entry.eq3_config new_devices = [ - ValveSensor(eq3), - AwayEndSensor(eq3), - SerialNumberSensor(eq3), - FirmwareVersionSensor(eq3), + ValveSensor(eq3_config, thermostat), + AwayEndSensor(eq3_config, thermostat), + SerialNumberSensor(eq3_config, thermostat), + FirmwareVersionSensor(eq3_config, thermostat), ] - async_add_entities(new_devices) - if debug_mode: - new_devices = [ - RssiSensor(eq3), - MacSensor(eq3), - RetriesSensor(eq3), - PathSensor(eq3), + + if eq3_config.debug_mode: + new_devices += [ + RssiSensor(eq3_config, thermostat), + MacSensor(eq3_config, thermostat), + RetriesSensor(eq3_config, thermostat), + PathSensor(eq3_config, thermostat), ] - async_add_entities(new_devices) + async_add_entities(new_devices) + + +class Base(Eq3Entity, SensorEntity): + """Base class for all eQ-3 sensors.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) -class Base(SensorEntity): - def __init__(self, _thermostat: Thermostat): - self._thermostat = _thermostat self._attr_has_entity_name = True @property @@ -54,21 +75,24 @@ def unique_id(self) -> str | None: if self.name is None or isinstance(self.name, UndefinedType): return None - return format_mac(self._thermostat.mac) + "_" + self.name + return format_mac(self._eq3_config.mac_address) + "_" + self.name @property def device_info(self) -> DeviceInfo: return DeviceInfo( - identifiers={(DOMAIN, self._thermostat.mac)}, + identifiers={(DOMAIN, self._eq3_config.mac_address)}, ) class ValveSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Valve" - self._attr_icon = "mdi:pipe-valve" + """Sensor for the valve state.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_VALVE + self._attr_icon = ENTITY_ICON_VALVE self._attr_native_unit_of_measurement = PERCENTAGE @property @@ -77,10 +101,13 @@ def state(self) -> int | None: class AwayEndSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Away until" + """Sensor for the away end time.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_AWAY_END self._attr_device_class = SensorDeviceClass.DATE @property @@ -92,10 +119,15 @@ def native_value(self) -> datetime | None: class RssiSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat._conn.register_connection_callback(self.schedule_update_ha_state) - self._attr_name = "Rssi" + """Sensor for the RSSI value.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat._conn.register_connection_callback( + self.schedule_update_ha_state + ) + self._attr_name = ENTITY_NAME_RSSI self._attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT self._attr_entity_category = EntityCategory.DIAGNOSTIC @@ -105,10 +137,13 @@ def state(self) -> int | None: class SerialNumberSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Serial" + """Sensor for the serial number.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_SERIAL_NUMBER self._attr_entity_category = EntityCategory.DIAGNOSTIC @property @@ -117,10 +152,13 @@ def state(self) -> str | None: class FirmwareVersionSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Firmware Version" + """Sensor for the firmware version.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_FIRMWARE_VERSION self._attr_entity_category = EntityCategory.DIAGNOSTIC async def async_added_to_hass(self) -> None: @@ -157,9 +195,12 @@ def state(self) -> str | None: class MacSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "MAC" + """Sensor for the MAC address.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._attr_name = ENTITY_NAME_MAC self._attr_entity_category = EntityCategory.DIAGNOSTIC @property @@ -168,10 +209,15 @@ def state(self) -> str | None: class RetriesSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat._conn.register_connection_callback(self.schedule_update_ha_state) - self._attr_name = "Retries" + """Sensor for the number of retries.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat._conn.register_connection_callback( + self.schedule_update_ha_state + ) + self._attr_name = ENTITY_NAME_RETRIES self._attr_entity_category = EntityCategory.DIAGNOSTIC @property @@ -180,10 +226,15 @@ def state(self) -> int: class PathSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat._conn.register_connection_callback(self.schedule_update_ha_state) - self._attr_name = "Path" + """Sensor for the device path.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat._conn.register_connection_callback( + self.schedule_update_ha_state + ) + self._attr_name = ENTITY_NAME_PATH self._attr_entity_category = EntityCategory.DIAGNOSTIC @property diff --git a/custom_components/eq3btsmart/switch.py b/custom_components/eq3btsmart/switch.py index 6905e51..26adc93 100644 --- a/custom_components/eq3btsmart/switch.py +++ b/custom_components/eq3btsmart/switch.py @@ -1,9 +1,10 @@ -import logging +"""Platform for eQ-3 switch entities.""" + +from datetime import datetime from typing import Any -import voluptuous as vol +from custom_components.eq3btsmart.eq3_entity import Eq3Entity from eq3btsmart import Thermostat -from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry, UndefinedType from homeassistant.core import HomeAssistant @@ -13,14 +14,17 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_DEBUG_MODE, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SET_AWAY_UNTIL_SCHEMA = { - vol.Required("away_until"): cv.datetime, - vol.Required("temperature"): vol.Range(min=EQ3BT_OFF_TEMP, max=EQ3BT_MAX_TEMP), -} +from .const import ( + DOMAIN, + ENTITY_ICON_AWAY_SWITCH, + ENTITY_ICON_BOOST_SWITCH, + ENTITY_ICON_CONNECTION, + ENTITY_NAME_AWAY_SWITCH, + ENTITY_NAME_BOOST_SWITCH, + ENTITY_NAME_CONNECTION, +) +from .models import Eq3Config, Eq3ConfigEntry +from .schemas import SCHEMA_SET_AWAY_UNTIL async def async_setup_entry( @@ -28,30 +32,36 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - eq3 = hass.data[DOMAIN][config_entry.entry_id] - debug_mode = config_entry.options.get(CONF_DEBUG_MODE, False) + """Called when an entry is setup.""" + + eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN][config_entry.entry_id] + thermostat = eq3_config_entry.thermostat + eq3_config = eq3_config_entry.eq3_config - new_devices = [ - AwaySwitch(eq3), - BoostSwitch(eq3), + entities_to_add = [ + AwaySwitch(eq3_config, thermostat), + BoostSwitch(eq3_config, thermostat), ] - async_add_entities(new_devices) - if debug_mode: - new_devices = [ConnectionSwitch(eq3)] - async_add_entities(new_devices) - platform = entity_platform.async_get_current_platform() + if eq3_config.debug_mode: + entities_to_add += [ConnectionSwitch(eq3_config, thermostat)] + + async_add_entities(entities_to_add) + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( "set_away_until", - cv.make_entity_service_schema(SET_AWAY_UNTIL_SCHEMA), + cv.make_entity_service_schema(SCHEMA_SET_AWAY_UNTIL), "set_away_until", ) -class Base(SwitchEntity): - def __init__(self, _thermostat: Thermostat): - self._thermostat = _thermostat +class Base(Eq3Entity, SwitchEntity): + """Base class for all eQ-3 switches.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + self._attr_has_entity_name = True @property @@ -59,21 +69,24 @@ def unique_id(self) -> str | None: if self.name is None or isinstance(self.name, UndefinedType): return None - return format_mac(self._thermostat.mac) + "_" + self.name + return format_mac(self._eq3_config.mac_address) + "_" + self.name @property def device_info(self) -> DeviceInfo: return DeviceInfo( - identifiers={(DOMAIN, self._thermostat.mac)}, + identifiers={(DOMAIN, self._eq3_config.mac_address)}, ) class AwaySwitch(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Away" - self._attr_icon = "mdi:lock" + """Switch to set the thermostat to away mode.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_AWAY_SWITCH + self._attr_icon = ENTITY_ICON_AWAY_SWITCH async def async_turn_on(self, **kwargs: Any) -> None: await self._thermostat.async_set_away(True) @@ -85,16 +98,19 @@ async def async_turn_off(self, **kwargs: Any) -> None: def is_on(self) -> bool | None: return self._thermostat.away - async def set_away_until(self, away_until, temperature: float) -> None: + async def set_away_until(self, away_until: datetime, temperature: float) -> None: await self._thermostat.async_set_away_until(away_until, temperature) class BoostSwitch(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._attr_name = "Boost" - self._attr_icon = "mdi:speedometer" + """Switch to set the thermostat to boost mode.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_update_callback(self.schedule_update_ha_state) + self._attr_name = ENTITY_NAME_BOOST_SWITCH + self._attr_icon = ENTITY_ICON_BOOST_SWITCH async def async_turn_on(self, **kwargs: Any) -> None: await self._thermostat.async_set_boost(True) @@ -108,11 +124,16 @@ def is_on(self) -> bool | None: class ConnectionSwitch(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - _thermostat._conn.register_connection_callback(self.schedule_update_ha_state) - self._attr_name = "Connection" - self._attr_icon = "mdi:bluetooth" + """Switch to connect/disconnect the thermostat.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat._conn.register_connection_callback( + self.schedule_update_ha_state + ) + self._attr_name = ENTITY_NAME_CONNECTION + self._attr_icon = ENTITY_ICON_CONNECTION self._attr_assumed_state = True self._attr_entity_category = EntityCategory.DIAGNOSTIC From 0ab5dc6cf8cfd054e1450527aec0bdcc87e7050b Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Wed, 17 Jan 2024 20:27:10 +0100 Subject: [PATCH 02/20] refactor: use f-strings --- custom_components/eq3btsmart/button.py | 17 ++++++++++------- custom_components/eq3btsmart/climate.py | 13 ++++--------- custom_components/eq3btsmart/config_flow.py | 6 ++---- custom_components/eq3btsmart/const.py | 3 +++ custom_components/eq3btsmart/schemas.py | 10 ++++++---- custom_components/eq3btsmart/sensor.py | 5 +---- custom_components/eq3btsmart/switch.py | 8 ++++---- 7 files changed, 30 insertions(+), 32 deletions(-) diff --git a/custom_components/eq3btsmart/button.py b/custom_components/eq3btsmart/button.py index 8411c69..296d919 100644 --- a/custom_components/eq3btsmart/button.py +++ b/custom_components/eq3btsmart/button.py @@ -15,7 +15,12 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ENTITY_NAME_FETCH, ENTITY_NAME_FETCH_SCHEDULE +from .const import ( + DOMAIN, + ENTITY_NAME_FETCH, + ENTITY_NAME_FETCH_SCHEDULE, + SERVICE_SET_SCHEDULE, +) from .schemas import SCHEMA_SCHEDULE_SET _LOGGER = logging.getLogger(__name__) @@ -42,9 +47,9 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( - "set_schedule", + SERVICE_SET_SCHEDULE, SCHEMA_SCHEDULE_SET, - "set_schedule", + SERVICE_SET_SCHEDULE, ) @@ -84,15 +89,13 @@ async def async_press(self) -> None: await self._thermostat.async_query_schedule(x) _LOGGER.debug( - "[%s] schedule (day %s): %s", - self._thermostat.name, - self._thermostat.schedule, + f"[{self._eq3_config.name}] schedule: {self._thermostat.schedule}", ) async def set_schedule(self, **kwargs) -> None: """Called when the set_schedule service is invoked.""" - _LOGGER.debug("[%s] set_schedule (day %s)", self._thermostat.name, kwargs) + _LOGGER.debug(f"[{self._eq3_config.name}] set_schedule: {kwargs}") for day in kwargs["days"]: times = [ diff --git a/custom_components/eq3btsmart/climate.py b/custom_components/eq3btsmart/climate.py index db12813..7a1e47e 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -87,9 +87,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): self._attr_should_poll = False _LOGGER.debug( - "[%s] created climate entity %s, %s, %s", - self.name, - self._eq3_config.external_temp_sensor, + f"[{self._eq3_config.name}] created climate entity", ) async def async_added_to_hass(self) -> None: @@ -121,7 +119,7 @@ def _on_updated(self): if self.entity_id is None: _LOGGER.warn( - "[%s] Updated but the entity is not loaded", self._thermostat.name + f"[{self._eq3_config.name}] Updated but the entity is not loaded", ) return @@ -188,8 +186,7 @@ async def async_set_temperature(self, **kwargs) -> None: await self.async_set_hvac_mode(mode) else: _LOGGER.warning( - "[%s] Can't change temperature while changing HVAC mode to off. Ignoring mode change.", - self._thermostat.name, + f"[{self._eq3_config.name}] Can't change temperature while changing HVAC mode to off. Ignoring mode change.", ) temperature = kwargs.get(ATTR_TEMPERATURE) @@ -311,7 +308,5 @@ async def async_scan(self) -> None: self._is_available = False self.schedule_update_ha_state() _LOGGER.error( - "[%s] Error updating: %s", - self._thermostat.name, - ex, + f"[{self._eq3_config.name}] Error updating: {ex}", ) diff --git a/custom_components/eq3btsmart/config_flow.py b/custom_components/eq3btsmart/config_flow.py index a943aff..c9596bd 100644 --- a/custom_components/eq3btsmart/config_flow.py +++ b/custom_components/eq3btsmart/config_flow.py @@ -49,7 +49,7 @@ async def async_step_user( ) -> FlowResult: """Handle a flow initialized by the user.""" - _LOGGER.debug("async_step_user: %s", user_input) + _LOGGER.debug(f"async_step_user: {user_input}") errors: dict[str, str] | None = {} if user_input is None: @@ -72,9 +72,7 @@ async def async_step_bluetooth( self._abort_if_unique_id_configured() _LOGGER.debug( - "Discovered eQ3 thermostat using bluetooth: %s, %s", - discovery_info, - discovery_info.device.name, + f"Discovered eQ3 thermostat using bluetooth: {discovery_info}, {discovery_info.device.name}", ) self.discovery_info = discovery_info diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index 44d6ebc..eb497ce 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -80,6 +80,9 @@ class Preset(str, Enum): ENTITY_ICON_BOOST_SWITCH = "mdi:speedometer" ENTITY_ICON_CONNECTION = "mdi:bluetooth" +SERVICE_SET_AWAY_UNTIL = "set_away_until" +SERVICE_SET_SCHEDULE = "set_schedule" + class Adapter(str, Enum): AUTO = "AUTO" diff --git a/custom_components/eq3btsmart/schemas.py b/custom_components/eq3btsmart/schemas.py index 150b136..45f8016 100644 --- a/custom_components/eq3btsmart/schemas.py +++ b/custom_components/eq3btsmart/schemas.py @@ -207,7 +207,9 @@ def schema_options( SCHEMA_OPTIONS = schema_options -SCHEMA_SET_AWAY_UNTIL = { - vol.Required("away_until"): cv.datetime, - vol.Required("temperature"): vol.Range(min=EQ3BT_OFF_TEMP, max=EQ3BT_MAX_TEMP), -} +SCHEMA_SET_AWAY_UNTIL = cv.make_entity_service_schema( + { + vol.Required("away_until"): cv.datetime, + vol.Required("temperature"): vol.Range(min=EQ3BT_OFF_TEMP, max=EQ3BT_MAX_TEMP), + } +) diff --git a/custom_components/eq3btsmart/sensor.py b/custom_components/eq3btsmart/sensor.py index 5ce33bc..96a8ced 100644 --- a/custom_components/eq3btsmart/sensor.py +++ b/custom_components/eq3btsmart/sensor.py @@ -183,10 +183,7 @@ async def fetch_serial(self) -> None: ) _LOGGER.debug( - "[%s] firmware: %s serial: %s", - self._thermostat.name, - self._thermostat.firmware_version, - self._thermostat.device_serial, + f"[{self._eq3_config.name}] firmware: {self._thermostat.firmware_version} serial: {self._thermostat.device_serial}", ) @property diff --git a/custom_components/eq3btsmart/switch.py b/custom_components/eq3btsmart/switch.py index 26adc93..f985e17 100644 --- a/custom_components/eq3btsmart/switch.py +++ b/custom_components/eq3btsmart/switch.py @@ -8,7 +8,6 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry, UndefinedType from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity import DeviceInfo, EntityCategory @@ -22,6 +21,7 @@ ENTITY_NAME_AWAY_SWITCH, ENTITY_NAME_BOOST_SWITCH, ENTITY_NAME_CONNECTION, + SERVICE_SET_AWAY_UNTIL, ) from .models import Eq3Config, Eq3ConfigEntry from .schemas import SCHEMA_SET_AWAY_UNTIL @@ -50,9 +50,9 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( - "set_away_until", - cv.make_entity_service_schema(SCHEMA_SET_AWAY_UNTIL), - "set_away_until", + SERVICE_SET_AWAY_UNTIL, + SCHEMA_SET_AWAY_UNTIL, + SERVICE_SET_AWAY_UNTIL, ) From 1c8f9a8a8583833e1b5965f4a2ef1b4b985c6c90 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Thu, 18 Jan 2024 15:40:35 +0100 Subject: [PATCH 03/20] refactor: remove obsolete properties, improve code style --- custom_components/eq3btsmart/__init__.py | 8 +- custom_components/eq3btsmart/binary_sensor.py | 10 +- custom_components/eq3btsmart/button.py | 4 +- custom_components/eq3btsmart/climate.py | 6 +- custom_components/eq3btsmart/const.py | 7 +- custom_components/eq3btsmart/lock.py | 2 +- custom_components/eq3btsmart/sensor.py | 10 +- eq3btsmart/bleakconnection.py | 33 +- eq3btsmart/const.py | 49 ++- eq3btsmart/models.py | 122 ++++++ eq3btsmart/structures.py | 123 +++--- eq3btsmart/thermostat.py | 367 +++++------------- eq3btsmart/thermostat_config.py | 11 + 13 files changed, 381 insertions(+), 371 deletions(-) create mode 100644 eq3btsmart/models.py create mode 100644 eq3btsmart/thermostat_config.py diff --git a/custom_components/eq3btsmart/__init__.py b/custom_components/eq3btsmart/__init__.py index 983c5e4..ead0dfb 100644 --- a/custom_components/eq3btsmart/__init__.py +++ b/custom_components/eq3btsmart/__init__.py @@ -3,6 +3,7 @@ from typing import Any from eq3btsmart import Thermostat +from eq3btsmart.thermostat_config import ThermostatConfig from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant @@ -67,11 +68,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: scan_interval=scan_interval, ) - thermostat = Thermostat( - mac=mac_address, + thermostat_config = ThermostatConfig( + mac_address=mac_address, name=name, adapter=adapter, stay_connected=stay_connected, + ) + thermostat = Thermostat( + thermostat_config=thermostat_config, hass=hass, ) diff --git a/custom_components/eq3btsmart/binary_sensor.py b/custom_components/eq3btsmart/binary_sensor.py index 9bfbad1..f9c53b5 100644 --- a/custom_components/eq3btsmart/binary_sensor.py +++ b/custom_components/eq3btsmart/binary_sensor.py @@ -64,12 +64,12 @@ def unique_id(self) -> str | None: if self.name is None or isinstance(self.name, UndefinedType): return None - return format_mac(self._thermostat.mac) + "_" + self.name + return format_mac(self._eq3_config.mac_address) + "_" + self.name @property def device_info(self) -> DeviceInfo: return DeviceInfo( - identifiers={(DOMAIN, self._thermostat.mac)}, + identifiers={(DOMAIN, self._eq3_config.mac_address)}, ) @@ -134,7 +134,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): self._attr_entity_category = EntityCategory.DIAGNOSTIC @property - def is_on(self): + def is_on(self) -> bool | None: return self._thermostat.low_battery @@ -149,7 +149,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): self._attr_device_class = BinarySensorDeviceClass.WINDOW @property - def is_on(self): + def is_on(self) -> bool | None: return self._thermostat.window_open @@ -164,5 +164,5 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): self._attr_entity_category = EntityCategory.DIAGNOSTIC @property - def is_on(self): + def is_on(self) -> bool | None: return self._thermostat.dst diff --git a/custom_components/eq3btsmart/button.py b/custom_components/eq3btsmart/button.py index 296d919..20eb60b 100644 --- a/custom_components/eq3btsmart/button.py +++ b/custom_components/eq3btsmart/button.py @@ -66,12 +66,12 @@ def unique_id(self) -> str | None: if self.name is None or isinstance(self.name, UndefinedType): return None - return format_mac(self._thermostat.mac) + "_" + self.name + return format_mac(self._eq3_config.mac_address) + "_" + self.name @property def device_info(self) -> DeviceInfo: return DeviceInfo( - identifiers={(DOMAIN, self._thermostat.mac)}, + identifiers={(DOMAIN, self._eq3_config.mac_address)}, ) diff --git a/custom_components/eq3btsmart/climate.py b/custom_components/eq3btsmart/climate.py index 7a1e47e..01a3125 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -207,7 +207,7 @@ async def async_set_temperature(self, **kwargs) -> None: try: await self.async_set_temperature_now() except Exception as ex: - _LOGGER.error(f"[{self._thermostat.name}] Failed setting temperature: {ex}") + _LOGGER.error(f"[{self._eq3_config.name}] Failed setting temperature: {ex}") self._target_temperature_to_set = previous_temperature self.async_schedule_update_ha_state() @@ -289,11 +289,11 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: @property def device_info(self) -> DeviceInfo: return DeviceInfo( - name=self._thermostat.name, + name=self._eq3_config.name, manufacturer=MANUFACTURER, model=DEVICE_MODEL, identifiers={(DOMAIN, self._eq3_config.mac_address)}, - sw_version=self._thermostat.firmware_version, + sw_version=str(self._thermostat.firmware_version), connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, ) diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index eb497ce..662f568 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -1,7 +1,7 @@ """Constants for EQ3 Bluetooth Smart Radiator Valves.""" from enum import Enum -from eq3btsmart.const import Mode +from eq3btsmart.const import Adapter, Mode from homeassistant.components.climate import HVACMode from homeassistant.components.climate.const import ( PRESET_AWAY, @@ -84,11 +84,6 @@ class Preset(str, Enum): SERVICE_SET_SCHEDULE = "set_schedule" -class Adapter(str, Enum): - AUTO = "AUTO" - LOCAL = "LOCAL" - - class CurrentTemperatureSelector(str, Enum): NOTHING = "NOTHING" UI = "UI" diff --git a/custom_components/eq3btsmart/lock.py b/custom_components/eq3btsmart/lock.py index e4a408c..563086e 100644 --- a/custom_components/eq3btsmart/lock.py +++ b/custom_components/eq3btsmart/lock.py @@ -49,7 +49,7 @@ def unique_id(self) -> str | None: @property def device_info(self) -> DeviceInfo: return DeviceInfo( - identifiers={(DOMAIN, self._thermostat.mac)}, + identifiers={(DOMAIN, self._eq3_config.mac_address)}, ) diff --git a/custom_components/eq3btsmart/sensor.py b/custom_components/eq3btsmart/sensor.py index 96a8ced..b76669a 100644 --- a/custom_components/eq3btsmart/sensor.py +++ b/custom_components/eq3btsmart/sensor.py @@ -169,17 +169,17 @@ async def fetch_serial(self) -> None: await self._thermostat.async_query_id() except Exception as e: _LOGGER.error( - f"[{self._thermostat.name}] Error fetching serial number: {e}" + f"[{self._eq3_config.name}] Error fetching serial number: {e}" ) return device_registry = dr.async_get(self.hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, self._thermostat.mac)}, + identifiers={(DOMAIN, self._eq3_config.mac_address)}, ) if device: device_registry.async_update_device( - device_id=device.id, sw_version=self._thermostat.firmware_version + device_id=device.id, sw_version=str(self._thermostat.firmware_version) ) _LOGGER.debug( @@ -188,7 +188,7 @@ async def fetch_serial(self) -> None: @property def state(self) -> str | None: - return self._thermostat.firmware_version + return str(self._thermostat.firmware_version) class MacSensor(Base): @@ -202,7 +202,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def state(self) -> str | None: - return self._thermostat.mac + return self._eq3_config.mac_address class RetriesSensor(Base): diff --git a/eq3btsmart/bleakconnection.py b/eq3btsmart/bleakconnection.py index 06b9e2a..5dab74c 100644 --- a/eq3btsmart/bleakconnection.py +++ b/eq3btsmart/bleakconnection.py @@ -19,6 +19,7 @@ Adapter, ) from eq3btsmart.exceptions import BackendException +from eq3btsmart.thermostat_config import ThermostatConfig # bleak backends are very loud on debug, this reduces the log spam when using --debug # logging.getLogger("bleak.backends").setLevel(logging.WARNING) @@ -30,18 +31,12 @@ class BleakConnection: def __init__( self, - mac: str, - name: str, - adapter: str, - stay_connected: bool, + thermostat_config: ThermostatConfig, hass: HomeAssistant, callback, ): """Initialize the connection.""" - self._mac = mac - self._name = name - self._adapter = adapter - self._stay_connected = stay_connected + self.thermostat_config = thermostat_config self._hass = hass self._callback = callback self._notify_event = asyncio.Event() @@ -64,7 +59,7 @@ def _on_connection_event(self) -> None: def shutdown(self) -> None: _LOGGER.debug( "[%s] closing connections", - self._name, + self.thermostat_config.name, ) self._terminate_event.set() self._notify_event.set() @@ -76,9 +71,9 @@ async def throw_if_terminating(self) -> None: raise Exception("Connection cancelled by shutdown") async def async_get_connection(self) -> BleakClient: - if self._adapter == Adapter.AUTO: + if self.thermostat_config.adapter == Adapter.AUTO: self._ble_device = bluetooth.async_ble_device_from_address( - self._hass, self._mac, connectable=True + self._hass, self.thermostat_config.mac_address, connectable=True ) if self._ble_device is None: raise Exception("Device not found") @@ -86,7 +81,7 @@ async def async_get_connection(self) -> BleakClient: self._conn = await establish_connection( client_class=BleakClient, device=self._ble_device, - name=self._name, + name=self.thermostat_config.name, disconnected_callback=lambda client: self._on_connection_event(), max_attempts=2, use_services_cache=True, @@ -94,13 +89,15 @@ async def async_get_connection(self) -> BleakClient: else: device_advertisement_datas = sorted( bluetooth.async_scanner_devices_by_address( - hass=self._hass, address=self._mac, connectable=True + hass=self._hass, + address=self.thermostat_config.mac_address, + connectable=True, ), key=lambda device_advertisement_data: device_advertisement_data.advertisement.rssi or NO_RSSI_VALUE, reverse=True, ) - if self._adapter == Adapter.LOCAL: + if self.thermostat_config.adapter == Adapter.LOCAL: if len(device_advertisement_datas) == 0: raise Exception("Device not found") d_and_a = device_advertisement_datas[ @@ -129,7 +126,7 @@ async def async_get_connection(self) -> BleakClient: self._on_connection_event() if self._conn is not None and self._conn.is_connected: - _LOGGER.debug("[%s] Connected", self._name) + _LOGGER.debug("[%s] Connected", self.thermostat_config.name) else: raise BackendException("Can't connect") return self._conn @@ -144,7 +141,7 @@ async def on_notification( else: _LOGGER.error( "[%s] wrong charasteristic: %s, %s", - self._name, + self.thermostat_config.name, handle.handle, handle.uuid, ) @@ -177,7 +174,7 @@ async def _async_make_request_try(self, value, retries) -> None: self._notify_event.wait(), REQUEST_TIMEOUT ) finally: - if self._stay_connected: + if self.thermostat_config.stay_connected: await conn.stop_notify(PROP_NTFY_UUID) else: await conn.disconnect() @@ -186,7 +183,7 @@ async def _async_make_request_try(self, value, retries) -> None: await self.throw_if_terminating() _LOGGER.debug( "[%s] Broken connection [retry %s/%s]: %s", - self._name, + self.thermostat_config.name, self.retries, retries, ex, diff --git a/eq3btsmart/const.py b/eq3btsmart/const.py index c2d3fb7..9680c93 100644 --- a/eq3btsmart/const.py +++ b/eq3btsmart/const.py @@ -1,6 +1,6 @@ """Constants for the eq3btsmart library.""" -from enum import Enum, IntEnum +from enum import Enum, IntEnum, IntFlag PROP_ID_QUERY = 0 PROP_ID_RETURN = 1 @@ -44,14 +44,47 @@ DEFAULT_AWAY_TEMP = 12 -class Mode(IntEnum): - """Thermostat modes.""" +class ScheduleCommand(IntEnum): + """Schedule commands.""" - Unknown = 0 - Off = 0 - On = 1 - Auto = 2 - Manual = 3 + WRITE = PROP_SCHEDULE_SET + RESPONSE = PROP_SCHEDULE_RETURN + + +class WeekDay(IntEnum): + """Weekdays.""" + + SATURDAY = 0 + SUNDAY = 1 + MONDAY = 2 + TUESDAY = 3 + WEDNESDAY = 4 + THURSDAY = 5 + FRIDAY = 6 + + +class OperationMode(IntEnum): + """Operation modes.""" + + UNKNOWN = 0 + AUTO = 1 + MANUAL = 2 + ON = 3 + OFF = 4 + + +class DeviceModeFlags(IntFlag): + """Device modes.""" + + AUTO = 0x00 # always True, doesnt affect building + MANUAL = 0x01 + AWAY = 0x02 + BOOST = 0x04 + DST = 0x08 + WINDOW = 0x10 + LOCKED = 0x20 + UNKNOWN = 0x40 + LOW_BATTERY = 0x80 class Adapter(str, Enum): diff --git a/eq3btsmart/models.py b/eq3btsmart/models.py new file mode 100644 index 0000000..2b323ba --- /dev/null +++ b/eq3btsmart/models.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from datetime import datetime, time, timedelta + +from construct_typed import DataclassStruct + +from eq3btsmart.const import EQ3BT_OFF_TEMP, EQ3BT_ON_TEMP, OperationMode, WeekDay +from eq3btsmart.structures import ( + DeviceIdStruct, + ScheduleEntryStruct, + ScheduleStruct, + StatusStruct, +) + + +@dataclass +class DeviceData: + firmware_version: int + device_serial: str + + @classmethod + def from_struct(cls, struct: DeviceIdStruct) -> "DeviceData": + return cls( + firmware_version=struct.version, + device_serial=struct.serial, + ) + + @classmethod + def from_bytearray(cls, data: bytearray) -> "DeviceData": + return cls.from_struct(DataclassStruct(DeviceIdStruct).parse(data)) + + +@dataclass +class Status: + valve: int + target_temperature: float + _operation_mode: OperationMode + is_away: bool + is_boost: bool + is_dst: bool + is_window_open: bool + is_locked: bool + is_low_battery: bool + away_datetime: datetime | None + window_open_temperature: float | None + window_open_time: timedelta | None + comfort_temperature: float | None + eco_temperature: float | None + offset_temperature: float | None + + @property + def operation_mode(self) -> OperationMode: + if self.target_temperature == EQ3BT_OFF_TEMP: + return OperationMode.OFF + + if self.target_temperature == EQ3BT_ON_TEMP: + return OperationMode.ON + + return self._operation_mode + + @classmethod + def from_struct(cls, struct: StatusStruct) -> "Status": + return cls( + valve=struct.valve, + target_temperature=struct.target_temp, + _operation_mode=OperationMode.MANUAL + if struct.mode & struct.mode.MANUAL + else OperationMode.AUTO, + is_away=bool(struct.mode & struct.mode.AWAY), + is_boost=bool(struct.mode & struct.mode.BOOST), + is_dst=bool(struct.mode & struct.mode.DST), + is_window_open=bool(struct.mode & struct.mode.WINDOW), + is_locked=bool(struct.mode & struct.mode.LOCKED), + is_low_battery=bool(struct.mode & struct.mode.LOW_BATTERY), + away_datetime=struct.away, + window_open_temperature=struct.presets.window_open_temp + if struct.presets + else None, + window_open_time=struct.presets.window_open_time + if struct.presets + else None, + comfort_temperature=struct.presets.comfort_temp if struct.presets else None, + eco_temperature=struct.presets.eco_temp if struct.presets else None, + offset_temperature=struct.presets.offset if struct.presets else None, + ) + + @classmethod + def from_bytes(cls, data: bytearray | bytes) -> "Status": + return cls.from_struct(DataclassStruct(StatusStruct).parse(data)) + + +@dataclass +class ScheduleEntry: + target_temperature: float + next_change_at: time + + @classmethod + def from_struct(cls, struct: ScheduleEntryStruct) -> "ScheduleEntry": + return cls( + target_temperature=struct.target_temp, + next_change_at=struct.next_change_at, + ) + + @classmethod + def from_bytes(cls, data: bytearray | bytes) -> "ScheduleEntry": + return cls.from_struct(DataclassStruct(ScheduleEntryStruct).parse(data)) + + +@dataclass +class Schedule: + entries: dict[WeekDay, list[ScheduleEntry]] = {} + + def add_struct(self, struct: ScheduleStruct) -> None: + if struct.day in self.entries: + self.entries[struct.day] = [] + + self.entries[struct.day] = [] + + for entry in struct.hours: + self.entries[struct.day].append(ScheduleEntry.from_struct(entry)) + + def add_bytes(self, data: bytearray | bytes) -> None: + self.add_struct(DataclassStruct(ScheduleStruct).parse(data)) diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py index 4a5fc61..a5e6fde 100644 --- a/eq3btsmart/structures.py +++ b/eq3btsmart/structures.py @@ -1,25 +1,25 @@ -""" Contains construct adapters and structures. """ +"""Structures for the eQ-3 Bluetooth Smart Thermostat.""" from datetime import datetime, time, timedelta +from attr import dataclass from construct import ( Adapter, Bytes, Const, - Enum, - FlagsEnum, GreedyRange, IfThenElse, Int8ub, Optional, - Struct, ) +from construct_typed import DataclassMixin, DataclassStruct, TEnum, TFlagsEnum, csfield from eq3btsmart.const import ( HOUR_24_PLACEHOLDER, - NAME_TO_CMD, - NAME_TO_DAY, PROP_ID_RETURN, PROP_INFO_RETURN, + DeviceModeFlags, + ScheduleCommand, + WeekDay, ) @@ -81,20 +81,6 @@ def _encode(self, obj, context, path): ) -ModeFlags = "ModeFlags" / FlagsEnum( - Int8ub, - AUTO=0x00, # always True, doesnt affect building - MANUAL=0x01, - AWAY=0x02, - BOOST=0x04, - DST=0x08, - WINDOW=0x10, - LOCKED=0x20, - UNKNOWN=0x40, - LOW_BATTERY=0x80, -) - - class AwayDataAdapter(Adapter): """Adapter to encode and decode away data.""" @@ -126,46 +112,63 @@ def _decode(self, obj, context, path): return bytearray(n - 0x30 for n in obj).decode() -Status = "Status" / Struct( - "cmd" / Const(PROP_INFO_RETURN, Int8ub), - Const(0x01, Int8ub), - "mode" / ModeFlags, - "valve" / Int8ub, - Const(0x04, Int8ub), - "target_temp" / TempAdapter(Int8ub), - "away" - / IfThenElse( - lambda ctx: ctx.mode.AWAY, AwayDataAdapter(Bytes(4)), Optional(Bytes(4)) - ), - "presets" - / Optional( - Struct( - "window_open_temp" / TempAdapter(Int8ub), - "window_open_time" / WindowOpenTimeAdapter(Int8ub), - "comfort_temp" / TempAdapter(Int8ub), - "eco_temp" / TempAdapter(Int8ub), - "offset" / TempOffsetAdapter(Int8ub), +@dataclass +class PresetsStruct(DataclassMixin): + """Structure for presets data.""" + + window_open_temp: float = csfield(TempAdapter(Int8ub)) + window_open_time: timedelta = csfield(WindowOpenTimeAdapter(Int8ub)) + comfort_temp: float = csfield(TempAdapter(Int8ub)) + eco_temp: float = csfield(TempAdapter(Int8ub)) + offset: float = csfield(TempOffsetAdapter(Int8ub)) + + +@dataclass +class StatusStruct(DataclassMixin): + """Structure for status data.""" + + cmd: int = csfield(Const(PROP_INFO_RETURN, Int8ub)) + const_1: int = csfield(Const(0x01, Int8ub)) + mode: DeviceModeFlags = csfield(TFlagsEnum(Int8ub, DeviceModeFlags)) + valve: int = csfield(Int8ub) + const_2: int = csfield(Const(0x04, Int8ub)) + target_temp: float = csfield(TempAdapter(Int8ub)) + away: datetime | None = csfield( + IfThenElse( + lambda ctx: ctx.mode & DeviceModeFlags.AWAY, + AwayDataAdapter(Bytes(4)), + Optional(Bytes(4)), ) - ), -) + ) + presets: PresetsStruct | None = csfield(Optional(DataclassStruct(PresetsStruct))) -Schedule = "Schedule" / Struct( - "cmd" / Enum(Int8ub, **NAME_TO_CMD), - "day" / Enum(Int8ub, **NAME_TO_DAY), - "hours" - / GreedyRange( - Struct( - "target_temp" / TempAdapter(Int8ub), - "next_change_at" / TimeAdapter(Int8ub), - ) - ), -) -DeviceId = "DeviceId" / Struct( - "cmd" / Const(PROP_ID_RETURN, Int8ub), - "version" / Int8ub, - Int8ub, - Int8ub, - "serial" / DeviceSerialAdapter(Bytes(10)), - Int8ub, -) +@dataclass +class ScheduleEntryStruct(DataclassMixin): + """Structure for schedule entry data.""" + + target_temp: float = csfield(TempAdapter(Int8ub)) + next_change_at: time = csfield(TimeAdapter(Int8ub)) + + +@dataclass +class ScheduleStruct(DataclassMixin): + """Structure for schedule data.""" + + cmd: ScheduleCommand = csfield(TEnum(Int8ub, ScheduleCommand)) + day: WeekDay = csfield(TEnum(Int8ub, WeekDay)) + hours: list[ScheduleEntryStruct] = csfield( + GreedyRange(DataclassStruct(ScheduleEntryStruct)) + ) + + +@dataclass +class DeviceIdStruct(DataclassMixin): + """Structure for device data.""" + + cmd: int = csfield(Const(PROP_ID_RETURN, Int8ub)) + version: int = csfield(Int8ub) + unknown_1: int = csfield(Int8ub) + unknown_2: int = csfield(Int8ub) + serial: str = csfield(DeviceSerialAdapter(Bytes(10))) + unknown_3: int = csfield(Int8ub) diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index baf09b5..3ff069b 100644 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -11,9 +11,10 @@ import logging import struct from datetime import datetime, timedelta -from typing import Any, Callable +from typing import Callable -from construct import Byte, Container +from construct import Byte +from construct_typed import DataclassStruct from homeassistant.core import HomeAssistant from eq3btsmart.bleakconnection import BleakConnection @@ -41,10 +42,18 @@ PROP_SCHEDULE_RETURN, PROP_TEMPERATURE_WRITE, PROP_WINDOW_OPEN_CONFIG, - Mode, + OperationMode, + ScheduleCommand, + WeekDay, ) from eq3btsmart.exceptions import TemperatureException -from eq3btsmart.structures import AwayDataAdapter, DeviceId, Schedule, Status +from eq3btsmart.models import DeviceData, Schedule, Status +from eq3btsmart.structures import ( + AwayDataAdapter, + ScheduleEntryStruct, + ScheduleStruct, +) +from eq3btsmart.thermostat_config import ThermostatConfig _LOGGER = logging.getLogger(__name__) @@ -54,101 +63,98 @@ class Thermostat: def __init__( self, - mac: str, - name: str, - adapter: str, - stay_connected: bool, + thermostat_config: ThermostatConfig, hass: HomeAssistant, ): """Initialize the thermostat.""" - self.name = name - self._status: Container[Any] | None = None - self._presets: Container[Any] | None = None - self._device_data: Container[Any] | None = None - self._schedule: dict[str, Container[Any]] = {} - self.default_away_hours: float = DEFAULT_AWAY_HOURS - self.default_away_temp: float = DEFAULT_AWAY_TEMP + self.thermostat_config = thermostat_config + self.status: Status | None = None + self.device_data: DeviceData | None = None + self.schedule: Schedule = Schedule() self._on_update_callbacks: list[Callable] = [] self._conn = BleakConnection( - mac=mac, - name=name, - adapter=adapter, - stay_connected=stay_connected, + thermostat_config=self.thermostat_config, hass=hass, callback=self.handle_notification, ) def register_update_callback(self, on_update: Callable) -> None: + """Register a callback function that will be called when an update is received.""" + self._on_update_callbacks.append(on_update) def shutdown(self) -> None: + """Shutdown the connection to the thermostat.""" + self._conn.shutdown() def _verify_temperature(self, temp: float) -> None: - """Verifies that the temperature is valid. + """ + Verifies that the temperature is valid. :raises TemperatureException: On invalid temperature. """ + if temp < EQ3BT_MIN_TEMP or temp > EQ3BT_MAX_TEMP: raise TemperatureException( - "Temperature {} out of range [{}, {}]".format( - temp, EQ3BT_MIN_TEMP, EQ3BT_MAX_TEMP - ) + f"Temperature {temp} out of range [{EQ3BT_MIN_TEMP}, {EQ3BT_MAX_TEMP}]" ) - def parse_schedule(self, data) -> Container[Any]: - """Parses the device sent schedule.""" - sched = Schedule.parse(data) - if sched is None: - raise Exception("Parsed empty schedule data") - _LOGGER.debug("[%s] Got schedule data for day '%s'", self.name, sched.day) - - return sched - def handle_notification(self, data: bytearray) -> None: """Handle Callback from a Bluetooth (GATT) request.""" - _LOGGER.debug("[%s] Received notification from the device.", self.name) - updated = True + + _LOGGER.debug( + f"[{self.thermostat_config.name}] Received notification from the device.", + ) + + updated: bool = True + if data[0] == PROP_INFO_RETURN and data[1] == 1: - _LOGGER.debug("[%s] Got status: %s", self.name, codecs.encode(data, "hex")) - self._status = Status.parse(data) + _LOGGER.debug( + f"[{self.thermostat_config.name}] Got status: {codecs.encode(data, 'hex')!r}", + ) - if self._status is None: - raise Exception("Parsed empty status data") + self.status = Status.from_bytes(data) - self._presets = self._status.presets - _LOGGER.debug("[%s] Parsed status: %s", self.name, self._status) + _LOGGER.debug( + f"[{self.thermostat_config.name}] Parsed status: {self.status}", + ) elif data[0] == PROP_SCHEDULE_RETURN: - parsed = self.parse_schedule(data) - self._schedule[parsed.day] = parsed + self.schedule.add_bytes(data) elif data[0] == PROP_ID_RETURN: - self._device_data = DeviceId.parse(data) - _LOGGER.debug("[%s] Parsed device data: %s", self.name, self._device_data) + self.device_data = DeviceData.from_bytearray(data) + _LOGGER.debug( + f"[{self.thermostat_config.name}] Parsed device data: {self.device_data}", + ) else: updated = False + _LOGGER.debug( - "[%s] Unknown notification %s (%s)", - self.name, - data[0], - codecs.encode(data, "hex"), + f"[{self.thermostat_config.name}] Unknown notification {data[0]} ({codecs.encode(data, 'hex')!r})", ) + if updated: for callback in self._on_update_callbacks: callback() async def async_query_id(self) -> None: """Query device identification information, e.g. the serial number.""" - _LOGGER.debug("[%s] Querying id..", self.name) + + _LOGGER.debug(f"[{self.thermostat_config.name}] Querying id..") + value = struct.pack("B", PROP_ID_QUERY) await self._conn.async_make_request(value) - _LOGGER.debug("[%s] Finished Querying id..", self.name) + + _LOGGER.debug(f"[{self.thermostat_config.name}] Finished Querying id..") async def async_update(self) -> None: """Update the data from the thermostat. Always sets the current time.""" - _LOGGER.debug("[%s] Querying the device..", self.name) + + _LOGGER.debug(f"[{self.thermostat_config.name}] Querying the device..") + time = datetime.now() value = struct.pack( "BBBBBBB", @@ -164,112 +170,78 @@ async def async_update(self) -> None: await self._conn.async_make_request(value) async def async_query_schedule(self, day: int) -> None: - _LOGGER.debug("[%s] Querying schedule..", self.name) + """Query the schedule for the given day.""" + + _LOGGER.debug(f"[{self.thermostat_config.name}] Querying schedule..") if day < 0 or day > 6: - _LOGGER.error("[%s] Invalid day: %s", self.name, day) + raise ValueError(f"Invalid day: {day}") value = struct.pack("BB", PROP_SCHEDULE_QUERY, day) - await self._conn.async_make_request(value) - @property - def schedule(self) -> dict[str, Container[Any]]: - """Returns previously fetched schedule. - :return: Schedule structure or None if not fetched. - """ - return self._schedule + async def async_set_schedule( + self, day: WeekDay, hours: list[ScheduleEntryStruct] + ) -> None: + """Sets the schedule for the given day.""" - async def async_set_schedule(self, day, hours) -> None: _LOGGER.debug( - "[%s] Setting schedule day=[%s], hours=[%s]", self.name, day, hours + f"[{self.thermostat_config.name}] Setting schedule day=[{day}], hours=[{hours}]", ) - """Sets the schedule for the given day.""" - data = Schedule.build( - { - "cmd": "write", - "day": day, - "hours": hours, - } + data = DataclassStruct(ScheduleStruct).build( + ScheduleStruct( + cmd=ScheduleCommand.WRITE, + day=day, + hours=hours, + ) ) await self._conn.async_make_request(data) - parsed = self.parse_schedule(data) - self._schedule[parsed.day] = parsed + self.schedule.add_bytes(data) + for callback in self._on_update_callbacks: callback() - @property - def target_temperature(self) -> float: - """Return the temperature we try to reach.""" - return self._status.target_temp if self._status else -1 - async def async_set_target_temperature(self, temperature: float | None) -> None: """Set new target temperature.""" + if temperature is None: return - dev_temp = int(temperature * 2) + temperature_int = int(temperature * 2) if temperature == EQ3BT_OFF_TEMP or temperature == EQ3BT_ON_TEMP: - dev_temp |= 0x40 - value = struct.pack("BB", PROP_MODE_WRITE, dev_temp) + temperature_int |= 0x40 + value = struct.pack("BB", PROP_MODE_WRITE, temperature_int) else: self._verify_temperature(temperature) - value = struct.pack("BB", PROP_TEMPERATURE_WRITE, dev_temp) + value = struct.pack("BB", PROP_TEMPERATURE_WRITE, temperature_int) await self._conn.async_make_request(value) - @property - def mode(self) -> Mode: - """Return the current operation mode""" - if self._status is None: - return Mode.Unknown - if self.target_temperature == EQ3BT_OFF_TEMP: - return Mode.Off - if self.target_temperature == EQ3BT_ON_TEMP: - return Mode.On - if self._status.mode.MANUAL: - return Mode.Manual - return Mode.Auto - - async def async_set_mode(self, mode: Mode) -> None: + async def async_set_mode(self, operation_mode: OperationMode) -> None: """Set the operation mode.""" - _LOGGER.debug("[%s] Setting new mode: %s", self.name, mode) - match mode: - case Mode.Off: + if self.status is None: + raise Exception("Status not set") + + _LOGGER.debug( + f"[{self.thermostat_config.name}] Setting new mode: {operation_mode}" + ) + + match operation_mode: + case OperationMode.OFF: await self.async_set_target_temperature(EQ3BT_OFF_TEMP) - case Mode.On: + case OperationMode.ON: await self.async_set_target_temperature(EQ3BT_ON_TEMP) - case Mode.Auto: + case OperationMode.AUTO: await self._async_set_mode(0) - case Mode.Manual: + case OperationMode.MANUAL: temperature = max( - min(self.target_temperature, EQ3BT_MAX_TEMP), EQ3BT_MIN_TEMP + min(self.status.target_temperature, EQ3BT_MAX_TEMP), EQ3BT_MIN_TEMP ) await self._async_set_mode(0x40 | int(temperature * 2)) - @property - def away(self) -> bool | None: - """Returns True if the thermostat is in away mode.""" - - if self._status is None: - return None - - return self.away_end is not None - - @property - def away_end(self) -> datetime | None: - """Returns the end datetime of the away mode.""" - if self._status is None: - return None - - if not isinstance(self._status.away, datetime): - return None - - return self._status.away - async def async_set_away_until( self, away_end: datetime, temperature: float ) -> None: @@ -280,7 +252,7 @@ async def async_set_away_until( away_end = away_end - timedelta(minutes=away_end.minute % 30) _LOGGER.debug( - "[%s] Setting away until %s, temp %s", self.name, away_end, temperature + f"[{self.thermostat_config.name}] Setting away until {away_end}, temp {temperature}", ) adapter = AwayDataAdapter(Byte[4]) packed = adapter.build(away_end) @@ -290,12 +262,14 @@ async def async_set_away_until( async def async_set_away(self, away: bool) -> None: """Sets away mode with default temperature.""" if not away: - _LOGGER.debug("[%s] Disabling away, going to auto mode.", self.name) + _LOGGER.debug( + f"[{self.thermostat_config.name}] Disabling away, going to auto mode." + ) return await self._async_set_mode(0x00) - away_end = datetime.now() + timedelta(hours=self.default_away_hours) + away_end = datetime.now() + timedelta(hours=DEFAULT_AWAY_HOURS) - await self.async_set_away_until(away_end, self.default_away_temp) + await self.async_set_away_until(away_end, DEFAULT_AWAY_TEMP) async def _async_set_mode(self, mode: int, payload: bytes | None = None) -> None: value = struct.pack("BB", PROP_MODE_WRITE, mode) @@ -303,50 +277,20 @@ async def _async_set_mode(self, mode: int, payload: bytes | None = None) -> None value += payload await self._conn.async_make_request(value) - @property - def boost(self) -> bool | None: - """Returns True if the thermostat is in boost mode.""" - - if self._status is None: - return None - - return self._status.mode.BOOST - async def async_set_boost(self, boost: bool) -> None: """Sets boost mode.""" - _LOGGER.debug("[%s] Setting boost mode: %s", self.name, boost) + + _LOGGER.debug(f"[{self.thermostat_config.name}] Setting boost mode: {boost}") value = struct.pack("BB", PROP_BOOST, boost) await self._conn.async_make_request(value) - @property - def valve_state(self) -> int | None: - """Returns the valve state. Probably reported as percent open.""" - - if self._status is None: - return None - - return self._status.valve - - @property - def window_open(self) -> bool | None: - """Returns True if the thermostat reports a open window - (detected by sudden drop of temperature)""" - - if self._status is None: - return False - - return self._status.mode.WINDOW - async def async_window_open_config( self, temperature: float, duration: timedelta ) -> None: """Configures the window open behavior. The duration is specified in 5 minute increments.""" _LOGGER.debug( - "[%s] Window open config, temperature: %s duration: %s", - self.name, - temperature, - duration, + f"[{self.thermostat_config.name}] Window open config, temperature: {temperature} duration: {duration}", ) self._verify_temperature(temperature) if duration.seconds < 0 and duration.seconds > 3600: @@ -360,65 +304,17 @@ async def async_window_open_config( ) await self._conn.async_make_request(value) - @property - def window_open_temperature(self) -> float | None: - """The temperature to set when an open window is detected.""" - - if self._presets is None: - return None - - return self._presets.window_open_temp - - @property - def window_open_time(self) -> timedelta | None: - """Timeout to reset the thermostat after an open window is detected.""" - - if self._presets is None: - return None - - return self._presets.window_open_time - - @property - def dst(self) -> bool | None: - """Returns True if the thermostat is in Daylight Saving Time.""" - - if self._status is None: - return None - - return self._status.mode.DST - - @property - def locked(self) -> bool | None: - """Returns True if the thermostat is locked.""" - - if self._status is None: - return None - - return self._status.mode.LOCKED - async def async_set_locked(self, lock: bool) -> None: """Locks or unlocks the thermostat.""" - _LOGGER.debug("[%s] Setting the lock: %s", self.name, lock) + _LOGGER.debug(f"[{self.thermostat_config.name}] Setting the lock: {lock}") value = struct.pack("BB", PROP_LOCK, lock) await self._conn.async_make_request(value) - @property - def low_battery(self) -> bool | None: - """Returns True if the thermostat reports a low battery.""" - - if self._status is None: - return None - - return self._status.mode.LOW_BATTERY - async def async_temperature_presets(self, comfort: float, eco: float) -> None: """Set the thermostats preset temperatures comfort (sun) and eco (moon).""" _LOGGER.debug( - "[%s] Setting temperature presets, comfort: %s eco: %s", - self.name, - comfort, - eco, + f"[{self.thermostat_config.name}] Setting temperature presets, comfort: {comfort} eco: {eco}", ) self._verify_temperature(comfort) self._verify_temperature(eco) @@ -427,40 +323,13 @@ async def async_temperature_presets(self, comfort: float, eco: float) -> None: ) await self._conn.async_make_request(value) - @property - def comfort_temperature(self) -> float | None: - """Returns the comfort temperature preset of the thermostat.""" - - if self._presets is None: - return None - - return self._presets.comfort_temp - - @property - def eco_temperature(self) -> float | None: - """Returns the eco temperature preset of the thermostat.""" - - if self._presets is None: - return None - - return self._presets.eco_temp - - @property - def temperature_offset(self) -> float | None: - """Returns the thermostat's temperature offset.""" - - if self._presets is None: - return None - - return self._presets.offset - async def async_set_temperature_offset(self, offset: float) -> None: """Sets the thermostat's temperature offset.""" - _LOGGER.debug("[%s] Setting offset: %s", self.name, offset) + _LOGGER.debug(f"[{self.thermostat_config.name}] Setting offset: {offset}") # [-3,5 .. 0 .. 3,5 ] # [00 .. 07 .. 0e ] if offset < EQ3BT_MIN_OFFSET or offset > EQ3BT_MAX_OFFSET: - raise TemperatureException("Invalid value: %s" % offset) + raise TemperatureException(f"Invalid value: {offset}") current = -3.5 values = {} @@ -480,27 +349,3 @@ async def async_activate_eco(self) -> None: """Activates the comfort temperature.""" value = struct.pack("B", PROP_ECO) await self._conn.async_make_request(value) - - @property - def firmware_version(self) -> str | None: - """Return the firmware version.""" - - if self._device_data is None: - return None - - return self._device_data.version - - @property - def device_serial(self) -> str | None: - """Return the device serial number.""" - - if self._device_data is None: - return None - - return self._device_data.serial - - @property - def mac(self) -> str: - """Return the mac address.""" - - return self._conn._mac diff --git a/eq3btsmart/thermostat_config.py b/eq3btsmart/thermostat_config.py new file mode 100644 index 0000000..848ba95 --- /dev/null +++ b/eq3btsmart/thermostat_config.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from eq3btsmart.const import Adapter + + +@dataclass +class ThermostatConfig: + mac_address: str + name: str + adapter: Adapter + stay_connected: bool From 3ef13ba157315e260ef1adfe90f46d5e0b23cc25 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 12:05:25 +0100 Subject: [PATCH 04/20] feat: improve architecture --- README.md | 42 ++ custom_components/eq3btsmart/binary_sensor.py | 2 +- custom_components/eq3btsmart/button.py | 26 +- custom_components/eq3btsmart/climate.py | 4 +- custom_components/eq3btsmart/number.py | 14 +- custom_components/eq3btsmart/sensor.py | 2 +- eq3btsmart/bleakconnection.py | 144 +++--- eq3btsmart/const.py | 77 ++-- eq3btsmart/eq3_away_time.py | 44 ++ eq3btsmart/eq3_duration.py | 22 + eq3btsmart/eq3_schedule_time.py | 19 + eq3btsmart/eq3_temperature.py | 63 +++ eq3btsmart/eq3_temperature_offset.py | 24 + eq3btsmart/eq3_time.py | 40 ++ eq3btsmart/models.py | 86 ++-- eq3btsmart/structures.py | 281 ++++++++---- eq3btsmart/thermostat.py | 418 ++++++++---------- poetry.lock | 73 ++- pyproject.toml | 10 + tests/__init__.py | 0 tests/test_eq3_temperature.py | 8 + tests/test_eq3_temperature_offset.py | 8 + 22 files changed, 896 insertions(+), 511 deletions(-) create mode 100644 eq3btsmart/eq3_away_time.py create mode 100644 eq3btsmart/eq3_duration.py create mode 100644 eq3btsmart/eq3_schedule_time.py create mode 100644 eq3btsmart/eq3_temperature.py create mode 100644 eq3btsmart/eq3_temperature_offset.py create mode 100644 eq3btsmart/eq3_time.py create mode 100644 tests/__init__.py create mode 100644 tests/test_eq3_temperature.py create mode 100644 tests/test_eq3_temperature_offset.py diff --git a/README.md b/README.md index 65b730e..32701ed 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,45 @@ To setup `pre-commit` for automatic issue detection while committing you need to * `poetry run pre-commit install` * `poetry run pre-commit install --hook-type commit-msg` * `poetry run pre-commit install-hooks` + +# Protocol + +### `ID_GET` +`0x00` + +### `INFO_GET` +`0x03` + +### `COMFORT_ECO_CONFIGURE` +`0x11 (COMFORT_TEMP * 2) (ECO_TEMP * 2)` + +### OFFSET_CONFIGURE +`0x13 TEMP_INDEX` + +### WINDOW_OPEN_CONFIGURE +`0x14 (TEMP * 2) (FLOOR(SECONDS / 300))` + +### `SCHEDULE_GET` +`0x20 DAY` + +### `MODE_SET` +* On: `0x40 (0x40 OR 0x3C)` +* Off: `0x40 (0x40 OR 0x09)` +* Manual: `0x40 (0x40 OR TEMP * 2)` +* Auto: `0x40 0x00` +* Away: `0x40 (0x80 OR TEMP * 2) DAY (YEAR - 2000) (HOUR * 2) (MONTH)` + +### `TEMPERATURE_SET` +`0x41 (TEMP * 2)` + +### `COMFORT_SET` +`0x43` + +### `ECO_SET` +`0x44` + +### `BOOST_SET` +`0x45 (0x00 | 0x01)` + +### `LOCK_SET` +`0x80 (0x00 | 0x01)` diff --git a/custom_components/eq3btsmart/binary_sensor.py b/custom_components/eq3btsmart/binary_sensor.py index f9c53b5..40d2824 100644 --- a/custom_components/eq3btsmart/binary_sensor.py +++ b/custom_components/eq3btsmart/binary_sensor.py @@ -105,7 +105,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def extra_state_attributes(self) -> dict[str, str] | None: - if (device := self._thermostat._conn._ble_device) is None: + if (device := self._thermostat._conn._device) is None: return None if (details := device.details) is None: return None diff --git a/custom_components/eq3btsmart/button.py b/custom_components/eq3btsmart/button.py index 20eb60b..6dc52f9 100644 --- a/custom_components/eq3btsmart/button.py +++ b/custom_components/eq3btsmart/button.py @@ -6,7 +6,8 @@ from custom_components.eq3btsmart.eq3_entity import Eq3Entity from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat -from eq3btsmart.const import HOUR_24_PLACEHOLDER +from eq3btsmart.const import HOUR_24_PLACEHOLDER, WeekDay +from eq3btsmart.models import Schedule, ScheduleDay, ScheduleHour from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry, UndefinedType from homeassistant.core import HomeAssistant @@ -86,7 +87,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): async def async_press(self) -> None: for x in range(0, 7): - await self._thermostat.async_query_schedule(x) + await self._thermostat.async_get_schedule(x) _LOGGER.debug( f"[{self._eq3_config.name}] schedule: {self._thermostat.schedule}", @@ -97,21 +98,28 @@ async def set_schedule(self, **kwargs) -> None: _LOGGER.debug(f"[{self._eq3_config.name}] set_schedule: {kwargs}") + schedule = Schedule() for day in kwargs["days"]: + week_day = WeekDay[day.upper()] + schedule_hours: list[ScheduleHour] = [] + schedule_day = ScheduleDay(week_day=week_day, schedule_hours=schedule_hours) + times = [ kwargs.get(f"next_change_at_{i}", datetime.time(0, 0)) for i in range(6) ] times[times.index(datetime.time(0, 0))] = HOUR_24_PLACEHOLDER temps = [kwargs.get(f"target_temp_{i}", 0) for i in range(7)] - hours = [] + for i in range(0, 6): - hours.append( - { - "target_temp": temps[i], - "next_change_at": times[i], - } + schedule_hour = ScheduleHour( + target_temperature=temps[i], + next_change_at=times[i], ) - await self._thermostat.async_set_schedule(day=day, hours=hours) + schedule_hours.append(schedule_hour) + + schedule.days.append(schedule_day) + + await self._thermostat.async_set_schedule(schedule=schedule) @property def extra_state_attributes(self): diff --git a/custom_components/eq3btsmart/climate.py b/custom_components/eq3btsmart/climate.py index 01a3125..224a869 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -212,9 +212,7 @@ async def async_set_temperature(self, **kwargs) -> None: self.async_schedule_update_ha_state() async def async_set_temperature_now(self) -> None: - await self._thermostat.async_set_target_temperature( - self._target_temperature_to_set - ) + await self._thermostat.async_set_temperature(self._target_temperature_to_set) self._is_setting_temperature = False @property diff --git a/custom_components/eq3btsmart/number.py b/custom_components/eq3btsmart/number.py index 77b539a..cb01d06 100644 --- a/custom_components/eq3btsmart/number.py +++ b/custom_components/eq3btsmart/number.py @@ -108,7 +108,9 @@ async def async_set_native_value(self, value: float) -> None: if other is None: return - await self._thermostat.async_temperature_presets(comfort=value, eco=other) + await self._thermostat.async_configure_presets( + comfort_temperature=value, eco_temperature=other + ) class EcoTemperature(Base): @@ -130,7 +132,9 @@ async def async_set_native_value(self, value: float) -> None: if other is None: return - await self._thermostat.async_temperature_presets(comfort=other, eco=value) + await self._thermostat.async_configure_presets( + comfort_temperature=other, eco_temperature=value + ) class OffsetTemperature(Base): @@ -148,7 +152,7 @@ def native_value(self) -> float | None: return self._thermostat.temperature_offset async def async_set_native_value(self, value: float) -> None: - await self._thermostat.async_set_temperature_offset(value) + await self._thermostat.async_temperature_offset_configure(value) class WindowOpenTemperature(Base): @@ -169,7 +173,7 @@ async def async_set_native_value(self, value: float) -> None: if self._thermostat.window_open_time is None: return - await self._thermostat.async_window_open_config( + await self._thermostat.async_configure_window_open( temperature=value, duration=self._thermostat.window_open_time ) @@ -202,7 +206,7 @@ async def async_set_native_value(self, value: float) -> None: if self._thermostat.window_open_temperature is None: return - await self._thermostat.async_window_open_config( + await self._thermostat.async_configure_window_open( temperature=self._thermostat.window_open_temperature, duration=timedelta(minutes=value), ) diff --git a/custom_components/eq3btsmart/sensor.py b/custom_components/eq3btsmart/sensor.py index b76669a..c847edf 100644 --- a/custom_components/eq3btsmart/sensor.py +++ b/custom_components/eq3btsmart/sensor.py @@ -166,7 +166,7 @@ async def async_added_to_hass(self) -> None: async def fetch_serial(self) -> None: try: - await self._thermostat.async_query_id() + await self._thermostat.async_get_id() except Exception as e: _LOGGER.error( f"[{self._eq3_config.name}] Error fetching serial number: {e}" diff --git a/eq3btsmart/bleakconnection.py b/eq3btsmart/bleakconnection.py index 5dab74c..c530d91 100644 --- a/eq3btsmart/bleakconnection.py +++ b/eq3btsmart/bleakconnection.py @@ -6,9 +6,7 @@ from bleak import BleakClient from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice -from bleak_retry_connector import NO_RSSI_VALUE, establish_connection -from homeassistant.components import bluetooth -from homeassistant.core import HomeAssistant +from bleak_retry_connector import establish_connection from eq3btsmart.const import ( PROP_NTFY_UUID, @@ -32,19 +30,18 @@ class BleakConnection: def __init__( self, thermostat_config: ThermostatConfig, - hass: HomeAssistant, - callback, + device: BLEDevice, + callback: Callable, ): """Initialize the connection.""" + self.thermostat_config = thermostat_config - self._hass = hass self._callback = callback self._notify_event = asyncio.Event() self._terminate_event = asyncio.Event() - self.rssi: int | None = None self._lock = asyncio.Lock() self._conn: BleakClient | None = None - self._ble_device: BLEDevice | None = None + self._device: BLEDevice = device self._connection_callbacks: list[Callable] = [] self.retries = 0 self._round_robin = 0 @@ -52,15 +49,31 @@ def __init__( def register_connection_callback(self, callback: Callable) -> None: self._connection_callbacks.append(callback) - def _on_connection_event(self) -> None: - for callback in self._connection_callbacks: - callback() + async def async_connect(self) -> None: + match self.thermostat_config.adapter: + case Adapter.AUTO: + self._conn = await establish_connection( + client_class=BleakClient, + device=self._device, + name=self.thermostat_config.name, + disconnected_callback=lambda client: self._on_connection_event(), + max_attempts=2, + use_services_cache=True, + ) + + case Adapter.LOCAL: + UnwrappedBleakClient = cast(type[BleakClient], BleakClient.__bases__[0]) + self._conn = UnwrappedBleakClient( + self._device, + disconnected_callback=lambda client: self._on_connection_event(), + dangerous_use_bleak_cache=True, + ) + await self._conn.connect() - def shutdown(self) -> None: - _LOGGER.debug( - "[%s] closing connections", - self.thermostat_config.name, - ) + if self._conn is None or not self._conn.is_connected: + raise BackendException("Can't connect") + + def disconnect(self) -> None: self._terminate_event.set() self._notify_event.set() @@ -70,74 +83,14 @@ async def throw_if_terminating(self) -> None: await self._conn.disconnect() raise Exception("Connection cancelled by shutdown") - async def async_get_connection(self) -> BleakClient: - if self.thermostat_config.adapter == Adapter.AUTO: - self._ble_device = bluetooth.async_ble_device_from_address( - self._hass, self.thermostat_config.mac_address, connectable=True - ) - if self._ble_device is None: - raise Exception("Device not found") - - self._conn = await establish_connection( - client_class=BleakClient, - device=self._ble_device, - name=self.thermostat_config.name, - disconnected_callback=lambda client: self._on_connection_event(), - max_attempts=2, - use_services_cache=True, - ) - else: - device_advertisement_datas = sorted( - bluetooth.async_scanner_devices_by_address( - hass=self._hass, - address=self.thermostat_config.mac_address, - connectable=True, - ), - key=lambda device_advertisement_data: device_advertisement_data.advertisement.rssi - or NO_RSSI_VALUE, - reverse=True, - ) - if self.thermostat_config.adapter == Adapter.LOCAL: - if len(device_advertisement_datas) == 0: - raise Exception("Device not found") - d_and_a = device_advertisement_datas[ - self._round_robin % len(device_advertisement_datas) - ] - else: # adapter is e.g /org/bluez/hci0 - list = [ - x - for x in device_advertisement_datas - if (d := x.ble_device.details) - and d.get("props", {}).get("Adapter") == self._adapter - ] - if len(list) == 0: - raise Exception("Device not found") - d_and_a = list[0] - self.rssi = d_and_a.advertisement.rssi - self._ble_device = d_and_a.ble_device - UnwrappedBleakClient = cast(type[BleakClient], BleakClient.__bases__[0]) - self._conn = UnwrappedBleakClient( - self._ble_device, - disconnected_callback=lambda client: self._on_connection_event(), - dangerous_use_bleak_cache=True, - ) - await self._conn.connect() - - self._on_connection_event() - - if self._conn is not None and self._conn.is_connected: - _LOGGER.debug("[%s] Connected", self.thermostat_config.name) - else: - raise BackendException("Can't connect") - return self._conn - async def on_notification( self, handle: BleakGATTCharacteristic, data: bytearray ) -> None: """Handle Callback from a Bluetooth (GATT) request.""" if PROP_NTFY_UUID == handle.uuid: self._notify_event.set() - self._callback(data) + data_bytes = bytes(data) + self._callback(data_bytes) else: _LOGGER.error( "[%s] wrong charasteristic: %s, %s", @@ -146,7 +99,7 @@ async def on_notification( handle.uuid, ) - async def async_make_request(self, value, retries=RETRIES) -> None: + async def async_make_request(self, value: bytes, retries=RETRIES) -> None: """Write a GATT Command with callback - not utf-8.""" async with self._lock: # only one concurrent request per thermostat try: @@ -155,19 +108,25 @@ async def async_make_request(self, value, retries=RETRIES) -> None: self.retries = 0 self._on_connection_event() - async def _async_make_request_try(self, value, retries) -> None: + async def _async_make_request_try(self, value: bytes, retries) -> None: self.retries = 0 while True: self.retries += 1 self._on_connection_event() try: await self.throw_if_terminating() - conn = await self.async_get_connection() + await self.async_connect() + + if self._conn is None: + raise BackendException("Can't connect") + self._notify_event.clear() if value != "ONLY CONNECT": try: - await conn.start_notify(PROP_NTFY_UUID, self.on_notification) - await conn.write_gatt_char( + await self._conn.start_notify( + PROP_NTFY_UUID, self.on_notification + ) + await self._conn.write_gatt_char( PROP_WRITE_UUID, value, response=True ) await asyncio.wait_for( @@ -175,21 +134,20 @@ async def _async_make_request_try(self, value, retries) -> None: ) finally: if self.thermostat_config.stay_connected: - await conn.stop_notify(PROP_NTFY_UUID) + await self._conn.stop_notify(PROP_NTFY_UUID) else: - await conn.disconnect() + await self._conn.disconnect() return except Exception as ex: await self.throw_if_terminating() - _LOGGER.debug( - "[%s] Broken connection [retry %s/%s]: %s", - self.thermostat_config.name, - self.retries, - retries, - ex, - exc_info=True, - ) + self._round_robin = self._round_robin + 1 + if self.retries >= retries: raise ex + await asyncio.sleep(RETRY_BACK_OFF_FACTOR * self.retries) + + def _on_connection_event(self) -> None: + for callback in self._connection_callbacks: + callback() diff --git a/eq3btsmart/const.py b/eq3btsmart/const.py index 9680c93..0a09b5e 100644 --- a/eq3btsmart/const.py +++ b/eq3btsmart/const.py @@ -1,28 +1,8 @@ """Constants for the eq3btsmart library.""" -from enum import Enum, IntEnum, IntFlag - -PROP_ID_QUERY = 0 -PROP_ID_RETURN = 1 -PROP_INFO_QUERY = 3 -PROP_INFO_RETURN = 2 -PROP_COMFORT_ECO_CONFIG = 0x11 -PROP_OFFSET = 0x13 -PROP_WINDOW_OPEN_CONFIG = 0x14 -PROP_SCHEDULE_SET = 0x10 -PROP_SCHEDULE_QUERY = 0x20 -PROP_SCHEDULE_RETURN = 0x21 - -PROP_MODE_WRITE = 0x40 -PROP_TEMPERATURE_WRITE = 0x41 -PROP_COMFORT = 0x43 -PROP_ECO = 0x44 -PROP_BOOST = 0x45 -PROP_LOCK = 0x80 - -NAME_TO_DAY = {"sat": 0, "sun": 1, "mon": 2, "tue": 3, "wed": 4, "thu": 5, "fri": 6} -NAME_TO_CMD = {"write": PROP_SCHEDULE_SET, "response": PROP_SCHEDULE_RETURN} -HOUR_24_PLACEHOLDER = 1234 +from enum import Enum, IntEnum + +from construct_typed import EnumBase, FlagsEnumBase EQ3BT_AWAY_TEMP = 12.0 EQ3BT_MIN_TEMP = 5.0 @@ -44,14 +24,26 @@ DEFAULT_AWAY_TEMP = 12 -class ScheduleCommand(IntEnum): - """Schedule commands.""" - - WRITE = PROP_SCHEDULE_SET - RESPONSE = PROP_SCHEDULE_RETURN - - -class WeekDay(IntEnum): +class Command(IntEnum): + ID_GET = 0x00 + ID_RETURN = 0x01 + INFO_RETURN = 0x02 + INFO_GET = 0x03 + SCHEDULE_SET = 0x10 + COMFORT_ECO_CONFIGURE = 0x11 + OFFSET_CONFIGURE = 0x13 + WINDOW_OPEN_CONFIGURE = 0x14 + SCHEDULE_GET = 0x20 + SCHEDULE_RETURN = 0x21 + MODE_SET = 0x40 + TEMPERATURE_SET = 0x41 + COMFORT_SET = 0x43 + ECO_SET = 0x44 + BOOST_SET = 0x45 + LOCK_SET = 0x80 + + +class WeekDay(EnumBase): """Weekdays.""" SATURDAY = 0 @@ -63,18 +55,18 @@ class WeekDay(IntEnum): FRIDAY = 6 -class OperationMode(IntEnum): +class OperationMode(EnumBase): """Operation modes.""" - UNKNOWN = 0 - AUTO = 1 - MANUAL = 2 - ON = 3 - OFF = 4 + AUTO = 0x00 + MANUAL = 0x40 + OFF = 0x49 + ON = 0x7B + AWAY = 0x80 -class DeviceModeFlags(IntFlag): - """Device modes.""" +class StatusFlags(FlagsEnumBase): + """Status flags.""" AUTO = 0x00 # always True, doesnt affect building MANUAL = 0x01 @@ -90,3 +82,10 @@ class DeviceModeFlags(IntFlag): class Adapter(str, Enum): AUTO = "AUTO" LOCAL = "LOCAL" + + +class Preset(Enum): + """Preset modes.""" + + COMFORT = 0 + ECO = 1 diff --git a/eq3btsmart/eq3_away_time.py b/eq3btsmart/eq3_away_time.py new file mode 100644 index 0000000..edfa6bf --- /dev/null +++ b/eq3btsmart/eq3_away_time.py @@ -0,0 +1,44 @@ +from datetime import datetime, timedelta +from typing import Self + + +class Eq3AwayTime(bytes): + """Adapter to encode and decode away time data.""" + + def __new__(cls, value: datetime): + value += timedelta(minutes=15) + value -= timedelta(minutes=value.minute % 30) + + if value.year < 2000 or value.year > 2099: + raise Exception("Invalid year, possible [2000, 2099]") + + year = value.year - 2000 + hour = value.hour * 2 + if value.minute != 0: + hour |= 0x01 + + return super().__new__(cls, bytes([value.day, year, hour, value.month])) + + @property + def friendly_value(self) -> datetime: + (day, year, hour_min, month) = self + year += 2000 + + min = 0 + if hour_min & 0x01: + min = 30 + hour = int(hour_min / 2) + + return datetime(year=year, month=month, day=day, hour=hour, minute=min) + + @classmethod + def from_device(cls, value: bytes) -> Self: + (day, year, hour_min, month) = value + year += 2000 + + min = 0 + if hour_min & 0x01: + min = 30 + hour = int(hour_min / 2) + + return cls(datetime(year=year, month=month, day=day, hour=hour, minute=min)) diff --git a/eq3btsmart/eq3_duration.py b/eq3btsmart/eq3_duration.py new file mode 100644 index 0000000..5331e02 --- /dev/null +++ b/eq3btsmart/eq3_duration.py @@ -0,0 +1,22 @@ +from datetime import timedelta + + +class Eq3Duration(int): + """Adapter to encode and decode duration data.""" + + def __new__(cls, duration: timedelta): + if duration.seconds < 0 or duration.seconds > 3600.0: + raise ValueError( + "Window open time must be between 0 and 60 minutes " + "in intervals of 5 minutes." + ) + + return super().__new__(cls, int(duration.seconds / 300.0)) + + @property + def friendly_value(self) -> timedelta: + return timedelta(minutes=self * 5.0) + + @classmethod + def from_device(cls, value: int): + return cls(timedelta(minutes=float(value * 5.0))) diff --git a/eq3btsmart/eq3_schedule_time.py b/eq3btsmart/eq3_schedule_time.py new file mode 100644 index 0000000..2244d66 --- /dev/null +++ b/eq3btsmart/eq3_schedule_time.py @@ -0,0 +1,19 @@ +from datetime import time +from typing import Self + + +class Eq3ScheduleTime(int): + """Adapter to encode and decode schedule time data.""" + + def __new__(cls, value: time): + return super().__new__(cls, int((value.hour * 60 + value.minute) / 10)) + + @property + def friendly_value(self) -> time: + hour, minute = divmod(self * 10, 60) + return time(hour=hour, minute=minute) + + @classmethod + def from_device(cls, value: int) -> Self: + hour, minute = divmod(value * 10, 60) + return cls(time(hour=hour, minute=minute)) diff --git a/eq3btsmart/eq3_temperature.py b/eq3btsmart/eq3_temperature.py new file mode 100644 index 0000000..8245df8 --- /dev/null +++ b/eq3btsmart/eq3_temperature.py @@ -0,0 +1,63 @@ +from eq3btsmart.const import ( + EQ3BT_OFF_TEMP, + EQ3BT_ON_TEMP, +) +from eq3btsmart.exceptions import TemperatureException + + +class Eq3Temperature(int): + """Adapter to encode and decode temperature data.""" + + def __new__(cls, value: float): + if value < EQ3BT_OFF_TEMP or value > EQ3BT_ON_TEMP: + raise TemperatureException( + f"Temperature {value} out of range [{EQ3BT_OFF_TEMP}, {EQ3BT_ON_TEMP}]" + ) + + return super().__new__(cls, int(value * 2)) + + @property + def friendly_value(self) -> float: + return self / 2 + + @classmethod + def from_device(cls, value: int): + return cls(value / 2) + + # @property + # def is_on_temperature(self) -> bool: + # return self == EQ3BT_ON_TEMP + + # @property + # def is_off_temperature(self) -> bool: + # return self == EQ3BT_OFF_TEMP + + # @property + # def command(self) -> Command: + # if self.is_off_temperature or self.is_on_temperature: + # return Command.MODE_SET + + # return Command.TEMPERATURE_SET + + # @property + # def manual_data_int(self) -> int: + # value = self.device_temperature + + # if self.is_off_temperature or self.is_on_temperature: + # value |= ModeFlags.MANUAL + + # return value + + # @property + # def away_data_int(self) -> int: + # value = self.device_temperature | ModeFlags.AWAY + + # return value + + # @property + # def manual_data_bytes(self) -> bytes: + # return struct.pack("B", self.manual_data_int) + + # @property + # def away_data_bytes(self) -> bytes: + # return struct.pack("B", self.away_data_int) diff --git a/eq3btsmart/eq3_temperature_offset.py b/eq3btsmart/eq3_temperature_offset.py new file mode 100644 index 0000000..0c64338 --- /dev/null +++ b/eq3btsmart/eq3_temperature_offset.py @@ -0,0 +1,24 @@ +from typing import Self + +from eq3btsmart.const import EQ3BT_MAX_OFFSET, EQ3BT_MIN_OFFSET +from eq3btsmart.exceptions import TemperatureException + + +class Eq3TemperatureOffset(int): + """Adapter to encode and decode temperature offset data.""" + + def __new__(cls, value: float): + if value < EQ3BT_MIN_OFFSET or value > EQ3BT_MAX_OFFSET: + raise TemperatureException( + f"Temperature {value} out of range [{EQ3BT_MIN_OFFSET}, {EQ3BT_MAX_OFFSET}]" + ) + + return super().__new__(cls, int((value + 3.5) / 0.5)) + + @property + def friendly_value(self) -> float: + return self * 0.5 - 3.5 + + @classmethod + def from_device(cls, value: int) -> Self: + return cls(value * 0.5 - 3.5) diff --git a/eq3btsmart/eq3_time.py b/eq3btsmart/eq3_time.py new file mode 100644 index 0000000..a151776 --- /dev/null +++ b/eq3btsmart/eq3_time.py @@ -0,0 +1,40 @@ +from datetime import datetime + + +class Eq3Time(bytes): + """Adapter to encode and decode time data.""" + + def __new__(cls, value: datetime): + return super().__new__( + cls, + bytes( + [ + value.year % 100, + value.month, + value.day, + value.hour, + value.minute, + value.second, + ] + ), + ) + + @property + def friendly_value(self) -> datetime: + (year, month, day, hour, minute, second) = self + year += 2000 + + return datetime( + year=year, month=month, day=day, hour=hour, minute=minute, second=second + ) + + @classmethod + def from_device(cls, value: bytes): + (year, month, day, hour, minute, second) = value + year += 2000 + + return cls( + datetime( + year=year, month=month, day=day, hour=hour, minute=minute, second=second + ) + ) diff --git a/eq3btsmart/models.py b/eq3btsmart/models.py index 2b323ba..d5ce15b 100644 --- a/eq3btsmart/models.py +++ b/eq3btsmart/models.py @@ -1,13 +1,17 @@ -from dataclasses import dataclass -from datetime import datetime, time, timedelta +from dataclasses import dataclass, field +from typing import Self from construct_typed import DataclassStruct from eq3btsmart.const import EQ3BT_OFF_TEMP, EQ3BT_ON_TEMP, OperationMode, WeekDay +from eq3btsmart.eq3_away_time import Eq3AwayTime +from eq3btsmart.eq3_duration import Eq3Duration +from eq3btsmart.eq3_schedule_time import Eq3ScheduleTime +from eq3btsmart.eq3_temperature import Eq3Temperature +from eq3btsmart.eq3_temperature_offset import Eq3TemperatureOffset from eq3btsmart.structures import ( DeviceIdStruct, - ScheduleEntryStruct, - ScheduleStruct, + ScheduleDayStruct, StatusStruct, ) @@ -18,21 +22,21 @@ class DeviceData: device_serial: str @classmethod - def from_struct(cls, struct: DeviceIdStruct) -> "DeviceData": + def from_device(cls, struct: DeviceIdStruct) -> Self: return cls( firmware_version=struct.version, device_serial=struct.serial, ) @classmethod - def from_bytearray(cls, data: bytearray) -> "DeviceData": - return cls.from_struct(DataclassStruct(DeviceIdStruct).parse(data)) + def from_bytes(cls, data: bytes) -> Self: + return cls.from_device(DataclassStruct(DeviceIdStruct).parse(data)) @dataclass class Status: valve: int - target_temperature: float + target_temperature: Eq3Temperature _operation_mode: OperationMode is_away: bool is_boost: bool @@ -40,12 +44,12 @@ class Status: is_window_open: bool is_locked: bool is_low_battery: bool - away_datetime: datetime | None - window_open_temperature: float | None - window_open_time: timedelta | None - comfort_temperature: float | None - eco_temperature: float | None - offset_temperature: float | None + away_datetime: Eq3AwayTime | None + window_open_temperature: Eq3Temperature | None + window_open_time: Eq3Duration | None + comfort_temperature: Eq3Temperature | None + eco_temperature: Eq3Temperature | None + offset_temperature: Eq3TemperatureOffset | None @property def operation_mode(self) -> OperationMode: @@ -58,7 +62,7 @@ def operation_mode(self) -> OperationMode: return self._operation_mode @classmethod - def from_struct(cls, struct: StatusStruct) -> "Status": + def from_device(cls, struct: StatusStruct) -> Self: return cls( valve=struct.valve, target_temperature=struct.target_temp, @@ -84,39 +88,49 @@ def from_struct(cls, struct: StatusStruct) -> "Status": ) @classmethod - def from_bytes(cls, data: bytearray | bytes) -> "Status": - return cls.from_struct(DataclassStruct(StatusStruct).parse(data)) + def from_bytes(cls, data: bytes) -> Self: + return cls.from_device(DataclassStruct(StatusStruct).parse(data)) @dataclass -class ScheduleEntry: - target_temperature: float - next_change_at: time +class ScheduleHour: + target_temperature: Eq3Temperature + next_change_at: Eq3ScheduleTime + + +@dataclass +class ScheduleDay: + week_day: WeekDay + schedule_hours: list[ScheduleHour] = field(default_factory=list) @classmethod - def from_struct(cls, struct: ScheduleEntryStruct) -> "ScheduleEntry": + def from_device(cls, struct: ScheduleDayStruct) -> Self: return cls( - target_temperature=struct.target_temp, - next_change_at=struct.next_change_at, + week_day=struct.day, + schedule_hours=[ + ScheduleHour( + target_temperature=hour.target_temp, + next_change_at=hour.next_change_at, + ) + for hour in struct.hours + ], ) @classmethod - def from_bytes(cls, data: bytearray | bytes) -> "ScheduleEntry": - return cls.from_struct(DataclassStruct(ScheduleEntryStruct).parse(data)) + def from_bytes(cls, data: bytes) -> Self: + return cls.from_device(DataclassStruct(ScheduleDayStruct).parse(data)) @dataclass class Schedule: - entries: dict[WeekDay, list[ScheduleEntry]] = {} + days: list[ScheduleDay] = field(default_factory=list) - def add_struct(self, struct: ScheduleStruct) -> None: - if struct.day in self.entries: - self.entries[struct.day] = [] + def merge(self, other_schedule: Self) -> None: + for schedule_day in other_schedule.days: + self.days[ + schedule_day.week_day + ].schedule_hours = schedule_day.schedule_hours - self.entries[struct.day] = [] - - for entry in struct.hours: - self.entries[struct.day].append(ScheduleEntry.from_struct(entry)) - - def add_bytes(self, data: bytearray | bytes) -> None: - self.add_struct(DataclassStruct(ScheduleStruct).parse(data)) + @classmethod + def from_bytes(cls, data: bytes) -> Self: + return cls(days=[ScheduleDay.from_bytes(data)]) diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py index a5e6fde..26645a8 100644 --- a/eq3btsmart/structures.py +++ b/eq3btsmart/structures.py @@ -1,12 +1,14 @@ """Structures for the eQ-3 Bluetooth Smart Thermostat.""" -from datetime import datetime, time, timedelta +from dataclasses import dataclass -from attr import dataclass from construct import ( Adapter, Bytes, Const, + Flag, + GreedyBytes, GreedyRange, + If, IfThenElse, Int8ub, Optional, @@ -14,95 +16,76 @@ from construct_typed import DataclassMixin, DataclassStruct, TEnum, TFlagsEnum, csfield from eq3btsmart.const import ( - HOUR_24_PLACEHOLDER, - PROP_ID_RETURN, - PROP_INFO_RETURN, - DeviceModeFlags, - ScheduleCommand, + Command, + StatusFlags, WeekDay, ) +from eq3btsmart.eq3_away_time import Eq3AwayTime +from eq3btsmart.eq3_duration import Eq3Duration +from eq3btsmart.eq3_schedule_time import Eq3ScheduleTime +from eq3btsmart.eq3_temperature import Eq3Temperature +from eq3btsmart.eq3_temperature_offset import Eq3TemperatureOffset +from eq3btsmart.eq3_time import Eq3Time -class TimeAdapter(Adapter): - """Adapter to encode and decode schedule times.""" +class Eq3TimeAdapter(Adapter): + """Adapter to encode and decode time data.""" - def _decode(self, obj, ctx, path): - h, m = divmod(obj * 10, 60) - if h == 24: # HACK, can we do better? - return HOUR_24_PLACEHOLDER - return time(hour=h, minute=m) + def _decode(self, obj: bytes, ctx, path) -> Eq3Time: + return Eq3Time.from_device(obj) - def _encode(self, obj, ctx, path): - # TODO: encode h == 24 hack - if obj == HOUR_24_PLACEHOLDER: - return int(24 * 60 / 10) - encoded = int((obj.hour * 60 + obj.minute) / 10) - return encoded + def _encode(self, obj: Eq3Time, ctx, path) -> bytes: + return obj -class TempAdapter(Adapter): - """Adapter to encode and decode temperature.""" +class Eq3ScheduleTimeAdapter(Adapter): + """Adapter to encode and decode schedule time data.""" - def _decode(self, obj, ctx, path): - return float(obj / 2.0) + def _decode(self, obj: int, ctx, path) -> Eq3ScheduleTime: + return Eq3ScheduleTime.from_device(obj) - def _encode(self, obj, ctx, path): - return int(obj * 2.0) + def _encode(self, obj: Eq3ScheduleTime, ctx, path) -> int: + return obj -class WindowOpenTimeAdapter(Adapter): - """Adapter to encode and decode window open times (5 min increments).""" +class Eq3TemperatureAdapter(Adapter): + """Adapter to encode and decode temperature data.""" - def _decode(self, obj, context, path): - return timedelta(minutes=float(obj * 5.0)) - - def _encode(self, obj, context, path): - if isinstance(obj, timedelta): - obj = obj.seconds - if 0 <= obj <= 3600.0: - return int(obj / 300.0) - raise ValueError( - "Window open time must be between 0 and 60 minutes " - "in intervals of 5 minutes." - ) + def _decode(self, obj: int, ctx, path) -> Eq3Temperature: + return Eq3Temperature.from_device(obj) + def _encode(self, obj: Eq3Temperature, ctx, path) -> int: + return obj -class TempOffsetAdapter(Adapter): - """Adapter to encode and decode the temperature offset.""" - def _decode(self, obj, context, path): - return float((obj - 7) / 2.0) +class Eq3TemperatureOffsetAdapter(Adapter): + """Adapter to encode and decode temperature offset data.""" - def _encode(self, obj, context, path): - if -3.5 <= obj <= 3.5: - return int(obj * 2.0) + 7 - raise ValueError( - "Temperature offset must be between -3.5 and 3.5 (in " "intervals of 0.5)." - ) + def _decode(self, obj: int, ctx, path) -> Eq3TemperatureOffset: + return Eq3TemperatureOffset.from_device(obj) + + def _encode(self, obj: Eq3TemperatureOffset, ctx, path) -> int: + return obj -class AwayDataAdapter(Adapter): - """Adapter to encode and decode away data.""" +class Eq3DurationAdapter(Adapter): + """Adapter to encode and decode duration data.""" - def _decode(self, obj, ctx, path): - (day, year, hour_min, month) = obj - year += 2000 + def _decode(self, obj: int, ctx, path) -> Eq3Duration: + return Eq3Duration.from_device(obj) - min = 0 - if hour_min & 0x01: - min = 30 - hour = int(hour_min / 2) + def _encode(self, obj: Eq3Duration, ctx, path) -> int: + return obj - return datetime(year=year, month=month, day=day, hour=hour, minute=min) - def _encode(self, obj, ctx, path): - if obj.year < 2000 or obj.year > 2099: - raise Exception("Invalid year, possible [2000,2099]") - year = obj.year - 2000 - hour = obj.hour * 2 - if obj.minute: # we encode all minute values to h:30 - hour |= 0x01 - return (obj.day, year, hour, obj.month) +class Eq3AwayTimeAdapter(Adapter): + """Adapter to encode and decode away time data.""" + + def _decode(self, obj: bytes, ctx, path) -> Eq3AwayTime: + return Eq3AwayTime.from_device(obj) + + def _encode(self, obj: Eq3AwayTime, ctx, path) -> bytes: + return obj class DeviceSerialAdapter(Adapter): @@ -116,27 +99,27 @@ def _decode(self, obj, context, path): class PresetsStruct(DataclassMixin): """Structure for presets data.""" - window_open_temp: float = csfield(TempAdapter(Int8ub)) - window_open_time: timedelta = csfield(WindowOpenTimeAdapter(Int8ub)) - comfort_temp: float = csfield(TempAdapter(Int8ub)) - eco_temp: float = csfield(TempAdapter(Int8ub)) - offset: float = csfield(TempOffsetAdapter(Int8ub)) + window_open_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + window_open_time: Eq3Duration = csfield(Eq3DurationAdapter(Int8ub)) + comfort_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + eco_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + offset: Eq3TemperatureOffset = csfield(Eq3TemperatureOffsetAdapter(Int8ub)) @dataclass class StatusStruct(DataclassMixin): """Structure for status data.""" - cmd: int = csfield(Const(PROP_INFO_RETURN, Int8ub)) + cmd: int = csfield(Const(Command.INFO_RETURN, Int8ub)) const_1: int = csfield(Const(0x01, Int8ub)) - mode: DeviceModeFlags = csfield(TFlagsEnum(Int8ub, DeviceModeFlags)) + mode: StatusFlags = csfield(TFlagsEnum(Int8ub, StatusFlags)) valve: int = csfield(Int8ub) const_2: int = csfield(Const(0x04, Int8ub)) - target_temp: float = csfield(TempAdapter(Int8ub)) - away: datetime | None = csfield( + target_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + away: Eq3AwayTime | None = csfield( IfThenElse( - lambda ctx: ctx.mode & DeviceModeFlags.AWAY, - AwayDataAdapter(Bytes(4)), + lambda ctx: ctx.mode & StatusFlags.AWAY, + Eq3AwayTimeAdapter(Bytes(4)), Optional(Bytes(4)), ) ) @@ -144,21 +127,20 @@ class StatusStruct(DataclassMixin): @dataclass -class ScheduleEntryStruct(DataclassMixin): +class ScheduleHourStruct(DataclassMixin): """Structure for schedule entry data.""" - target_temp: float = csfield(TempAdapter(Int8ub)) - next_change_at: time = csfield(TimeAdapter(Int8ub)) + target_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + next_change_at: Eq3ScheduleTime = csfield(Eq3ScheduleTimeAdapter(Int8ub)) @dataclass -class ScheduleStruct(DataclassMixin): +class ScheduleDayStruct(DataclassMixin): """Structure for schedule data.""" - cmd: ScheduleCommand = csfield(TEnum(Int8ub, ScheduleCommand)) day: WeekDay = csfield(TEnum(Int8ub, WeekDay)) - hours: list[ScheduleEntryStruct] = csfield( - GreedyRange(DataclassStruct(ScheduleEntryStruct)) + hours: list[ScheduleHourStruct] = csfield( + GreedyRange(DataclassStruct(ScheduleHourStruct)) ) @@ -166,9 +148,134 @@ class ScheduleStruct(DataclassMixin): class DeviceIdStruct(DataclassMixin): """Structure for device data.""" - cmd: int = csfield(Const(PROP_ID_RETURN, Int8ub)) + cmd: int = csfield(Const(Command.ID_RETURN, Int8ub)) version: int = csfield(Int8ub) unknown_1: int = csfield(Int8ub) unknown_2: int = csfield(Int8ub) serial: str = csfield(DeviceSerialAdapter(Bytes(10))) unknown_3: int = csfield(Int8ub) + + +@dataclass +class Eq3Command(DataclassMixin): + """Structure for eQ-3 commands.""" + + cmd: int = csfield(Int8ub) + payload: bytes | None = csfield(Optional(GreedyBytes)) + + def to_bytes(self) -> bytes: + """Convert the command to bytes.""" + + return DataclassStruct(self.__class__).build(self) + + +@dataclass +class IdGetCommand(Eq3Command): + """Structure for ID get command.""" + + cmd: int = csfield(Const(Command.ID_GET, Int8ub)) + + +@dataclass +class InfoGetCommand(Eq3Command): + """Structure for info get command.""" + + cmd: int = csfield(Const(Command.INFO_GET, Int8ub)) + time: Eq3Time = csfield(Eq3TimeAdapter(Bytes(6))) + + +@dataclass +class ComfortEcoConfigureCommand(Eq3Command): + """Structure for schedule get command.""" + + cmd: int = csfield(Const(Command.COMFORT_ECO_CONFIGURE, Int8ub)) + comfort_temperature: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + eco_temperature: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + + +@dataclass +class OffsetConfigureCommand(Eq3Command): + """Structure for offset configure command.""" + + cmd: int = csfield(Const(Command.OFFSET_CONFIGURE, Int8ub)) + offset: Eq3TemperatureOffset = csfield(Eq3TemperatureOffsetAdapter(Int8ub)) + + +@dataclass +class WindowOpenConfigureCommand(Eq3Command): + """Structure for window open configure command.""" + + cmd: int = csfield(Const(Command.WINDOW_OPEN_CONFIGURE, Int8ub)) + window_open_temperature: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + window_open_time: Eq3Duration = csfield(Eq3DurationAdapter(Int8ub)) + + +@dataclass +class ScheduleGetCommand(Eq3Command): + """Structure for schedule get command.""" + + cmd: int = csfield(Const(Command.SCHEDULE_GET, Int8ub)) + day: WeekDay = csfield(TEnum(Int8ub, WeekDay)) + + +@dataclass +class ModeSetCommand(Eq3Command): + """Structure for mode set command.""" + + cmd: int = csfield(Const(Command.MODE_SET, Int8ub)) + mode: int = csfield(Int8ub) + away_data: Eq3AwayTime | None = csfield( + If( + lambda ctx: ctx.mode & StatusFlags.AWAY, + Eq3AwayTimeAdapter(Bytes(4)), + ) + ) + + +@dataclass +class TemperatureSetCommand(Eq3Command): + """Structure for temperature set command.""" + + cmd: int = csfield(Const(Command.TEMPERATURE_SET, Int8ub)) + temperature: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + + +@dataclass +class ScheduleSetCommand(Eq3Command): + """Structure for schedule set command.""" + + cmd: int = csfield(Const(Command.SCHEDULE_SET, Int8ub)) + day: WeekDay = csfield(TEnum(Int8ub, WeekDay)) + hours: list[ScheduleHourStruct] = csfield( + GreedyRange(DataclassStruct(ScheduleHourStruct)) + ) + + +@dataclass +class ComfortSetCommand(Eq3Command): + """Structure for comfort set command.""" + + cmd: int = csfield(Const(Command.COMFORT_SET, Int8ub)) + + +@dataclass +class EcoSetCommand(Eq3Command): + """Structure for eco set command.""" + + cmd: int = csfield(Const(Command.ECO_SET, Int8ub)) + + +@dataclass +class BoostSetCommand(Eq3Command): + """Structure for boost set command.""" + + cmd: int = csfield(Const(Command.BOOST_SET, Int8ub)) + enable: bool = csfield(Flag) + + +@dataclass +class LockSetCommand(Eq3Command): + """Structure for lock set command.""" + + cmd: int = csfield(Const(Command.LOCK_SET, Int8ub)) + enable: bool = csfield(Flag) diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index 3ff069b..0597983 100644 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -7,51 +7,48 @@ Schedule needs to be requested with query_schedule() before accessing for similar reasons. """ -import codecs import logging -import struct from datetime import datetime, timedelta from typing import Callable -from construct import Byte +from bleak.backends.device import BLEDevice from construct_typed import DataclassStruct -from homeassistant.core import HomeAssistant from eq3btsmart.bleakconnection import BleakConnection from eq3btsmart.const import ( DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP, - EQ3BT_MAX_OFFSET, EQ3BT_MAX_TEMP, - EQ3BT_MIN_OFFSET, EQ3BT_MIN_TEMP, EQ3BT_OFF_TEMP, EQ3BT_ON_TEMP, - PROP_BOOST, - PROP_COMFORT, - PROP_COMFORT_ECO_CONFIG, - PROP_ECO, - PROP_ID_QUERY, - PROP_ID_RETURN, - PROP_INFO_QUERY, - PROP_INFO_RETURN, - PROP_LOCK, - PROP_MODE_WRITE, - PROP_OFFSET, - PROP_SCHEDULE_QUERY, - PROP_SCHEDULE_RETURN, - PROP_TEMPERATURE_WRITE, - PROP_WINDOW_OPEN_CONFIG, + Command, OperationMode, - ScheduleCommand, + Preset, WeekDay, ) -from eq3btsmart.exceptions import TemperatureException +from eq3btsmart.eq3_away_time import Eq3AwayTime +from eq3btsmart.eq3_duration import Eq3Duration +from eq3btsmart.eq3_temperature import Eq3Temperature +from eq3btsmart.eq3_temperature_offset import Eq3TemperatureOffset +from eq3btsmart.eq3_time import Eq3Time from eq3btsmart.models import DeviceData, Schedule, Status from eq3btsmart.structures import ( - AwayDataAdapter, - ScheduleEntryStruct, - ScheduleStruct, + BoostSetCommand, + ComfortEcoConfigureCommand, + ComfortSetCommand, + EcoSetCommand, + Eq3Command, + IdGetCommand, + InfoGetCommand, + LockSetCommand, + ModeSetCommand, + OffsetConfigureCommand, + ScheduleGetCommand, + ScheduleHourStruct, + ScheduleSetCommand, + TemperatureSetCommand, + WindowOpenConfigureCommand, ) from eq3btsmart.thermostat_config import ThermostatConfig @@ -64,7 +61,7 @@ class Thermostat: def __init__( self, thermostat_config: ThermostatConfig, - hass: HomeAssistant, + device: BLEDevice, ): """Initialize the thermostat.""" @@ -75,7 +72,7 @@ def __init__( self._on_update_callbacks: list[Callable] = [] self._conn = BleakConnection( thermostat_config=self.thermostat_config, - hass=hass, + device=device, callback=self.handle_notification, ) @@ -87,265 +84,214 @@ def register_update_callback(self, on_update: Callable) -> None: def shutdown(self) -> None: """Shutdown the connection to the thermostat.""" - self._conn.shutdown() + self._conn.disconnect() - def _verify_temperature(self, temp: float) -> None: - """ - Verifies that the temperature is valid. - :raises TemperatureException: On invalid temperature. - """ + async def async_get_id(self) -> None: + """Query device identification information, e.g. the serial number.""" - if temp < EQ3BT_MIN_TEMP or temp > EQ3BT_MAX_TEMP: - raise TemperatureException( - f"Temperature {temp} out of range [{EQ3BT_MIN_TEMP}, {EQ3BT_MAX_TEMP}]" - ) + await self._async_write_command(IdGetCommand()) - def handle_notification(self, data: bytearray) -> None: - """Handle Callback from a Bluetooth (GATT) request.""" + async def async_get_info(self) -> None: + """Query the thermostat status.""" - _LOGGER.debug( - f"[{self.thermostat_config.name}] Received notification from the device.", - ) + eq3_time = Eq3Time(datetime.now()) + await self._async_write_command(InfoGetCommand(time=eq3_time)) - updated: bool = True + async def async_get_schedule(self) -> None: + """Query the schedule.""" - if data[0] == PROP_INFO_RETURN and data[1] == 1: - _LOGGER.debug( - f"[{self.thermostat_config.name}] Got status: {codecs.encode(data, 'hex')!r}", - ) + for week_day in WeekDay: + await self._async_write_command(ScheduleGetCommand(day=week_day)) - self.status = Status.from_bytes(data) - - _LOGGER.debug( - f"[{self.thermostat_config.name}] Parsed status: {self.status}", - ) + async def async_configure_window_open( + self, temperature: float, duration: timedelta + ) -> None: + """Configures the window open behavior. The duration is specified in 5 minute increments.""" - elif data[0] == PROP_SCHEDULE_RETURN: - self.schedule.add_bytes(data) + eq3_temperature = Eq3Temperature(temperature) + eq3_duration = Eq3Duration(duration) - elif data[0] == PROP_ID_RETURN: - self.device_data = DeviceData.from_bytearray(data) - _LOGGER.debug( - f"[{self.thermostat_config.name}] Parsed device data: {self.device_data}", + await self._async_write_command( + WindowOpenConfigureCommand( + window_open_temperature=eq3_temperature, + window_open_time=eq3_duration, ) + ) - else: - updated = False - - _LOGGER.debug( - f"[{self.thermostat_config.name}] Unknown notification {data[0]} ({codecs.encode(data, 'hex')!r})", - ) + async def async_configure_presets( + self, + comfort_temperature: float | None = None, + eco_temperature: float | None = None, + ) -> None: + """Set the thermostats preset temperatures comfort (sun) and eco (moon).""" - if updated: - for callback in self._on_update_callbacks: - callback() + if self.status is None: + raise Exception("Status not set") - async def async_query_id(self) -> None: - """Query device identification information, e.g. the serial number.""" + if comfort_temperature is None and self.status.comfort_temperature is not None: + comfort_temperature = self.status.comfort_temperature.friendly_value - _LOGGER.debug(f"[{self.thermostat_config.name}] Querying id..") + if eco_temperature is None and self.status.eco_temperature is not None: + eco_temperature = self.status.eco_temperature.friendly_value - value = struct.pack("B", PROP_ID_QUERY) - await self._conn.async_make_request(value) + if comfort_temperature is None or eco_temperature is None: + raise Exception("Comfort or eco temperature not set") - _LOGGER.debug(f"[{self.thermostat_config.name}] Finished Querying id..") + eq3_comfort_temperature = Eq3Temperature(comfort_temperature) + eq3_eco_temperature = Eq3Temperature(eco_temperature) - async def async_update(self) -> None: - """Update the data from the thermostat. Always sets the current time.""" + await self._async_write_command( + ComfortEcoConfigureCommand( + comfort_temperature=eq3_comfort_temperature, + eco_temperature=eq3_eco_temperature, + ) + ) - _LOGGER.debug(f"[{self.thermostat_config.name}] Querying the device..") + async def async_temperature_offset_configure( + self, temperature_offset: float + ) -> None: + """Sets the thermostat's temperature offset.""" - time = datetime.now() - value = struct.pack( - "BBBBBBB", - PROP_INFO_QUERY, - time.year % 100, - time.month, - time.day, - time.hour, - time.minute, - time.second, + eq3_temperature_offset = Eq3TemperatureOffset(temperature_offset) + await self._async_write_command( + OffsetConfigureCommand(offset=eq3_temperature_offset) ) - await self._conn.async_make_request(value) + async def async_set_mode(self, operation_mode: OperationMode) -> None: + """Set new operation mode.""" - async def async_query_schedule(self, day: int) -> None: - """Query the schedule for the given day.""" + if self.status is None: + raise Exception("Status not set") - _LOGGER.debug(f"[{self.thermostat_config.name}] Querying schedule..") + command: ModeSetCommand - if day < 0 or day > 6: - raise ValueError(f"Invalid day: {day}") + match operation_mode: + case OperationMode.AUTO: + command = ModeSetCommand(mode=OperationMode.AUTO) + case OperationMode.MANUAL: + temperature = max( + min(self.status.target_temperature, Eq3Temperature(EQ3BT_MAX_TEMP)), + Eq3Temperature(EQ3BT_MIN_TEMP), + ) + command = ModeSetCommand(mode=OperationMode.MANUAL | temperature) + case OperationMode.OFF: + off_temperature = Eq3Temperature(EQ3BT_OFF_TEMP) + command = ModeSetCommand(mode=OperationMode.MANUAL | off_temperature) + case OperationMode.ON: + on_temperature = Eq3Temperature(EQ3BT_ON_TEMP) + command = ModeSetCommand(mode=OperationMode.MANUAL | on_temperature) - value = struct.pack("BB", PROP_SCHEDULE_QUERY, day) - await self._conn.async_make_request(value) + await self._async_write_command(command) - async def async_set_schedule( - self, day: WeekDay, hours: list[ScheduleEntryStruct] + async def async_set_away( + self, + enable: bool, + away_until: datetime | None = None, + temperature: float | None = None, ) -> None: - """Sets the schedule for the given day.""" + if not enable: + return await self.async_set_mode(OperationMode.AUTO) - _LOGGER.debug( - f"[{self.thermostat_config.name}] Setting schedule day=[{day}], hours=[{hours}]", - ) + if away_until is None: + away_until = datetime.now() + timedelta(hours=DEFAULT_AWAY_HOURS) - data = DataclassStruct(ScheduleStruct).build( - ScheduleStruct( - cmd=ScheduleCommand.WRITE, - day=day, - hours=hours, - ) - ) - await self._conn.async_make_request(data) + if temperature is None: + temperature = DEFAULT_AWAY_TEMP - self.schedule.add_bytes(data) + eq3_away_until = Eq3AwayTime(away_until) + eq3_temperature = Eq3Temperature(temperature) - for callback in self._on_update_callbacks: - callback() + await self._async_write_command( + ModeSetCommand( + mode=OperationMode.AWAY | eq3_temperature, + away_data=eq3_away_until, + ) + ) - async def async_set_target_temperature(self, temperature: float | None) -> None: + async def async_set_temperature(self, temperature: float) -> None: """Set new target temperature.""" - if temperature is None: - return + if temperature == EQ3BT_OFF_TEMP: + return await self.async_set_mode(OperationMode.OFF) - temperature_int = int(temperature * 2) - if temperature == EQ3BT_OFF_TEMP or temperature == EQ3BT_ON_TEMP: - temperature_int |= 0x40 - value = struct.pack("BB", PROP_MODE_WRITE, temperature_int) - else: - self._verify_temperature(temperature) - value = struct.pack("BB", PROP_TEMPERATURE_WRITE, temperature_int) + if temperature == EQ3BT_ON_TEMP: + return await self.async_set_mode(OperationMode.ON) - await self._conn.async_make_request(value) + eq3_temperature = Eq3Temperature(temperature) + await self._async_write_command( + TemperatureSetCommand(temperature=eq3_temperature) + ) - async def async_set_mode(self, operation_mode: OperationMode) -> None: - """Set the operation mode.""" + async def async_set_preset(self, preset: Preset): + """Sets the thermostat to the given preset.""" - if self.status is None: - raise Exception("Status not set") + command: ComfortSetCommand | EcoSetCommand - _LOGGER.debug( - f"[{self.thermostat_config.name}] Setting new mode: {operation_mode}" - ) + match preset: + case Preset.COMFORT: + command = ComfortSetCommand() + case Preset.ECO: + command = EcoSetCommand() - match operation_mode: - case OperationMode.OFF: - await self.async_set_target_temperature(EQ3BT_OFF_TEMP) - case OperationMode.ON: - await self.async_set_target_temperature(EQ3BT_ON_TEMP) - case OperationMode.AUTO: - await self._async_set_mode(0) - case OperationMode.MANUAL: - temperature = max( - min(self.status.target_temperature, EQ3BT_MAX_TEMP), EQ3BT_MIN_TEMP - ) - await self._async_set_mode(0x40 | int(temperature * 2)) + await self._async_write_command(command) - async def async_set_away_until( - self, away_end: datetime, temperature: float - ) -> None: - """Sets away mode with default temperature.""" + async def async_set_boost(self, enable: bool) -> None: + """Sets boost mode.""" - # rounding - away_end = away_end + timedelta(minutes=15) - away_end = away_end - timedelta(minutes=away_end.minute % 30) + await self._async_write_command(BoostSetCommand(enable=enable)) - _LOGGER.debug( - f"[{self.thermostat_config.name}] Setting away until {away_end}, temp {temperature}", - ) - adapter = AwayDataAdapter(Byte[4]) - packed = adapter.build(away_end) + async def async_set_locked(self, enable: bool) -> None: + """Locks or unlocks the thermostat.""" + + await self._async_write_command(LockSetCommand(enable=enable)) - await self._async_set_mode(0x80 | int(temperature * 2), packed) + async def async_set_schedule(self, schedule: Schedule) -> None: + """Sets the schedule for the given day.""" - async def async_set_away(self, away: bool) -> None: - """Sets away mode with default temperature.""" - if not away: - _LOGGER.debug( - f"[{self.thermostat_config.name}] Disabling away, going to auto mode." + for schedule_day in schedule.days: + command = ScheduleSetCommand( + day=schedule_day.week_day, + hours=[ + ScheduleHourStruct( + target_temp=schedule_hour.target_temperature, + next_change_at=schedule_hour.next_change_at, + ) + for schedule_hour in schedule_day.schedule_hours + ], ) - return await self._async_set_mode(0x00) - away_end = datetime.now() + timedelta(hours=DEFAULT_AWAY_HOURS) + await self._async_write_command(command) - await self.async_set_away_until(away_end, DEFAULT_AWAY_TEMP) + self.schedule.merge(schedule) - async def _async_set_mode(self, mode: int, payload: bytes | None = None) -> None: - value = struct.pack("BB", PROP_MODE_WRITE, mode) - if payload: - value += payload - await self._conn.async_make_request(value) + for callback in self._on_update_callbacks: + callback() - async def async_set_boost(self, boost: bool) -> None: - """Sets boost mode.""" + async def _async_write_command(self, command: Eq3Command) -> None: + """Write a EQ3 command to the thermostat.""" - _LOGGER.debug(f"[{self.thermostat_config.name}] Setting boost mode: {boost}") - value = struct.pack("BB", PROP_BOOST, boost) - await self._conn.async_make_request(value) + await self._conn.async_make_request(command.to_bytes()) - async def async_window_open_config( - self, temperature: float, duration: timedelta - ) -> None: - """Configures the window open behavior. The duration is specified in - 5 minute increments.""" - _LOGGER.debug( - f"[{self.thermostat_config.name}] Window open config, temperature: {temperature} duration: {duration}", - ) - self._verify_temperature(temperature) - if duration.seconds < 0 and duration.seconds > 3600: - raise ValueError - - value = struct.pack( - "BBB", - PROP_WINDOW_OPEN_CONFIG, - int(temperature * 2), - int(duration.seconds / 300), - ) - await self._conn.async_make_request(value) + def handle_notification(self, data: bytes) -> None: + """Handle Callback from a Bluetooth (GATT) request.""" - async def async_set_locked(self, lock: bool) -> None: - """Locks or unlocks the thermostat.""" - _LOGGER.debug(f"[{self.thermostat_config.name}] Setting the lock: {lock}") - value = struct.pack("BB", PROP_LOCK, lock) - await self._conn.async_make_request(value) - - async def async_temperature_presets(self, comfort: float, eco: float) -> None: - """Set the thermostats preset temperatures comfort (sun) and - eco (moon).""" - _LOGGER.debug( - f"[{self.thermostat_config.name}] Setting temperature presets, comfort: {comfort} eco: {eco}", - ) - self._verify_temperature(comfort) - self._verify_temperature(eco) - value = struct.pack( - "BBB", PROP_COMFORT_ECO_CONFIG, int(comfort * 2), int(eco * 2) - ) - await self._conn.async_make_request(value) + updated: bool = True - async def async_set_temperature_offset(self, offset: float) -> None: - """Sets the thermostat's temperature offset.""" - _LOGGER.debug(f"[{self.thermostat_config.name}] Setting offset: {offset}") - # [-3,5 .. 0 .. 3,5 ] - # [00 .. 07 .. 0e ] - if offset < EQ3BT_MIN_OFFSET or offset > EQ3BT_MAX_OFFSET: - raise TemperatureException(f"Invalid value: {offset}") - - current = -3.5 - values = {} - for i in range(15): - values[current] = i - current += 0.5 - - value = struct.pack("BB", PROP_OFFSET, values[offset]) - await self._conn.async_make_request(value) - - async def async_activate_comfort(self) -> None: - """Activates the comfort temperature.""" - value = struct.pack("B", PROP_COMFORT) - await self._conn.async_make_request(value) - - async def async_activate_eco(self) -> None: - """Activates the comfort temperature.""" - value = struct.pack("B", PROP_ECO) - await self._conn.async_make_request(value) + command = DataclassStruct(Eq3Command).parse(data) + + match command.cmd: + case Command.ID_RETURN: + self.device_data = DeviceData.from_bytes(data) + case Command.INFO_RETURN: + self.status = Status.from_bytes(data) + case Command.SCHEDULE_RETURN: + schedule = Schedule.from_bytes(data) + self.schedule.merge(schedule) + case _: + updated = False + + if not updated: + return + + for callback in self._on_update_callbacks: + callback() diff --git a/poetry.lock b/poetry.lock index 4b5ab08..998ee33 100644 --- a/poetry.lock +++ b/poetry.lock @@ -593,6 +593,17 @@ files = [ {file = "ciso8601-2.3.0.tar.gz", hash = "sha256:19e3fbd786d8bec3358eac94d8774d365b694b604fd1789244b87083f66c8900"}, ] +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + [[package]] name = "construct" version = "2.10.68" @@ -981,6 +992,17 @@ files = [ {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + [[package]] name = "jinja2" version = "3.1.2" @@ -1418,6 +1440,21 @@ files = [ docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +[[package]] +name = "pluggy" +version = "1.3.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + [[package]] name = "pre-commit" version = "3.6.0" @@ -1556,6 +1593,40 @@ cryptography = ">=38.0.0,<40.0.0 || >40.0.0,<40.0.1 || >40.0.1,<42" docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] test = ["flaky", "pretend", "pytest (>=3.0.1)"] +[[package]] +name = "pyserial" +version = "3.5" +description = "Python Serial Port Extension" +optional = false +python-versions = "*" +files = [ + {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, + {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, +] + +[package.extras] +cp2110 = ["hidapi"] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-slugify" version = "4.0.1" @@ -2211,4 +2282,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.13" -content-hash = "9f47cf0a3a730ce4834c67c0963a06b48eed3ac0ec1bbb9b95f945bb4e6cf09c" +content-hash = "3c9b47a0e4c0d20178d3c59e8e60a07b58c2ba2273349df58b7f728faff19c31" diff --git a/pyproject.toml b/pyproject.toml index 82516f2..753a38e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ construct = "^2.10.68" construct-typing = "^0.6.2" homeassistant = "^2023.1.1" bleak-retry-connector = "^3.4.0" +pyserial = "^3.5" [tool.poetry.group.dev.dependencies] ruff = "^0.1.13" @@ -24,6 +25,10 @@ pre-commit = "^3.6.0" homeassistant-stubs = "^2023.1.1" voluptuous-stubs = "^0.1.1" + +[tool.poetry.group.test.dependencies] +pytest = "^7.4.4" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" @@ -34,3 +39,8 @@ unfixable = ["F401"] [tool.mypy] check_untyped_defs = true explicit_package_bases = true + +[tool.pytest] +testpaths = [ + "tests", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_eq3_temperature.py b/tests/test_eq3_temperature.py new file mode 100644 index 0000000..37ddbd6 --- /dev/null +++ b/tests/test_eq3_temperature.py @@ -0,0 +1,8 @@ +from eq3btsmart.eq3_temperature import Eq3Temperature + + +def test_value(): + value_original = 21.5 + value = Eq3Temperature(value_original) + + assert value == 43 diff --git a/tests/test_eq3_temperature_offset.py b/tests/test_eq3_temperature_offset.py new file mode 100644 index 0000000..8772dc9 --- /dev/null +++ b/tests/test_eq3_temperature_offset.py @@ -0,0 +1,8 @@ +from eq3btsmart.eq3_temperature_offset import Eq3TemperatureOffset + + +def test_offset_index(): + value_original = 1.5 + value = Eq3TemperatureOffset(value_original) + + assert value == 10 From 15724a6f705837ee35e54c78b09fba05c33f9fae Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 14:05:58 +0100 Subject: [PATCH 05/20] feat: implement device connection logic in integration --- .pre-commit-config.yaml | 1 - custom_components/eq3btsmart/__init__.py | 45 +++++++++- custom_components/eq3btsmart/binary_sensor.py | 6 +- custom_components/eq3btsmart/button.py | 31 ++++--- custom_components/eq3btsmart/climate.py | 88 ++++++++++++------- custom_components/eq3btsmart/const.py | 19 ++-- custom_components/eq3btsmart/lock.py | 2 +- custom_components/eq3btsmart/manifest.json | 2 +- custom_components/eq3btsmart/models.py | 3 + custom_components/eq3btsmart/number.py | 66 +++++++++----- custom_components/eq3btsmart/sensor.py | 16 ++-- custom_components/eq3btsmart/switch.py | 8 +- eq3btsmart/__init__.py | 0 eq3btsmart/bleakconnection.py | 10 ++- eq3btsmart/const.py | 2 +- eq3btsmart/eq3_away_time.py | 0 eq3btsmart/eq3_duration.py | 0 eq3btsmart/eq3_schedule_time.py | 0 eq3btsmart/eq3_temperature.py | 0 eq3btsmart/eq3_temperature_offset.py | 0 eq3btsmart/eq3_time.py | 0 eq3btsmart/exceptions.py | 0 eq3btsmart/models.py | 46 +++++----- eq3btsmart/structures.py | 0 eq3btsmart/thermostat.py | 16 ++-- eq3btsmart/thermostat_config.py | 0 26 files changed, 229 insertions(+), 132 deletions(-) mode change 100644 => 100755 eq3btsmart/__init__.py mode change 100644 => 100755 eq3btsmart/bleakconnection.py mode change 100644 => 100755 eq3btsmart/const.py mode change 100644 => 100755 eq3btsmart/eq3_away_time.py mode change 100644 => 100755 eq3btsmart/eq3_duration.py mode change 100644 => 100755 eq3btsmart/eq3_schedule_time.py mode change 100644 => 100755 eq3btsmart/eq3_temperature.py mode change 100644 => 100755 eq3btsmart/eq3_temperature_offset.py mode change 100644 => 100755 eq3btsmart/eq3_time.py mode change 100644 => 100755 eq3btsmart/exceptions.py mode change 100644 => 100755 eq3btsmart/models.py mode change 100644 => 100755 eq3btsmart/structures.py mode change 100644 => 100755 eq3btsmart/thermostat.py mode change 100644 => 100755 eq3btsmart/thermostat_config.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a7d46a7..7556868 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,6 @@ repos: - id: trailing-whitespace - id: check-added-large-files - id: check-case-conflict - - id: check-executables-have-shebangs - id: check-json - id: check-merge-conflict - id: check-symlinks diff --git a/custom_components/eq3btsmart/__init__.py b/custom_components/eq3btsmart/__init__.py index ead0dfb..808edaf 100644 --- a/custom_components/eq3btsmart/__init__.py +++ b/custom_components/eq3btsmart/__init__.py @@ -2,8 +2,11 @@ from typing import Any +from bleak.backends.device import BLEDevice +from bleak_retry_connector import NO_RSSI_VALUE from eq3btsmart import Thermostat from eq3btsmart.thermostat_config import ThermostatConfig +from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant @@ -74,9 +77,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: adapter=adapter, stay_connected=stay_connected, ) + + device = await async_get_device(hass, eq3_config) + thermostat = Thermostat( thermostat_config=thermostat_config, - hass=hass, + device=device, ) eq3_config_entry = Eq3ConfigEntry(eq3_config=eq3_config, thermostat=thermostat) @@ -106,3 +112,40 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Called when an entry is updated.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_get_device(hass: HomeAssistant, config: Eq3Config) -> BLEDevice: + device: BLEDevice | None + + if config.adapter == Adapter.AUTO: + device = bluetooth.async_ble_device_from_address( + hass, config.mac_address, connectable=True + ) + if device is None: + raise Exception("Device not found") + else: + device_advertisement_datas = sorted( + bluetooth.async_scanner_devices_by_address( + hass=hass, address=config.mac_address, connectable=True + ), + key=lambda device_advertisement_data: device_advertisement_data.advertisement.rssi + or NO_RSSI_VALUE, + reverse=True, + ) + if config.adapter == Adapter.LOCAL: + if len(device_advertisement_datas) == 0: + raise Exception("Device not found") + d_and_a = device_advertisement_datas[0] + else: # adapter is e.g /org/bluez/hci0 + list = [ + x + for x in device_advertisement_datas + if (d := x.ble_device.details) + and d.get("props", {}).get("Adapter") == config.adapter + ] + if len(list) == 0: + raise Exception("Device not found") + d_and_a = list[0] + device = d_and_a.ble_device + + return device diff --git a/custom_components/eq3btsmart/binary_sensor.py b/custom_components/eq3btsmart/binary_sensor.py index 40d2824..5324a96 100644 --- a/custom_components/eq3btsmart/binary_sensor.py +++ b/custom_components/eq3btsmart/binary_sensor.py @@ -135,7 +135,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def is_on(self) -> bool | None: - return self._thermostat.low_battery + return self._thermostat.status.is_low_battery class WindowOpenSensor(Base): @@ -150,7 +150,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def is_on(self) -> bool | None: - return self._thermostat.window_open + return self._thermostat.status.is_window_open class DSTSensor(Base): @@ -165,4 +165,4 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def is_on(self) -> bool | None: - return self._thermostat.dst + return self._thermostat.status.is_dst diff --git a/custom_components/eq3btsmart/button.py b/custom_components/eq3btsmart/button.py index 6dc52f9..bc5abce 100644 --- a/custom_components/eq3btsmart/button.py +++ b/custom_components/eq3btsmart/button.py @@ -2,11 +2,12 @@ import datetime import logging +from typing import Any from custom_components.eq3btsmart.eq3_entity import Eq3Entity from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat -from eq3btsmart.const import HOUR_24_PLACEHOLDER, WeekDay +from eq3btsmart.const import WeekDay from eq3btsmart.models import Schedule, ScheduleDay, ScheduleHour from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry, UndefinedType @@ -86,8 +87,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): self._attr_name = ENTITY_NAME_FETCH_SCHEDULE async def async_press(self) -> None: - for x in range(0, 7): - await self._thermostat.async_get_schedule(x) + await self._thermostat.async_get_schedule() _LOGGER.debug( f"[{self._eq3_config.name}] schedule: {self._thermostat.schedule}", @@ -107,7 +107,7 @@ async def set_schedule(self, **kwargs) -> None: times = [ kwargs.get(f"next_change_at_{i}", datetime.time(0, 0)) for i in range(6) ] - times[times.index(datetime.time(0, 0))] = HOUR_24_PLACEHOLDER + # times[times.index(datetime.time(0, 0))] = HOUR_24_PLACEHOLDER temps = [kwargs.get(f"target_temp_{i}", 0) for i in range(7)] for i in range(0, 6): @@ -117,21 +117,24 @@ async def set_schedule(self, **kwargs) -> None: ) schedule_hours.append(schedule_hour) - schedule.days.append(schedule_day) + schedule.schedule_days.append(schedule_day) await self._thermostat.async_set_schedule(schedule=schedule) @property def extra_state_attributes(self): schedule = {} - for day in self._thermostat.schedule: - day_raw = self._thermostat.schedule[day] - day_nice = {"day": day} - for i, entry in enumerate(day_raw.hours): - day_nice[f"target_temp_{i}"] = entry.target_temp - if entry.next_change_at == HOUR_24_PLACEHOLDER: - break - day_nice[f"next_change_at_{i}"] = entry.next_change_at.isoformat() + for day in self._thermostat.schedule.schedule_days: + day_nice: dict[str, Any] = {"day": day} + for i, schedule_hour in enumerate(day.schedule_hours): + day_nice[ + f"target_temp_{i}" + ] = schedule_hour.target_temperature.friendly_value + # if schedule_hour.next_change_at == HOUR_24_PLACEHOLDER: + # break + day_nice[ + f"next_change_at_{i}" + ] = schedule_hour.next_change_at.friendly_value.isoformat() schedule[day] = day_nice return schedule @@ -147,4 +150,4 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): self._attr_entity_category = EntityCategory.DIAGNOSTIC async def async_press(self) -> None: - await self._thermostat.async_update() + await self._thermostat.async_get_info() diff --git a/custom_components/eq3btsmart/climate.py b/custom_components/eq3btsmart/climate.py index 224a869..d43c204 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -8,7 +8,7 @@ from custom_components.eq3btsmart.eq3_entity import Eq3Entity from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat -from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Mode +from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode from homeassistant.components.climate import ClimateEntity, HVACMode from homeassistant.components.climate.const import ( ATTR_HVAC_MODE, @@ -110,12 +110,15 @@ async def _async_scan_loop(self, now=None) -> None: def _on_updated(self): self._is_available = True - if self._target_temperature_to_set == self._thermostat.target_temperature: + if ( + self._target_temperature_to_set + == self._thermostat.status.target_temperature + ): self._is_setting_temperature = False if not self._is_setting_temperature: # temperature may have been updated from the thermostat - self._target_temperature_to_set = self._thermostat.target_temperature + self._target_temperature_to_set = self._thermostat.status.target_temperature if self.entity_id is None: _LOGGER.warn( @@ -131,10 +134,10 @@ def available(self) -> bool: @property def hvac_action(self) -> HVACAction | None: - if self._thermostat.mode == Mode.Off: + if self._thermostat.status.operation_mode == OperationMode.OFF: return HVACAction.OFF - if self._thermostat.valve_state == 0: + if self._thermostat.status.valve == 0: return HVACAction.IDLE return HVACAction.HEATING @@ -145,14 +148,20 @@ def current_temperature(self) -> float | None: case CurrentTemperatureSelector.NOTHING: return None case CurrentTemperatureSelector.VALVE: - if self._thermostat.valve_state is None: + if ( + self._thermostat.status.valve is None + or self._thermostat.status.target_temperature is None + ): return None - valve: int = self._thermostat.valve_state - return (1 - valve / 100) * 2 + self._thermostat.target_temperature - 2 + return ( + (1 - self._thermostat.status.valve / 100) * 2 + + self._thermostat.status.target_temperature + - 2 + ) case CurrentTemperatureSelector.UI: return self._target_temperature_to_set case CurrentTemperatureSelector.DEVICE: - return self._thermostat.target_temperature + return self._thermostat.status.target_temperature case CurrentTemperatureSelector.ENTITY: state = self.hass.states.get(self._eq3_config.external_temp_sensor) if state is not None: @@ -169,7 +178,7 @@ def target_temperature(self) -> float | None: case TargetTemperatureSelector.TARGET: return self._target_temperature_to_set case TargetTemperatureSelector.LAST_REPORTED: - return self._thermostat.target_temperature + return self._thermostat.status.target_temperature return None @@ -212,15 +221,17 @@ async def async_set_temperature(self, **kwargs) -> None: self.async_schedule_update_ha_state() async def async_set_temperature_now(self) -> None: + if self._target_temperature_to_set is None: + return await self._thermostat.async_set_temperature(self._target_temperature_to_set) self._is_setting_temperature = False @property def hvac_mode(self) -> HVACMode | None: - if self._thermostat.mode is None: + if self._thermostat.status.operation_mode is None: return None - return EQ_TO_HA_HVAC[self._thermostat.mode] + return EQ_TO_HA_HVAC[self._thermostat.status.operation_mode] async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: match hvac_mode: @@ -228,7 +239,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: self._target_temperature_to_set = EQ3BT_OFF_TEMP self._is_setting_temperature = True case _: - self._target_temperature_to_set = self._thermostat.target_temperature + self._target_temperature_to_set = ( + self._thermostat.status.target_temperature + ) self._is_setting_temperature = False self.async_schedule_update_ha_state() @@ -236,19 +249,25 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @property def preset_mode(self) -> str | None: - if self._thermostat.window_open: + if self._thermostat.status.is_window_open: return Preset.WINDOW_OPEN - if self._thermostat.boost: + if self._thermostat.status.is_boost: return Preset.BOOST - if self._thermostat.low_battery: + if self._thermostat.status.is_low_battery: return Preset.LOW_BATTERY - if self._thermostat.away: + if self._thermostat.status.is_away: return Preset.AWAY - if self._thermostat.target_temperature == self._thermostat.eco_temperature: + if ( + self._thermostat.status.target_temperature + == self._thermostat.status.eco_temperature + ): return Preset.ECO - if self._thermostat.target_temperature == self._thermostat.comfort_temperature: + if ( + self._thermostat.status.target_temperature + == self._thermostat.status.comfort_temperature + ): return Preset.COMFORT - if self._thermostat.mode == Mode.On: + if self._thermostat.status.operation_mode == OperationMode.ON: return Preset.OPEN return PRESET_NONE @@ -259,39 +278,42 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: case Preset.AWAY: await self._thermostat.async_set_away(True) case Preset.ECO: - if self._thermostat.boost: + if self._thermostat.status.is_boost: await self._thermostat.async_set_boost(False) - if self._thermostat.away: + if self._thermostat.status.is_away: await self._thermostat.async_set_away(False) - await self._thermostat.async_activate_eco() + await self._thermostat.async_set_preset(Eq3Preset.ECO) case Preset.COMFORT: - if self._thermostat.boost: + if self._thermostat.status.is_boost: await self._thermostat.async_set_boost(False) - if self._thermostat.away: + if self._thermostat.status.is_away: await self._thermostat.async_set_away(False) - await self._thermostat.async_activate_comfort() + await self._thermostat.async_set_preset(Eq3Preset.COMFORT) case Preset.OPEN: - if self._thermostat.boost: + if self._thermostat.status.is_boost: await self._thermostat.async_set_boost(False) - if self._thermostat.away: + if self._thermostat.status.is_away: await self._thermostat.async_set_away(False) - await self._thermostat.async_set_mode(Mode.On) + await self._thermostat.async_set_mode(OperationMode.ON) # by now, the target temperature should have been (maybe set) and fetched - self._target_temperature_to_set = self._thermostat.target_temperature + self._target_temperature_to_set = self._thermostat.status.target_temperature self._is_setting_temperature = False @property - def device_info(self) -> DeviceInfo: + def device_info(self) -> DeviceInfo | None: + if self._thermostat.device_data.firmware_version is None: + return None + return DeviceInfo( name=self._eq3_config.name, manufacturer=MANUFACTURER, model=DEVICE_MODEL, identifiers={(DOMAIN, self._eq3_config.mac_address)}, - sw_version=str(self._thermostat.firmware_version), + sw_version=str(self._thermostat.device_data.firmware_version), connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, ) @@ -299,7 +321,7 @@ async def async_scan(self) -> None: """Update the data from the thermostat.""" try: - await self._thermostat.async_update() + await self._thermostat.async_get_info() if self._is_setting_temperature: await self.async_set_temperature_now() except Exception as ex: diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index 662f568..da60b7c 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -1,7 +1,7 @@ """Constants for EQ3 Bluetooth Smart Radiator Valves.""" from enum import Enum -from eq3btsmart.const import Adapter, Mode +from eq3btsmart.const import Adapter, OperationMode from homeassistant.components.climate import HVACMode from homeassistant.components.climate.const import ( PRESET_AWAY, @@ -15,18 +15,17 @@ MANUFACTURER = "eQ-3 AG" DEVICE_MODEL = "CC-RT-BLE-EQ" -EQ_TO_HA_HVAC: dict[Mode, HVACMode] = { - Mode.Unknown: HVACMode.HEAT, - Mode.Off: HVACMode.OFF, - Mode.On: HVACMode.HEAT, - Mode.Auto: HVACMode.AUTO, - Mode.Manual: HVACMode.HEAT, +EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { + OperationMode.OFF: HVACMode.OFF, + OperationMode.ON: HVACMode.HEAT, + OperationMode.AUTO: HVACMode.AUTO, + OperationMode.MANUAL: HVACMode.HEAT, } HA_TO_EQ_HVAC = { - HVACMode.OFF: Mode.Off, - HVACMode.AUTO: Mode.Auto, - HVACMode.HEAT: Mode.Manual, + HVACMode.OFF: OperationMode.OFF, + HVACMode.AUTO: OperationMode.AUTO, + HVACMode.HEAT: OperationMode.MANUAL, } diff --git a/custom_components/eq3btsmart/lock.py b/custom_components/eq3btsmart/lock.py index 563086e..757b26b 100644 --- a/custom_components/eq3btsmart/lock.py +++ b/custom_components/eq3btsmart/lock.py @@ -70,4 +70,4 @@ async def async_unlock(self, **kwargs) -> None: @property def is_locked(self) -> bool | None: - return self._thermostat.locked + return self._thermostat.status.is_locked diff --git a/custom_components/eq3btsmart/manifest.json b/custom_components/eq3btsmart/manifest.json index 79921ad..eb0253d 100644 --- a/custom_components/eq3btsmart/manifest.json +++ b/custom_components/eq3btsmart/manifest.json @@ -3,7 +3,7 @@ "name": "eQ-3 Bluetooth Smart Thermostats", "documentation": "https://github.com/dbuezas/eq3btsmart", "issue_tracker": "https://github.com/dbuezas/eq3btsmart/issues", - "requirements": ["eq3btsmart==0.0.0"], + "requirements": ["construct"], "dependencies": ["bluetooth"], "codeowners": ["@dbuezas", "@eulemitkeule", "@rytilahti", "@lkempf"], "iot_class": "local_polling", diff --git a/custom_components/eq3btsmart/models.py b/custom_components/eq3btsmart/models.py index 45c9420..7abde1d 100644 --- a/custom_components/eq3btsmart/models.py +++ b/custom_components/eq3btsmart/models.py @@ -1,5 +1,6 @@ from dataclasses import dataclass +from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP from eq3btsmart.thermostat import Thermostat from .const import ( @@ -20,6 +21,8 @@ class Eq3Config: external_temp_sensor: str debug_mode: bool scan_interval: int + default_away_hours: float = DEFAULT_AWAY_HOURS + default_away_temperature: float = DEFAULT_AWAY_TEMP @dataclass diff --git a/custom_components/eq3btsmart/number.py b/custom_components/eq3btsmart/number.py index cb01d06..c8f8ed1 100644 --- a/custom_components/eq3btsmart/number.py +++ b/custom_components/eq3btsmart/number.py @@ -99,11 +99,13 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def native_value(self) -> float | None: - return self._thermostat.comfort_temperature + return self._thermostat.status.comfort_temperature async def async_set_native_value(self, value: float) -> None: - await self._thermostat.async_update() # to ensure the other temp is up to date - other = self._thermostat.eco_temperature + await ( + self._thermostat.async_get_info() + ) # to ensure the other temp is up to date + other = self._thermostat.status.eco_temperature if other is None: return @@ -123,11 +125,13 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def native_value(self) -> float | None: - return self._thermostat.eco_temperature + return self._thermostat.status.eco_temperature async def async_set_native_value(self, value: float) -> None: - await self._thermostat.async_update() # to ensure the other temp is up to date - other = self._thermostat.comfort_temperature + await ( + self._thermostat.async_get_info() + ) # to ensure the other temp is up to date + other = self._thermostat.status.comfort_temperature if other is None: return @@ -149,7 +153,10 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def native_value(self) -> float | None: - return self._thermostat.temperature_offset + if self._thermostat.status.offset_temperature is None: + return None + + return self._thermostat.status.offset_temperature.friendly_value async def async_set_native_value(self, value: float) -> None: await self._thermostat.async_temperature_offset_configure(value) @@ -165,16 +172,27 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def native_value(self) -> float | None: - return self._thermostat.window_open_temperature + if self._thermostat.status.window_open_temperature is None: + return None + + return self._thermostat.status.window_open_temperature.friendly_value async def async_set_native_value(self, value: float) -> None: - await self._thermostat.async_update() # to ensure the other value is up to date + await ( + self._thermostat.async_get_info() + ) # to ensure the other value is up to date - if self._thermostat.window_open_time is None: + if self._thermostat.status.window_open_time is None: return await self._thermostat.async_configure_window_open( - temperature=value, duration=self._thermostat.window_open_time + temperature=value, + duration=self._thermostat.status.window_open_time.friendly_value, + ) + + await self._thermostat.async_configure_window_open( + temperature=value, + duration=self._thermostat.status.window_open_time.friendly_value, ) @@ -195,19 +213,23 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def native_value(self) -> float | None: - if self._thermostat.window_open_time is None: + if self._thermostat.status.window_open_time is None: return None - return self._thermostat.window_open_time.total_seconds() / 60 + return ( + self._thermostat.status.window_open_time.friendly_value.total_seconds() / 60 + ) async def async_set_native_value(self, value: float) -> None: - await self._thermostat.async_update() # to ensure the other value is up to date + await ( + self._thermostat.async_get_info() + ) # to ensure the other value is up to date - if self._thermostat.window_open_temperature is None: + if self._thermostat.status.window_open_temperature is None: return await self._thermostat.async_configure_window_open( - temperature=self._thermostat.window_open_temperature, + temperature=self._thermostat.status.window_open_temperature.friendly_value, duration=timedelta(minutes=value), ) @@ -231,14 +253,14 @@ async def async_added_to_hass(self) -> None: data = await self.async_get_last_number_data() if data and data.native_value is not None: - self._thermostat.default_away_hours = data.native_value + self.default_away_hours = data.native_value async def async_set_native_value(self, value: float) -> None: - self._thermostat.default_away_hours = value + self._eq3_config.default_away_hours = value @property def native_value(self) -> float | None: - return self._thermostat.default_away_hours + return self._eq3_config.default_away_hours class AwayTemperature(Base, RestoreNumber): @@ -254,11 +276,11 @@ async def async_added_to_hass(self) -> None: data = await self.async_get_last_number_data() if data and data.native_value is not None: - self._thermostat.default_away_temp = data.native_value + self._eq3_config.default_away_temperature = data.native_value async def async_set_native_value(self, value: float) -> None: - self._thermostat.default_away_temp = value + self._eq3_config.default_away_temperature = value @property def native_value(self) -> float | None: - return self._thermostat.default_away_temp + return self._eq3_config.default_away_temperature diff --git a/custom_components/eq3btsmart/sensor.py b/custom_components/eq3btsmart/sensor.py index c847edf..c9bc459 100644 --- a/custom_components/eq3btsmart/sensor.py +++ b/custom_components/eq3btsmart/sensor.py @@ -97,7 +97,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def state(self) -> int | None: - return self._thermostat.valve_state + return self._thermostat.status.valve class AwayEndSensor(Base): @@ -112,10 +112,10 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def native_value(self) -> datetime | None: - if self._thermostat.away_end is None: + if self._thermostat.status.away_until is None: return None - return self._thermostat.away_end + return self._thermostat.status.away_until.friendly_value class RssiSensor(Base): @@ -133,6 +133,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def state(self) -> int | None: + return None return self._thermostat._conn.rssi @@ -148,7 +149,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def state(self) -> str | None: - return self._thermostat.device_serial + return self._thermostat.device_data.device_serial class FirmwareVersionSensor(Base): @@ -179,16 +180,17 @@ async def fetch_serial(self) -> None: ) if device: device_registry.async_update_device( - device_id=device.id, sw_version=str(self._thermostat.firmware_version) + device_id=device.id, + sw_version=str(self._thermostat.device_data.firmware_version), ) _LOGGER.debug( - f"[{self._eq3_config.name}] firmware: {self._thermostat.firmware_version} serial: {self._thermostat.device_serial}", + f"[{self._eq3_config.name}] firmware: {self._thermostat.device_data.firmware_version} serial: {self._thermostat.device_data.device_serial}", ) @property def state(self) -> str | None: - return str(self._thermostat.firmware_version) + return str(self._thermostat.device_data.firmware_version) class MacSensor(Base): diff --git a/custom_components/eq3btsmart/switch.py b/custom_components/eq3btsmart/switch.py index f985e17..42bcb9b 100644 --- a/custom_components/eq3btsmart/switch.py +++ b/custom_components/eq3btsmart/switch.py @@ -96,10 +96,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: @property def is_on(self) -> bool | None: - return self._thermostat.away + return self._thermostat.status.is_away async def set_away_until(self, away_until: datetime, temperature: float) -> None: - await self._thermostat.async_set_away_until(away_until, temperature) + await self._thermostat.async_set_away(True, away_until, temperature) class BoostSwitch(Base): @@ -120,7 +120,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: @property def is_on(self) -> bool | None: - return self._thermostat.boost + return self._thermostat.status.is_boost class ConnectionSwitch(Base): @@ -138,7 +138,7 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): self._attr_entity_category = EntityCategory.DIAGNOSTIC async def async_turn_on(self, **kwargs: Any) -> None: - await self._thermostat._conn.async_make_request("ONLY CONNECT") + await self._thermostat._conn.async_make_request() async def async_turn_off(self, **kwargs: Any) -> None: if self._thermostat._conn._conn: diff --git a/eq3btsmart/__init__.py b/eq3btsmart/__init__.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/bleakconnection.py b/eq3btsmart/bleakconnection.py old mode 100644 new mode 100755 index c530d91..f28aadf --- a/eq3btsmart/bleakconnection.py +++ b/eq3btsmart/bleakconnection.py @@ -99,7 +99,9 @@ async def on_notification( handle.uuid, ) - async def async_make_request(self, value: bytes, retries=RETRIES) -> None: + async def async_make_request( + self, value: bytes | None = None, retries: int = RETRIES + ) -> None: """Write a GATT Command with callback - not utf-8.""" async with self._lock: # only one concurrent request per thermostat try: @@ -108,7 +110,9 @@ async def async_make_request(self, value: bytes, retries=RETRIES) -> None: self.retries = 0 self._on_connection_event() - async def _async_make_request_try(self, value: bytes, retries) -> None: + async def _async_make_request_try( + self, value: bytes | None = None, retries: int = RETRIES + ) -> None: self.retries = 0 while True: self.retries += 1 @@ -121,7 +125,7 @@ async def _async_make_request_try(self, value: bytes, retries) -> None: raise BackendException("Can't connect") self._notify_event.clear() - if value != "ONLY CONNECT": + if value is not None: try: await self._conn.start_notify( PROP_NTFY_UUID, self.on_notification diff --git a/eq3btsmart/const.py b/eq3btsmart/const.py old mode 100644 new mode 100755 index 0a09b5e..1cea859 --- a/eq3btsmart/const.py +++ b/eq3btsmart/const.py @@ -84,7 +84,7 @@ class Adapter(str, Enum): LOCAL = "LOCAL" -class Preset(Enum): +class Eq3Preset(Enum): """Preset modes.""" COMFORT = 0 diff --git a/eq3btsmart/eq3_away_time.py b/eq3btsmart/eq3_away_time.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/eq3_duration.py b/eq3btsmart/eq3_duration.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/eq3_schedule_time.py b/eq3btsmart/eq3_schedule_time.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/eq3_temperature.py b/eq3btsmart/eq3_temperature.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/eq3_temperature_offset.py b/eq3btsmart/eq3_temperature_offset.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/eq3_time.py b/eq3btsmart/eq3_time.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/exceptions.py b/eq3btsmart/exceptions.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/models.py b/eq3btsmart/models.py old mode 100644 new mode 100755 index d5ce15b..0bb9ee3 --- a/eq3btsmart/models.py +++ b/eq3btsmart/models.py @@ -18,8 +18,8 @@ @dataclass class DeviceData: - firmware_version: int - device_serial: str + firmware_version: int | None = None + device_serial: str | None = None @classmethod def from_device(cls, struct: DeviceIdStruct) -> Self: @@ -35,24 +35,24 @@ def from_bytes(cls, data: bytes) -> Self: @dataclass class Status: - valve: int - target_temperature: Eq3Temperature - _operation_mode: OperationMode - is_away: bool - is_boost: bool - is_dst: bool - is_window_open: bool - is_locked: bool - is_low_battery: bool - away_datetime: Eq3AwayTime | None - window_open_temperature: Eq3Temperature | None - window_open_time: Eq3Duration | None - comfort_temperature: Eq3Temperature | None - eco_temperature: Eq3Temperature | None - offset_temperature: Eq3TemperatureOffset | None + valve: int | None = None + target_temperature: Eq3Temperature | None = None + _operation_mode: OperationMode | None = None + is_away: bool | None = None + is_boost: bool | None = None + is_dst: bool | None = None + is_window_open: bool | None = None + is_locked: bool | None = None + is_low_battery: bool | None = None + away_until: Eq3AwayTime | None = None + window_open_temperature: Eq3Temperature | None = None + window_open_time: Eq3Duration | None = None + comfort_temperature: Eq3Temperature | None = None + eco_temperature: Eq3Temperature | None = None + offset_temperature: Eq3TemperatureOffset | None = None @property - def operation_mode(self) -> OperationMode: + def operation_mode(self) -> OperationMode | None: if self.target_temperature == EQ3BT_OFF_TEMP: return OperationMode.OFF @@ -75,7 +75,7 @@ def from_device(cls, struct: StatusStruct) -> Self: is_window_open=bool(struct.mode & struct.mode.WINDOW), is_locked=bool(struct.mode & struct.mode.LOCKED), is_low_battery=bool(struct.mode & struct.mode.LOW_BATTERY), - away_datetime=struct.away, + away_until=struct.away, window_open_temperature=struct.presets.window_open_temp if struct.presets else None, @@ -123,14 +123,14 @@ def from_bytes(cls, data: bytes) -> Self: @dataclass class Schedule: - days: list[ScheduleDay] = field(default_factory=list) + schedule_days: list[ScheduleDay] = field(default_factory=list) def merge(self, other_schedule: Self) -> None: - for schedule_day in other_schedule.days: - self.days[ + for schedule_day in other_schedule.schedule_days: + self.schedule_days[ schedule_day.week_day ].schedule_hours = schedule_day.schedule_hours @classmethod def from_bytes(cls, data: bytes) -> Self: - return cls(days=[ScheduleDay.from_bytes(data)]) + return cls(schedule_days=[ScheduleDay.from_bytes(data)]) diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py old mode 100644 new mode 100755 index 0597983..9e23f3a --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -23,8 +23,8 @@ EQ3BT_OFF_TEMP, EQ3BT_ON_TEMP, Command, + Eq3Preset, OperationMode, - Preset, WeekDay, ) from eq3btsmart.eq3_away_time import Eq3AwayTime @@ -66,8 +66,8 @@ def __init__( """Initialize the thermostat.""" self.thermostat_config = thermostat_config - self.status: Status | None = None - self.device_data: DeviceData | None = None + self.status: Status = Status() + self.device_data: DeviceData = DeviceData() self.schedule: Schedule = Schedule() self._on_update_callbacks: list[Callable] = [] self._conn = BleakConnection( @@ -160,7 +160,7 @@ async def async_temperature_offset_configure( async def async_set_mode(self, operation_mode: OperationMode) -> None: """Set new operation mode.""" - if self.status is None: + if self.status is None or self.status.target_temperature is None: raise Exception("Status not set") command: ModeSetCommand @@ -222,15 +222,15 @@ async def async_set_temperature(self, temperature: float) -> None: TemperatureSetCommand(temperature=eq3_temperature) ) - async def async_set_preset(self, preset: Preset): + async def async_set_preset(self, preset: Eq3Preset): """Sets the thermostat to the given preset.""" command: ComfortSetCommand | EcoSetCommand match preset: - case Preset.COMFORT: + case Eq3Preset.COMFORT: command = ComfortSetCommand() - case Preset.ECO: + case Eq3Preset.ECO: command = EcoSetCommand() await self._async_write_command(command) @@ -248,7 +248,7 @@ async def async_set_locked(self, enable: bool) -> None: async def async_set_schedule(self, schedule: Schedule) -> None: """Sets the schedule for the given day.""" - for schedule_day in schedule.days: + for schedule_day in schedule.schedule_days: command = ScheduleSetCommand( day=schedule_day.week_day, hours=[ diff --git a/eq3btsmart/thermostat_config.py b/eq3btsmart/thermostat_config.py old mode 100644 new mode 100755 From d3142a3cb7559001aa16f032dd2aca7c74c139c2 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 17:30:22 +0100 Subject: [PATCH 06/20] chore: remove hass dependency --- custom_components/eq3btsmart/__init__.py | 18 +++++++++++++++++- custom_components/eq3btsmart/const.py | 3 +++ custom_components/eq3btsmart/manifest.json | 4 ++-- eq3btsmart/bleakconnection.py | 19 ++++++++++++++++--- eq3btsmart/thermostat.py | 12 ++++++++++-- poetry.lock | 2 +- pyproject.toml | 1 - 7 files changed, 49 insertions(+), 10 deletions(-) diff --git a/custom_components/eq3btsmart/__init__.py b/custom_components/eq3btsmart/__init__.py index 808edaf..8dc6771 100644 --- a/custom_components/eq3btsmart/__init__.py +++ b/custom_components/eq3btsmart/__init__.py @@ -1,5 +1,6 @@ """Support for EQ3 devices.""" +import logging from typing import Any from bleak.backends.device import BLEDevice @@ -25,6 +26,7 @@ DEFAULT_STAY_CONNECTED, DEFAULT_TARGET_TEMP_SELECTOR, DOMAIN, + GET_DEVICE_TIMEOUT, Adapter, ) from .models import Eq3Config, Eq3ConfigEntry @@ -39,6 +41,8 @@ Platform.NUMBER, ] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Called when an entry is setup.""" @@ -78,7 +82,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: stay_connected=stay_connected, ) - device = await async_get_device(hass, eq3_config) + try: + device = await async_get_device(hass, eq3_config) + except Exception as e: + _LOGGER.error(f"Could not connect to device: {e}") + + # reschedule setup entry in GET_DEVICE_TIMEOUT seconds + + async def reschedule_setup_entry(_now: Any) -> None: + await hass.config_entries.async_reload(entry.entry_id) + + hass.helpers.event.async_call_later(GET_DEVICE_TIMEOUT, reschedule_setup_entry) + + return False thermostat = Thermostat( thermostat_config=thermostat_config, diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index da60b7c..6ec9c8a 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -15,6 +15,9 @@ MANUFACTURER = "eQ-3 AG" DEVICE_MODEL = "CC-RT-BLE-EQ" +GET_DEVICE_TIMEOUT = 5 # seconds + + EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { OperationMode.OFF: HVACMode.OFF, OperationMode.ON: HVACMode.HEAT, diff --git a/custom_components/eq3btsmart/manifest.json b/custom_components/eq3btsmart/manifest.json index eb0253d..76afc42 100644 --- a/custom_components/eq3btsmart/manifest.json +++ b/custom_components/eq3btsmart/manifest.json @@ -3,8 +3,8 @@ "name": "eQ-3 Bluetooth Smart Thermostats", "documentation": "https://github.com/dbuezas/eq3btsmart", "issue_tracker": "https://github.com/dbuezas/eq3btsmart/issues", - "requirements": ["construct"], - "dependencies": ["bluetooth"], + "requirements": ["git+https://github.com/dbuezas/eq3btsmart.git@refactor-code-style"], + "after_dependencies": ["bluetooth"], "codeowners": ["@dbuezas", "@eulemitkeule", "@rytilahti", "@lkempf"], "iot_class": "local_polling", "loggers": ["bleak", "eq3bt"], diff --git a/eq3btsmart/bleakconnection.py b/eq3btsmart/bleakconnection.py index f28aadf..61e0b5b 100755 --- a/eq3btsmart/bleakconnection.py +++ b/eq3btsmart/bleakconnection.py @@ -1,7 +1,7 @@ """Bleak connection backend.""" import asyncio import logging -from typing import Callable, cast +from typing import Callable, Coroutine, cast from bleak import BleakClient from bleak.backends.characteristic import BleakGATTCharacteristic @@ -30,18 +30,26 @@ class BleakConnection: def __init__( self, thermostat_config: ThermostatConfig, - device: BLEDevice, callback: Callable, + device: BLEDevice | None = None, + get_device: Coroutine[None, None, BLEDevice] | None = None, ): """Initialize the connection.""" + if device is None and get_device is None: + raise Exception("Either device or get_device must be provided") + + if device is not None and get_device is not None: + raise Exception("Either device or get_device must be provided") + self.thermostat_config = thermostat_config self._callback = callback self._notify_event = asyncio.Event() self._terminate_event = asyncio.Event() self._lock = asyncio.Lock() self._conn: BleakClient | None = None - self._device: BLEDevice = device + self._device: BLEDevice | None = device + self._get_device: Coroutine[None, None, BLEDevice] | None = get_device self._connection_callbacks: list[Callable] = [] self.retries = 0 self._round_robin = 0 @@ -50,6 +58,11 @@ def register_connection_callback(self, callback: Callable) -> None: self._connection_callbacks.append(callback) async def async_connect(self) -> None: + """Connect to the thermostat.""" + + if self._device is None: + raise NotImplementedError("get_device not implemented") + match self.thermostat_config.adapter: case Adapter.AUTO: self._conn = await establish_connection( diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index 9e23f3a..d22b948 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -9,7 +9,7 @@ import logging from datetime import datetime, timedelta -from typing import Callable +from typing import Callable, Coroutine from bleak.backends.device import BLEDevice from construct_typed import DataclassStruct @@ -61,10 +61,17 @@ class Thermostat: def __init__( self, thermostat_config: ThermostatConfig, - device: BLEDevice, + device: BLEDevice | None = None, + get_device: Coroutine[None, None, BLEDevice] | None = None, ): """Initialize the thermostat.""" + if device is None and get_device is None: + raise Exception("Either device or get_device must be provided") + + if device is not None and get_device is not None: + raise Exception("Either device or get_device must be provided") + self.thermostat_config = thermostat_config self.status: Status = Status() self.device_data: DeviceData = DeviceData() @@ -73,6 +80,7 @@ def __init__( self._conn = BleakConnection( thermostat_config=self.thermostat_config, device=device, + get_device=get_device, callback=self.handle_notification, ) diff --git a/poetry.lock b/poetry.lock index 998ee33..06d6cdb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2282,4 +2282,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.13" -content-hash = "3c9b47a0e4c0d20178d3c59e8e60a07b58c2ba2273349df58b7f728faff19c31" +content-hash = "b48795d3c19674c2f43a5a74e31c99f279a3b2adf69d420787a3f6a1e4ca85a8" diff --git a/pyproject.toml b/pyproject.toml index 753a38e..accbaa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ python = ">=3.11,<3.13" bleak = "^0.21.1" construct = "^2.10.68" construct-typing = "^0.6.2" -homeassistant = "^2023.1.1" bleak-retry-connector = "^3.4.0" pyserial = "^3.5" From 43a9253631218816dc98a1450f39ba816e040ecf Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 18:55:22 +0100 Subject: [PATCH 07/20] fix: temperature issues, connection logic, etc --- custom_components/eq3btsmart/__init__.py | 13 ++----- custom_components/eq3btsmart/binary_sensor.py | 1 + custom_components/eq3btsmart/climate.py | 31 ++++++++++------ custom_components/eq3btsmart/eq3_entity.py | 4 ++- custom_components/eq3btsmart/manifest.json | 1 + custom_components/eq3btsmart/number.py | 36 +++++++------------ custom_components/eq3btsmart/sensor.py | 3 +- eq3btsmart/structures.py | 4 +-- 8 files changed, 41 insertions(+), 52 deletions(-) diff --git a/custom_components/eq3btsmart/__init__.py b/custom_components/eq3btsmart/__init__.py index 8dc6771..54c88d8 100644 --- a/custom_components/eq3btsmart/__init__.py +++ b/custom_components/eq3btsmart/__init__.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from .const import ( CONF_ADAPTER, @@ -26,7 +27,6 @@ DEFAULT_STAY_CONNECTED, DEFAULT_TARGET_TEMP_SELECTOR, DOMAIN, - GET_DEVICE_TIMEOUT, Adapter, ) from .models import Eq3Config, Eq3ConfigEntry @@ -85,16 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device = await async_get_device(hass, eq3_config) except Exception as e: - _LOGGER.error(f"Could not connect to device: {e}") - - # reschedule setup entry in GET_DEVICE_TIMEOUT seconds - - async def reschedule_setup_entry(_now: Any) -> None: - await hass.config_entries.async_reload(entry.entry_id) - - hass.helpers.event.async_call_later(GET_DEVICE_TIMEOUT, reschedule_setup_entry) - - return False + raise ConfigEntryNotReady(f"Could not connect to device: {e}") thermostat = Thermostat( thermostat_config=thermostat_config, diff --git a/custom_components/eq3btsmart/binary_sensor.py b/custom_components/eq3btsmart/binary_sensor.py index 5324a96..6bae48d 100644 --- a/custom_components/eq3btsmart/binary_sensor.py +++ b/custom_components/eq3btsmart/binary_sensor.py @@ -1,5 +1,6 @@ """Platform for eQ-3 binary sensor entities.""" + import json from custom_components.eq3btsmart.eq3_entity import Eq3Entity diff --git a/custom_components/eq3btsmart/climate.py b/custom_components/eq3btsmart/climate.py index d43c204..b9f786f 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -111,14 +111,20 @@ def _on_updated(self): self._is_available = True if ( - self._target_temperature_to_set - == self._thermostat.status.target_temperature + self._thermostat.status.target_temperature is not None + and self._target_temperature_to_set + == self._thermostat.status.target_temperature.friendly_value ): self._is_setting_temperature = False - if not self._is_setting_temperature: + if ( + not self._is_setting_temperature + and self._thermostat.status.target_temperature is not None + ): # temperature may have been updated from the thermostat - self._target_temperature_to_set = self._thermostat.status.target_temperature + self._target_temperature_to_set = ( + self._thermostat.status.target_temperature.friendly_value + ) if self.entity_id is None: _LOGGER.warn( @@ -155,13 +161,16 @@ def current_temperature(self) -> float | None: return None return ( (1 - self._thermostat.status.valve / 100) * 2 - + self._thermostat.status.target_temperature + + self._thermostat.status.target_temperature.friendly_value - 2 ) case CurrentTemperatureSelector.UI: return self._target_temperature_to_set case CurrentTemperatureSelector.DEVICE: - return self._thermostat.status.target_temperature + if self._thermostat.status.target_temperature is None: + return None + + return self._thermostat.status.target_temperature.friendly_value case CurrentTemperatureSelector.ENTITY: state = self.hass.states.get(self._eq3_config.external_temp_sensor) if state is not None: @@ -178,7 +187,10 @@ def target_temperature(self) -> float | None: case TargetTemperatureSelector.TARGET: return self._target_temperature_to_set case TargetTemperatureSelector.LAST_REPORTED: - return self._thermostat.status.target_temperature + if self._thermostat.status.target_temperature is None: + return None + + return self._thermostat.status.target_temperature.friendly_value return None @@ -305,15 +317,12 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: @property def device_info(self) -> DeviceInfo | None: - if self._thermostat.device_data.firmware_version is None: - return None - return DeviceInfo( name=self._eq3_config.name, manufacturer=MANUFACTURER, model=DEVICE_MODEL, identifiers={(DOMAIN, self._eq3_config.mac_address)}, - sw_version=str(self._thermostat.device_data.firmware_version), + sw_version=str(self._thermostat.device_data.firmware_version or "unknown"), connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, ) diff --git a/custom_components/eq3btsmart/eq3_entity.py b/custom_components/eq3btsmart/eq3_entity.py index c42768b..b8ead99 100644 --- a/custom_components/eq3btsmart/eq3_entity.py +++ b/custom_components/eq3btsmart/eq3_entity.py @@ -1,10 +1,12 @@ from custom_components.eq3btsmart.models import Eq3Config from eq3btsmart.thermostat import Thermostat +from homeassistant.helpers.entity import Entity -class Eq3Entity: +class Eq3Entity(Entity): """Base class for all eQ-3 entities.""" def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): self._eq3_config = eq3_config self._thermostat = thermostat + self._attr_name = self._eq3_config.name diff --git a/custom_components/eq3btsmart/manifest.json b/custom_components/eq3btsmart/manifest.json index 76afc42..70f4471 100644 --- a/custom_components/eq3btsmart/manifest.json +++ b/custom_components/eq3btsmart/manifest.json @@ -5,6 +5,7 @@ "issue_tracker": "https://github.com/dbuezas/eq3btsmart/issues", "requirements": ["git+https://github.com/dbuezas/eq3btsmart.git@refactor-code-style"], "after_dependencies": ["bluetooth"], + "dependencies": ["bluetooth_adapters"], "codeowners": ["@dbuezas", "@eulemitkeule", "@rytilahti", "@lkempf"], "iot_class": "local_polling", "loggers": ["bleak", "eq3bt"], diff --git a/custom_components/eq3btsmart/number.py b/custom_components/eq3btsmart/number.py index c8f8ed1..e12f0c2 100644 --- a/custom_components/eq3btsmart/number.py +++ b/custom_components/eq3btsmart/number.py @@ -99,20 +99,14 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def native_value(self) -> float | None: - return self._thermostat.status.comfort_temperature - - async def async_set_native_value(self, value: float) -> None: - await ( - self._thermostat.async_get_info() - ) # to ensure the other temp is up to date - other = self._thermostat.status.eco_temperature + if self._thermostat.status.comfort_temperature is None: + return None - if other is None: - return + return self._thermostat.status.comfort_temperature.friendly_value - await self._thermostat.async_configure_presets( - comfort_temperature=value, eco_temperature=other - ) + async def async_set_native_value(self, value: float) -> None: + await self._thermostat.async_get_info() + await self._thermostat.async_configure_presets(comfort_temperature=value) class EcoTemperature(Base): @@ -125,20 +119,14 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def native_value(self) -> float | None: - return self._thermostat.status.eco_temperature - - async def async_set_native_value(self, value: float) -> None: - await ( - self._thermostat.async_get_info() - ) # to ensure the other temp is up to date - other = self._thermostat.status.comfort_temperature + if self._thermostat.status.eco_temperature is None: + return None - if other is None: - return + return self._thermostat.status.eco_temperature.friendly_value - await self._thermostat.async_configure_presets( - comfort_temperature=other, eco_temperature=value - ) + async def async_set_native_value(self, value: float) -> None: + await self._thermostat.async_get_info() + await self._thermostat.async_configure_presets(eco_temperature=value) class OffsetTemperature(Base): diff --git a/custom_components/eq3btsmart/sensor.py b/custom_components/eq3btsmart/sensor.py index c9bc459..55f5cb7 100644 --- a/custom_components/eq3btsmart/sensor.py +++ b/custom_components/eq3btsmart/sensor.py @@ -7,8 +7,7 @@ from custom_components.eq3btsmart.eq3_entity import Eq3Entity from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat -from homeassistant.components.homekit import SensorDeviceClass -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry, UndefinedType from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py index 26645a8..4123e19 100755 --- a/eq3btsmart/structures.py +++ b/eq3btsmart/structures.py @@ -9,7 +9,6 @@ GreedyBytes, GreedyRange, If, - IfThenElse, Int8ub, Optional, ) @@ -117,10 +116,9 @@ class StatusStruct(DataclassMixin): const_2: int = csfield(Const(0x04, Int8ub)) target_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) away: Eq3AwayTime | None = csfield( - IfThenElse( + If( lambda ctx: ctx.mode & StatusFlags.AWAY, Eq3AwayTimeAdapter(Bytes(4)), - Optional(Bytes(4)), ) ) presets: PresetsStruct | None = csfield(Optional(DataclassStruct(PresetsStruct))) From 5b280cad6225a4497add4abc579b5607c895a4ac Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 19:07:18 +0100 Subject: [PATCH 08/20] fix: handle invalid status temperature --- eq3btsmart/thermostat.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index d22b948..7d72d90 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -32,6 +32,7 @@ from eq3btsmart.eq3_temperature import Eq3Temperature from eq3btsmart.eq3_temperature_offset import Eq3TemperatureOffset from eq3btsmart.eq3_time import Eq3Time +from eq3btsmart.exceptions import TemperatureException from eq3btsmart.models import DeviceData, Schedule, Status from eq3btsmart.structures import ( BoostSetCommand, @@ -291,7 +292,10 @@ def handle_notification(self, data: bytes) -> None: case Command.ID_RETURN: self.device_data = DeviceData.from_bytes(data) case Command.INFO_RETURN: - self.status = Status.from_bytes(data) + try: + self.status = Status.from_bytes(data) + except TemperatureException: + pass case Command.SCHEDULE_RETURN: schedule = Schedule.from_bytes(data) self.schedule.merge(schedule) From bdad72ef564d5ffb147413c90461ac7e3f08c76b Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 19:13:31 +0100 Subject: [PATCH 09/20] fix: status struct --- eq3btsmart/structures.py | 4 +++- eq3btsmart/thermostat.py | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py index 4123e19..e1ac78d 100755 --- a/eq3btsmart/structures.py +++ b/eq3btsmart/structures.py @@ -9,6 +9,7 @@ GreedyBytes, GreedyRange, If, + IfThenElse, Int8ub, Optional, ) @@ -116,9 +117,10 @@ class StatusStruct(DataclassMixin): const_2: int = csfield(Const(0x04, Int8ub)) target_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) away: Eq3AwayTime | None = csfield( - If( + IfThenElse( lambda ctx: ctx.mode & StatusFlags.AWAY, Eq3AwayTimeAdapter(Bytes(4)), + Optional(Eq3AwayTimeAdapter(Bytes(4))), ) ) presets: PresetsStruct | None = csfield(Optional(DataclassStruct(PresetsStruct))) diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index 7d72d90..d22b948 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -32,7 +32,6 @@ from eq3btsmart.eq3_temperature import Eq3Temperature from eq3btsmart.eq3_temperature_offset import Eq3TemperatureOffset from eq3btsmart.eq3_time import Eq3Time -from eq3btsmart.exceptions import TemperatureException from eq3btsmart.models import DeviceData, Schedule, Status from eq3btsmart.structures import ( BoostSetCommand, @@ -292,10 +291,7 @@ def handle_notification(self, data: bytes) -> None: case Command.ID_RETURN: self.device_data = DeviceData.from_bytes(data) case Command.INFO_RETURN: - try: - self.status = Status.from_bytes(data) - except TemperatureException: - pass + self.status = Status.from_bytes(data) case Command.SCHEDULE_RETURN: schedule = Schedule.from_bytes(data) self.schedule.merge(schedule) From 55621f1ea2c0acb2ea03eec535c9120a3e64829e Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 19:16:28 +0100 Subject: [PATCH 10/20] fix: away time decode --- eq3btsmart/eq3_away_time.py | 6 +++++- eq3btsmart/structures.py | 11 ++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/eq3btsmart/eq3_away_time.py b/eq3btsmart/eq3_away_time.py index edfa6bf..40d370b 100755 --- a/eq3btsmart/eq3_away_time.py +++ b/eq3btsmart/eq3_away_time.py @@ -32,8 +32,12 @@ def friendly_value(self) -> datetime: return datetime(year=year, month=month, day=day, hour=hour, minute=min) @classmethod - def from_device(cls, value: bytes) -> Self: + def from_device(cls, value: bytes) -> Self | None: + if value == bytes([0x00, 0x00, 0x00, 0x00]): + return None + (day, year, hour_min, month) = value + year += 2000 min = 0 diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py index e1ac78d..9af5291 100755 --- a/eq3btsmart/structures.py +++ b/eq3btsmart/structures.py @@ -9,7 +9,6 @@ GreedyBytes, GreedyRange, If, - IfThenElse, Int8ub, Optional, ) @@ -81,7 +80,7 @@ def _encode(self, obj: Eq3Duration, ctx, path) -> int: class Eq3AwayTimeAdapter(Adapter): """Adapter to encode and decode away time data.""" - def _decode(self, obj: bytes, ctx, path) -> Eq3AwayTime: + def _decode(self, obj: bytes, ctx, path) -> Eq3AwayTime | None: return Eq3AwayTime.from_device(obj) def _encode(self, obj: Eq3AwayTime, ctx, path) -> bytes: @@ -116,13 +115,7 @@ class StatusStruct(DataclassMixin): valve: int = csfield(Int8ub) const_2: int = csfield(Const(0x04, Int8ub)) target_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) - away: Eq3AwayTime | None = csfield( - IfThenElse( - lambda ctx: ctx.mode & StatusFlags.AWAY, - Eq3AwayTimeAdapter(Bytes(4)), - Optional(Eq3AwayTimeAdapter(Bytes(4))), - ) - ) + away: Eq3AwayTime | None = csfield(Eq3AwayTimeAdapter(Bytes(4))) presets: PresetsStruct | None = csfield(Optional(DataclassStruct(PresetsStruct))) From d3b9a2ae69d44b19bd4a7f3a5623e903ea110e44 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 19:19:40 +0100 Subject: [PATCH 11/20] fix: mode set command creation --- eq3btsmart/thermostat.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index d22b948..fc2a49d 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -175,19 +175,25 @@ async def async_set_mode(self, operation_mode: OperationMode) -> None: match operation_mode: case OperationMode.AUTO: - command = ModeSetCommand(mode=OperationMode.AUTO) + command = ModeSetCommand(mode=OperationMode.AUTO, away_data=None) case OperationMode.MANUAL: temperature = max( min(self.status.target_temperature, Eq3Temperature(EQ3BT_MAX_TEMP)), Eq3Temperature(EQ3BT_MIN_TEMP), ) - command = ModeSetCommand(mode=OperationMode.MANUAL | temperature) + command = ModeSetCommand( + mode=OperationMode.MANUAL | temperature, away_data=None + ) case OperationMode.OFF: off_temperature = Eq3Temperature(EQ3BT_OFF_TEMP) - command = ModeSetCommand(mode=OperationMode.MANUAL | off_temperature) + command = ModeSetCommand( + mode=OperationMode.MANUAL | off_temperature, away_data=None + ) case OperationMode.ON: on_temperature = Eq3Temperature(EQ3BT_ON_TEMP) - command = ModeSetCommand(mode=OperationMode.MANUAL | on_temperature) + command = ModeSetCommand( + mode=OperationMode.MANUAL | on_temperature, away_data=None + ) await self._async_write_command(command) From ba06ea330eaa2c98b70257eab079fb7c78fec030 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 19:21:30 +0100 Subject: [PATCH 12/20] fix: make mode set away data optional --- eq3btsmart/structures.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py index 9af5291..d99c416 100755 --- a/eq3btsmart/structures.py +++ b/eq3btsmart/structures.py @@ -8,7 +8,6 @@ Flag, GreedyBytes, GreedyRange, - If, Int8ub, Optional, ) @@ -217,12 +216,7 @@ class ModeSetCommand(Eq3Command): cmd: int = csfield(Const(Command.MODE_SET, Int8ub)) mode: int = csfield(Int8ub) - away_data: Eq3AwayTime | None = csfield( - If( - lambda ctx: ctx.mode & StatusFlags.AWAY, - Eq3AwayTimeAdapter(Bytes(4)), - ) - ) + away_data: Eq3AwayTime | None = csfield(Optional(Eq3AwayTimeAdapter(Bytes(4)))) @dataclass From 8e3ffae56e042137a539acbace72117282dcb58e Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 19:33:26 +0100 Subject: [PATCH 13/20] fix: use away set command --- eq3btsmart/structures.py | 8 +++++++- eq3btsmart/thermostat.py | 19 +++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py index d99c416..ffc8a92 100755 --- a/eq3btsmart/structures.py +++ b/eq3btsmart/structures.py @@ -216,7 +216,13 @@ class ModeSetCommand(Eq3Command): cmd: int = csfield(Const(Command.MODE_SET, Int8ub)) mode: int = csfield(Int8ub) - away_data: Eq3AwayTime | None = csfield(Optional(Eq3AwayTimeAdapter(Bytes(4)))) + + +@dataclass +class AwaySetCommand(ModeSetCommand): + """Structure for away set command.""" + + away_until: Eq3AwayTime = csfield(Eq3AwayTimeAdapter(Bytes(4))) @dataclass diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index fc2a49d..29d067c 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -34,6 +34,7 @@ from eq3btsmart.eq3_time import Eq3Time from eq3btsmart.models import DeviceData, Schedule, Status from eq3btsmart.structures import ( + AwaySetCommand, BoostSetCommand, ComfortEcoConfigureCommand, ComfortSetCommand, @@ -175,25 +176,19 @@ async def async_set_mode(self, operation_mode: OperationMode) -> None: match operation_mode: case OperationMode.AUTO: - command = ModeSetCommand(mode=OperationMode.AUTO, away_data=None) + command = ModeSetCommand(mode=OperationMode.AUTO) case OperationMode.MANUAL: temperature = max( min(self.status.target_temperature, Eq3Temperature(EQ3BT_MAX_TEMP)), Eq3Temperature(EQ3BT_MIN_TEMP), ) - command = ModeSetCommand( - mode=OperationMode.MANUAL | temperature, away_data=None - ) + command = ModeSetCommand(mode=OperationMode.MANUAL | temperature) case OperationMode.OFF: off_temperature = Eq3Temperature(EQ3BT_OFF_TEMP) - command = ModeSetCommand( - mode=OperationMode.MANUAL | off_temperature, away_data=None - ) + command = ModeSetCommand(mode=OperationMode.MANUAL | off_temperature) case OperationMode.ON: on_temperature = Eq3Temperature(EQ3BT_ON_TEMP) - command = ModeSetCommand( - mode=OperationMode.MANUAL | on_temperature, away_data=None - ) + command = ModeSetCommand(mode=OperationMode.MANUAL | on_temperature) await self._async_write_command(command) @@ -216,9 +211,9 @@ async def async_set_away( eq3_temperature = Eq3Temperature(temperature) await self._async_write_command( - ModeSetCommand( + AwaySetCommand( mode=OperationMode.AWAY | eq3_temperature, - away_data=eq3_away_until, + away_until=eq3_away_until, ) ) From 0fcc6c5560fb557d10f09faa576ea92447fc8899 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sat, 20 Jan 2024 21:16:19 +0100 Subject: [PATCH 14/20] fix: invalid target temperature when changing mode --- custom_components/eq3btsmart/climate.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/eq3btsmart/climate.py b/custom_components/eq3btsmart/climate.py index b9f786f..f21e970 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -251,9 +251,10 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: self._target_temperature_to_set = EQ3BT_OFF_TEMP self._is_setting_temperature = True case _: - self._target_temperature_to_set = ( - self._thermostat.status.target_temperature - ) + if self._thermostat.status.target_temperature is not None: + self._target_temperature_to_set = ( + self._thermostat.status.target_temperature.friendly_value + ) self._is_setting_temperature = False self.async_schedule_update_ha_state() From 0d8e2168fa238151edf9f540d4b1cedd99e9444e Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sun, 21 Jan 2024 18:22:56 +0100 Subject: [PATCH 15/20] fix: bluetooth connection issues --- custom_components/eq3btsmart/climate.py | 2 +- custom_components/eq3btsmart/const.py | 2 +- .../eq3btsmart/translations/en.json | 2 +- eq3btsmart/structures.py | 2 +- eq3btsmart/thermostat.py | 25 +++++++++++-------- tests/test_status.py | 6 +++++ 6 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 tests/test_status.py diff --git a/custom_components/eq3btsmart/climate.py b/custom_components/eq3btsmart/climate.py index f21e970..48fd57f 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -101,7 +101,7 @@ async def _async_scan_loop(self, now=None) -> None: await self.async_scan() if self._platform_state != EntityPlatformState.REMOVED: - delay = timedelta(minutes=self._eq3_config.scan_interval) + delay = timedelta(seconds=self._eq3_config.scan_interval) self._cancel_timer = async_call_later( self.hass, delay, self._async_scan_loop ) diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index 6ec9c8a..cc002b0 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -104,4 +104,4 @@ class TargetTemperatureSelector(str, Enum): DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET DEFAULT_STAY_CONNECTED = True DEFAULT_DEBUG_MODE = False -DEFAULT_SCAN_INTERVAL = 1 # minutes +DEFAULT_SCAN_INTERVAL = 10 # seconds diff --git a/custom_components/eq3btsmart/translations/en.json b/custom_components/eq3btsmart/translations/en.json index 85e9222..cff4189 100644 --- a/custom_components/eq3btsmart/translations/en.json +++ b/custom_components/eq3btsmart/translations/en.json @@ -30,7 +30,7 @@ "title": "EQ-3 Options", "description": "Increasing the scan interval to 10 minutes and disabling persistant connections may save battery. Set the bluetooth adapter to 'local' avoid using BTProxy, or pick manually if you have multiple available and want to distribute connection across them.", "data": { - "scan_interval": "Scan interval in minutes", + "scan_interval": "Scan interval in seconds", "conf_current_temp_selector": "What to show as current temperature", "conf_target_temp_selector": "What to show as target temperature", "conf_external_temp_sensor": "External temperature sensor", diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py index ffc8a92..7874751 100755 --- a/eq3btsmart/structures.py +++ b/eq3btsmart/structures.py @@ -114,7 +114,7 @@ class StatusStruct(DataclassMixin): valve: int = csfield(Int8ub) const_2: int = csfield(Const(0x04, Int8ub)) target_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) - away: Eq3AwayTime | None = csfield(Eq3AwayTimeAdapter(Bytes(4))) + away: Eq3AwayTime | None = csfield(Optional(Eq3AwayTimeAdapter(Bytes(4)))) presets: PresetsStruct | None = csfield(Optional(DataclassStruct(PresetsStruct))) diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index 29d067c..24416d2 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -288,16 +288,21 @@ def handle_notification(self, data: bytes) -> None: command = DataclassStruct(Eq3Command).parse(data) - match command.cmd: - case Command.ID_RETURN: - self.device_data = DeviceData.from_bytes(data) - case Command.INFO_RETURN: - self.status = Status.from_bytes(data) - case Command.SCHEDULE_RETURN: - schedule = Schedule.from_bytes(data) - self.schedule.merge(schedule) - case _: - updated = False + try: + match command.cmd: + case Command.ID_RETURN: + self.device_data = DeviceData.from_bytes(data) + case Command.INFO_RETURN: + self.status = Status.from_bytes(data) + case Command.SCHEDULE_RETURN: + schedule = Schedule.from_bytes(data) + self.schedule.merge(schedule) + case _: + updated = False + except Exception: + # print all bytes received in this format: Received: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 + _LOGGER.exception("Received: %s", " ".join([f"0x{b:02x}" for b in data])) + updated = False if not updated: return diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..3f83eb2 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,6 @@ +from eq3btsmart.models import Status + + +def test_status(): + received = bytes([0x02, 0x01, 0x09, 0x11, 0x04, 0x2A]) + Status.from_bytes(received) From e436efb541c1bd087978bc9f66a33d7c70699a38 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 22 Jan 2024 15:15:03 +0100 Subject: [PATCH 16/20] fix: set schedule service --- custom_components/eq3btsmart/__init__.py | 9 +- custom_components/eq3btsmart/binary_sensor.py | 35 ++-- custom_components/eq3btsmart/button.py | 49 ++--- custom_components/eq3btsmart/const.py | 3 +- .../eq3btsmart/eq3_coordinator.py | 16 ++ custom_components/eq3btsmart/sensor.py | 51 +----- custom_components/eq3btsmart/switch.py | 13 +- eq3btsmart/__init__.py | 1 - eq3btsmart/bleakconnection.py | 170 ------------------ eq3btsmart/const.py | 11 +- eq3btsmart/models.py | 19 +- eq3btsmart/thermostat.py | 79 +++++--- tests/test_schedule.py | 49 +++++ tests/test_schedule_set.py | 37 ++++ 14 files changed, 236 insertions(+), 306 deletions(-) create mode 100644 custom_components/eq3btsmart/eq3_coordinator.py delete mode 100755 eq3btsmart/bleakconnection.py create mode 100644 tests/test_schedule.py create mode 100644 tests/test_schedule_set.py diff --git a/custom_components/eq3btsmart/__init__.py b/custom_components/eq3btsmart/__init__.py index 54c88d8..d078427 100644 --- a/custom_components/eq3btsmart/__init__.py +++ b/custom_components/eq3btsmart/__init__.py @@ -89,9 +89,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: thermostat = Thermostat( thermostat_config=thermostat_config, - device=device, + ble_device=device, ) + try: + await thermostat.async_connect() + except Exception as e: + raise ConfigEntryNotReady(f"Could not connect to device: {e}") + eq3_config_entry = Eq3ConfigEntry(eq3_config=eq3_config, thermostat=thermostat) domain_data: dict[str, Any] = hass.data.setdefault(DOMAIN, {}) @@ -110,7 +115,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN].pop(entry.entry_id) - eq3_config_entry.thermostat.shutdown() + await eq3_config_entry.thermostat.async_disconnect() return unload_ok diff --git a/custom_components/eq3btsmart/binary_sensor.py b/custom_components/eq3btsmart/binary_sensor.py index 6bae48d..c6c2a03 100644 --- a/custom_components/eq3btsmart/binary_sensor.py +++ b/custom_components/eq3btsmart/binary_sensor.py @@ -1,8 +1,6 @@ """Platform for eQ-3 binary sensor entities.""" -import json - from custom_components.eq3btsmart.eq3_entity import Eq3Entity from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat @@ -80,15 +78,13 @@ class BusySensor(Base): def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): super().__init__(eq3_config, thermostat) - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) + self._thermostat.register_connection_callback(self.schedule_update_ha_state) self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_name = ENTITY_NAME_BUSY @property def is_on(self) -> bool: - return self._thermostat._conn._lock.locked() + return self._thermostat._lock.locked() class ConnectedSensor(Base): @@ -97,30 +93,25 @@ class ConnectedSensor(Base): def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): super().__init__(eq3_config, thermostat) - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) + self._thermostat.register_connection_callback(self.schedule_update_ha_state) self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_name = ENTITY_NAME_CONNECTED self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - @property - def extra_state_attributes(self) -> dict[str, str] | None: - if (device := self._thermostat._conn._device) is None: - return None - if (details := device.details) is None: - return None - if "props" not in details: - return None + # @property + # def extra_state_attributes(self) -> dict[str, str] | None: + # if (device := self._thermostat._conn._device) is None: + # return None + # if (details := device.details) is None: + # return None + # if "props" not in details: + # return None - return json.loads(json.dumps(details["props"], default=lambda obj: None)) + # return json.loads(json.dumps(details["props"], default=lambda obj: None)) @property def is_on(self) -> bool: - if self._thermostat._conn._conn is None: - return False - - return self._thermostat._conn._conn.is_connected + return self._thermostat._conn.is_connected class BatterySensor(Base): diff --git a/custom_components/eq3btsmart/button.py b/custom_components/eq3btsmart/button.py index bc5abce..960d2fe 100644 --- a/custom_components/eq3btsmart/button.py +++ b/custom_components/eq3btsmart/button.py @@ -1,16 +1,17 @@ """Platform for eQ-3 button entities.""" -import datetime import logging -from typing import Any from custom_components.eq3btsmart.eq3_entity import Eq3Entity from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat from eq3btsmart.const import WeekDay +from eq3btsmart.eq3_schedule_time import Eq3ScheduleTime +from eq3btsmart.eq3_temperature import Eq3Temperature from eq3btsmart.models import Schedule, ScheduleDay, ScheduleHour from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry, UndefinedType +from homeassistant.const import WEEKDAYS from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import format_mac @@ -100,20 +101,30 @@ async def set_schedule(self, **kwargs) -> None: schedule = Schedule() for day in kwargs["days"]: - week_day = WeekDay[day.upper()] + index = WEEKDAYS.index(day) + week_day = WeekDay.from_index(index) + schedule_hours: list[ScheduleHour] = [] schedule_day = ScheduleDay(week_day=week_day, schedule_hours=schedule_hours) times = [ - kwargs.get(f"next_change_at_{i}", datetime.time(0, 0)) for i in range(6) + kwargs.get(f"next_change_at_{i}", None) + for i in range(6) + if f"next_change_at_{i}" in kwargs ] # times[times.index(datetime.time(0, 0))] = HOUR_24_PLACEHOLDER - temps = [kwargs.get(f"target_temp_{i}", 0) for i in range(7)] + temps = [kwargs.get(f"target_temp_{i}", None) for i in range(6)] + + times = list(filter(None, times)) + temps = list(filter(None, temps)) + + if len(times) != len(temps) - 1: + raise ValueError("Times and temps must be of equal length") - for i in range(0, 6): + for time, temp in zip(times, temps): schedule_hour = ScheduleHour( - target_temperature=temps[i], - next_change_at=times[i], + target_temperature=Eq3Temperature(temp), + next_change_at=Eq3ScheduleTime(time), ) schedule_hours.append(schedule_hour) @@ -125,19 +136,15 @@ async def set_schedule(self, **kwargs) -> None: def extra_state_attributes(self): schedule = {} for day in self._thermostat.schedule.schedule_days: - day_nice: dict[str, Any] = {"day": day} - for i, schedule_hour in enumerate(day.schedule_hours): - day_nice[ - f"target_temp_{i}" - ] = schedule_hour.target_temperature.friendly_value - # if schedule_hour.next_change_at == HOUR_24_PLACEHOLDER: - # break - day_nice[ - f"next_change_at_{i}" - ] = schedule_hour.next_change_at.friendly_value.isoformat() - schedule[day] = day_nice - - return schedule + schedule[str(day.week_day)] = [ + { + "target_temperature": schedule_hour.target_temperature.friendly_value, + "next_change_at": schedule_hour.next_change_at.friendly_value.isoformat(), + } + for schedule_hour in day.schedule_hours + ] + + return {"schedule": schedule} class FetchButton(Base): diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index cc002b0..4956b71 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -2,7 +2,7 @@ from enum import Enum from eq3btsmart.const import Adapter, OperationMode -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate import PRESET_NONE, HVACMode from homeassistant.components.climate.const import ( PRESET_AWAY, PRESET_BOOST, @@ -33,6 +33,7 @@ class Preset(str, Enum): + NONE = PRESET_NONE ECO = PRESET_ECO COMFORT = PRESET_COMFORT BOOST = PRESET_BOOST diff --git a/custom_components/eq3btsmart/eq3_coordinator.py b/custom_components/eq3btsmart/eq3_coordinator.py new file mode 100644 index 0000000..4f678c4 --- /dev/null +++ b/custom_components/eq3btsmart/eq3_coordinator.py @@ -0,0 +1,16 @@ +from datetime import timedelta +from logging import Logger + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +class Eq3Coordinator(DataUpdateCoordinator): + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + name: str, + update_interval: timedelta | None, + ): + super().__init__(hass, logger, name=name, update_interval=update_interval) diff --git a/custom_components/eq3btsmart/sensor.py b/custom_components/eq3btsmart/sensor.py index 55f5cb7..d83df1b 100644 --- a/custom_components/eq3btsmart/sensor.py +++ b/custom_components/eq3btsmart/sensor.py @@ -22,8 +22,6 @@ ENTITY_NAME_AWAY_END, ENTITY_NAME_FIRMWARE_VERSION, ENTITY_NAME_MAC, - ENTITY_NAME_PATH, - ENTITY_NAME_RETRIES, ENTITY_NAME_RSSI, ENTITY_NAME_SERIAL_NUMBER, ENTITY_NAME_VALVE, @@ -54,8 +52,6 @@ async def async_setup_entry( new_devices += [ RssiSensor(eq3_config, thermostat), MacSensor(eq3_config, thermostat), - RetriesSensor(eq3_config, thermostat), - PathSensor(eq3_config, thermostat), ] async_add_entities(new_devices) @@ -123,17 +119,14 @@ class RssiSensor(Base): def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): super().__init__(eq3_config, thermostat) - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) + self._thermostat.register_connection_callback(self.schedule_update_ha_state) self._attr_name = ENTITY_NAME_RSSI self._attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT self._attr_entity_category = EntityCategory.DIAGNOSTIC @property def state(self) -> int | None: - return None - return self._thermostat._conn.rssi + return self._thermostat._device._rssi class SerialNumberSensor(Base): @@ -204,43 +197,3 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def state(self) -> str | None: return self._eq3_config.mac_address - - -class RetriesSensor(Base): - """Sensor for the number of retries.""" - - def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): - super().__init__(eq3_config, thermostat) - - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) - self._attr_name = ENTITY_NAME_RETRIES - self._attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def state(self) -> int: - return self._thermostat._conn.retries - - -class PathSensor(Base): - """Sensor for the device path.""" - - def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): - super().__init__(eq3_config, thermostat) - - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) - self._attr_name = ENTITY_NAME_PATH - self._attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def state(self) -> str | None: - if self._thermostat._conn._conn is None: - return None - - if not hasattr(self._thermostat._conn._conn._backend, "_device_path"): - return None - - return self._thermostat._conn._conn._backend._device_path diff --git a/custom_components/eq3btsmart/switch.py b/custom_components/eq3btsmart/switch.py index 42bcb9b..dc2dd55 100644 --- a/custom_components/eq3btsmart/switch.py +++ b/custom_components/eq3btsmart/switch.py @@ -129,23 +129,18 @@ class ConnectionSwitch(Base): def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): super().__init__(eq3_config, thermostat) - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) + self._thermostat.register_connection_callback(self.schedule_update_ha_state) self._attr_name = ENTITY_NAME_CONNECTION self._attr_icon = ENTITY_ICON_CONNECTION self._attr_assumed_state = True self._attr_entity_category = EntityCategory.DIAGNOSTIC async def async_turn_on(self, **kwargs: Any) -> None: - await self._thermostat._conn.async_make_request() + await self._thermostat.async_connect() async def async_turn_off(self, **kwargs: Any) -> None: - if self._thermostat._conn._conn: - await self._thermostat._conn._conn.disconnect() + await self._thermostat.async_disconnect() @property def is_on(self) -> bool | None: - if self._thermostat._conn._conn is None: - return None - return self._thermostat._conn._conn.is_connected + return self._thermostat._conn.is_connected diff --git a/eq3btsmart/__init__.py b/eq3btsmart/__init__.py index 5786d31..b56521e 100755 --- a/eq3btsmart/__init__.py +++ b/eq3btsmart/__init__.py @@ -1,2 +1 @@ -from eq3btsmart.bleakconnection import BleakConnection as BleakConnection from eq3btsmart.thermostat import Thermostat as Thermostat diff --git a/eq3btsmart/bleakconnection.py b/eq3btsmart/bleakconnection.py deleted file mode 100755 index 61e0b5b..0000000 --- a/eq3btsmart/bleakconnection.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Bleak connection backend.""" -import asyncio -import logging -from typing import Callable, Coroutine, cast - -from bleak import BleakClient -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.device import BLEDevice -from bleak_retry_connector import establish_connection - -from eq3btsmart.const import ( - PROP_NTFY_UUID, - PROP_WRITE_UUID, - REQUEST_TIMEOUT, - RETRIES, - RETRY_BACK_OFF_FACTOR, - Adapter, -) -from eq3btsmart.exceptions import BackendException -from eq3btsmart.thermostat_config import ThermostatConfig - -# bleak backends are very loud on debug, this reduces the log spam when using --debug -# logging.getLogger("bleak.backends").setLevel(logging.WARNING) -_LOGGER = logging.getLogger(__name__) - - -class BleakConnection: - """Representation of a BTLE Connection.""" - - def __init__( - self, - thermostat_config: ThermostatConfig, - callback: Callable, - device: BLEDevice | None = None, - get_device: Coroutine[None, None, BLEDevice] | None = None, - ): - """Initialize the connection.""" - - if device is None and get_device is None: - raise Exception("Either device or get_device must be provided") - - if device is not None and get_device is not None: - raise Exception("Either device or get_device must be provided") - - self.thermostat_config = thermostat_config - self._callback = callback - self._notify_event = asyncio.Event() - self._terminate_event = asyncio.Event() - self._lock = asyncio.Lock() - self._conn: BleakClient | None = None - self._device: BLEDevice | None = device - self._get_device: Coroutine[None, None, BLEDevice] | None = get_device - self._connection_callbacks: list[Callable] = [] - self.retries = 0 - self._round_robin = 0 - - def register_connection_callback(self, callback: Callable) -> None: - self._connection_callbacks.append(callback) - - async def async_connect(self) -> None: - """Connect to the thermostat.""" - - if self._device is None: - raise NotImplementedError("get_device not implemented") - - match self.thermostat_config.adapter: - case Adapter.AUTO: - self._conn = await establish_connection( - client_class=BleakClient, - device=self._device, - name=self.thermostat_config.name, - disconnected_callback=lambda client: self._on_connection_event(), - max_attempts=2, - use_services_cache=True, - ) - - case Adapter.LOCAL: - UnwrappedBleakClient = cast(type[BleakClient], BleakClient.__bases__[0]) - self._conn = UnwrappedBleakClient( - self._device, - disconnected_callback=lambda client: self._on_connection_event(), - dangerous_use_bleak_cache=True, - ) - await self._conn.connect() - - if self._conn is None or not self._conn.is_connected: - raise BackendException("Can't connect") - - def disconnect(self) -> None: - self._terminate_event.set() - self._notify_event.set() - - async def throw_if_terminating(self) -> None: - if self._terminate_event.is_set(): - if self._conn: - await self._conn.disconnect() - raise Exception("Connection cancelled by shutdown") - - async def on_notification( - self, handle: BleakGATTCharacteristic, data: bytearray - ) -> None: - """Handle Callback from a Bluetooth (GATT) request.""" - if PROP_NTFY_UUID == handle.uuid: - self._notify_event.set() - data_bytes = bytes(data) - self._callback(data_bytes) - else: - _LOGGER.error( - "[%s] wrong charasteristic: %s, %s", - self.thermostat_config.name, - handle.handle, - handle.uuid, - ) - - async def async_make_request( - self, value: bytes | None = None, retries: int = RETRIES - ) -> None: - """Write a GATT Command with callback - not utf-8.""" - async with self._lock: # only one concurrent request per thermostat - try: - await self._async_make_request_try(value, retries) - finally: - self.retries = 0 - self._on_connection_event() - - async def _async_make_request_try( - self, value: bytes | None = None, retries: int = RETRIES - ) -> None: - self.retries = 0 - while True: - self.retries += 1 - self._on_connection_event() - try: - await self.throw_if_terminating() - await self.async_connect() - - if self._conn is None: - raise BackendException("Can't connect") - - self._notify_event.clear() - if value is not None: - try: - await self._conn.start_notify( - PROP_NTFY_UUID, self.on_notification - ) - await self._conn.write_gatt_char( - PROP_WRITE_UUID, value, response=True - ) - await asyncio.wait_for( - self._notify_event.wait(), REQUEST_TIMEOUT - ) - finally: - if self.thermostat_config.stay_connected: - await self._conn.stop_notify(PROP_NTFY_UUID) - else: - await self._conn.disconnect() - return - except Exception as ex: - await self.throw_if_terminating() - - self._round_robin = self._round_robin + 1 - - if self.retries >= retries: - raise ex - - await asyncio.sleep(RETRY_BACK_OFF_FACTOR * self.retries) - - def _on_connection_event(self) -> None: - for callback in self._connection_callbacks: - callback() diff --git a/eq3btsmart/const.py b/eq3btsmart/const.py index 1cea859..afbc04a 100755 --- a/eq3btsmart/const.py +++ b/eq3btsmart/const.py @@ -14,9 +14,9 @@ # Handles in linux and BTProxy are off by 1. Using UUIDs instead for consistency PROP_WRITE_UUID = "3fa4585a-ce4a-3bad-db4b-b8df8179ea09" -PROP_NTFY_UUID = "d0e8434d-cd29-0996-af41-6c90f4e0eb2a" +PROP_NOTIFY_UUID = "d0e8434d-cd29-0996-af41-6c90f4e0eb2a" -REQUEST_TIMEOUT = 5 +REQUEST_TIMEOUT = 10 RETRY_BACK_OFF_FACTOR = 0.25 RETRIES = 14 @@ -54,6 +54,13 @@ class WeekDay(EnumBase): THURSDAY = 5 FRIDAY = 6 + @classmethod + def from_index(cls, index: int) -> "WeekDay": + """Return weekday from index.""" + + adjusted_index = index + 2 if index < 5 else index - 5 + return cls(adjusted_index) + class OperationMode(EnumBase): """Operation modes.""" diff --git a/eq3btsmart/models.py b/eq3btsmart/models.py index 0bb9ee3..8da09c9 100755 --- a/eq3btsmart/models.py +++ b/eq3btsmart/models.py @@ -126,10 +126,21 @@ class Schedule: schedule_days: list[ScheduleDay] = field(default_factory=list) def merge(self, other_schedule: Self) -> None: - for schedule_day in other_schedule.schedule_days: - self.schedule_days[ - schedule_day.week_day - ].schedule_hours = schedule_day.schedule_hours + for other_schedule_day in other_schedule.schedule_days: + schedule_day = next( + ( + schedule_day + for schedule_day in self.schedule_days + if schedule_day.week_day == other_schedule_day.week_day + ), + None, + ) + + if not schedule_day: + self.schedule_days.append(other_schedule_day) + continue + + schedule_day.schedule_hours = other_schedule_day.schedule_hours @classmethod def from_bytes(cls, data: bytes) -> Self: diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index 24416d2..cba95ac 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -7,14 +7,16 @@ Schedule needs to be requested with query_schedule() before accessing for similar reasons. """ +import asyncio import logging from datetime import datetime, timedelta -from typing import Callable, Coroutine +from typing import Callable +from bleak import BleakClient +from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice from construct_typed import DataclassStruct -from eq3btsmart.bleakconnection import BleakConnection from eq3btsmart.const import ( DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP, @@ -22,6 +24,9 @@ EQ3BT_MIN_TEMP, EQ3BT_OFF_TEMP, EQ3BT_ON_TEMP, + PROP_NOTIFY_UUID, + PROP_WRITE_UUID, + REQUEST_TIMEOUT, Command, Eq3Preset, OperationMode, @@ -62,38 +67,44 @@ class Thermostat: def __init__( self, thermostat_config: ThermostatConfig, - device: BLEDevice | None = None, - get_device: Coroutine[None, None, BLEDevice] | None = None, + ble_device: BLEDevice, ): """Initialize the thermostat.""" - if device is None and get_device is None: - raise Exception("Either device or get_device must be provided") - - if device is not None and get_device is not None: - raise Exception("Either device or get_device must be provided") - self.thermostat_config = thermostat_config self.status: Status = Status() self.device_data: DeviceData = DeviceData() self.schedule: Schedule = Schedule() self._on_update_callbacks: list[Callable] = [] - self._conn = BleakConnection( - thermostat_config=self.thermostat_config, - device=device, - get_device=get_device, - callback=self.handle_notification, + self._on_connection_callbacks: list[Callable] = [] + self._device = ble_device + self._conn: BleakClient = BleakClient( + ble_device, + disconnected_callback=lambda client: self.on_connection(), + timeout=REQUEST_TIMEOUT, ) + self._lock = asyncio.Lock() + + def register_connection_callback(self, on_connect: Callable) -> None: + """Register a callback function that will be called when a connection is established.""" + + self._on_connection_callbacks.append(on_connect) def register_update_callback(self, on_update: Callable) -> None: """Register a callback function that will be called when an update is received.""" self._on_update_callbacks.append(on_update) - def shutdown(self) -> None: + async def async_connect(self) -> None: + """Connect to the thermostat.""" + + await self._conn.connect() + await self._conn.start_notify(PROP_NOTIFY_UUID, self.on_notification) + + async def async_disconnect(self) -> None: """Shutdown the connection to the thermostat.""" - self._conn.disconnect() + await self._conn.disconnect() async def async_get_id(self) -> None: """Query device identification information, e.g. the serial number.""" @@ -279,29 +290,47 @@ async def async_set_schedule(self, schedule: Schedule) -> None: async def _async_write_command(self, command: Eq3Command) -> None: """Write a EQ3 command to the thermostat.""" - await self._conn.async_make_request(command.to_bytes()) + if not self._conn.is_connected: + return + + async with self._lock: + await self._conn.write_gatt_char(PROP_WRITE_UUID, command.to_bytes()) - def handle_notification(self, data: bytes) -> None: + self.on_connection() + + def on_connection(self) -> None: + for callback in self._on_connection_callbacks: + callback() + + def on_notification(self, handle: BleakGATTCharacteristic, data: bytearray) -> None: """Handle Callback from a Bluetooth (GATT) request.""" updated: bool = True + data_bytes = bytes(data) - command = DataclassStruct(Eq3Command).parse(data) + command = DataclassStruct(Eq3Command).parse(data_bytes) + + if command.payload is None: + return + + is_status_command = command.payload[0] == 0x01 try: match command.cmd: case Command.ID_RETURN: - self.device_data = DeviceData.from_bytes(data) + self.device_data = DeviceData.from_bytes(data_bytes) case Command.INFO_RETURN: - self.status = Status.from_bytes(data) + if is_status_command: + self.status = Status.from_bytes(data_bytes) case Command.SCHEDULE_RETURN: - schedule = Schedule.from_bytes(data) + schedule = Schedule.from_bytes(data_bytes) self.schedule.merge(schedule) case _: updated = False except Exception: - # print all bytes received in this format: Received: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 - _LOGGER.exception("Received: %s", " ".join([f"0x{b:02x}" for b in data])) + _LOGGER.exception( + "Received: %s", " ".join([f"0x{b:02x}" for b in data_bytes]) + ) updated = False if not updated: diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..46ec8e3 --- /dev/null +++ b/tests/test_schedule.py @@ -0,0 +1,49 @@ +from eq3btsmart.models import Schedule + + +def test_schedule(): + received = bytes( + [ + 0x21, + 0x06, + 0x26, + 0x1B, + 0x22, + 0x39, + 0x27, + 0x90, + 0x2C, + 0x81, + 0x22, + 0x90, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + schedule = Schedule.from_bytes(received) + + received = bytes( + [ + 0x21, + 0x06, + 0x26, + 0x1B, + 0x22, + 0x39, + 0x27, + 0x90, + 0x2C, + 0x81, + 0x22, + 0x90, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + schedule2 = Schedule.from_bytes(received) + + schedule.merge(schedule2) diff --git a/tests/test_schedule_set.py b/tests/test_schedule_set.py new file mode 100644 index 0000000..817de6b --- /dev/null +++ b/tests/test_schedule_set.py @@ -0,0 +1,37 @@ +from datetime import time + +from eq3btsmart.const import WeekDay +from eq3btsmart.eq3_schedule_time import Eq3ScheduleTime +from eq3btsmart.eq3_temperature import Eq3Temperature +from eq3btsmart.models import Schedule, ScheduleDay, ScheduleHour +from eq3btsmart.structures import ScheduleHourStruct, ScheduleSetCommand + + +def test_schedule_set(): + schedule = Schedule( + schedule_days=[ + ScheduleDay( + WeekDay.MONDAY, + schedule_hours=[ + ScheduleHour( + target_temperature=Eq3Temperature(20), + next_change_at=Eq3ScheduleTime(time(hour=6, minute=0)), + ), + ], + ) + ] + ) + + for schedule_day in schedule.schedule_days: + command = ScheduleSetCommand( + day=schedule_day.week_day, + hours=[ + ScheduleHourStruct( + target_temp=schedule_hour.target_temperature, + next_change_at=schedule_hour.next_change_at, + ) + for schedule_hour in schedule_day.schedule_hours + ], + ) + + command.to_bytes() From 9ef65176da46341b769a8ac46c616ba5cb78eac3 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 22 Jan 2024 19:58:20 +0100 Subject: [PATCH 17/20] fix: keep climate entity available --- custom_components/eq3btsmart/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/eq3btsmart/climate.py b/custom_components/eq3btsmart/climate.py index 48fd57f..6b94516 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -335,7 +335,7 @@ async def async_scan(self) -> None: if self._is_setting_temperature: await self.async_set_temperature_now() except Exception as ex: - self._is_available = False + # self._is_available = False self.schedule_update_ha_state() _LOGGER.error( f"[{self._eq3_config.name}] Error updating: {ex}", From 077883d169c526e1f9754f14027dc3071091b9f6 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 22 Jan 2024 23:02:58 +0100 Subject: [PATCH 18/20] feat: add connection monitoring and reconnect logic --- custom_components/eq3btsmart/binary_sensor.py | 29 +++++++++++++++++++ custom_components/eq3btsmart/const.py | 1 + .../eq3btsmart/eq3_coordinator.py | 16 ---------- eq3btsmart/eq3_connection_monitor.py | 24 +++++++++++++++ eq3btsmart/thermostat.py | 6 ++++ 5 files changed, 60 insertions(+), 16 deletions(-) delete mode 100644 custom_components/eq3btsmart/eq3_coordinator.py create mode 100644 eq3btsmart/eq3_connection_monitor.py diff --git a/custom_components/eq3btsmart/binary_sensor.py b/custom_components/eq3btsmart/binary_sensor.py index c6c2a03..a67366b 100644 --- a/custom_components/eq3btsmart/binary_sensor.py +++ b/custom_components/eq3btsmart/binary_sensor.py @@ -20,6 +20,7 @@ ENTITY_NAME_BUSY, ENTITY_NAME_CONNECTED, ENTITY_NAME_DST, + ENTITY_NAME_MONITORING, ENTITY_NAME_WINDOW_OPEN, ) @@ -45,6 +46,7 @@ async def async_setup_entry( entities_to_add += [ BusySensor(eq3_config, thermostat), ConnectedSensor(eq3_config, thermostat), + MonitoringSensor(eq3_config, thermostat), ] async_add_entities(entities_to_add) @@ -114,6 +116,33 @@ def is_on(self) -> bool: return self._thermostat._conn.is_connected +class MonitoringSensor(Base): + """Binary sensor that reports if the thermostat connection monitor is running.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) + + self._thermostat.register_connection_callback(self.schedule_update_ha_state) + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._attr_name = ENTITY_NAME_MONITORING + self._attr_device_class = BinarySensorDeviceClass.RUNNING + + # @property + # def extra_state_attributes(self) -> dict[str, str] | None: + # if (device := self._thermostat._conn._device) is None: + # return None + # if (details := device.details) is None: + # return None + # if "props" not in details: + # return None + + # return json.loads(json.dumps(details["props"], default=lambda obj: None)) + + @property + def is_on(self) -> bool: + return self._thermostat._monitor._run + + class BatterySensor(Base): """Binary sensor that reports if the thermostat battery is low.""" diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index 4956b71..1b50e01 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -77,6 +77,7 @@ class Preset(str, Enum): ENTITY_NAME_AWAY_SWITCH = "Away" ENTITY_NAME_BOOST_SWITCH = "Boost" ENTITY_NAME_CONNECTION = "Connection" +ENTITY_NAME_MONITORING = "Monitoring" ENTITY_ICON_VALVE = "mdi:pipe-valve" ENTITY_ICON_AWAY_SWITCH = "mdi:lock" diff --git a/custom_components/eq3btsmart/eq3_coordinator.py b/custom_components/eq3btsmart/eq3_coordinator.py deleted file mode 100644 index 4f678c4..0000000 --- a/custom_components/eq3btsmart/eq3_coordinator.py +++ /dev/null @@ -1,16 +0,0 @@ -from datetime import timedelta -from logging import Logger - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - - -class Eq3Coordinator(DataUpdateCoordinator): - def __init__( - self, - hass: HomeAssistant, - logger: Logger, - name: str, - update_interval: timedelta | None, - ): - super().__init__(hass, logger, name=name, update_interval=update_interval) diff --git a/eq3btsmart/eq3_connection_monitor.py b/eq3btsmart/eq3_connection_monitor.py new file mode 100644 index 0000000..204b262 --- /dev/null +++ b/eq3btsmart/eq3_connection_monitor.py @@ -0,0 +1,24 @@ +import asyncio + +from bleak import BleakClient + + +class Eq3ConnectionMonitor: + def __init__(self, client: BleakClient): + self._client = client + self._run = False + + async def run(self): + self._run = True + + while self._run: + try: + if not self._client.is_connected: + await self._client.connect() + except Exception: + pass + + await asyncio.sleep(5) + + async def stop(self): + self._run = False diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index cba95ac..a6ad9a3 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -33,6 +33,7 @@ WeekDay, ) from eq3btsmart.eq3_away_time import Eq3AwayTime +from eq3btsmart.eq3_connection_monitor import Eq3ConnectionMonitor from eq3btsmart.eq3_duration import Eq3Duration from eq3btsmart.eq3_temperature import Eq3Temperature from eq3btsmart.eq3_temperature_offset import Eq3TemperatureOffset @@ -84,6 +85,7 @@ def __init__( timeout=REQUEST_TIMEOUT, ) self._lock = asyncio.Lock() + self._monitor = Eq3ConnectionMonitor(self._conn) def register_connection_callback(self, on_connect: Callable) -> None: """Register a callback function that will be called when a connection is established.""" @@ -101,9 +103,13 @@ async def async_connect(self) -> None: await self._conn.connect() await self._conn.start_notify(PROP_NOTIFY_UUID, self.on_notification) + loop = asyncio.get_running_loop() + loop.create_task(self._monitor.run()) + async def async_disconnect(self) -> None: """Shutdown the connection to the thermostat.""" + await self._monitor.stop() await self._conn.disconnect() async def async_get_id(self) -> None: From 59e7a932db1fbd3b7d68dbdb635ce4763231451b Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 22 Jan 2024 23:05:14 +0100 Subject: [PATCH 19/20] refactor: change default current temperature selector --- custom_components/eq3btsmart/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index 1b50e01..05f8e24 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -102,7 +102,7 @@ class TargetTemperatureSelector(str, Enum): DEFAULT_ADAPTER = Adapter.AUTO -DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.UI +DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.DEVICE DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET DEFAULT_STAY_CONNECTED = True DEFAULT_DEBUG_MODE = False From 732a813b8018b9ea4ec3c2f79b84cbec1ae52b76 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 22 Jan 2024 23:08:31 +0100 Subject: [PATCH 20/20] refactor: add monitor interval constant --- eq3btsmart/const.py | 6 +++--- eq3btsmart/eq3_connection_monitor.py | 4 +++- eq3btsmart/thermostat.py | 10 ++++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/eq3btsmart/const.py b/eq3btsmart/const.py index afbc04a..034f403 100755 --- a/eq3btsmart/const.py +++ b/eq3btsmart/const.py @@ -12,13 +12,13 @@ EQ3BT_MIN_OFFSET = -3.5 EQ3BT_MAX_OFFSET = 3.5 -# Handles in linux and BTProxy are off by 1. Using UUIDs instead for consistency -PROP_WRITE_UUID = "3fa4585a-ce4a-3bad-db4b-b8df8179ea09" -PROP_NOTIFY_UUID = "d0e8434d-cd29-0996-af41-6c90f4e0eb2a" +WRITE_CHARACTERISTIC_UUID = "3fa4585a-ce4a-3bad-db4b-b8df8179ea09" +NOTIFY_CHARACTERISTIC_UUID = "d0e8434d-cd29-0996-af41-6c90f4e0eb2a" REQUEST_TIMEOUT = 10 RETRY_BACK_OFF_FACTOR = 0.25 RETRIES = 14 +MONITOR_INTERVAL = 10 DEFAULT_AWAY_HOURS = 30 * 24 DEFAULT_AWAY_TEMP = 12 diff --git a/eq3btsmart/eq3_connection_monitor.py b/eq3btsmart/eq3_connection_monitor.py index 204b262..a34dea4 100644 --- a/eq3btsmart/eq3_connection_monitor.py +++ b/eq3btsmart/eq3_connection_monitor.py @@ -2,6 +2,8 @@ from bleak import BleakClient +from eq3btsmart.const import MONITOR_INTERVAL + class Eq3ConnectionMonitor: def __init__(self, client: BleakClient): @@ -18,7 +20,7 @@ async def run(self): except Exception: pass - await asyncio.sleep(5) + await asyncio.sleep(MONITOR_INTERVAL) async def stop(self): self._run = False diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index a6ad9a3..4dcf093 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -24,9 +24,9 @@ EQ3BT_MIN_TEMP, EQ3BT_OFF_TEMP, EQ3BT_ON_TEMP, - PROP_NOTIFY_UUID, - PROP_WRITE_UUID, + NOTIFY_CHARACTERISTIC_UUID, REQUEST_TIMEOUT, + WRITE_CHARACTERISTIC_UUID, Command, Eq3Preset, OperationMode, @@ -101,7 +101,7 @@ async def async_connect(self) -> None: """Connect to the thermostat.""" await self._conn.connect() - await self._conn.start_notify(PROP_NOTIFY_UUID, self.on_notification) + await self._conn.start_notify(NOTIFY_CHARACTERISTIC_UUID, self.on_notification) loop = asyncio.get_running_loop() loop.create_task(self._monitor.run()) @@ -300,7 +300,9 @@ async def _async_write_command(self, command: Eq3Command) -> None: return async with self._lock: - await self._conn.write_gatt_char(PROP_WRITE_UUID, command.to_bytes()) + await self._conn.write_gatt_char( + WRITE_CHARACTERISTIC_UUID, command.to_bytes() + ) self.on_connection()