From 25ee8445efb0978e850a002a7de946dbfa004a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 5 Jun 2025 10:23:52 +0100 Subject: [PATCH 1/6] WIP: converter for testing ISE05/ISE06 --- letpot/converters.py | 82 ++++++++++++++++++++++++++++++++++++++++++++ letpot/models.py | 14 ++++++++ 2 files changed, 96 insertions(+) diff --git a/letpot/converters.py b/letpot/converters.py index db43a95..6a5b915 100644 --- a/letpot/converters.py +++ b/letpot/converters.py @@ -20,6 +20,7 @@ _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") @@ -396,9 +397,90 @@ 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(0) + + def get_current_status_message(self) -> list[int]: + return [65, 1] + + def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: + 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_duration or 0) / 256), + (status.pump_duration or 0) % 256, + math.floor((status.pump_cycle_duration or 0) / 256), + (status.pump_cycle_duration or 0) % 256, + math.floor((status.pump_cycle_workingduration or 0) / 256), + (status.pump_cycle_workingduration 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) + + return LetPotDeviceStatus( + raw=data, + light_brightness=None, + light_mode=0, + light_schedule_end=time(), + light_schedule_start=time(), + online=True, + plant_days=0, + pump_mode=data[9], + pump_nutrient=None, + pump_status=None, + system_on=data[7] == 1, + system_sound=None, + errors=LetPotDeviceErrors(low_water=None), + wifi_state=data[6], + pump_on=data[8] == 1, + pump_duration=256 * data[10] + data[11], + pump_countdown=data[12:16], + pump_cycle_on=data[16] == 1, + pump_cycle_duration=256 * data[17] + data[18], + pump_cycle_workingduration=256 * data[19] + data[20], + pump_cycle_mode=data[21], + pump_cycle_workinginterval=256 * data[22] + data[23], + pump_cycle_restinterval=256 * data[24] + data[25], + pump_works_latest_reason=data[26], + pump_works_latest_time=data[27:31], + pump_works_next_time=data[31:35], + 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/models.py b/letpot/models.py index f6b5876..18363f4 100644 --- a/letpot/models.py +++ b/letpot/models.py @@ -107,3 +107,17 @@ class LetPotDeviceStatus: temperature_value: int | None = None water_mode: int | None = None water_level: int | None = None + wifi_state: int | None = None + pump_on: bool | None = None + pump_duration: int | None = None + pump_countdown: list[int] | None = None + pump_cycle_on: bool | None = None + pump_cycle_duration: int | None = None + pump_cycle_workingduration: int | None = None + pump_cycle_mode: int | None = None + pump_cycle_workinginterval: int | None = None + pump_cycle_restinterval: int | None = None + pump_works_latest_reason: int | None = None + pump_works_latest_time: list[int] | None = None + pump_works_next_time: list[int] | None = None + pump_cycle_skip_water: int | None = None From 57e06fe321516fb6281361fe2611634557842f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sat, 19 Jul 2025 21:29:43 +0200 Subject: [PATCH 2/6] Split status class, add test - Changed LetPotDeviceStatus to a base class and created LetPotGardenStatus / LetPotWateringSystemStatus for device specifics - Added ISE05/ISE06 to the automated tests --- letpot/converters.py | 35 ++++++++++++++++-------------- letpot/deviceclient.py | 41 ++++++++++++++++++++++++----------- letpot/exceptions.py | 4 ++++ letpot/models.py | 48 +++++++++++++++++++++++++---------------- tests/test_converter.py | 34 ++++++++++++++++++++++++----- 5 files changed, 111 insertions(+), 51 deletions(-) diff --git a/letpot/converters.py b/letpot/converters.py index 6a5b915..a3bda06 100644 --- a/letpot/converters.py +++ b/letpot/converters.py @@ -8,9 +8,12 @@ from aiomqtt.types import PayloadType -from letpot.exceptions import LetPotException +from letpot.exceptions import LetPotDeviceTypeException, LetPotException from letpot.models import ( DeviceFeature, + LetPotGardenStatus, + LetPotWateringSystemStatus, + TemperatureUnit, LetPotDeviceErrors, LetPotDeviceStatus, LightMode, @@ -113,6 +116,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 LetPotDeviceTypeException() return [ 97, 2, @@ -143,7 +148,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]), @@ -190,6 +195,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 LetPotDeviceTypeException() return [ 11, 2, @@ -216,7 +223,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]), @@ -263,6 +270,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 LetPotDeviceTypeException() return [ 13, 2, @@ -291,7 +300,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]), @@ -344,6 +353,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 LetPotDeviceTypeException() return [ 101, 2, @@ -369,7 +380,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]), @@ -414,6 +425,8 @@ 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 LetPotDeviceTypeException() return [ 65, 2, @@ -443,20 +456,10 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No else: pump_cycle_skipwater = math.floor((256 * data[35] + data[36]) / 60) - return LetPotDeviceStatus( + return LetPotWateringSystemStatus( raw=data, - light_brightness=None, - light_mode=0, - light_schedule_end=time(), - light_schedule_start=time(), - online=True, - plant_days=0, pump_mode=data[9], - pump_nutrient=None, - pump_status=None, system_on=data[7] == 1, - system_sound=None, - errors=LetPotDeviceErrors(low_water=None), wifi_state=data[6], pump_on=data[8] == 1, pump_duration=256 * data[10] + data[11], diff --git a/letpot/deviceclient.py b/letpot/deviceclient.py index 32566e5..bb8289a 100644 --- a/letpot/deviceclient.py +++ b/letpot/deviceclient.py @@ -18,6 +18,7 @@ from letpot.exceptions import ( LetPotAuthenticationException, LetPotConnectionException, + LetPotDeviceTypeException, LetPotException, LetPotFeatureException, ) @@ -25,6 +26,8 @@ AuthenticationInfo, DeviceFeature, LetPotDeviceInfo, + LetPotGardenStatus, + TemperatureUnit, LetPotDeviceStatus, LightMode, TemperatureUnit, @@ -406,15 +409,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 LetPotDeviceTypeException() + 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 LetPotDeviceTypeException() + status = dataclasses.replace(use_status, light_mode=mode) await self._publish_status(serial, status) @requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN) @@ -423,6 +430,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 LetPotDeviceTypeException() 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,7 +444,10 @@ 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 LetPotDeviceTypeException() + status = dataclasses.replace(use_status, plant_days=days) await self._publish_status(serial, status) async def set_power(self, serial: str, on: bool) -> None: @@ -453,23 +465,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 LetPotDeviceTypeException() + 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 LetPotDeviceTypeException() + 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 LetPotDeviceTypeException() + 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..56f12fd 100644 --- a/letpot/exceptions.py +++ b/letpot/exceptions.py @@ -13,5 +13,9 @@ class LetPotAuthenticationException(LetPotException): """LetPot authentication exception.""" +class LetPotDeviceTypeException(LetPotException): + """LetPot incorrect device type exception.""" + + class LetPotFeatureException(LetPotException): """LetPot device feature exception.""" diff --git a/letpot/models.py b/letpot/models.py index 18363f4..4214dc9 100644 --- a/letpot/models.py +++ b/letpot/models.py @@ -88,7 +88,16 @@ class LetPotDeviceErrors: @dataclass class LetPotDeviceStatus: - """Device status model.""" + """Generic device status model.""" + + raw: list[int] + system_on: bool + pump_mode: int + + +@dataclass +class LetPotGardenStatus(LetPotDeviceStatus): + """Device status model for a hydroponic garden.""" errors: LetPotDeviceErrors light_brightness: int | None @@ -97,27 +106,30 @@ class LetPotDeviceStatus: 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 - wifi_state: int | None = None - pump_on: bool | None = None - pump_duration: int | None = None - pump_countdown: list[int] | None = None - pump_cycle_on: bool | None = None - pump_cycle_duration: int | None = None - pump_cycle_workingduration: int | None = None - pump_cycle_mode: int | None = None - pump_cycle_workinginterval: int | None = None - pump_cycle_restinterval: int | None = None - pump_works_latest_reason: int | None = None - pump_works_latest_time: list[int] | None = None - pump_works_next_time: list[int] | None = None - pump_cycle_skip_water: int | None = None + + +@dataclass +class LetPotWateringSystemStatus(LetPotDeviceStatus): + """Device status model for a watering system.""" + + wifi_state: int + pump_on: bool + pump_duration: int + pump_countdown: list[int] + pump_cycle_on: bool + pump_cycle_duration: int + pump_cycle_workingduration: int + pump_cycle_mode: int + pump_cycle_workinginterval: int + pump_cycle_restinterval: int + pump_works_latest_reason: int + pump_works_latest_time: list[int] + pump_works_next_time: list[int] + pump_cycle_skip_water: int | None diff --git a/tests/test_converter.py b/tests/test_converter.py index d3c3d3d..40c576c 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -4,10 +4,11 @@ from letpot.converters import CONVERTERS, LetPotDeviceConverter, LPHx1Converter from letpot.exceptions import LetPotException +from letpot.models import LetPotDeviceErrors, LetPotGardenStatus from . import DEVICE_STATUS -SUPPORTED_DEVICE_TYPES = [ +SUPPORTED_DEVICE_TYPES_GARDEN = [ "IGS01", "LPH11", "LPH21", @@ -20,11 +21,34 @@ "LPH62", "LPH63", ] +SUPPORTED_DEVICE_TYPES_WATERING = ["ISE05", "ISE06"] +SUPPORTED_DEVICE_TYPES_ALL = ( + SUPPORTED_DEVICE_TYPES_GARDEN + SUPPORTED_DEVICE_TYPES_WATERING +) +DEVICE_STATUS_GARDEN = LetPotGardenStatus( + errors=LetPotDeviceErrors(low_water=True), + light_brightness=500, + light_mode=1, + light_schedule_end=time(17, 0), + light_schedule_start=time(7, 30), + online=True, + plant_days=0, + pump_mode=1, + pump_nutrient=None, + pump_status=0, + raw=[77, 0, 1, 18, 98, 1, 0, 1, 1, 1, 1, 0, 0, 7, 30, 17, 0, 1, 244, 0, 0, 0], + system_on=True, + system_sound=False, + temperature_unit=None, + temperature_value=None, + water_mode=None, + water_level=None, +) @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 +60,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 +89,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 +111,4 @@ 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 From 58899316cbcf800ed6bf3cf18f1f46af1662689b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Mon, 6 Oct 2025 21:16:01 +0200 Subject: [PATCH 3/6] Fixes after rebase - Fix tests/models - Sort imports - Add CATEGORY_WATERING_SYSTEM --- letpot/client.py | 3 ++- letpot/converters.py | 5 ++--- letpot/deviceclient.py | 3 +-- letpot/models.py | 3 +++ tests/__init__.py | 4 ++-- tests/test_converter.py | 22 +--------------------- tests/test_deviceclient.py | 6 +++--- 7 files changed, 14 insertions(+), 32 deletions(-) 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 a3bda06..b5b1314 100644 --- a/letpot/converters.py +++ b/letpot/converters.py @@ -11,11 +11,10 @@ from letpot.exceptions import LetPotDeviceTypeException, LetPotException from letpot.models import ( DeviceFeature, - LetPotGardenStatus, - LetPotWateringSystemStatus, - TemperatureUnit, LetPotDeviceErrors, LetPotDeviceStatus, + LetPotGardenStatus, + LetPotWateringSystemStatus, LightMode, TemperatureUnit, ) diff --git a/letpot/deviceclient.py b/letpot/deviceclient.py index bb8289a..41be46a 100644 --- a/letpot/deviceclient.py +++ b/letpot/deviceclient.py @@ -26,9 +26,8 @@ AuthenticationInfo, DeviceFeature, LetPotDeviceInfo, - LetPotGardenStatus, - TemperatureUnit, LetPotDeviceStatus, + LetPotGardenStatus, LightMode, TemperatureUnit, ) diff --git a/letpot/models.py b/letpot/models.py index 4214dc9..8ffc021 100644 --- a/letpot/models.py +++ b/letpot/models.py @@ -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() diff --git a/tests/__init__.py b/tests/__init__.py index f0d7db0..b1399fb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,7 +5,7 @@ from letpot.models import ( AuthenticationInfo, LetPotDeviceErrors, - LetPotDeviceStatus, + LetPotGardenStatus, LightMode, ) @@ -19,7 +19,7 @@ ) -DEVICE_STATUS = LetPotDeviceStatus( +DEVICE_STATUS_GARDEN = LetPotGardenStatus( errors=LetPotDeviceErrors(low_water=True), light_brightness=500, light_mode=LightMode.VEGETABLE, diff --git a/tests/test_converter.py b/tests/test_converter.py index 40c576c..6d97edc 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -4,9 +4,8 @@ from letpot.converters import CONVERTERS, LetPotDeviceConverter, LPHx1Converter from letpot.exceptions import LetPotException -from letpot.models import LetPotDeviceErrors, LetPotGardenStatus -from . import DEVICE_STATUS +from . import DEVICE_STATUS_GARDEN SUPPORTED_DEVICE_TYPES_GARDEN = [ "IGS01", @@ -25,25 +24,6 @@ SUPPORTED_DEVICE_TYPES_ALL = ( SUPPORTED_DEVICE_TYPES_GARDEN + SUPPORTED_DEVICE_TYPES_WATERING ) -DEVICE_STATUS_GARDEN = LetPotGardenStatus( - errors=LetPotDeviceErrors(low_water=True), - light_brightness=500, - light_mode=1, - light_schedule_end=time(17, 0), - light_schedule_start=time(7, 30), - online=True, - plant_days=0, - pump_mode=1, - pump_nutrient=None, - pump_status=0, - raw=[77, 0, 1, 18, 98, 1, 0, 1, 1, 1, 1, 0, 0, 7, 30, 17, 0, 1, 244, 0, 0, 0], - system_on=True, - system_sound=False, - temperature_unit=None, - temperature_value=None, - water_mode=None, - water_level=None, -) @pytest.mark.parametrize( 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) From 397450534d7eec800aa360d3b050a458912ef011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Mon, 6 Oct 2025 22:36:45 +0200 Subject: [PATCH 4/6] Feedback from testing and tweaks - Fix error mapping - Rename cycle duration to cycle frequency, cycle working duration to cycle duration, duration to manual duration - Introduce enum for cycle watering mode - Reorder properties to group them logically and add descriptions for units --- letpot/converters.py | 23 +++++++++++++---------- letpot/deviceclient.py | 6 +++++- letpot/models.py | 29 +++++++++++++++++++++++------ 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/letpot/converters.py b/letpot/converters.py index b5b1314..07afabc 100644 --- a/letpot/converters.py +++ b/letpot/converters.py @@ -10,6 +10,7 @@ from letpot.exceptions import LetPotDeviceTypeException, LetPotException from letpot.models import ( + CycleWateringMode, DeviceFeature, LetPotDeviceErrors, LetPotDeviceStatus, @@ -418,7 +419,7 @@ def get_device_model(self) -> tuple[str, str] | None: return MODEL_DI def supported_features(self) -> DeviceFeature: - return DeviceFeature(0) + return DeviceFeature.CATEGORY_WATERING_SYSTEM def get_current_status_message(self) -> list[int]: return [65, 1] @@ -431,12 +432,12 @@ def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: 2, 1 if status.pump_mode > 0 else 0, 1 if status.pump_cycle_on is True else 0, - math.floor((status.pump_duration or 0) / 256), - (status.pump_duration or 0) % 256, + 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, - math.floor((status.pump_cycle_workingduration or 0) / 256), - (status.pump_cycle_workingduration 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, @@ -458,15 +459,17 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No return LetPotWateringSystemStatus( raw=data, pump_mode=data[9], - system_on=data[7] == 1, + errors=LetPotDeviceErrors( + low_water=True if data[7] & 1 else False, + ), wifi_state=data[6], pump_on=data[8] == 1, - pump_duration=256 * data[10] + data[11], + pump_manual_duration=256 * data[10] + data[11], pump_countdown=data[12:16], pump_cycle_on=data[16] == 1, - pump_cycle_duration=256 * data[17] + data[18], - pump_cycle_workingduration=256 * data[19] + data[20], - pump_cycle_mode=data[21], + pump_cycle_frequency=256 * data[17] + data[18], + pump_cycle_duration=256 * data[19] + data[20], + pump_cycle_mode=CycleWateringMode(data[10]), pump_cycle_workinginterval=256 * data[22] + data[23], pump_cycle_restinterval=256 * data[24] + data[25], pump_works_latest_reason=data[26], diff --git a/letpot/deviceclient.py b/letpot/deviceclient.py index 41be46a..825a6c0 100644 --- a/letpot/deviceclient.py +++ b/letpot/deviceclient.py @@ -449,9 +449,13 @@ async def set_plant_days(self, serial: str, days: int) -> None: 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 LetPotDeviceTypeException() + 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: diff --git a/letpot/models.py b/letpot/models.py index 8ffc021..f49fe16 100644 --- a/letpot/models.py +++ b/letpot/models.py @@ -25,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.""" @@ -94,15 +101,15 @@ class LetPotDeviceStatus: """Generic device status model.""" raw: list[int] - system_on: bool pump_mode: int + errors: LetPotDeviceErrors @dataclass class LetPotGardenStatus(LetPotDeviceStatus): """Device status model for a hydroponic garden.""" - errors: LetPotDeviceErrors + system_on: bool light_brightness: int | None light_mode: LightMode light_schedule_end: time @@ -124,15 +131,25 @@ class LetPotWateringSystemStatus(LetPotDeviceStatus): wifi_state: int pump_on: bool - pump_duration: int pump_countdown: list[int] + 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 - pump_cycle_workingduration: int - pump_cycle_mode: 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_latest_reason: int pump_works_latest_time: list[int] pump_works_next_time: list[int] - pump_cycle_skip_water: int | None From f1dd318caf6e5d3e30e3ae8136f68607c63a155d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Mon, 6 Oct 2025 22:51:47 +0200 Subject: [PATCH 5/6] Adjust device type exception to be a specific feature exception --- letpot/converters.py | 12 ++++++------ letpot/deviceclient.py | 18 +++++++++--------- letpot/exceptions.py | 8 ++++---- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/letpot/converters.py b/letpot/converters.py index 07afabc..75004cd 100644 --- a/letpot/converters.py +++ b/letpot/converters.py @@ -8,7 +8,7 @@ from aiomqtt.types import PayloadType -from letpot.exceptions import LetPotDeviceTypeException, LetPotException +from letpot.exceptions import LetPotDeviceCategoryException, LetPotException from letpot.models import ( CycleWateringMode, DeviceFeature, @@ -117,7 +117,7 @@ def get_current_status_message(self) -> list[int]: def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: if not isinstance(status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() return [ 97, 2, @@ -196,7 +196,7 @@ def get_current_status_message(self) -> list[int]: def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: if not isinstance(status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() return [ 11, 2, @@ -271,7 +271,7 @@ def get_current_status_message(self) -> list[int]: def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: if not isinstance(status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() return [ 13, 2, @@ -354,7 +354,7 @@ def get_current_status_message(self) -> list[int]: def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: if not isinstance(status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() return [ 101, 2, @@ -426,7 +426,7 @@ def get_current_status_message(self) -> list[int]: def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]: if not isinstance(status, LetPotWateringSystemStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() return [ 65, 2, diff --git a/letpot/deviceclient.py b/letpot/deviceclient.py index 825a6c0..3129326 100644 --- a/letpot/deviceclient.py +++ b/letpot/deviceclient.py @@ -18,7 +18,7 @@ from letpot.exceptions import ( LetPotAuthenticationException, LetPotConnectionException, - LetPotDeviceTypeException, + LetPotDeviceCategoryException, LetPotException, LetPotFeatureException, ) @@ -410,7 +410,7 @@ async def set_light_brightness(self, serial: str, level: int) -> None: use_status = self._get_publish_status(serial) if not isinstance(use_status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() status = dataclasses.replace(use_status, light_brightness=level) await self._publish_status(serial, status) @@ -419,7 +419,7 @@ async def set_light_mode(self, serial: str, mode: LightMode) -> None: """Set the light mode for this device (flower/vegetable).""" use_status = self._get_publish_status(serial) if not isinstance(use_status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() status = dataclasses.replace(use_status, light_mode=mode) await self._publish_status(serial, status) @@ -430,7 +430,7 @@ async def set_light_schedule( """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 LetPotDeviceTypeException() + 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( @@ -445,7 +445,7 @@ async def set_plant_days(self, serial: str, days: int) -> None: """Set the plant days counter for this device (number of days).""" use_status = self._get_publish_status(serial) if not isinstance(use_status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() status = dataclasses.replace(use_status, plant_days=days) await self._publish_status(serial, status) @@ -454,7 +454,7 @@ async def set_power(self, serial: str, on: bool) -> None: """Set the general power for this device (on/off).""" use_status = self._get_publish_status(serial) if not isinstance(use_status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() status = dataclasses.replace(use_status, system_on=on) await self._publish_status(serial, status) @@ -470,7 +470,7 @@ async def set_sound(self, serial: str, on: bool) -> None: """Set the alarm sound for this device (on/off).""" use_status = self._get_publish_status(serial) if not isinstance(use_status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() status = dataclasses.replace(use_status, system_sound=on) await self._publish_status(serial, status) @@ -479,7 +479,7 @@ async def set_temperature_unit(self, serial: str, unit: TemperatureUnit) -> None """Set the temperature unit for this device (Celsius/Fahrenheit).""" use_status = self._get_publish_status(serial) if not isinstance(use_status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() status = dataclasses.replace(use_status, temperature_unit=unit) await self._publish_status(serial, status) @@ -488,7 +488,7 @@ async def set_water_mode(self, serial: str, on: bool) -> None: """Set the automatic water/nutrient mode for this device (on/off).""" use_status = self._get_publish_status(serial) if not isinstance(use_status, LetPotGardenStatus): - raise LetPotDeviceTypeException() + raise LetPotDeviceCategoryException() status = dataclasses.replace(use_status, water_mode=1 if on else 0) await self._publish_status(serial, status) diff --git a/letpot/exceptions.py b/letpot/exceptions.py index 56f12fd..8c464d6 100644 --- a/letpot/exceptions.py +++ b/letpot/exceptions.py @@ -13,9 +13,9 @@ class LetPotAuthenticationException(LetPotException): """LetPot authentication exception.""" -class LetPotDeviceTypeException(LetPotException): - """LetPot incorrect device type exception.""" - - class LetPotFeatureException(LetPotException): """LetPot device feature exception.""" + + +class LetPotDeviceCategoryException(LetPotFeatureException): + """LetPot incorrect device type exception.""" From 22749ab38be78862275fb994a5e2d0c8a2930c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Mon, 2 Mar 2026 20:14:24 +0100 Subject: [PATCH 6/6] Feedback: add datetimes and WateringReason, tests, fix cycle mode --- letpot/converters.py | 29 +++++-- letpot/models.py | 24 +++++- poetry.lock | 46 ++++++++++- pyproject.toml | 1 + tests/__init__.py | 179 +++++++++++++++++++++++++++++++++++++++- tests/test_converter.py | 47 ++++++++++- 6 files changed, 311 insertions(+), 15 deletions(-) diff --git a/letpot/converters.py b/letpot/converters.py index 75004cd..0d68169 100644 --- a/letpot/converters.py +++ b/letpot/converters.py @@ -3,7 +3,7 @@ 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 @@ -18,6 +18,7 @@ LetPotWateringSystemStatus, LightMode, TemperatureUnit, + WateringReason, ) _LOGGER = logging.getLogger(__name__) @@ -456,6 +457,22 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No 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], @@ -465,16 +482,16 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No wifi_state=data[6], pump_on=data[8] == 1, pump_manual_duration=256 * data[10] + data[11], - pump_countdown=data[12:16], 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[10]), + pump_cycle_mode=CycleWateringMode(data[21]), pump_cycle_workinginterval=256 * data[22] + data[23], pump_cycle_restinterval=256 * data[24] + data[25], - pump_works_latest_reason=data[26], - pump_works_latest_time=data[27:31], - pump_works_next_time=data[31:35], + 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, ) diff --git a/letpot/models.py b/letpot/models.py index f49fe16..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 @@ -48,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.""" @@ -131,7 +141,7 @@ class LetPotWateringSystemStatus(LetPotDeviceStatus): wifi_state: int pump_on: bool - pump_countdown: list[int] + pump_manual_duration: int """Manual watering run duration, in minutes""" @@ -150,6 +160,12 @@ class LetPotWateringSystemStatus(LetPotDeviceStatus): """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: list[int] - pump_works_next_time: list[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 b1399fb..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, LetPotGardenStatus, + LetPotWateringSystemStatus, LightMode, + WateringReason, ) AUTHENTICATION = AuthenticationInfo( @@ -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 6d97edc..57371dc 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,11 +1,22 @@ """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_GARDEN +from . import ( + DEVICE_STATUS_DI_CYCLE, + DEVICE_STATUS_DI_IDLE, + DEVICE_STATUS_DI_MANUAL, + DEVICE_STATUS_GARDEN, +) SUPPORTED_DEVICE_TYPES_GARDEN = [ "IGS01", @@ -92,3 +103,35 @@ def test_lph21_message_to_status() -> None: message = b"4d000112620100010101010000071e110001f4000000" status = converter.convert_hex_to_status(message) 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