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/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/__init__.py b/custom_components/eq3btsmart/__init__.py index 79bbb3e..d078427 100644 --- a/custom_components/eq3btsmart/__init__.py +++ b/custom_components/eq3btsmart/__init__.py @@ -1,20 +1,35 @@ """Support for EQ3 devices.""" -from __future__ import annotations import logging +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 Platform +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, + 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, @@ -28,45 +43,121 @@ _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_config = ThermostatConfig( + mac_address=mac_address, + name=name, + adapter=adapter, + stay_connected=stay_connected, + ) + + try: + device = await async_get_device(hass, eq3_config) + except Exception as e: + raise ConfigEntryNotReady(f"Could not connect to device: {e}") 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), - hass=hass, + thermostat_config=thermostat_config, + ble_device=device, ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = thermostat - entry.async_on_unload(entry.add_update_listener(update_listener)) + try: + await thermostat.async_connect() + except Exception as e: + raise ConfigEntryNotReady(f"Could not connect to device: {e}") - # 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. + eq3_config_entry = Eq3ConfigEntry(eq3_config=eq3_config, thermostat=thermostat) + + 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) + await eq3_config_entry.thermostat.async_disconnect() + 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) + + +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 0285b16..a67366b 100644 --- a/custom_components/eq3btsmart/binary_sensor.py +++ b/custom_components/eq3btsmart/binary_sensor.py @@ -1,6 +1,8 @@ -import json -import logging +"""Platform for eQ-3 binary sensor entities.""" + +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 +11,18 @@ 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_MONITORING, + ENTITY_NAME_WINDOW_OPEN, +) async def async_setup_entry( @@ -22,26 +30,34 @@ 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), + MonitoringSensor(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 @@ -49,92 +65,125 @@ 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 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.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() + return self._thermostat._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.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: + # 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 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: - return None - if "props" not in details: - return None + 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 - return json.loads(json.dumps(details["props"], default=json_serial)) + # @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: - if self._thermostat._conn._conn is None: - return False - return self._thermostat._conn._conn.is_connected + return self._thermostat._monitor._run 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 @property - def is_on(self): - return self._thermostat.low_battery + def is_on(self) -> bool | None: + return self._thermostat.status.is_low_battery 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 - def is_on(self): - return self._thermostat.window_open + def is_on(self) -> bool | None: + return self._thermostat.status.is_window_open 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 - def is_on(self): - return self._thermostat.dst + def is_on(self) -> bool | None: + return self._thermostat.status.is_dst diff --git a/custom_components/eq3btsmart/button.py b/custom_components/eq3btsmart/button.py index d3879bc..960d2fe 100644 --- a/custom_components/eq3btsmart/button.py +++ b/custom_components/eq3btsmart/button.py @@ -1,107 +1,67 @@ -import datetime +"""Platform for eQ-3 button entities.""" + 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 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 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, + SERVICE_SET_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 - "set_schedule", + SERVICE_SET_SCHEDULE, + SCHEMA_SCHEDULE_SET, + SERVICE_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 @@ -109,72 +69,92 @@ 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 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.""" + + 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_FETCH_SCHEDULE async def async_press(self) -> None: - await self.fetch_schedule() + await self._thermostat.async_get_schedule() - async def fetch_schedule(self): - for x in range(0, 7): - 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: - _LOGGER.debug("[%s] set_schedule (day %s)", self._thermostat.name, kwargs) + """Called when the set_schedule service is invoked.""" + + _LOGGER.debug(f"[{self._eq3_config.name}] set_schedule: {kwargs}") + + schedule = Schedule() for day in kwargs["days"]: + 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)] - hours = [] - for i in range(0, 6): - hours.append( - { - "target_temp": temps[i], - "next_change_at": times[i], - } + # times[times.index(datetime.time(0, 0))] = HOUR_24_PLACEHOLDER + 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 time, temp in zip(times, temps): + schedule_hour = ScheduleHour( + target_temperature=Eq3Temperature(temp), + next_change_at=Eq3ScheduleTime(time), ) - await self._thermostat.async_set_schedule(day=day, hours=hours) + schedule_hours.append(schedule_hour) + + 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() - schedule[day] = day_nice + for day in self._thermostat.schedule.schedule_days: + 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 + return {"schedule": schedule} 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: - 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 9237d92..6b94516 100644 --- a/custom_components/eq3btsmart/climate.py +++ b/custom_components/eq3btsmart/climate.py @@ -1,15 +1,14 @@ -"""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 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, @@ -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,15 @@ 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, + f"[{self._eq3_config.name}] created climate entity", ) async def async_added_to_hass(self) -> None: @@ -137,8 +99,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(seconds=self._eq3_config.scan_interval) self._cancel_timer = async_call_later( self.hass, delay, self._async_scan_loop ) @@ -146,66 +109,92 @@ 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: + + if ( + 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.target_temperature + self._target_temperature_to_set = ( + self._thermostat.status.target_temperature.friendly_value + ) + 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 + 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: + 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 @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.status.valve is None + or self._thermostat.status.target_temperature is None + ): + return None + return ( + (1 - self._thermostat.status.valve / 100) * 2 + + self._thermostat.status.target_temperature.friendly_value + - 2 + ) + case CurrentTemperatureSelector.UI: + return self._target_temperature_to_set + case CurrentTemperatureSelector.DEVICE: + 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: + 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 + if self._thermostat.status.target_temperature is None: + return None + + return self._thermostat.status.target_temperature.friendly_value + + 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. @@ -218,13 +207,14 @@ 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) + 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) @@ -238,112 +228,115 @@ 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() async def async_set_temperature_now(self) -> None: - await self._thermostat.async_set_target_temperature( - self._target_temperature_to_set - ) + 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: - """Return the current operation mode.""" - 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: - """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 _: + 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() 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" - if self._thermostat.boost: + if self._thermostat.status.is_window_open: + return Preset.WINDOW_OPEN + if self._thermostat.status.is_boost: return Preset.BOOST - if self._thermostat.low_battery: - return "Low Battery" - if self._thermostat.away: + if self._thermostat.status.is_low_battery: + return Preset.LOW_BATTERY + 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 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) 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: return DeviceInfo( - name=self._thermostat.name, - manufacturer="eQ-3 AG", - model="CC-RT-BLE-EQ", - identifiers={(DOMAIN, self._thermostat.mac)}, - sw_version=self._thermostat.firmware_version, - connections={(CONNECTION_BLUETOOTH, self._thermostat.mac)}, + 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 or "unknown"), + 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() + await self._thermostat.async_get_info() 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( - "[%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 e0fc745..c9596bd 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) + + _LOGGER.debug(f"async_step_user: {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,14 +67,14 @@ 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() _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 name = self.discovery_info.device.name or self.discovery_info.name self.context.update( @@ -84,7 +82,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 +90,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 +122,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..05f8e24 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -1,8 +1,8 @@ """Constants for EQ3 Bluetooth Smart Radiator Valves.""" from enum import Enum -from eq3btsmart.const import Mode -from homeassistant.components.climate import HVACMode +from eq3btsmart.const import Adapter, OperationMode +from homeassistant.components.climate import PRESET_NONE, HVACMode from homeassistant.components.climate.const import ( PRESET_AWAY, PRESET_BOOST, @@ -12,27 +12,35 @@ DOMAIN = "eq3btsmart" -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, +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, + 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, } class Preset(str, Enum): + NONE = PRESET_NONE ECO = PRESET_ECO COMFORT = PRESET_COMFORT BOOST = PRESET_BOOST AWAY = PRESET_AWAY OPEN = "Open" + LOW_BATTERY = "Low Battery" + WINDOW_OPEN = "Window" CONF_ADAPTER = "conf_adapter" @@ -41,13 +49,43 @@ 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 - - -class Adapter(str, Enum): - AUTO = "AUTO" - LOCAL = "LOCAL" +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_NAME_MONITORING = "Monitoring" + +ENTITY_ICON_VALVE = "mdi:pipe-valve" +ENTITY_ICON_AWAY_SWITCH = "mdi:lock" +ENTITY_ICON_BOOST_SWITCH = "mdi:speedometer" +ENTITY_ICON_CONNECTION = "mdi:bluetooth" + +SERVICE_SET_AWAY_UNTIL = "set_away_until" +SERVICE_SET_SCHEDULE = "set_schedule" class CurrentTemperatureSelector(str, Enum): @@ -64,6 +102,8 @@ 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 +DEFAULT_SCAN_INTERVAL = 10 # seconds diff --git a/custom_components/eq3btsmart/eq3_entity.py b/custom_components/eq3btsmart/eq3_entity.py new file mode 100644 index 0000000..b8ead99 --- /dev/null +++ b/custom_components/eq3btsmart/eq3_entity.py @@ -0,0 +1,12 @@ +from custom_components.eq3btsmart.models import Eq3Config +from eq3btsmart.thermostat import Thermostat +from homeassistant.helpers.entity import Entity + + +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/lock.py b/custom_components/eq3btsmart/lock.py index 9f79c87..757b26b 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,20 +44,23 @@ 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 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) @@ -59,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..70f4471 100644 --- a/custom_components/eq3btsmart/manifest.json +++ b/custom_components/eq3btsmart/manifest.json @@ -3,8 +3,9 @@ "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"], - "dependencies": ["bluetooth"], + "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/models.py b/custom_components/eq3btsmart/models.py new file mode 100644 index 0000000..7abde1d --- /dev/null +++ b/custom_components/eq3btsmart/models.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass + +from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP +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 + default_away_hours: float = DEFAULT_AWAY_HOURS + default_away_temperature: float = DEFAULT_AWAY_TEMP + + +@dataclass +class Eq3ConfigEntry: + eq3_config: Eq3Config + thermostat: Thermostat diff --git a/custom_components/eq3btsmart/number.py b/custom_components/eq3btsmart/number.py index 2de4aa2..e12f0c2 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,198 +80,195 @@ 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: - return self._thermostat.comfort_temperature + if self._thermostat.status.comfort_temperature is None: + return None + + return self._thermostat.status.comfort_temperature.friendly_value 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() + await self._thermostat.async_configure_presets(comfort_temperature=value) - if other is None: - return - await self._thermostat.async_temperature_presets(comfort=value, eco=other) +class EcoTemperature(Base): + """Number entity for the eco temperature.""" + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) -class EcoTemperature(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "Eco" + self._attr_name = ENTITY_NAME_ECO @property def native_value(self) -> float | None: - return self._thermostat.eco_temperature + if self._thermostat.status.eco_temperature is None: + return None + + return self._thermostat.status.eco_temperature.friendly_value 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() + await self._thermostat.async_configure_presets(eco_temperature=value) - if other is None: - return - await self._thermostat.async_temperature_presets(comfort=other, eco=value) +class OffsetTemperature(Base): + """Number entity for the temperature offset.""" + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) -class OffsetTemperature(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "Offset" + self._attr_name = ENTITY_NAME_OFFSET self._attr_native_min_value = EQ3BT_MIN_OFFSET self._attr_native_max_value = EQ3BT_MAX_OFFSET @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_set_temperature_offset(value) + await self._thermostat.async_temperature_offset_configure(value) 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: - 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_window_open_config( - temperature=value, duration=self._thermostat.window_open_time + await self._thermostat.async_configure_window_open( + 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, + ) + + +class WindowOpenTimeout(Base): + """Number entity for the window open timeout.""" + + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) -class WindowOpenTimeout(NumberEntity): - def __init__(self, _thermostat: Thermostat): - _thermostat.register_update_callback(self.schedule_update_ha_state) - self._thermostat = _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: + 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_window_open_config( - temperature=self._thermostat.window_open_temperature, + await self._thermostat.async_configure_window_open( + temperature=self._thermostat.status.window_open_temperature.friendly_value, duration=timedelta(minutes=value), ) -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.""" 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): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "Away" + """Number entity for the away temperature.""" - @property - def unique_id(self) -> str | None: - if self.name is None or isinstance(self.name, UndefinedType): - return None + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) - return format_mac(self._thermostat.mac) + "_" + self.name - - @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 + 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/schemas.py b/custom_components/eq3btsmart/schemas.py new file mode 100644 index 0000000..45f8016 --- /dev/null +++ b/custom_components/eq3btsmart/schemas.py @@ -0,0 +1,215 @@ +"""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 = 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 1ce8fb8..d83df1b 100644 --- a/custom_components/eq3btsmart/sensor.py +++ b/custom_components/eq3btsmart/sensor.py @@ -1,10 +1,13 @@ +"""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 +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 @@ -13,7 +16,16 @@ 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_RSSI, + ENTITY_NAME_SERIAL_NUMBER, + ENTITY_NAME_VALVE, +) _LOGGER = logging.getLogger(__name__) @@ -23,30 +35,34 @@ 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), ] - 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,73 +70,88 @@ 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 def state(self) -> int | None: - return self._thermostat.valve_state + return self._thermostat.status.valve 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 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): - 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.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 self._thermostat._conn.rssi + return self._thermostat._device._rssi 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 def state(self) -> str | None: - return self._thermostat.device_serial + return self._thermostat.device_data.device_serial 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: @@ -128,70 +159,41 @@ 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._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.device_data.firmware_version), ) _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.device_data.firmware_version} serial: {self._thermostat.device_data.device_serial}", ) @property def state(self) -> str | None: - return self._thermostat.firmware_version + return str(self._thermostat.device_data.firmware_version) class MacSensor(Base): - def __init__(self, _thermostat: Thermostat): - super().__init__(_thermostat) - self._attr_name = "MAC" - self._attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def state(self) -> str | None: - return self._thermostat.mac + """Sensor for the MAC address.""" + def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): + super().__init__(eq3_config, thermostat) -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" - self._attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def state(self) -> int: - return self._thermostat._conn.retries - - -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" + self._attr_name = ENTITY_NAME_MAC 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 + return self._eq3_config.mac_address diff --git a/custom_components/eq3btsmart/switch.py b/custom_components/eq3btsmart/switch.py index 6905e51..dc2dd55 100644 --- a/custom_components/eq3btsmart/switch.py +++ b/custom_components/eq3btsmart/switch.py @@ -1,26 +1,30 @@ -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 -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 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, + SERVICE_SET_AWAY_UNTIL, +) +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), - "set_away_until", + SERVICE_SET_AWAY_UNTIL, + SCHEMA_SET_AWAY_UNTIL, + SERVICE_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) @@ -83,18 +96,21 @@ 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, temperature: float) -> None: - await self._thermostat.async_set_away_until(away_until, temperature) + async def set_away_until(self, away_until: datetime, temperature: float) -> None: + await self._thermostat.async_set_away(True, 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) @@ -104,27 +120,27 @@ 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): - 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.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("ONLY CONNECT") + 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/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/__init__.py b/eq3btsmart/__init__.py old mode 100644 new mode 100755 index 5786d31..b56521e --- 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 100644 index 06b9e2a..0000000 --- a/eq3btsmart/bleakconnection.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Bleak connection backend.""" -import asyncio -import logging -from typing import Callable, cast - -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 eq3btsmart.const import ( - PROP_NTFY_UUID, - PROP_WRITE_UUID, - REQUEST_TIMEOUT, - RETRIES, - RETRY_BACK_OFF_FACTOR, - Adapter, -) -from eq3btsmart.exceptions import BackendException - -# 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, - mac: str, - name: str, - adapter: str, - stay_connected: bool, - hass: HomeAssistant, - callback, - ): - """Initialize the connection.""" - self._mac = mac - self._name = name - self._adapter = adapter - self._stay_connected = stay_connected - 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._connection_callbacks: list[Callable] = [] - self.retries = 0 - self._round_robin = 0 - - 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() - - def shutdown(self) -> None: - _LOGGER.debug( - "[%s] closing connections", - self._name, - ) - 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 async_get_connection(self) -> BleakClient: - if self._adapter == Adapter.AUTO: - self._ble_device = bluetooth.async_ble_device_from_address( - self._hass, self._mac, 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._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._mac, connectable=True - ), - key=lambda device_advertisement_data: device_advertisement_data.advertisement.rssi - or NO_RSSI_VALUE, - reverse=True, - ) - if self._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._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) - else: - _LOGGER.error( - "[%s] wrong charasteristic: %s, %s", - self._name, - handle.handle, - handle.uuid, - ) - - async def async_make_request(self, value, retries=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, 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() - self._notify_event.clear() - if value != "ONLY CONNECT": - try: - await conn.start_notify(PROP_NTFY_UUID, self.on_notification) - await conn.write_gatt_char( - PROP_WRITE_UUID, value, response=True - ) - await asyncio.wait_for( - self._notify_event.wait(), REQUEST_TIMEOUT - ) - finally: - if self._stay_connected: - await conn.stop_notify(PROP_NTFY_UUID) - else: - await conn.disconnect() - return - except Exception as ex: - await self.throw_if_terminating() - _LOGGER.debug( - "[%s] Broken connection [retry %s/%s]: %s", - self._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) diff --git a/eq3btsmart/const.py b/eq3btsmart/const.py old mode 100644 new mode 100755 index c2d3fb7..034f403 --- a/eq3btsmart/const.py +++ b/eq3btsmart/const.py @@ -2,27 +2,7 @@ from enum import Enum, IntEnum -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 construct_typed import EnumBase, FlagsEnumBase EQ3BT_AWAY_TEMP = 12.0 EQ3BT_MIN_TEMP = 5.0 @@ -32,28 +12,87 @@ 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_NTFY_UUID = "d0e8434d-cd29-0996-af41-6c90f4e0eb2a" +WRITE_CHARACTERISTIC_UUID = "3fa4585a-ce4a-3bad-db4b-b8df8179ea09" +NOTIFY_CHARACTERISTIC_UUID = "d0e8434d-cd29-0996-af41-6c90f4e0eb2a" -REQUEST_TIMEOUT = 5 +REQUEST_TIMEOUT = 10 RETRY_BACK_OFF_FACTOR = 0.25 RETRIES = 14 +MONITOR_INTERVAL = 10 DEFAULT_AWAY_HOURS = 30 * 24 DEFAULT_AWAY_TEMP = 12 -class Mode(IntEnum): - """Thermostat modes.""" +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 - Unknown = 0 - Off = 0 - On = 1 - Auto = 2 - Manual = 3 + +class WeekDay(EnumBase): + """Weekdays.""" + + SATURDAY = 0 + SUNDAY = 1 + MONDAY = 2 + TUESDAY = 3 + WEDNESDAY = 4 + 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.""" + + AUTO = 0x00 + MANUAL = 0x40 + OFF = 0x49 + ON = 0x7B + AWAY = 0x80 + + +class StatusFlags(FlagsEnumBase): + """Status flags.""" + + 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): AUTO = "AUTO" LOCAL = "LOCAL" + + +class Eq3Preset(Enum): + """Preset modes.""" + + COMFORT = 0 + ECO = 1 diff --git a/eq3btsmart/eq3_away_time.py b/eq3btsmart/eq3_away_time.py new file mode 100755 index 0000000..40d370b --- /dev/null +++ b/eq3btsmart/eq3_away_time.py @@ -0,0 +1,48 @@ +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 | None: + if value == bytes([0x00, 0x00, 0x00, 0x00]): + return None + + (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_connection_monitor.py b/eq3btsmart/eq3_connection_monitor.py new file mode 100644 index 0000000..a34dea4 --- /dev/null +++ b/eq3btsmart/eq3_connection_monitor.py @@ -0,0 +1,26 @@ +import asyncio + +from bleak import BleakClient + +from eq3btsmart.const import MONITOR_INTERVAL + + +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(MONITOR_INTERVAL) + + async def stop(self): + self._run = False diff --git a/eq3btsmart/eq3_duration.py b/eq3btsmart/eq3_duration.py new file mode 100755 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 100755 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 100755 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 100755 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 100755 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/exceptions.py b/eq3btsmart/exceptions.py old mode 100644 new mode 100755 diff --git a/eq3btsmart/models.py b/eq3btsmart/models.py new file mode 100755 index 0000000..8da09c9 --- /dev/null +++ b/eq3btsmart/models.py @@ -0,0 +1,147 @@ +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, + ScheduleDayStruct, + StatusStruct, +) + + +@dataclass +class DeviceData: + firmware_version: int | None = None + device_serial: str | None = None + + @classmethod + def from_device(cls, struct: DeviceIdStruct) -> Self: + return cls( + firmware_version=struct.version, + device_serial=struct.serial, + ) + + @classmethod + def from_bytes(cls, data: bytes) -> Self: + return cls.from_device(DataclassStruct(DeviceIdStruct).parse(data)) + + +@dataclass +class Status: + 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 | None: + 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_device(cls, struct: StatusStruct) -> Self: + 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_until=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: bytes) -> Self: + return cls.from_device(DataclassStruct(StatusStruct).parse(data)) + + +@dataclass +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_device(cls, struct: ScheduleDayStruct) -> Self: + return cls( + 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: bytes) -> Self: + return cls.from_device(DataclassStruct(ScheduleDayStruct).parse(data)) + + +@dataclass +class Schedule: + schedule_days: list[ScheduleDay] = field(default_factory=list) + + def merge(self, other_schedule: Self) -> None: + 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: + return cls(schedule_days=[ScheduleDay.from_bytes(data)]) diff --git a/eq3btsmart/structures.py b/eq3btsmart/structures.py old mode 100644 new mode 100755 index 4a5fc61..7874751 --- a/eq3btsmart/structures.py +++ b/eq3btsmart/structures.py @@ -1,122 +1,89 @@ -""" Contains construct adapters and structures. """ -from datetime import datetime, time, timedelta +"""Structures for the eQ-3 Bluetooth Smart Thermostat.""" +from dataclasses import dataclass from construct import ( Adapter, Bytes, Const, - Enum, - FlagsEnum, + Flag, + GreedyBytes, 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, + 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 _decode(self, obj: int, ctx, path) -> Eq3Temperature: + return Eq3Temperature.from_device(obj) - 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 _encode(self, obj: Eq3Temperature, ctx, path) -> int: + return obj -class TempOffsetAdapter(Adapter): - """Adapter to encode and decode the temperature offset.""" +class Eq3TemperatureOffsetAdapter(Adapter): + """Adapter to encode and decode temperature offset data.""" - def _decode(self, obj, context, path): - return float((obj - 7) / 2.0) + def _decode(self, obj: int, ctx, path) -> Eq3TemperatureOffset: + return Eq3TemperatureOffset.from_device(obj) - 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 _encode(self, obj: Eq3TemperatureOffset, ctx, path) -> int: + return obj -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 Eq3DurationAdapter(Adapter): + """Adapter to encode and decode duration data.""" + def _decode(self, obj: int, ctx, path) -> Eq3Duration: + return Eq3Duration.from_device(obj) -class AwayDataAdapter(Adapter): - """Adapter to encode and decode away data.""" + def _encode(self, obj: Eq3Duration, ctx, path) -> int: + return obj - def _decode(self, obj, ctx, path): - (day, year, hour_min, month) = obj - year += 2000 - min = 0 - if hour_min & 0x01: - min = 30 - hour = int(hour_min / 2) +class Eq3AwayTimeAdapter(Adapter): + """Adapter to encode and decode away time data.""" - return datetime(year=year, month=month, day=day, hour=hour, minute=min) + def _decode(self, obj: bytes, ctx, path) -> Eq3AwayTime | None: + return Eq3AwayTime.from_device(obj) - 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) + def _encode(self, obj: Eq3AwayTime, ctx, path) -> bytes: + return obj class DeviceSerialAdapter(Adapter): @@ -126,46 +93,182 @@ 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.""" -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), - ) - ), -) + 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)) -DeviceId = "DeviceId" / Struct( - "cmd" / Const(PROP_ID_RETURN, Int8ub), - "version" / Int8ub, - Int8ub, - Int8ub, - "serial" / DeviceSerialAdapter(Bytes(10)), - Int8ub, -) + +@dataclass +class StatusStruct(DataclassMixin): + """Structure for status data.""" + + cmd: int = csfield(Const(Command.INFO_RETURN, Int8ub)) + const_1: int = csfield(Const(0x01, Int8ub)) + mode: StatusFlags = csfield(TFlagsEnum(Int8ub, StatusFlags)) + valve: int = csfield(Int8ub) + const_2: int = csfield(Const(0x04, Int8ub)) + target_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + away: Eq3AwayTime | None = csfield(Optional(Eq3AwayTimeAdapter(Bytes(4)))) + presets: PresetsStruct | None = csfield(Optional(DataclassStruct(PresetsStruct))) + + +@dataclass +class ScheduleHourStruct(DataclassMixin): + """Structure for schedule entry data.""" + + target_temp: Eq3Temperature = csfield(Eq3TemperatureAdapter(Int8ub)) + next_change_at: Eq3ScheduleTime = csfield(Eq3ScheduleTimeAdapter(Int8ub)) + + +@dataclass +class ScheduleDayStruct(DataclassMixin): + """Structure for schedule data.""" + + day: WeekDay = csfield(TEnum(Int8ub, WeekDay)) + hours: list[ScheduleHourStruct] = csfield( + GreedyRange(DataclassStruct(ScheduleHourStruct)) + ) + + +@dataclass +class DeviceIdStruct(DataclassMixin): + """Structure for device data.""" + + 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) + + +@dataclass +class AwaySetCommand(ModeSetCommand): + """Structure for away set command.""" + + away_until: Eq3AwayTime = csfield(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 old mode 100644 new mode 100755 index baf09b5..4dcf093 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -7,44 +7,57 @@ Schedule needs to be requested with query_schedule() before accessing for similar reasons. """ -import codecs +import asyncio import logging -import struct from datetime import datetime, timedelta -from typing import Any, Callable +from typing import Callable -from construct import Byte, Container -from homeassistant.core import HomeAssistant +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, - 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, - Mode, + NOTIFY_CHARACTERISTIC_UUID, + REQUEST_TIMEOUT, + WRITE_CHARACTERISTIC_UUID, + Command, + Eq3Preset, + OperationMode, + WeekDay, ) -from eq3btsmart.exceptions import TemperatureException -from eq3btsmart.structures import AwayDataAdapter, DeviceId, Schedule, Status +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 +from eq3btsmart.eq3_time import Eq3Time +from eq3btsmart.models import DeviceData, Schedule, Status +from eq3btsmart.structures import ( + AwaySetCommand, + BoostSetCommand, + ComfortEcoConfigureCommand, + ComfortSetCommand, + EcoSetCommand, + Eq3Command, + IdGetCommand, + InfoGetCommand, + LockSetCommand, + ModeSetCommand, + OffsetConfigureCommand, + ScheduleGetCommand, + ScheduleHourStruct, + ScheduleSetCommand, + TemperatureSetCommand, + WindowOpenConfigureCommand, +) +from eq3btsmart.thermostat_config import ThermostatConfig _LOGGER = logging.getLogger(__name__) @@ -54,453 +67,282 @@ class Thermostat: def __init__( self, - mac: str, - name: str, - adapter: str, - stay_connected: bool, - hass: HomeAssistant, + thermostat_config: ThermostatConfig, + ble_device: BLEDevice, ): """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 = Status() + self.device_data: DeviceData = DeviceData() + self.schedule: Schedule = Schedule() self._on_update_callbacks: list[Callable] = [] - self._conn = BleakConnection( - mac=mac, - name=name, - adapter=adapter, - stay_connected=stay_connected, - hass=hass, - 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() + self._monitor = Eq3ConnectionMonitor(self._conn) - def register_update_callback(self, on_update: Callable) -> None: - self._on_update_callbacks.append(on_update) + def register_connection_callback(self, on_connect: Callable) -> None: + """Register a callback function that will be called when a connection is established.""" - def shutdown(self) -> None: - self._conn.shutdown() - - def _verify_temperature(self, temp: float) -> None: - """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 - ) - ) + self._on_connection_callbacks.append(on_connect) - 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) + def register_update_callback(self, on_update: Callable) -> None: + """Register a callback function that will be called when an update is received.""" - return sched + self._on_update_callbacks.append(on_update) - 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 - 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) + async def async_connect(self) -> None: + """Connect to the thermostat.""" - if self._status is None: - raise Exception("Parsed empty status data") + await self._conn.connect() + await self._conn.start_notify(NOTIFY_CHARACTERISTIC_UUID, self.on_notification) - self._presets = self._status.presets - _LOGGER.debug("[%s] Parsed status: %s", self.name, self._status) + loop = asyncio.get_running_loop() + loop.create_task(self._monitor.run()) - elif data[0] == PROP_SCHEDULE_RETURN: - parsed = self.parse_schedule(data) - self._schedule[parsed.day] = parsed + async def async_disconnect(self) -> None: + """Shutdown the connection to the thermostat.""" - elif data[0] == PROP_ID_RETURN: - self._device_data = DeviceId.parse(data) - _LOGGER.debug("[%s] Parsed device data: %s", self.name, self._device_data) + await self._monitor.stop() + await self._conn.disconnect() - else: - updated = False - _LOGGER.debug( - "[%s] Unknown notification %s (%s)", - self.name, - data[0], - codecs.encode(data, "hex"), - ) - if updated: - for callback in self._on_update_callbacks: - callback() - - async def async_query_id(self) -> None: + async def async_get_id(self) -> None: """Query device identification information, e.g. the serial number.""" - _LOGGER.debug("[%s] Querying id..", self.name) - value = struct.pack("B", PROP_ID_QUERY) - await self._conn.async_make_request(value) - _LOGGER.debug("[%s] Finished Querying id..", self.name) - - 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) - time = datetime.now() - value = struct.pack( - "BBBBBBB", - PROP_INFO_QUERY, - time.year % 100, - time.month, - time.day, - time.hour, - time.minute, - time.second, - ) - await self._conn.async_make_request(value) + await self._async_write_command(IdGetCommand()) - async def async_query_schedule(self, day: int) -> None: - _LOGGER.debug("[%s] Querying schedule..", self.name) + async def async_get_info(self) -> None: + """Query the thermostat status.""" - if day < 0 or day > 6: - _LOGGER.error("[%s] Invalid day: %s", self.name, day) + eq3_time = Eq3Time(datetime.now()) + await self._async_write_command(InfoGetCommand(time=eq3_time)) - value = struct.pack("BB", PROP_SCHEDULE_QUERY, day) + async def async_get_schedule(self) -> None: + """Query the schedule.""" - await self._conn.async_make_request(value) + for week_day in WeekDay: + await self._async_write_command(ScheduleGetCommand(day=week_day)) - @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_configure_window_open( + self, temperature: float, duration: timedelta + ) -> None: + """Configures the window open behavior. The duration is specified in 5 minute increments.""" - async def async_set_schedule(self, day, hours) -> None: - _LOGGER.debug( - "[%s] Setting schedule day=[%s], hours=[%s]", self.name, day, hours - ) + eq3_temperature = Eq3Temperature(temperature) + eq3_duration = Eq3Duration(duration) - """Sets the schedule for the given day.""" - data = Schedule.build( - { - "cmd": "write", - "day": day, - "hours": hours, - } + await self._async_write_command( + WindowOpenConfigureCommand( + window_open_temperature=eq3_temperature, + window_open_time=eq3_duration, + ) ) - await self._conn.async_make_request(data) - - parsed = self.parse_schedule(data) - self._schedule[parsed.day] = parsed - 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) - if temperature == EQ3BT_OFF_TEMP or temperature == EQ3BT_ON_TEMP: - dev_temp |= 0x40 - value = struct.pack("BB", PROP_MODE_WRITE, dev_temp) - else: - self._verify_temperature(temperature) - value = struct.pack("BB", PROP_TEMPERATURE_WRITE, dev_temp) - - 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: - """Set the operation mode.""" - _LOGGER.debug("[%s] Setting new mode: %s", self.name, mode) - - match mode: - case Mode.Off: - await self.async_set_target_temperature(EQ3BT_OFF_TEMP) - case Mode.On: - await self.async_set_target_temperature(EQ3BT_ON_TEMP) - case Mode.Auto: - await self._async_set_mode(0) - case Mode.Manual: - temperature = max( - min(self.target_temperature, EQ3BT_MAX_TEMP), EQ3BT_MIN_TEMP - ) - await self._async_set_mode(0x40 | int(temperature * 2)) + 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).""" - @property - def away(self) -> bool | None: - """Returns True if the thermostat is in away mode.""" + if self.status is None: + raise Exception("Status not set") - if self._status is None: - return None + if comfort_temperature is None and self.status.comfort_temperature is not None: + comfort_temperature = self.status.comfort_temperature.friendly_value - return self.away_end is not None + if eco_temperature is None and self.status.eco_temperature is not None: + eco_temperature = self.status.eco_temperature.friendly_value - @property - def away_end(self) -> datetime | None: - """Returns the end datetime of the away mode.""" - if self._status is None: - return None + if comfort_temperature is None or eco_temperature is None: + raise Exception("Comfort or eco temperature not set") - if not isinstance(self._status.away, datetime): - return None + eq3_comfort_temperature = Eq3Temperature(comfort_temperature) + eq3_eco_temperature = Eq3Temperature(eco_temperature) - return self._status.away + await self._async_write_command( + ComfortEcoConfigureCommand( + comfort_temperature=eq3_comfort_temperature, + eco_temperature=eq3_eco_temperature, + ) + ) - async def async_set_away_until( - self, away_end: datetime, temperature: float + async def async_temperature_offset_configure( + self, temperature_offset: float ) -> None: - """Sets away mode with default temperature.""" - - # rounding - away_end = away_end + timedelta(minutes=15) - away_end = away_end - timedelta(minutes=away_end.minute % 30) + """Sets the thermostat's temperature offset.""" - _LOGGER.debug( - "[%s] Setting away until %s, temp %s", self.name, away_end, temperature + eq3_temperature_offset = Eq3TemperatureOffset(temperature_offset) + await self._async_write_command( + OffsetConfigureCommand(offset=eq3_temperature_offset) ) - adapter = AwayDataAdapter(Byte[4]) - packed = adapter.build(away_end) - - await self._async_set_mode(0x80 | int(temperature * 2), packed) - - 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) - return await self._async_set_mode(0x00) - - away_end = datetime.now() + timedelta(hours=self.default_away_hours) - - await self.async_set_away_until(away_end, self.default_away_temp) - - 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) - @property - def boost(self) -> bool | None: - """Returns True if the thermostat is in boost mode.""" + async def async_set_mode(self, operation_mode: OperationMode) -> None: + """Set new operation mode.""" - if self._status is None: - return None + if self.status is None or self.status.target_temperature is None: + raise Exception("Status not set") - return self._status.mode.BOOST + command: ModeSetCommand - async def async_set_boost(self, boost: bool) -> None: - """Sets boost mode.""" - _LOGGER.debug("[%s] Setting boost mode: %s", self.name, 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.""" + 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) - if self._status is None: - return None + await self._async_write_command(command) - return self._status.valve + async def async_set_away( + self, + enable: bool, + away_until: datetime | None = None, + temperature: float | None = None, + ) -> None: + if not enable: + return await self.async_set_mode(OperationMode.AUTO) - @property - def window_open(self) -> bool | None: - """Returns True if the thermostat reports a open window - (detected by sudden drop of temperature)""" + if away_until is None: + away_until = datetime.now() + timedelta(hours=DEFAULT_AWAY_HOURS) - if self._status is None: - return False + if temperature is None: + temperature = DEFAULT_AWAY_TEMP - return self._status.mode.WINDOW + eq3_away_until = Eq3AwayTime(away_until) + eq3_temperature = Eq3Temperature(temperature) - 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, - ) - 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._async_write_command( + AwaySetCommand( + mode=OperationMode.AWAY | eq3_temperature, + away_until=eq3_away_until, + ) ) - 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 + async def async_set_temperature(self, temperature: float) -> None: + """Set new target temperature.""" - @property - def window_open_time(self) -> timedelta | None: - """Timeout to reset the thermostat after an open window is detected.""" + if temperature == EQ3BT_OFF_TEMP: + return await self.async_set_mode(OperationMode.OFF) - if self._presets is None: - return None + if temperature == EQ3BT_ON_TEMP: + return await self.async_set_mode(OperationMode.ON) - return self._presets.window_open_time + eq3_temperature = Eq3Temperature(temperature) + await self._async_write_command( + TemperatureSetCommand(temperature=eq3_temperature) + ) - @property - def dst(self) -> bool | None: - """Returns True if the thermostat is in Daylight Saving Time.""" + async def async_set_preset(self, preset: Eq3Preset): + """Sets the thermostat to the given preset.""" - if self._status is None: - return None + command: ComfortSetCommand | EcoSetCommand - return self._status.mode.DST + match preset: + case Eq3Preset.COMFORT: + command = ComfortSetCommand() + case Eq3Preset.ECO: + command = EcoSetCommand() - @property - def locked(self) -> bool | None: - """Returns True if the thermostat is locked.""" + await self._async_write_command(command) - if self._status is None: - return None + async def async_set_boost(self, enable: bool) -> None: + """Sets boost mode.""" - return self._status.mode.LOCKED + await self._async_write_command(BoostSetCommand(enable=enable)) - async def async_set_locked(self, lock: bool) -> None: + async def async_set_locked(self, enable: bool) -> None: """Locks or unlocks the thermostat.""" - _LOGGER.debug("[%s] Setting the lock: %s", self.name, 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, - ) - 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) - - @property - def comfort_temperature(self) -> float | None: - """Returns the comfort temperature preset of the thermostat.""" - if self._presets is None: - return None + await self._async_write_command(LockSetCommand(enable=enable)) - 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 + async def async_set_schedule(self, schedule: Schedule) -> None: + """Sets the schedule for the given day.""" - @property - def temperature_offset(self) -> float | None: - """Returns the thermostat's temperature offset.""" + 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 + ], + ) - if self._presets is None: - return None + await self._async_write_command(command) - return self._presets.offset + self.schedule.merge(schedule) - 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) - # [-3,5 .. 0 .. 3,5 ] - # [00 .. 07 .. 0e ] - if offset < EQ3BT_MIN_OFFSET or offset > EQ3BT_MAX_OFFSET: - raise TemperatureException("Invalid value: %s" % offset) + for callback in self._on_update_callbacks: + callback() - current = -3.5 - values = {} - for i in range(15): - values[current] = i - current += 0.5 + async def _async_write_command(self, command: Eq3Command) -> None: + """Write a EQ3 command to the thermostat.""" - value = struct.pack("BB", PROP_OFFSET, values[offset]) - await self._conn.async_make_request(value) + if not self._conn.is_connected: + return - 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 with self._lock: + await self._conn.write_gatt_char( + WRITE_CHARACTERISTIC_UUID, command.to_bytes() + ) - async def async_activate_eco(self) -> None: - """Activates the comfort temperature.""" - value = struct.pack("B", PROP_ECO) - await self._conn.async_make_request(value) + self.on_connection() - @property - def firmware_version(self) -> str | None: - """Return the firmware version.""" + def on_connection(self) -> None: + for callback in self._on_connection_callbacks: + callback() - if self._device_data is None: - return None + def on_notification(self, handle: BleakGATTCharacteristic, data: bytearray) -> None: + """Handle Callback from a Bluetooth (GATT) request.""" - return self._device_data.version + updated: bool = True + data_bytes = bytes(data) - @property - def device_serial(self) -> str | None: - """Return the device serial number.""" + command = DataclassStruct(Eq3Command).parse(data_bytes) - if self._device_data is None: - return None + if command.payload is None: + return - return self._device_data.serial + is_status_command = command.payload[0] == 0x01 + + try: + match command.cmd: + case Command.ID_RETURN: + self.device_data = DeviceData.from_bytes(data_bytes) + case Command.INFO_RETURN: + if is_status_command: + self.status = Status.from_bytes(data_bytes) + case Command.SCHEDULE_RETURN: + schedule = Schedule.from_bytes(data_bytes) + self.schedule.merge(schedule) + case _: + updated = False + except Exception: + _LOGGER.exception( + "Received: %s", " ".join([f"0x{b:02x}" for b in data_bytes]) + ) + updated = False - @property - def mac(self) -> str: - """Return the mac address.""" + if not updated: + return - return self._conn._mac + for callback in self._on_update_callbacks: + callback() diff --git a/eq3btsmart/thermostat_config.py b/eq3btsmart/thermostat_config.py new file mode 100755 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 diff --git a/poetry.lock b/poetry.lock index 4b5ab08..06d6cdb 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 = "b48795d3c19674c2f43a5a74e31c99f279a3b2adf69d420787a3f6a1e4ca85a8" diff --git a/pyproject.toml b/pyproject.toml index 82516f2..accbaa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,8 @@ 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" [tool.poetry.group.dev.dependencies] ruff = "^0.1.13" @@ -24,6 +24,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 +38,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 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() 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)