Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 13 additions & 18 deletions letpot/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import logging
import math
from abc import ABC, abstractmethod
from datetime import datetime, time, timedelta
from typing import Sequence
from collections.abc import Sequence
from datetime import UTC, datetime, time, timedelta

from aiomqtt.types import PayloadType

Expand Down Expand Up @@ -47,7 +47,6 @@ def __init__(self, device_type: str) -> None:
@abstractmethod
def supports_type(device_type: str) -> bool:
"""Returns if the converter supports the supplied type."""
pass

@abstractmethod
def get_device_model(self) -> tuple[str, str] | None:
Expand All @@ -60,22 +59,18 @@ def supported_features(self) -> DeviceFeature:
@abstractmethod
def get_current_status_message(self) -> list[int]:
"""Returns the message content for getting the current device status."""
pass

@abstractmethod
def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | None:
"""Converts a hexadecimal bytes status message to a status dataclass."""
pass

@abstractmethod
def get_update_status_message(self, status: LetPotDeviceStatus) -> list[int]:
"""Returns the message content for updating the device status."""
pass

@abstractmethod
def get_light_brightness_levels(self) -> list[int]:
"""Returns the brightness steps supported by the device for this converter."""
pass

def _hex_bytes_to_int_array(self, hex_message: PayloadType) -> list[int] | None:
"""Converts a hexadecimal bytes message to a list of integers."""
Expand Down Expand Up @@ -148,7 +143,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No
if self._device_type in ["LPH21", "LPH22"]:
error_pump_malfunction = None
else:
error_pump_malfunction = True if data[7] & 2 else False
error_pump_malfunction = bool(data[7] & 2)

return LetPotGardenStatus(
raw=data,
Expand All @@ -164,7 +159,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No
system_on=data[8] == 1,
system_sound=data[20] == 1 if data[20] is not None else None,
errors=LetPotDeviceErrors(
low_water=True if data[7] & 1 else False,
low_water=bool(data[7] & 1),
pump_malfunction=error_pump_malfunction,
),
)
Expand Down Expand Up @@ -227,7 +222,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No
if self._device_type == "IGS01":
error_low_water = None
else:
error_low_water = True if data[7] & 1 else False
error_low_water = bool(data[7] & 1)

return LetPotGardenStatus(
raw=data,
Expand Down Expand Up @@ -320,9 +315,9 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No
system_on=data[8] == 1,
system_sound=data[25] == 1 if data[25] is not None else None,
errors=LetPotDeviceErrors(
low_water=True if data[7] & 2 else False,
low_nutrients=True if data[7] & 1 else False,
refill_error=True if data[7] & 4 else False,
low_water=bool(data[7] & 2),
low_nutrients=bool(data[7] & 1),
refill_error=bool(data[7] & 4),
),
temperature_unit=TemperatureUnit(data[24]),
temperature_value=256 * data[22] + data[23],
Expand Down Expand Up @@ -400,9 +395,9 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No
system_on=data[8] == 1,
system_sound=None,
errors=LetPotDeviceErrors(
low_water=True if data[7] & 2 else False,
low_nutrients=True if data[7] & 1 else False,
refill_error=True if data[7] & 4 else False,
low_water=bool(data[7] & 2),
low_nutrients=bool(data[7] & 1),
refill_error=bool(data[7] & 4),
),
temperature_unit=TemperatureUnit(data[24]),
temperature_value=256 * data[22] + data[23],
Expand Down Expand Up @@ -467,7 +462,7 @@ 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()
now = datetime.now(tz=UTC)
if (seconds := int.from_bytes(data[12:16], byteorder="big")) == 0:
pump_works_end = None
else:
Expand All @@ -487,7 +482,7 @@ def convert_hex_to_status(self, message: PayloadType) -> LetPotDeviceStatus | No
raw=data,
pump_mode=data[9],
errors=LetPotDeviceErrors(
low_water=True if data[7] & 1 else False,
low_water=bool(data[7] & 1),
),
wifi_state=data[6],
pump_on=data[8] == 1,
Expand Down
45 changes: 24 additions & 21 deletions letpot/deviceclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
import os
import ssl
import time as systime
from collections.abc import Coroutine
from collections.abc import Callable, Coroutine
from datetime import time
from functools import wraps
from hashlib import md5, sha256
from typing import Any, Callable, ParamSpec, TypeVar, cast
from typing import Any, ParamSpec, TypeVar, cast

import aiomqtt

Expand Down Expand Up @@ -83,29 +83,30 @@ def _create_ssl_context() -> ssl.SSLContext:
class LetPotDeviceClient:
"""Client for connecting to LetPot device."""

AUTH_ERROR_RC = [4, 5, 134, 135]
AUTH_ERROR_RC = (4, 5, 134, 135)
BROKER_HOST = "broker.letpot.net"
MTU = 128

_client: aiomqtt.Client | None = None
_client_task: asyncio.Task | None = None
_connected: asyncio.Future[bool] | None = None
_topics: list[str] = []
_message_id: int = 0

_user_id: str
_email: str

_device_callbacks: dict[str, Callable[[LetPotDeviceStatus], None]] = {}
_device_status_last: dict[str, LetPotDeviceStatus | None] = {}
_device_status_pending: dict[str, LetPotDeviceStatus | None] = {}
_device_status_timeout: dict[str, asyncio.Task | None] = {}
_device_status_event: dict[str, asyncio.Event | None] = {}

def __init__(self, info: AuthenticationInfo) -> None:
self._user_id = info.user_id
self._email = info.email

self._topics: list[str] = []

self._device_callbacks: dict[str, Callable[[LetPotDeviceStatus], None]] = {}
self._device_status_last: dict[str, LetPotDeviceStatus | None] = {}
self._device_status_pending: dict[str, LetPotDeviceStatus | None] = {}
self._device_status_timeout: dict[str, asyncio.Task | None] = {}
self._device_status_event: dict[str, asyncio.Event | None] = {}

def _converter(self, serial: str) -> LetPotDeviceConverter:
"""Get the device converter for the current serial number."""
device_type = serial[:5]
Expand Down Expand Up @@ -172,7 +173,7 @@ def _handle_message(self, message: aiomqtt.Message) -> None:
event = self._device_status_event.get(serial)
if event is not None and not event.is_set():
event.set()
except Exception: # noqa: BLE001
except Exception:
_LOGGER.warning(
f"Exception while handling message for {message.topic.value}, ignoring",
exc_info=True,
Expand Down Expand Up @@ -274,14 +275,16 @@ async def _connect(self) -> None:
except aiomqtt.MqttError as err:
self._client = None

if isinstance(err, aiomqtt.MqttCodeError):
if err.rc in self.AUTH_ERROR_RC:
msg = "MQTT auth error"
_LOGGER.error("%s: %s", msg, err)
auth_exception = LetPotAuthenticationException(msg)
if self._connected is not None and not self._connected.done():
self._connected.set_exception(auth_exception)
raise auth_exception from err
if (
isinstance(err, aiomqtt.MqttCodeError)
and err.rc in self.AUTH_ERROR_RC
):
msg = "MQTT auth error"
_LOGGER.error("%s: %s", msg, err)
auth_exception = LetPotAuthenticationException(msg)
if self._connected is not None and not self._connected.done():
self._connected.set_exception(auth_exception)
raise auth_exception from err

connection_attempts += 1
if connection_attempts == 1:
Expand Down Expand Up @@ -346,10 +349,10 @@ async def subscribe(
await self._client.subscribe(topic)
self._topics.append(topic)
self._device_callbacks[serial] = callback
except aiomqtt.MqttError as err:
except aiomqtt.MqttError:
if len(self._topics) == 0:
await self._disconnect()
raise err
raise

async def unsubscribe(self, serial: str) -> None:
"""Unsubscribes from device updates, and cancels the active device client connection if required."""
Expand Down
42 changes: 21 additions & 21 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ aiohttp = "^3.11"
aiomqtt = "^2.0"

[tool.poetry.group.dev.dependencies]
ruff = "0.15.12"
ruff = "0.16.9"
pytest = "9.0.3"
pytest-asyncio = "1.3.0"
pytest-cov = "7.1.0"
Expand Down
12 changes: 6 additions & 6 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for Python client for LetPot hydroponic gardens."""

from datetime import datetime, time
from datetime import UTC, datetime, time

from letpot.models import (
AuthenticationInfo,
Expand Down Expand Up @@ -152,9 +152,9 @@
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_end=datetime(2026, 3, 1, 0, 2, 12, tzinfo=UTC),
pump_works_latest_reason=WateringReason.MANUAL,
pump_works_latest_time=datetime(2026, 2, 28, 23, 59, 11),
pump_works_latest_time=datetime(2026, 2, 28, 23, 59, 11, tzinfo=UTC),
pump_works_next_time=None,
)

Expand Down Expand Up @@ -210,8 +210,8 @@
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_end=datetime(2026, 3, 1, 0, 2, 3, tzinfo=UTC),
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),
pump_works_latest_time=datetime(2026, 2, 28, 23, 59, 3, tzinfo=UTC),
pump_works_next_time=datetime(2026, 3, 1, 11, 59, tzinfo=UTC),
)
2 changes: 1 addition & 1 deletion tests/test_converter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for the converters."""

from freezegun import freeze_time
import pytest
from freezegun import freeze_time

from letpot.converters import (
CONVERTERS,
Expand Down
18 changes: 13 additions & 5 deletions tests/test_models_auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for the AuthenticationInfo model."""

import dataclasses
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta

from . import AUTHENTICATION

Expand All @@ -10,8 +10,12 @@ def test_valid_info() -> None:
"""Test auth with access token expiring in the future is valid."""
auth_info = dataclasses.replace(
AUTHENTICATION,
access_token_expires=int((datetime.now() + timedelta(days=7)).timestamp()),
refresh_token_expires=int((datetime.now() + timedelta(days=30)).timestamp()),
access_token_expires=int(
(datetime.now(tz=UTC) + timedelta(days=7)).timestamp()
),
refresh_token_expires=int(
(datetime.now(tz=UTC) + timedelta(days=30)).timestamp()
),
)
assert auth_info.is_valid is True

Expand All @@ -20,7 +24,11 @@ def test_expired_info() -> None:
"""Test auth with expired access token is considered invalid."""
auth_info = dataclasses.replace(
AUTHENTICATION,
access_token_expires=int((datetime.now() - timedelta(days=7)).timestamp()),
refresh_token_expires=int((datetime.now() + timedelta(days=14)).timestamp()),
access_token_expires=int(
(datetime.now(tz=UTC) - timedelta(days=7)).timestamp()
),
refresh_token_expires=int(
(datetime.now(tz=UTC) + timedelta(days=14)).timestamp()
),
)
assert auth_info.is_valid is False
Loading