From e436efb541c1bd087978bc9f66a33d7c70699a38 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 22 Jan 2024 15:15:03 +0100 Subject: [PATCH] fix: set schedule service --- custom_components/eq3btsmart/__init__.py | 9 +- custom_components/eq3btsmart/binary_sensor.py | 35 ++-- custom_components/eq3btsmart/button.py | 49 ++--- custom_components/eq3btsmart/const.py | 3 +- .../eq3btsmart/eq3_coordinator.py | 16 ++ custom_components/eq3btsmart/sensor.py | 51 +----- custom_components/eq3btsmart/switch.py | 13 +- eq3btsmart/__init__.py | 1 - eq3btsmart/bleakconnection.py | 170 ------------------ eq3btsmart/const.py | 11 +- eq3btsmart/models.py | 19 +- eq3btsmart/thermostat.py | 79 +++++--- tests/test_schedule.py | 49 +++++ tests/test_schedule_set.py | 37 ++++ 14 files changed, 236 insertions(+), 306 deletions(-) create mode 100644 custom_components/eq3btsmart/eq3_coordinator.py delete mode 100755 eq3btsmart/bleakconnection.py create mode 100644 tests/test_schedule.py create mode 100644 tests/test_schedule_set.py diff --git a/custom_components/eq3btsmart/__init__.py b/custom_components/eq3btsmart/__init__.py index 54c88d8..d078427 100644 --- a/custom_components/eq3btsmart/__init__.py +++ b/custom_components/eq3btsmart/__init__.py @@ -89,9 +89,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: thermostat = Thermostat( thermostat_config=thermostat_config, - device=device, + ble_device=device, ) + try: + await thermostat.async_connect() + except Exception as e: + raise ConfigEntryNotReady(f"Could not connect to device: {e}") + eq3_config_entry = Eq3ConfigEntry(eq3_config=eq3_config, thermostat=thermostat) domain_data: dict[str, Any] = hass.data.setdefault(DOMAIN, {}) @@ -110,7 +115,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: eq3_config_entry: Eq3ConfigEntry = hass.data[DOMAIN].pop(entry.entry_id) - eq3_config_entry.thermostat.shutdown() + await eq3_config_entry.thermostat.async_disconnect() return unload_ok diff --git a/custom_components/eq3btsmart/binary_sensor.py b/custom_components/eq3btsmart/binary_sensor.py index 6bae48d..c6c2a03 100644 --- a/custom_components/eq3btsmart/binary_sensor.py +++ b/custom_components/eq3btsmart/binary_sensor.py @@ -1,8 +1,6 @@ """Platform for eQ-3 binary sensor entities.""" -import json - from custom_components.eq3btsmart.eq3_entity import Eq3Entity from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat @@ -80,15 +78,13 @@ class BusySensor(Base): def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): super().__init__(eq3_config, thermostat) - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) + self._thermostat.register_connection_callback(self.schedule_update_ha_state) self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_name = ENTITY_NAME_BUSY @property def is_on(self) -> bool: - return self._thermostat._conn._lock.locked() + return self._thermostat._lock.locked() class ConnectedSensor(Base): @@ -97,30 +93,25 @@ class ConnectedSensor(Base): def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): super().__init__(eq3_config, thermostat) - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) + self._thermostat.register_connection_callback(self.schedule_update_ha_state) self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_name = ENTITY_NAME_CONNECTED self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - @property - def extra_state_attributes(self) -> dict[str, str] | None: - if (device := self._thermostat._conn._device) is None: - return None - if (details := device.details) is None: - return None - if "props" not in details: - return None + # @property + # def extra_state_attributes(self) -> dict[str, str] | None: + # if (device := self._thermostat._conn._device) is None: + # return None + # if (details := device.details) is None: + # return None + # if "props" not in details: + # return None - return json.loads(json.dumps(details["props"], default=lambda obj: None)) + # return json.loads(json.dumps(details["props"], default=lambda obj: None)) @property def is_on(self) -> bool: - if self._thermostat._conn._conn is None: - return False - - return self._thermostat._conn._conn.is_connected + return self._thermostat._conn.is_connected class BatterySensor(Base): diff --git a/custom_components/eq3btsmart/button.py b/custom_components/eq3btsmart/button.py index bc5abce..960d2fe 100644 --- a/custom_components/eq3btsmart/button.py +++ b/custom_components/eq3btsmart/button.py @@ -1,16 +1,17 @@ """Platform for eQ-3 button entities.""" -import datetime import logging -from typing import Any from custom_components.eq3btsmart.eq3_entity import Eq3Entity from custom_components.eq3btsmart.models import Eq3Config, Eq3ConfigEntry from eq3btsmart import Thermostat from eq3btsmart.const import WeekDay +from eq3btsmart.eq3_schedule_time import Eq3ScheduleTime +from eq3btsmart.eq3_temperature import Eq3Temperature from eq3btsmart.models import Schedule, ScheduleDay, ScheduleHour from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry, UndefinedType +from homeassistant.const import WEEKDAYS from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import format_mac @@ -100,20 +101,30 @@ async def set_schedule(self, **kwargs) -> None: schedule = Schedule() for day in kwargs["days"]: - week_day = WeekDay[day.upper()] + index = WEEKDAYS.index(day) + week_day = WeekDay.from_index(index) + schedule_hours: list[ScheduleHour] = [] schedule_day = ScheduleDay(week_day=week_day, schedule_hours=schedule_hours) times = [ - kwargs.get(f"next_change_at_{i}", datetime.time(0, 0)) for i in range(6) + kwargs.get(f"next_change_at_{i}", None) + for i in range(6) + if f"next_change_at_{i}" in kwargs ] # times[times.index(datetime.time(0, 0))] = HOUR_24_PLACEHOLDER - temps = [kwargs.get(f"target_temp_{i}", 0) for i in range(7)] + temps = [kwargs.get(f"target_temp_{i}", None) for i in range(6)] + + times = list(filter(None, times)) + temps = list(filter(None, temps)) + + if len(times) != len(temps) - 1: + raise ValueError("Times and temps must be of equal length") - for i in range(0, 6): + for time, temp in zip(times, temps): schedule_hour = ScheduleHour( - target_temperature=temps[i], - next_change_at=times[i], + target_temperature=Eq3Temperature(temp), + next_change_at=Eq3ScheduleTime(time), ) schedule_hours.append(schedule_hour) @@ -125,19 +136,15 @@ async def set_schedule(self, **kwargs) -> None: def extra_state_attributes(self): schedule = {} for day in self._thermostat.schedule.schedule_days: - day_nice: dict[str, Any] = {"day": day} - for i, schedule_hour in enumerate(day.schedule_hours): - day_nice[ - f"target_temp_{i}" - ] = schedule_hour.target_temperature.friendly_value - # if schedule_hour.next_change_at == HOUR_24_PLACEHOLDER: - # break - day_nice[ - f"next_change_at_{i}" - ] = schedule_hour.next_change_at.friendly_value.isoformat() - schedule[day] = day_nice - - return schedule + schedule[str(day.week_day)] = [ + { + "target_temperature": schedule_hour.target_temperature.friendly_value, + "next_change_at": schedule_hour.next_change_at.friendly_value.isoformat(), + } + for schedule_hour in day.schedule_hours + ] + + return {"schedule": schedule} class FetchButton(Base): diff --git a/custom_components/eq3btsmart/const.py b/custom_components/eq3btsmart/const.py index cc002b0..4956b71 100644 --- a/custom_components/eq3btsmart/const.py +++ b/custom_components/eq3btsmart/const.py @@ -2,7 +2,7 @@ from enum import Enum from eq3btsmart.const import Adapter, OperationMode -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate import PRESET_NONE, HVACMode from homeassistant.components.climate.const import ( PRESET_AWAY, PRESET_BOOST, @@ -33,6 +33,7 @@ class Preset(str, Enum): + NONE = PRESET_NONE ECO = PRESET_ECO COMFORT = PRESET_COMFORT BOOST = PRESET_BOOST diff --git a/custom_components/eq3btsmart/eq3_coordinator.py b/custom_components/eq3btsmart/eq3_coordinator.py new file mode 100644 index 0000000..4f678c4 --- /dev/null +++ b/custom_components/eq3btsmart/eq3_coordinator.py @@ -0,0 +1,16 @@ +from datetime import timedelta +from logging import Logger + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +class Eq3Coordinator(DataUpdateCoordinator): + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + name: str, + update_interval: timedelta | None, + ): + super().__init__(hass, logger, name=name, update_interval=update_interval) diff --git a/custom_components/eq3btsmart/sensor.py b/custom_components/eq3btsmart/sensor.py index 55f5cb7..d83df1b 100644 --- a/custom_components/eq3btsmart/sensor.py +++ b/custom_components/eq3btsmart/sensor.py @@ -22,8 +22,6 @@ ENTITY_NAME_AWAY_END, ENTITY_NAME_FIRMWARE_VERSION, ENTITY_NAME_MAC, - ENTITY_NAME_PATH, - ENTITY_NAME_RETRIES, ENTITY_NAME_RSSI, ENTITY_NAME_SERIAL_NUMBER, ENTITY_NAME_VALVE, @@ -54,8 +52,6 @@ async def async_setup_entry( new_devices += [ RssiSensor(eq3_config, thermostat), MacSensor(eq3_config, thermostat), - RetriesSensor(eq3_config, thermostat), - PathSensor(eq3_config, thermostat), ] async_add_entities(new_devices) @@ -123,17 +119,14 @@ class RssiSensor(Base): def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): super().__init__(eq3_config, thermostat) - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) + self._thermostat.register_connection_callback(self.schedule_update_ha_state) self._attr_name = ENTITY_NAME_RSSI self._attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT self._attr_entity_category = EntityCategory.DIAGNOSTIC @property def state(self) -> int | None: - return None - return self._thermostat._conn.rssi + return self._thermostat._device._rssi class SerialNumberSensor(Base): @@ -204,43 +197,3 @@ def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): @property def state(self) -> str | None: return self._eq3_config.mac_address - - -class RetriesSensor(Base): - """Sensor for the number of retries.""" - - def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): - super().__init__(eq3_config, thermostat) - - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) - self._attr_name = ENTITY_NAME_RETRIES - self._attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def state(self) -> int: - return self._thermostat._conn.retries - - -class PathSensor(Base): - """Sensor for the device path.""" - - def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): - super().__init__(eq3_config, thermostat) - - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) - self._attr_name = ENTITY_NAME_PATH - self._attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def state(self) -> str | None: - if self._thermostat._conn._conn is None: - return None - - if not hasattr(self._thermostat._conn._conn._backend, "_device_path"): - return None - - return self._thermostat._conn._conn._backend._device_path diff --git a/custom_components/eq3btsmart/switch.py b/custom_components/eq3btsmart/switch.py index 42bcb9b..dc2dd55 100644 --- a/custom_components/eq3btsmart/switch.py +++ b/custom_components/eq3btsmart/switch.py @@ -129,23 +129,18 @@ class ConnectionSwitch(Base): def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat): super().__init__(eq3_config, thermostat) - self._thermostat._conn.register_connection_callback( - self.schedule_update_ha_state - ) + self._thermostat.register_connection_callback(self.schedule_update_ha_state) self._attr_name = ENTITY_NAME_CONNECTION self._attr_icon = ENTITY_ICON_CONNECTION self._attr_assumed_state = True self._attr_entity_category = EntityCategory.DIAGNOSTIC async def async_turn_on(self, **kwargs: Any) -> None: - await self._thermostat._conn.async_make_request() + await self._thermostat.async_connect() async def async_turn_off(self, **kwargs: Any) -> None: - if self._thermostat._conn._conn: - await self._thermostat._conn._conn.disconnect() + await self._thermostat.async_disconnect() @property def is_on(self) -> bool | None: - if self._thermostat._conn._conn is None: - return None - return self._thermostat._conn._conn.is_connected + return self._thermostat._conn.is_connected diff --git a/eq3btsmart/__init__.py b/eq3btsmart/__init__.py index 5786d31..b56521e 100755 --- a/eq3btsmart/__init__.py +++ b/eq3btsmart/__init__.py @@ -1,2 +1 @@ -from eq3btsmart.bleakconnection import BleakConnection as BleakConnection from eq3btsmart.thermostat import Thermostat as Thermostat diff --git a/eq3btsmart/bleakconnection.py b/eq3btsmart/bleakconnection.py deleted file mode 100755 index 61e0b5b..0000000 --- a/eq3btsmart/bleakconnection.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Bleak connection backend.""" -import asyncio -import logging -from typing import Callable, Coroutine, cast - -from bleak import BleakClient -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.device import BLEDevice -from bleak_retry_connector import establish_connection - -from eq3btsmart.const import ( - PROP_NTFY_UUID, - PROP_WRITE_UUID, - REQUEST_TIMEOUT, - RETRIES, - RETRY_BACK_OFF_FACTOR, - Adapter, -) -from eq3btsmart.exceptions import BackendException -from eq3btsmart.thermostat_config import ThermostatConfig - -# bleak backends are very loud on debug, this reduces the log spam when using --debug -# logging.getLogger("bleak.backends").setLevel(logging.WARNING) -_LOGGER = logging.getLogger(__name__) - - -class BleakConnection: - """Representation of a BTLE Connection.""" - - def __init__( - self, - thermostat_config: ThermostatConfig, - callback: Callable, - device: BLEDevice | None = None, - get_device: Coroutine[None, None, BLEDevice] | None = None, - ): - """Initialize the connection.""" - - if device is None and get_device is None: - raise Exception("Either device or get_device must be provided") - - if device is not None and get_device is not None: - raise Exception("Either device or get_device must be provided") - - self.thermostat_config = thermostat_config - self._callback = callback - self._notify_event = asyncio.Event() - self._terminate_event = asyncio.Event() - self._lock = asyncio.Lock() - self._conn: BleakClient | None = None - self._device: BLEDevice | None = device - self._get_device: Coroutine[None, None, BLEDevice] | None = get_device - self._connection_callbacks: list[Callable] = [] - self.retries = 0 - self._round_robin = 0 - - def register_connection_callback(self, callback: Callable) -> None: - self._connection_callbacks.append(callback) - - async def async_connect(self) -> None: - """Connect to the thermostat.""" - - if self._device is None: - raise NotImplementedError("get_device not implemented") - - match self.thermostat_config.adapter: - case Adapter.AUTO: - self._conn = await establish_connection( - client_class=BleakClient, - device=self._device, - name=self.thermostat_config.name, - disconnected_callback=lambda client: self._on_connection_event(), - max_attempts=2, - use_services_cache=True, - ) - - case Adapter.LOCAL: - UnwrappedBleakClient = cast(type[BleakClient], BleakClient.__bases__[0]) - self._conn = UnwrappedBleakClient( - self._device, - disconnected_callback=lambda client: self._on_connection_event(), - dangerous_use_bleak_cache=True, - ) - await self._conn.connect() - - if self._conn is None or not self._conn.is_connected: - raise BackendException("Can't connect") - - def disconnect(self) -> None: - self._terminate_event.set() - self._notify_event.set() - - async def throw_if_terminating(self) -> None: - if self._terminate_event.is_set(): - if self._conn: - await self._conn.disconnect() - raise Exception("Connection cancelled by shutdown") - - async def on_notification( - self, handle: BleakGATTCharacteristic, data: bytearray - ) -> None: - """Handle Callback from a Bluetooth (GATT) request.""" - if PROP_NTFY_UUID == handle.uuid: - self._notify_event.set() - data_bytes = bytes(data) - self._callback(data_bytes) - else: - _LOGGER.error( - "[%s] wrong charasteristic: %s, %s", - self.thermostat_config.name, - handle.handle, - handle.uuid, - ) - - async def async_make_request( - self, value: bytes | None = None, retries: int = RETRIES - ) -> None: - """Write a GATT Command with callback - not utf-8.""" - async with self._lock: # only one concurrent request per thermostat - try: - await self._async_make_request_try(value, retries) - finally: - self.retries = 0 - self._on_connection_event() - - async def _async_make_request_try( - self, value: bytes | None = None, retries: int = RETRIES - ) -> None: - self.retries = 0 - while True: - self.retries += 1 - self._on_connection_event() - try: - await self.throw_if_terminating() - await self.async_connect() - - if self._conn is None: - raise BackendException("Can't connect") - - self._notify_event.clear() - if value is not None: - try: - await self._conn.start_notify( - PROP_NTFY_UUID, self.on_notification - ) - await self._conn.write_gatt_char( - PROP_WRITE_UUID, value, response=True - ) - await asyncio.wait_for( - self._notify_event.wait(), REQUEST_TIMEOUT - ) - finally: - if self.thermostat_config.stay_connected: - await self._conn.stop_notify(PROP_NTFY_UUID) - else: - await self._conn.disconnect() - return - except Exception as ex: - await self.throw_if_terminating() - - self._round_robin = self._round_robin + 1 - - if self.retries >= retries: - raise ex - - await asyncio.sleep(RETRY_BACK_OFF_FACTOR * self.retries) - - def _on_connection_event(self) -> None: - for callback in self._connection_callbacks: - callback() diff --git a/eq3btsmart/const.py b/eq3btsmart/const.py index 1cea859..afbc04a 100755 --- a/eq3btsmart/const.py +++ b/eq3btsmart/const.py @@ -14,9 +14,9 @@ # Handles in linux and BTProxy are off by 1. Using UUIDs instead for consistency PROP_WRITE_UUID = "3fa4585a-ce4a-3bad-db4b-b8df8179ea09" -PROP_NTFY_UUID = "d0e8434d-cd29-0996-af41-6c90f4e0eb2a" +PROP_NOTIFY_UUID = "d0e8434d-cd29-0996-af41-6c90f4e0eb2a" -REQUEST_TIMEOUT = 5 +REQUEST_TIMEOUT = 10 RETRY_BACK_OFF_FACTOR = 0.25 RETRIES = 14 @@ -54,6 +54,13 @@ class WeekDay(EnumBase): THURSDAY = 5 FRIDAY = 6 + @classmethod + def from_index(cls, index: int) -> "WeekDay": + """Return weekday from index.""" + + adjusted_index = index + 2 if index < 5 else index - 5 + return cls(adjusted_index) + class OperationMode(EnumBase): """Operation modes.""" diff --git a/eq3btsmart/models.py b/eq3btsmart/models.py index 0bb9ee3..8da09c9 100755 --- a/eq3btsmart/models.py +++ b/eq3btsmart/models.py @@ -126,10 +126,21 @@ class Schedule: schedule_days: list[ScheduleDay] = field(default_factory=list) def merge(self, other_schedule: Self) -> None: - for schedule_day in other_schedule.schedule_days: - self.schedule_days[ - schedule_day.week_day - ].schedule_hours = schedule_day.schedule_hours + for other_schedule_day in other_schedule.schedule_days: + schedule_day = next( + ( + schedule_day + for schedule_day in self.schedule_days + if schedule_day.week_day == other_schedule_day.week_day + ), + None, + ) + + if not schedule_day: + self.schedule_days.append(other_schedule_day) + continue + + schedule_day.schedule_hours = other_schedule_day.schedule_hours @classmethod def from_bytes(cls, data: bytes) -> Self: diff --git a/eq3btsmart/thermostat.py b/eq3btsmart/thermostat.py index 24416d2..cba95ac 100755 --- a/eq3btsmart/thermostat.py +++ b/eq3btsmart/thermostat.py @@ -7,14 +7,16 @@ Schedule needs to be requested with query_schedule() before accessing for similar reasons. """ +import asyncio import logging from datetime import datetime, timedelta -from typing import Callable, Coroutine +from typing import Callable +from bleak import BleakClient +from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice from construct_typed import DataclassStruct -from eq3btsmart.bleakconnection import BleakConnection from eq3btsmart.const import ( DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP, @@ -22,6 +24,9 @@ EQ3BT_MIN_TEMP, EQ3BT_OFF_TEMP, EQ3BT_ON_TEMP, + PROP_NOTIFY_UUID, + PROP_WRITE_UUID, + REQUEST_TIMEOUT, Command, Eq3Preset, OperationMode, @@ -62,38 +67,44 @@ class Thermostat: def __init__( self, thermostat_config: ThermostatConfig, - device: BLEDevice | None = None, - get_device: Coroutine[None, None, BLEDevice] | None = None, + ble_device: BLEDevice, ): """Initialize the thermostat.""" - if device is None and get_device is None: - raise Exception("Either device or get_device must be provided") - - if device is not None and get_device is not None: - raise Exception("Either device or get_device must be provided") - self.thermostat_config = thermostat_config self.status: Status = Status() self.device_data: DeviceData = DeviceData() self.schedule: Schedule = Schedule() self._on_update_callbacks: list[Callable] = [] - self._conn = BleakConnection( - thermostat_config=self.thermostat_config, - device=device, - get_device=get_device, - callback=self.handle_notification, + self._on_connection_callbacks: list[Callable] = [] + self._device = ble_device + self._conn: BleakClient = BleakClient( + ble_device, + disconnected_callback=lambda client: self.on_connection(), + timeout=REQUEST_TIMEOUT, ) + self._lock = asyncio.Lock() + + def register_connection_callback(self, on_connect: Callable) -> None: + """Register a callback function that will be called when a connection is established.""" + + self._on_connection_callbacks.append(on_connect) def register_update_callback(self, on_update: Callable) -> None: """Register a callback function that will be called when an update is received.""" self._on_update_callbacks.append(on_update) - def shutdown(self) -> None: + async def async_connect(self) -> None: + """Connect to the thermostat.""" + + await self._conn.connect() + await self._conn.start_notify(PROP_NOTIFY_UUID, self.on_notification) + + async def async_disconnect(self) -> None: """Shutdown the connection to the thermostat.""" - self._conn.disconnect() + await self._conn.disconnect() async def async_get_id(self) -> None: """Query device identification information, e.g. the serial number.""" @@ -279,29 +290,47 @@ async def async_set_schedule(self, schedule: Schedule) -> None: async def _async_write_command(self, command: Eq3Command) -> None: """Write a EQ3 command to the thermostat.""" - await self._conn.async_make_request(command.to_bytes()) + if not self._conn.is_connected: + return + + async with self._lock: + await self._conn.write_gatt_char(PROP_WRITE_UUID, command.to_bytes()) - def handle_notification(self, data: bytes) -> None: + self.on_connection() + + def on_connection(self) -> None: + for callback in self._on_connection_callbacks: + callback() + + def on_notification(self, handle: BleakGATTCharacteristic, data: bytearray) -> None: """Handle Callback from a Bluetooth (GATT) request.""" updated: bool = True + data_bytes = bytes(data) - command = DataclassStruct(Eq3Command).parse(data) + command = DataclassStruct(Eq3Command).parse(data_bytes) + + if command.payload is None: + return + + is_status_command = command.payload[0] == 0x01 try: match command.cmd: case Command.ID_RETURN: - self.device_data = DeviceData.from_bytes(data) + self.device_data = DeviceData.from_bytes(data_bytes) case Command.INFO_RETURN: - self.status = Status.from_bytes(data) + if is_status_command: + self.status = Status.from_bytes(data_bytes) case Command.SCHEDULE_RETURN: - schedule = Schedule.from_bytes(data) + schedule = Schedule.from_bytes(data_bytes) self.schedule.merge(schedule) case _: updated = False except Exception: - # print all bytes received in this format: Received: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 - _LOGGER.exception("Received: %s", " ".join([f"0x{b:02x}" for b in data])) + _LOGGER.exception( + "Received: %s", " ".join([f"0x{b:02x}" for b in data_bytes]) + ) updated = False if not updated: diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..46ec8e3 --- /dev/null +++ b/tests/test_schedule.py @@ -0,0 +1,49 @@ +from eq3btsmart.models import Schedule + + +def test_schedule(): + received = bytes( + [ + 0x21, + 0x06, + 0x26, + 0x1B, + 0x22, + 0x39, + 0x27, + 0x90, + 0x2C, + 0x81, + 0x22, + 0x90, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + schedule = Schedule.from_bytes(received) + + received = bytes( + [ + 0x21, + 0x06, + 0x26, + 0x1B, + 0x22, + 0x39, + 0x27, + 0x90, + 0x2C, + 0x81, + 0x22, + 0x90, + 0x00, + 0x00, + 0x00, + 0x00, + ] + ) + schedule2 = Schedule.from_bytes(received) + + schedule.merge(schedule2) diff --git a/tests/test_schedule_set.py b/tests/test_schedule_set.py new file mode 100644 index 0000000..817de6b --- /dev/null +++ b/tests/test_schedule_set.py @@ -0,0 +1,37 @@ +from datetime import time + +from eq3btsmart.const import WeekDay +from eq3btsmart.eq3_schedule_time import Eq3ScheduleTime +from eq3btsmart.eq3_temperature import Eq3Temperature +from eq3btsmart.models import Schedule, ScheduleDay, ScheduleHour +from eq3btsmart.structures import ScheduleHourStruct, ScheduleSetCommand + + +def test_schedule_set(): + schedule = Schedule( + schedule_days=[ + ScheduleDay( + WeekDay.MONDAY, + schedule_hours=[ + ScheduleHour( + target_temperature=Eq3Temperature(20), + next_change_at=Eq3ScheduleTime(time(hour=6, minute=0)), + ), + ], + ) + ] + ) + + for schedule_day in schedule.schedule_days: + command = ScheduleSetCommand( + day=schedule_day.week_day, + hours=[ + ScheduleHourStruct( + target_temp=schedule_hour.target_temperature, + next_change_at=schedule_hour.next_change_at, + ) + for schedule_hour in schedule_day.schedule_hours + ], + ) + + command.to_bytes()