diff --git a/letpot/client.py b/letpot/client.py index 16f1772..43d06ae 100644 --- a/letpot/client.py +++ b/letpot/client.py @@ -1,7 +1,8 @@ """Python client for LetPot hydroponic gardens.""" import time -from aiohttp import ClientSession, ClientResponse + +from aiohttp import ClientResponse, ClientSession from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException diff --git a/letpot/converters.py b/letpot/converters.py index db43a95..0d68169 100644 --- a/letpot/converters.py +++ b/letpot/converters.py @@ -3,23 +3,28 @@ import logging import math from abc import ABC, abstractmethod -from datetime import time +from datetime import datetime, time, timedelta from typing import Sequence from aiomqtt.types import PayloadType -from letpot.exceptions import LetPotException +from letpot.exceptions import LetPotDeviceCategoryException, LetPotException from letpot.models import ( + CycleWateringMode, DeviceFeature, LetPotDeviceErrors, LetPotDeviceStatus, + LetPotGardenStatus, + LetPotWateringSystemStatus, LightMode, TemperatureUnit, + WateringReason, ) _LOGGER = logging.getLogger(__name__) MODEL_AIR = ("LetPot Air", "LPH-AIR") +MODEL_DI = ("LetPot Automatic Watering System", "DI") MODEL_MAX = ("LetPot Max", "LPH-MAX") MODEL_MINI = ("LetPot Mini", "LPH-MINI") MODEL_PRO = ("LetPot Pro", "LPH-PRO") @@ -112,6 +117,8 @@ def get_current_status_message(self) -> list[int]: return [97, 1] def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: + if not isinstance(status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() return [ 97, 2, @@ -142,7 +149,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No else: error_pump_malfunction = True if data[7] & 2 else False - return LetPotDeviceStatus( + return LetPotGardenStatus( raw=data, light_brightness=256 * data[17] + data[18], light_mode=LightMode(data[10]), @@ -189,6 +196,8 @@ def get_current_status_message(self) -> list[int]: return [11, 1] def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: + if not isinstance(status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() return [ 11, 2, @@ -215,7 +224,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No else: error_low_water = True if data[7] & 1 else False - return LetPotDeviceStatus( + return LetPotGardenStatus( raw=data, light_brightness=None, light_mode=LightMode(data[10]), @@ -262,6 +271,8 @@ def get_current_status_message(self) -> list[int]: return [13, 1] def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: + if not isinstance(status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() return [ 13, 2, @@ -290,7 +301,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No _LOGGER.debug("Invalid message received, ignoring: %s", message) return None - return LetPotDeviceStatus( + return LetPotGardenStatus( raw=data, light_brightness=256 * data[18] + data[19], light_mode=LightMode(data[10]), @@ -343,6 +354,8 @@ def get_current_status_message(self) -> list[int]: return [101, 1] def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: + if not isinstance(status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() return [ 101, 2, @@ -368,7 +381,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No _LOGGER.debug("Invalid message received, ignoring: %s", message) return None - return LetPotDeviceStatus( + return LetPotGardenStatus( raw=data, light_brightness=256 * data[18] + data[19], light_mode=LightMode(data[10]), @@ -396,9 +409,100 @@ def get_light_brightness_levels(self) -> list[int]: return [0, 125, 250, 375, 500, 625, 750, 875, 1000] +class ISEConverter(LetPotDeviceConverter): + """Converters and info for device type ISE05, ISE06 (Automatic Watering System).""" + + @staticmethod + def supports_type(device_type: str) -> bool: + return device_type in ["ISE05", "ISE06"] + + def get_device_model(self) -> tuple[str, str] | None: + return MODEL_DI + + def supported_features(self) -> DeviceFeature: + return DeviceFeature.CATEGORY_WATERING_SYSTEM + + def get_current_status_message(self) -> list[int]: + return [65, 1] + + def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: + if not isinstance(status, LetPotWateringSystemStatus): + raise LetPotDeviceCategoryException() + return [ + 65, + 2, + 1 if status.pump_mode > 0 else 0, + 1 if status.pump_cycle_on is True else 0, + math.floor((status.pump_manual_duration or 0) / 256), + (status.pump_manual_duration or 0) % 256, + math.floor((status.pump_cycle_frequency or 0) / 256), + (status.pump_cycle_frequency or 0) % 256, + math.floor((status.pump_cycle_duration or 0) / 256), + (status.pump_cycle_duration or 0) % 256, + status.pump_cycle_mode or 0, + math.floor((status.pump_cycle_workinginterval or 0) / 256), + (status.pump_cycle_workinginterval or 0) % 256, + math.floor((status.pump_cycle_restinterval or 0) / 256), + (status.pump_cycle_restinterval or 0) % 256, + ] + + def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | None: + data = self._hex_bytes_to_int_array(message) + if data is None or data[4] != 66 or data[5] != 1: + _LOGGER.debug("Invalid message received, ignoring: %s", message) + return None + + if self._device_type == "ISE05": + pump_cycle_skipwater = None + else: + pump_cycle_skipwater = math.floor((256 * data[35] + data[36]) / 60) + + now = datetime.now() + if (seconds := int.from_bytes(data[12:16], byteorder="big")) == 0: + pump_works_end = None + else: + pump_works_end = now + timedelta(seconds=seconds) + + if (seconds := int.from_bytes(data[27:31], byteorder="big")) == 0: + pump_works_latest_time = None + else: + pump_works_latest_time = now - timedelta(seconds=seconds) + + if (seconds := int.from_bytes(data[31:35], byteorder="big")) == 0: + pump_works_next_time = None + else: + pump_works_next_time = now + timedelta(seconds=seconds) + + return LetPotWateringSystemStatus( + raw=data, + pump_mode=data[9], + errors=LetPotDeviceErrors( + low_water=True if data[7] & 1 else False, + ), + wifi_state=data[6], + pump_on=data[8] == 1, + pump_manual_duration=256 * data[10] + data[11], + pump_cycle_on=data[16] == 1, + pump_cycle_frequency=256 * data[17] + data[18], + pump_cycle_duration=256 * data[19] + data[20], + pump_cycle_mode=CycleWateringMode(data[21]), + pump_cycle_workinginterval=256 * data[22] + data[23], + pump_cycle_restinterval=256 * data[24] + data[25], + pump_works_end=pump_works_end, + pump_works_latest_reason=WateringReason(data[26]), + pump_works_latest_time=pump_works_latest_time, + pump_works_next_time=pump_works_next_time, + pump_cycle_skip_water=pump_cycle_skipwater, + ) + + def get_light_brightness_levels(self) -> list[int]: + return [] + + CONVERTERS: Sequence[type[LetPotDeviceConverter]] = [ LPHx1Converter, IGSorAltConverter, LPH6xConverter, LPH63Converter, + ISEConverter, ] diff --git a/letpot/deviceclient.py b/letpot/deviceclient.py index 32566e5..3129326 100644 --- a/letpot/deviceclient.py +++ b/letpot/deviceclient.py @@ -18,6 +18,7 @@ from letpot.exceptions import ( LetPotAuthenticationException, LetPotConnectionException, + LetPotDeviceCategoryException, LetPotException, LetPotFeatureException, ) @@ -26,6 +27,7 @@ DeviceFeature, LetPotDeviceInfo, LetPotDeviceStatus, + LetPotGardenStatus, LightMode, TemperatureUnit, ) @@ -406,15 +408,19 @@ async def set_light_brightness(self, serial: str, level: int) -> None: f"Device doesn't support setting light brightness to {level}" ) - status = dataclasses.replace( - self._get_publish_status(serial), light_brightness=level - ) + use_status = self._get_publish_status(serial) + if not isinstance(use_status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() + status = dataclasses.replace(use_status, light_brightness=level) await self._publish_status(serial, status) @requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN) async def set_light_mode(self, serial: str, mode: LightMode) -> None: """Set the light mode for this device (flower/vegetable).""" - status = dataclasses.replace(self._get_publish_status(serial), light_mode=mode) + use_status = self._get_publish_status(serial) + if not isinstance(use_status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() + status = dataclasses.replace(use_status, light_mode=mode) await self._publish_status(serial, status) @requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN) @@ -423,6 +429,8 @@ async def set_light_schedule( ) -> None: """Set the light schedule for this device (start time and/or end time).""" use_status = self._get_publish_status(serial) + if not isinstance(use_status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() start_time = use_status.light_schedule_start if start is None else start end_time = use_status.light_schedule_end if end is None else end status = dataclasses.replace( @@ -435,12 +443,19 @@ async def set_light_schedule( @requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN) async def set_plant_days(self, serial: str, days: int) -> None: """Set the plant days counter for this device (number of days).""" - status = dataclasses.replace(self._get_publish_status(serial), plant_days=days) + use_status = self._get_publish_status(serial) + if not isinstance(use_status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() + status = dataclasses.replace(use_status, plant_days=days) await self._publish_status(serial, status) + @requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN) async def set_power(self, serial: str, on: bool) -> None: """Set the general power for this device (on/off).""" - status = dataclasses.replace(self._get_publish_status(serial), system_on=on) + use_status = self._get_publish_status(serial) + if not isinstance(use_status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() + status = dataclasses.replace(use_status, system_on=on) await self._publish_status(serial, status) async def set_pump_mode(self, serial: str, on: bool) -> None: @@ -453,23 +468,28 @@ async def set_pump_mode(self, serial: str, on: bool) -> None: @requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN) async def set_sound(self, serial: str, on: bool) -> None: """Set the alarm sound for this device (on/off).""" - status = dataclasses.replace(self._get_publish_status(serial), system_sound=on) + use_status = self._get_publish_status(serial) + if not isinstance(use_status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() + status = dataclasses.replace(use_status, system_sound=on) await self._publish_status(serial, status) @requires_feature(DeviceFeature.TEMPERATURE_SET_UNIT) async def set_temperature_unit(self, serial: str, unit: TemperatureUnit) -> None: """Set the temperature unit for this device (Celsius/Fahrenheit).""" - status = dataclasses.replace( - self._get_publish_status(serial), temperature_unit=unit - ) + use_status = self._get_publish_status(serial) + if not isinstance(use_status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() + status = dataclasses.replace(use_status, temperature_unit=unit) await self._publish_status(serial, status) @requires_feature(DeviceFeature.PUMP_AUTO) async def set_water_mode(self, serial: str, on: bool) -> None: """Set the automatic water/nutrient mode for this device (on/off).""" - status = dataclasses.replace( - self._get_publish_status(serial), water_mode=1 if on else 0 - ) + use_status = self._get_publish_status(serial) + if not isinstance(use_status, LetPotGardenStatus): + raise LetPotDeviceCategoryException() + status = dataclasses.replace(use_status, water_mode=1 if on else 0) await self._publish_status(serial, status) # endregion diff --git a/letpot/exceptions.py b/letpot/exceptions.py index b41c0cf..8c464d6 100644 --- a/letpot/exceptions.py +++ b/letpot/exceptions.py @@ -15,3 +15,7 @@ class LetPotAuthenticationException(LetPotException): class LetPotFeatureException(LetPotException): """LetPot device feature exception.""" + + +class LetPotDeviceCategoryException(LetPotFeatureException): + """LetPot incorrect device type exception.""" diff --git a/letpot/models.py b/letpot/models.py index f6b5876..6fdfdbe 100644 --- a/letpot/models.py +++ b/letpot/models.py @@ -2,7 +2,7 @@ import time as systime from dataclasses import dataclass -from datetime import time +from datetime import datetime, time from enum import IntEnum, IntFlag, auto @@ -12,6 +12,9 @@ class DeviceFeature(IntFlag): CATEGORY_HYDROPONIC_GARDEN = auto() """Features common to the hydroponic garden device category.""" + CATEGORY_WATERING_SYSTEM = auto() + """Features common to the watering system device category.""" + LIGHT_BRIGHTNESS_LOW_HIGH = auto() LIGHT_BRIGHTNESS_LEVELS = auto() NUTRIENT_BUTTON = auto() @@ -22,6 +25,13 @@ class DeviceFeature(IntFlag): WATER_LEVEL = auto() +class CycleWateringMode(IntEnum): + """Device cycle watering mode.""" + + CONTINUOUS = 0 + INTERMITTENT = 1 + + class LightMode(IntEnum): """Device light mode.""" @@ -38,6 +48,16 @@ class TemperatureUnit(IntEnum): CELSIUS = 1 +class WateringReason(IntEnum): + """Reason for latest watering run (observed values, may not be accurate).""" + + NO_RUN = 0 + INTERRUPTED = 1 + MANUAL = 2 + CYCLE = 3 + SCHEDULED = 4 + + @dataclass class AuthenticationInfo: """Authentication info model.""" @@ -88,22 +108,64 @@ class LetPotDeviceErrors: @dataclass class LetPotDeviceStatus: - """Device status model.""" + """Generic device status model.""" + raw: list[int] + pump_mode: int errors: LetPotDeviceErrors + + +@dataclass +class LetPotGardenStatus(LetPotDeviceStatus): + """Device status model for a hydroponic garden.""" + + system_on: bool light_brightness: int | None light_mode: LightMode light_schedule_end: time light_schedule_start: time online: bool plant_days: int - pump_mode: int pump_nutrient: int | None pump_status: int | None - raw: list[int] - system_on: bool system_sound: bool | None temperature_unit: TemperatureUnit | None = None temperature_value: int | None = None water_mode: int | None = None water_level: int | None = None + + +@dataclass +class LetPotWateringSystemStatus(LetPotDeviceStatus): + """Device status model for a watering system.""" + + wifi_state: int + pump_on: bool + + pump_manual_duration: int + """Manual watering run duration, in minutes""" + + pump_cycle_on: bool + pump_cycle_frequency: int + """Cycle watering run frequency, in hours""" + + pump_cycle_duration: int + """Cycle watering run duration, in minutes""" + + pump_cycle_mode: CycleWateringMode + pump_cycle_workinginterval: int + """Intermittent cycle watering mode work interval, in seconds""" + + pump_cycle_restinterval: int + """Intermittent cycle watering mode rest interval, in seconds""" + + pump_cycle_skip_water: int | None + pump_works_end: datetime | None + """End of currently running watering event, or None if there is no active event""" + + pump_works_latest_reason: int + pump_works_latest_time: datetime | None + """When the latest watering event happened, or None if not set""" + + pump_works_next_time: datetime | None + """Scheduled next watering event, or None if not set""" diff --git a/poetry.lock b/poetry.lock index cbfd1a2..01ad3ea 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -301,6 +301,21 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "freezegun" +version = "1.5.5" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, + {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "frozenlist" version = "1.7.0" @@ -874,6 +889,21 @@ pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "ruff" version = "0.14.0" @@ -903,6 +933,18 @@ files = [ {file = "ruff-0.14.0.tar.gz", hash = "sha256:62ec8969b7510f77945df916de15da55311fade8d6050995ff7f680afe582c57"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "typing-extensions" version = "4.14.1" @@ -1038,4 +1080,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "5cff0ca6c75d695a7aa7e44c632e28a3d54a0b63c278b1a34a8e23c7c22a40b5" +content-hash = "a664cec1f8c622affa90216bc8ebe201efbe2a25234d037998f440a8c74126a5" diff --git a/pyproject.toml b/pyproject.toml index 83fd115..8308192 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ pytest = "8.4.2" pytest-asyncio = "1.2.0" pytest-cov = "7.0.0" mypy = "1.18.2" +freezegun = "1.5.5" [tool.poetry.urls] Changelog = "https://github.com/jpelgrom/python-letpot/releases" diff --git a/tests/__init__.py b/tests/__init__.py index f0d7db0..3dd7212 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,12 +1,15 @@ """Tests for Python client for LetPot hydroponic gardens.""" -from datetime import time +from datetime import datetime, time from letpot.models import ( AuthenticationInfo, + CycleWateringMode, LetPotDeviceErrors, - LetPotDeviceStatus, + LetPotGardenStatus, + LetPotWateringSystemStatus, LightMode, + WateringReason, ) AUTHENTICATION = AuthenticationInfo( @@ -19,7 +22,7 @@ ) -DEVICE_STATUS = LetPotDeviceStatus( +DEVICE_STATUS_GARDEN = LetPotGardenStatus( errors=LetPotDeviceErrors(low_water=True), light_brightness=500, light_mode=LightMode.VEGETABLE, @@ -38,3 +41,177 @@ water_mode=None, water_level=None, ) + +DEVICE_STATUS_DI_IDLE = LetPotWateringSystemStatus( + raw=[ + 77, + 0, + 1, + 33, + 66, + 1, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 24, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + pump_mode=0, + errors=LetPotDeviceErrors(low_water=False), + wifi_state=0, + pump_on=False, + pump_manual_duration=3, + pump_cycle_on=False, + pump_cycle_frequency=24, + pump_cycle_duration=3, + pump_cycle_mode=CycleWateringMode.CONTINUOUS, + pump_cycle_workinginterval=0, + pump_cycle_restinterval=0, + pump_cycle_skip_water=0, + pump_works_end=None, + pump_works_latest_reason=WateringReason.NO_RUN, + pump_works_latest_time=None, + pump_works_next_time=None, +) + +DEVICE_STATUS_DI_MANUAL = LetPotWateringSystemStatus( + raw=[ + 77, + 0, + 1, + 33, + 66, + 1, + 0, + 0, + 1, + 1, + 0, + 3, + 0, + 0, + 0, + 132, + 0, + 0, + 24, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 49, + 0, + 0, + 0, + 0, + 0, + 0, + ], + pump_mode=1, + errors=LetPotDeviceErrors(low_water=False), + wifi_state=0, + pump_on=True, + pump_manual_duration=3, + pump_cycle_on=False, + pump_cycle_frequency=24, + pump_cycle_duration=3, + pump_cycle_mode=CycleWateringMode.CONTINUOUS, + pump_cycle_workinginterval=0, + pump_cycle_restinterval=0, + pump_cycle_skip_water=0, + pump_works_end=datetime(2026, 3, 1, 0, 2, 12), + pump_works_latest_reason=WateringReason.MANUAL, + pump_works_latest_time=datetime(2026, 2, 28, 23, 59, 11), + pump_works_next_time=None, +) + +DEVICE_STATUS_DI_CYCLE = LetPotWateringSystemStatus( + raw=[ + 77, + 0, + 1, + 33, + 66, + 1, + 0, + 0, + 1, + 1, + 0, + 3, + 0, + 0, + 0, + 123, + 1, + 0, + 12, + 0, + 5, + 1, + 0, + 30, + 0, + 15, + 3, + 0, + 0, + 0, + 57, + 0, + 0, + 168, + 132, + 0, + 0, + ], + pump_mode=1, + errors=LetPotDeviceErrors(low_water=False), + wifi_state=0, + pump_on=True, + pump_manual_duration=3, + pump_cycle_on=True, + pump_cycle_frequency=12, + pump_cycle_duration=5, + pump_cycle_mode=CycleWateringMode.INTERMITTENT, + pump_cycle_workinginterval=30, + pump_cycle_restinterval=15, + pump_cycle_skip_water=0, + pump_works_end=datetime(2026, 3, 1, 0, 2, 3), + pump_works_latest_reason=WateringReason.CYCLE, + pump_works_latest_time=datetime(2026, 2, 28, 23, 59, 3), + pump_works_next_time=datetime(2026, 3, 1, 11, 59), +) diff --git a/tests/test_converter.py b/tests/test_converter.py index d3c3d3d..57371dc 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,13 +1,24 @@ """Tests for the converters.""" +from freezegun import freeze_time import pytest -from letpot.converters import CONVERTERS, LetPotDeviceConverter, LPHx1Converter +from letpot.converters import ( + CONVERTERS, + ISEConverter, + LetPotDeviceConverter, + LPHx1Converter, +) from letpot.exceptions import LetPotException -from . import DEVICE_STATUS +from . import ( + DEVICE_STATUS_DI_CYCLE, + DEVICE_STATUS_DI_IDLE, + DEVICE_STATUS_DI_MANUAL, + DEVICE_STATUS_GARDEN, +) -SUPPORTED_DEVICE_TYPES = [ +SUPPORTED_DEVICE_TYPES_GARDEN = [ "IGS01", "LPH11", "LPH21", @@ -20,11 +31,15 @@ "LPH62", "LPH63", ] +SUPPORTED_DEVICE_TYPES_WATERING = ["ISE05", "ISE06"] +SUPPORTED_DEVICE_TYPES_ALL = ( + SUPPORTED_DEVICE_TYPES_GARDEN + SUPPORTED_DEVICE_TYPES_WATERING +) @pytest.mark.parametrize( "device_type", - SUPPORTED_DEVICE_TYPES, + SUPPORTED_DEVICE_TYPES_ALL, ) def test_supported_finds_converter(device_type: str) -> None: """Test support by a converter for all supported device types.""" @@ -36,7 +51,7 @@ def test_supported_finds_converter(device_type: str) -> None: @pytest.mark.parametrize( "device_type", - SUPPORTED_DEVICE_TYPES, + SUPPORTED_DEVICE_TYPES_ALL, ) def test_supported_has_model(device_type: str) -> None: """Test model information for all supported device types.""" @@ -65,7 +80,7 @@ def test_unsupported_raises_exception(converter: type[LetPotDeviceConverter]) -> @pytest.mark.parametrize( "device_type", - ["LPH21", "IGS01", "LPH60", "LPH63"], + ["LPH21", "IGS01", "LPH60", "LPH63", "ISE05"], ) def test_unexpected_status_is_ignored(device_type: str) -> None: """Test that processing a weird status message returns None.""" @@ -87,4 +102,36 @@ def test_lph21_message_to_status() -> None: converter = LPHx1Converter("LPH21") message = b"4d000112620100010101010000071e110001f4000000" status = converter.convert_hex_to_status(message) - assert status == DEVICE_STATUS + assert status == DEVICE_STATUS_GARDEN + + +def test_ise06_idle_message_to_status() -> None: + """Test that a message from a ISE06 device type when idle decodes to a certain status.""" + converter = ISEConverter("ISE06") + message = ( + b"4d000121420100000000000300000000000018000300000000000000000000000000000000" + ) + status = converter.convert_hex_to_status(message) + assert status == DEVICE_STATUS_DI_IDLE + + +@freeze_time("2026-03-01") +def test_ise06_manual_message_to_status() -> None: + """Test that a message from a ISE06 device type when manually started decodes to a certain status.""" + converter = ISEConverter("ISE06") + message = ( + b"4d000121420100000101000300000084000018000300000000000200000031000000000000" + ) + status = converter.convert_hex_to_status(message) + assert status == DEVICE_STATUS_DI_MANUAL + + +@freeze_time("2026-03-01") +def test_ise06_cycle_message_to_status() -> None: + """Test that a message from a ISE06 device type when cycle watering decodes to a certain status.""" + converter = ISEConverter("ISE06") + message = ( + b"4d00012142010000010100030000007b01000c000501001e000f03000000390000a8840000" + ) + status = converter.convert_hex_to_status(message) + assert status == DEVICE_STATUS_DI_CYCLE diff --git a/tests/test_deviceclient.py b/tests/test_deviceclient.py index 1bcce69..0c768bf 100644 --- a/tests/test_deviceclient.py +++ b/tests/test_deviceclient.py @@ -13,7 +13,7 @@ from letpot.exceptions import LetPotFeatureException from letpot.models import TemperatureUnit -from . import AUTHENTICATION, DEVICE_STATUS +from . import AUTHENTICATION, DEVICE_STATUS_GARDEN class MockMessagesIterator: @@ -161,7 +161,7 @@ async def test_requires_feature_one( """Test the requires_feature annotation requiring one feature.""" # Prepare device client and mock status for use in call await device_client.subscribe(serial, lambda _: None) - device_client._device_status_last[serial] = DEVICE_STATUS + device_client._device_status_last[serial] = DEVICE_STATUS_GARDEN with expected_result: await device_client.set_temperature_unit(serial, TemperatureUnit.CELSIUS) @@ -189,7 +189,7 @@ async def test_requires_feature_or( """Test the requires_feature annotation requiring any of n features.""" # Prepare device client and mock status for use in call await device_client.subscribe(serial, lambda _: None) - device_client._device_status_last[serial] = DEVICE_STATUS + device_client._device_status_last[serial] = DEVICE_STATUS_GARDEN with expected_result: await device_client.set_light_brightness(serial, 500)