Skip to content

Commit fcda34a

Browse files
authored
Use one (shared) device client for all devices (#17)
* Make one connection, multiple subscriptions * Add tests for multiple subs one connection * Update deviceclient functions to take device serial - Do not require a device serial for deviceclient init - Update deviceclient device functions to require a device serial - Send received messages to the correct callback (+ test)
1 parent 47e1ab1 commit fcda34a

File tree

9 files changed

+1001
-669
lines changed

9 files changed

+1001
-669
lines changed

.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"python.testing.pytestArgs": [
3+
"tests"
4+
],
5+
"python.testing.unittestEnabled": false,
6+
"python.testing.pytestEnabled": true
7+
}

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ async def main():
3030
devices = await client.get_devices()
3131
print(devices)
3232

33-
device_client = LetPotDeviceClient(auth, devices[0].serial_number)
34-
await device_client.subscribe(lambda status: print(status))
35-
await device_client.request_status_update()
33+
device_client = LetPotDeviceClient(auth)
34+
device_serial = devices[0].serial_number
35+
await device_client.subscribe(device_serial, lambda status: print(status))
36+
await device_client.request_status_update(device_serial)
3637

3738
# do work, and finally
38-
device_client.disconnect()
39+
device_client.disconnect(device_serial)
3940

4041

4142
asyncio.run(main())

letpot/deviceclient.py

Lines changed: 194 additions & 118 deletions
Large diffs are not rendered by default.

letpot/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ class LetPotDevice:
5454
is_remote: bool | None
5555

5656

57+
@dataclass
58+
class LetPotDeviceInfo:
59+
"""Information about a device model, based on the serial number."""
60+
61+
model: str
62+
model_name: str | None
63+
model_code: str | None
64+
features: DeviceFeature
65+
66+
5767
@dataclass
5868
class LetPotDeviceErrors:
5969
"""Device errors model. Errors not supported by the device will be None."""

poetry.lock

Lines changed: 637 additions & 535 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ ruff = "0.12.3"
2626
pytest = "8.4.1"
2727
pytest-cov = "6.2.1"
2828
mypy = "1.17.0"
29+
pytest-asyncio = "1.0.0"
2930

3031
[tool.poetry.urls]
3132
Changelog = "https://github.com/jpelgrom/python-letpot/releases"
3233
Issues = "https://github.com/jpelgrom/python-letpot/issues"
3334

35+
[tool.pytest.ini_options]
36+
asyncio_mode = "auto"
37+
3438
[build-system]
3539
requires = ["poetry-core"]
3640
build-backend = "poetry.core.masonry.api"

tests/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,12 @@
11
"""Tests for Python client for LetPot hydroponic gardens."""
2+
3+
from letpot.models import AuthenticationInfo
4+
5+
AUTHENTICATION = AuthenticationInfo(
6+
access_token="access_token",
7+
access_token_expires=1738368000, # 2025-02-01 00:00:00 GMT
8+
refresh_token="refresh_token",
9+
refresh_token_expires=1740441600, # 2025-02-25 00:00:00 GMT
10+
user_id="a1b2c3d4e5f6a1b2c3d4e5f6",
11+
12+
)

tests/test_deviceclient.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Tests for the device client."""
2+
3+
from collections.abc import AsyncGenerator
4+
from unittest.mock import MagicMock, patch
5+
6+
from aiomqtt import Client, Message
7+
8+
import pytest_asyncio
9+
import asyncio
10+
11+
from . import AUTHENTICATION
12+
13+
from letpot.deviceclient import LetPotDeviceClient
14+
15+
16+
class MockMessagesIterator:
17+
"""A simple iterator which waits for messages in a queue."""
18+
19+
def __init__(self, queue=None):
20+
self.queue = queue or asyncio.Queue()
21+
self.next_call_count = 0
22+
23+
def __aiter__(self):
24+
return self
25+
26+
async def __anext__(self):
27+
self.next_call_count += 1
28+
item = await self.queue.get()
29+
if item is StopAsyncIteration:
30+
raise StopAsyncIteration
31+
return item
32+
33+
34+
@pytest_asyncio.fixture()
35+
async def mock_aiomqtt() -> AsyncGenerator[MagicMock]:
36+
"""Mock a aiomqtt.Client."""
37+
38+
with patch("letpot.deviceclient.aiomqtt.Client") as mock_client_class:
39+
client = MagicMock(spec=Client)
40+
client.messages = MockMessagesIterator()
41+
42+
mock_client_class.return_value.__aenter__.return_value = client
43+
44+
yield mock_client_class
45+
46+
47+
async def test_subscribe_setup_shutdown(mock_aiomqtt: MagicMock) -> None:
48+
"""Test subscribing/unsubscribing creates a client and shuts it down."""
49+
device_client = LetPotDeviceClient(AUTHENTICATION)
50+
topic = "LPH21ABCD/status"
51+
52+
# Test subscribing sets up a client + subscription
53+
await device_client.subscribe(topic, lambda _: None)
54+
assert device_client._client is not None
55+
assert (
56+
device_client._connected is not None
57+
and device_client._connected.result() is True
58+
)
59+
60+
# Test unsubscribing cancels the subscription + shuts down client
61+
await device_client.unsubscribe(topic)
62+
assert device_client._client is None
63+
assert device_client._client_task.cancelled()
64+
65+
66+
async def test_subscribe_multiple(mock_aiomqtt: MagicMock) -> None:
67+
"""Test multiple subscriptions use one client and shuts down only when all are done."""
68+
device_client = LetPotDeviceClient(AUTHENTICATION)
69+
device1 = "LPH21ABCD"
70+
device2 = "LPH21DEFG"
71+
72+
await device_client.subscribe(device1, lambda _: None)
73+
await device_client.subscribe(device2, lambda _: None)
74+
assert device_client._client is not None
75+
assert device_client._client.subscribe.call_count == 2 # type: ignore[attr-defined]
76+
# Check number of calls on message queue. Nothing is sent so 1 call = 1 client.
77+
assert device_client._client.messages.next_call_count == 1 # type: ignore[attr-defined]
78+
79+
await device_client.unsubscribe(device1)
80+
assert device_client._client.unsubscribe.call_count == 1 # type: ignore[attr-defined]
81+
assert device_client._client is not None
82+
83+
await device_client.unsubscribe(device2)
84+
assert device_client._client is None
85+
assert device_client._client_task.cancelled()
86+
87+
88+
async def test_subscribe_callback(mock_aiomqtt: MagicMock) -> None:
89+
"""Test subscription receiving a status update passing it to the callback."""
90+
device_client = LetPotDeviceClient(AUTHENTICATION)
91+
device1 = "LPH21ABCD"
92+
device2 = "LPH21DEFG"
93+
callback1 = MagicMock()
94+
callback2 = MagicMock()
95+
96+
await device_client.subscribe(device1, callback1)
97+
await device_client.subscribe(device2, callback2)
98+
99+
assert device_client._client is not None
100+
device_client._handle_message(
101+
Message(
102+
topic=f"{device1}/data",
103+
payload=b"4d0001126201000101010100000f000f1e01f4000000",
104+
qos=0,
105+
retain=False,
106+
mid=1,
107+
properties=None,
108+
)
109+
)
110+
# Only device1 should be called
111+
assert callback1.call_count == 1
112+
assert not callback2.called
113+
114+
device_client._handle_message(
115+
Message(
116+
topic=f"{device2}/data",
117+
payload=b"4d0001126201000101010100000f000f1e01f4000000",
118+
qos=0,
119+
retain=False,
120+
mid=1,
121+
properties=None,
122+
)
123+
)
124+
# Only device2 should be called, device1 should be same as before
125+
assert callback1.call_count == 1
126+
assert callback2.call_count == 1
127+
128+
# Shutdown gracefully
129+
await device_client.unsubscribe(device1)
130+
await device_client.unsubscribe(device2)

tests/test_models_auth.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,13 @@
33
import dataclasses
44
from datetime import datetime, timedelta
55

6-
from letpot.models import AuthenticationInfo
7-
8-
INFO = AuthenticationInfo(
9-
access_token="abcdef",
10-
access_token_expires=0,
11-
refresh_token="123456",
12-
refresh_token_expires=0,
13-
user_id="a1b2c3d4e5f6a1b2c3d4e5f6",
14-
15-
)
6+
from . import AUTHENTICATION
167

178

189
def test_valid_info() -> None:
1910
"""Test auth with access token expiring in the future is valid."""
2011
auth_info = dataclasses.replace(
21-
INFO,
12+
AUTHENTICATION,
2213
access_token_expires=int((datetime.now() + timedelta(days=7)).timestamp()),
2314
refresh_token_expires=int((datetime.now() + timedelta(days=30)).timestamp()),
2415
)
@@ -28,7 +19,7 @@ def test_valid_info() -> None:
2819
def test_expired_info() -> None:
2920
"""Test auth with expired access token is considered invalid."""
3021
auth_info = dataclasses.replace(
31-
INFO,
22+
AUTHENTICATION,
3223
access_token_expires=int((datetime.now() - timedelta(days=7)).timestamp()),
3324
refresh_token_expires=int((datetime.now() + timedelta(days=14)).timestamp()),
3425
)

0 commit comments

Comments
 (0)