Skip to content

Commit c44a126

Browse files
authored
Add feature exceptions + add feature: category hydroponic garden (#20)
* Add feature exceptions, add feature: category hydroponic garden - To prepare for other device categories, add a DeviceFeature for the 'hydroponic garden' category - Introduce LetPotFeatureException for exceptions related to features - Annotate device client functions to require certain features * Cleanup imports
1 parent 210916f commit c44a126

File tree

7 files changed

+182
-53
lines changed

7 files changed

+182
-53
lines changed

letpot/converters.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
"""Python client for LetPot hydroponic gardens."""
22

3-
from abc import ABC, abstractmethod
4-
from datetime import time
53
import logging
64
import math
5+
from abc import ABC, abstractmethod
6+
from datetime import time
77
from typing import Sequence
8+
89
from aiomqtt.types import PayloadType
910

1011
from letpot.exceptions import LetPotException
1112
from letpot.models import (
1213
DeviceFeature,
13-
LightMode,
14-
TemperatureUnit,
1514
LetPotDeviceErrors,
1615
LetPotDeviceStatus,
16+
LightMode,
17+
TemperatureUnit,
1718
)
1819

1920
_LOGGER = logging.getLogger(__name__)
@@ -102,7 +103,7 @@ def get_device_model(self) -> tuple[str, str] | None:
102103
return None
103104

104105
def supported_features(self) -> DeviceFeature:
105-
features = DeviceFeature.PUMP_STATUS
106+
features = DeviceFeature.CATEGORY_HYDROPONIC_GARDEN | DeviceFeature.PUMP_STATUS
106107
if self._device_type in ["LPH21", "LPH31"]:
107108
features |= DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH
108109
return features
@@ -182,7 +183,7 @@ def get_device_model(self) -> tuple[str, str] | None:
182183
return None
183184

184185
def supported_features(self) -> DeviceFeature:
185-
return DeviceFeature(0)
186+
return DeviceFeature.CATEGORY_HYDROPONIC_GARDEN
186187

187188
def get_current_status_message(self) -> list[int]:
188189
return [11, 1]
@@ -246,7 +247,8 @@ def get_device_model(self) -> tuple[str, str] | None:
246247

247248
def supported_features(self) -> DeviceFeature:
248249
features = (
249-
DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
250+
DeviceFeature.CATEGORY_HYDROPONIC_GARDEN
251+
| DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
250252
| DeviceFeature.PUMP_AUTO
251253
| DeviceFeature.TEMPERATURE
252254
| DeviceFeature.TEMPERATURE_SET_UNIT
@@ -328,7 +330,8 @@ def get_device_model(self) -> tuple[str, str] | None:
328330

329331
def supported_features(self) -> DeviceFeature:
330332
return (
331-
DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
333+
DeviceFeature.CATEGORY_HYDROPONIC_GARDEN
334+
| DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
332335
| DeviceFeature.NUTRIENT_BUTTON
333336
| DeviceFeature.PUMP_AUTO
334337
| DeviceFeature.PUMP_STATUS

letpot/deviceclient.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,32 @@
22

33
import asyncio
44
import dataclasses
5-
from datetime import time
6-
from hashlib import md5, sha256
75
import logging
86
import os
9-
import time as systime
107
import ssl
11-
from typing import Callable
8+
import time as systime
9+
from collections.abc import Coroutine
10+
from datetime import time
11+
from functools import wraps
12+
from hashlib import md5, sha256
13+
from typing import Any, Callable, Concatenate
14+
1215
import aiomqtt
1316

1417
from letpot.converters import CONVERTERS, LetPotDeviceConverter
1518
from letpot.exceptions import (
1619
LetPotAuthenticationException,
1720
LetPotConnectionException,
1821
LetPotException,
22+
LetPotFeatureException,
1923
)
2024
from letpot.models import (
2125
AuthenticationInfo,
26+
DeviceFeature,
2227
LetPotDeviceInfo,
28+
LetPotDeviceStatus,
2329
LightMode,
2430
TemperatureUnit,
25-
LetPotDeviceStatus,
2631
)
2732

2833
_LOGGER = logging.getLogger(__name__)
@@ -38,6 +43,37 @@ def _create_ssl_context() -> ssl.SSLContext:
3843
_SSL_CONTEXT = _create_ssl_context()
3944

4045

46+
def requires_feature[T: "LetPotDeviceClient", _R, **P](
47+
*required_feature: DeviceFeature,
48+
) -> Callable[
49+
[Callable[Concatenate[T, str, P], Coroutine[Any, Any, _R]]],
50+
Callable[Concatenate[T, str, P], Coroutine[Any, Any, _R]],
51+
]:
52+
"""Decorate the function to require device type support for a specific feature (inferred from serial)."""
53+
54+
def decorator(
55+
func: Callable[Concatenate[T, str, P], Coroutine[Any, Any, _R]],
56+
) -> Callable[Concatenate[T, str, P], Coroutine[Any, Any, _R]]:
57+
@wraps(func)
58+
async def wrapper(
59+
self: T, serial: str, *args: P.args, **kwargs: P.kwargs
60+
) -> _R:
61+
exception_message = f"Device missing required feature: {required_feature}"
62+
try:
63+
supported_features = self._converter(serial).supported_features()
64+
if not any(
65+
feature in supported_features for feature in required_feature
66+
):
67+
raise LetPotFeatureException(exception_message)
68+
except LetPotException:
69+
raise LetPotFeatureException(exception_message)
70+
return await func(self, serial, *args, **kwargs)
71+
72+
return wrapper
73+
74+
return decorator
75+
76+
4177
class LetPotDeviceClient:
4278
"""Client for connecting to LetPot device."""
4379

@@ -356,10 +392,13 @@ async def get_current_status(self, serial: str) -> LetPotDeviceStatus | None:
356392
await status_event.wait()
357393
return self._device_status_last.get(serial)
358394

395+
@requires_feature(
396+
DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH, DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
397+
)
359398
async def set_light_brightness(self, serial: str, level: int) -> None:
360399
"""Set the light brightness for this device (brightness level)."""
361400
if level not in self.get_light_brightness_levels(serial):
362-
raise LetPotException(
401+
raise LetPotFeatureException(
363402
f"Device doesn't support setting light brightness to {level}"
364403
)
365404

@@ -368,11 +407,13 @@ async def set_light_brightness(self, serial: str, level: int) -> None:
368407
)
369408
await self._publish_status(serial, status)
370409

410+
@requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN)
371411
async def set_light_mode(self, serial: str, mode: LightMode) -> None:
372412
"""Set the light mode for this device (flower/vegetable)."""
373413
status = dataclasses.replace(self._get_publish_status(serial), light_mode=mode)
374414
await self._publish_status(serial, status)
375415

416+
@requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN)
376417
async def set_light_schedule(
377418
self, serial: str, start: time | None, end: time | None
378419
) -> None:
@@ -387,6 +428,7 @@ async def set_light_schedule(
387428
)
388429
await self._publish_status(serial, status)
389430

431+
@requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN)
390432
async def set_plant_days(self, serial: str, days: int) -> None:
391433
"""Set the plant days counter for this device (number of days)."""
392434
status = dataclasses.replace(self._get_publish_status(serial), plant_days=days)
@@ -404,18 +446,21 @@ async def set_pump_mode(self, serial: str, on: bool) -> None:
404446
)
405447
await self._publish_status(serial, status)
406448

449+
@requires_feature(DeviceFeature.CATEGORY_HYDROPONIC_GARDEN)
407450
async def set_sound(self, serial: str, on: bool) -> None:
408451
"""Set the alarm sound for this device (on/off)."""
409452
status = dataclasses.replace(self._get_publish_status(serial), system_sound=on)
410453
await self._publish_status(serial, status)
411454

455+
@requires_feature(DeviceFeature.TEMPERATURE_SET_UNIT)
412456
async def set_temperature_unit(self, serial: str, unit: TemperatureUnit) -> None:
413457
"""Set the temperature unit for this device (Celsius/Fahrenheit)."""
414458
status = dataclasses.replace(
415459
self._get_publish_status(serial), temperature_unit=unit
416460
)
417461
await self._publish_status(serial, status)
418462

463+
@requires_feature(DeviceFeature.PUMP_AUTO)
419464
async def set_water_mode(self, serial: str, on: bool) -> None:
420465
"""Set the automatic water/nutrient mode for this device (on/off)."""
421466
status = dataclasses.replace(

letpot/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ class LetPotConnectionException(LetPotException):
1111

1212
class LetPotAuthenticationException(LetPotException):
1313
"""LetPot authentication exception."""
14+
15+
16+
class LetPotFeatureException(LetPotException):
17+
"""LetPot device feature exception."""

letpot/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
"""Models for Python client for LetPot hydroponic gardens."""
22

3+
import time as systime
34
from dataclasses import dataclass
45
from datetime import time
56
from enum import IntEnum, IntFlag, auto
6-
import time as systime
77

88

99
class DeviceFeature(IntFlag):
1010
"""Features that a LetPot device can support."""
1111

12+
CATEGORY_HYDROPONIC_GARDEN = auto()
13+
"""Features common to the hydroponic garden device category."""
14+
1215
LIGHT_BRIGHTNESS_LOW_HIGH = auto()
1316
LIGHT_BRIGHTNESS_LEVELS = auto()
1417
NUTRIENT_BUTTON = auto()

tests/__init__.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
"""Tests for Python client for LetPot hydroponic gardens."""
22

3-
from letpot.models import AuthenticationInfo
3+
from datetime import time
4+
5+
from letpot.models import (
6+
AuthenticationInfo,
7+
LetPotDeviceErrors,
8+
LetPotDeviceStatus,
9+
LightMode,
10+
)
411

512
AUTHENTICATION = AuthenticationInfo(
613
access_token="access_token",
@@ -10,3 +17,24 @@
1017
user_id="a1b2c3d4e5f6a1b2c3d4e5f6",
1118
1219
)
20+
21+
22+
DEVICE_STATUS = LetPotDeviceStatus(
23+
errors=LetPotDeviceErrors(low_water=True),
24+
light_brightness=500,
25+
light_mode=LightMode.VEGETABLE,
26+
light_schedule_end=time(17, 0),
27+
light_schedule_start=time(7, 30),
28+
online=True,
29+
plant_days=0,
30+
pump_mode=1,
31+
pump_nutrient=None,
32+
pump_status=0,
33+
raw=[77, 0, 1, 18, 98, 1, 0, 1, 1, 1, 1, 0, 0, 7, 30, 17, 0, 1, 244, 0, 0, 0],
34+
system_on=True,
35+
system_sound=False,
36+
temperature_unit=None,
37+
temperature_value=None,
38+
water_mode=None,
39+
water_level=None,
40+
)

tests/test_converter.py

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
"""Tests for the converters."""
22

3-
from datetime import time
4-
53
import pytest
64

7-
from letpot.converters import CONVERTERS, LPHx1Converter, LetPotDeviceConverter
5+
from letpot.converters import CONVERTERS, LetPotDeviceConverter, LPHx1Converter
86
from letpot.exceptions import LetPotException
9-
from letpot.models import LetPotDeviceErrors, LetPotDeviceStatus, LightMode
107

8+
from . import DEVICE_STATUS
119

1210
SUPPORTED_DEVICE_TYPES = [
1311
"IGS01",
@@ -22,25 +20,6 @@
2220
"LPH62",
2321
"LPH63",
2422
]
25-
DEVICE_STATUS = LetPotDeviceStatus(
26-
errors=LetPotDeviceErrors(low_water=True),
27-
light_brightness=500,
28-
light_mode=LightMode.VEGETABLE,
29-
light_schedule_end=time(17, 0),
30-
light_schedule_start=time(7, 30),
31-
online=True,
32-
plant_days=0,
33-
pump_mode=1,
34-
pump_nutrient=None,
35-
pump_status=0,
36-
raw=[77, 0, 1, 18, 98, 1, 0, 1, 1, 1, 1, 0, 0, 7, 30, 17, 0, 1, 244, 0, 0, 0],
37-
system_on=True,
38-
system_sound=False,
39-
temperature_unit=None,
40-
temperature_value=None,
41-
water_mode=None,
42-
water_level=None,
43-
)
4423

4524

4625
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)