pyheltycloud 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyheltycloud-0.2.0/src/pyheltycloud.egg-info → pyheltycloud-0.2.2}/PKG-INFO +1 -1
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/pyproject.toml +1 -1
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/auth.py +18 -2
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/client.py +99 -31
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2/src/pyheltycloud.egg-info}/PKG-INFO +1 -1
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/tests/test_auth_flow.py +32 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/tests/test_client.py +252 -35
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/LICENSE +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/README.md +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/setup.cfg +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/__init__.py +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/const.py +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/exceptions.py +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/models.py +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/py.typed +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud.egg-info/SOURCES.txt +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud.egg-info/dependency_links.txt +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud.egg-info/requires.txt +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud.egg-info/top_level.txt +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/tests/test_auth.py +0 -0
- {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/tests/test_models.py +0 -0
|
@@ -26,6 +26,19 @@ import aiohttp
|
|
|
26
26
|
|
|
27
27
|
from .exceptions import HeltyCloudAuthError, HeltyCloudConnectionError
|
|
28
28
|
|
|
29
|
+
#: Cognito errors that mean the credentials themselves were refused. Anything
|
|
30
|
+
#: else it answers with, like throttling or its own internal errors, is a
|
|
31
|
+
#: passing failure: reporting it as bad credentials would send the user to
|
|
32
|
+
#: re-enter a password that is still right.
|
|
33
|
+
CREDENTIAL_ERRORS = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"NotAuthorizedException",
|
|
36
|
+
"PasswordResetRequiredException",
|
|
37
|
+
"UserNotConfirmedException",
|
|
38
|
+
"UserNotFoundException",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
29
42
|
# 3072-bit prime and generator, fixed by the Cognito protocol.
|
|
30
43
|
N_HEX = (
|
|
31
44
|
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74"
|
|
@@ -195,8 +208,11 @@ class CognitoSrpAuth:
|
|
|
195
208
|
raise HeltyCloudConnectionError(f"{target}: unexpected answer {text[:200]}")
|
|
196
209
|
|
|
197
210
|
if status != 200:
|
|
198
|
-
|
|
199
|
-
|
|
211
|
+
error_type = str(body.get("__type", "")).rpartition("#")[2]
|
|
212
|
+
message = body.get("message") or error_type or str(body)
|
|
213
|
+
if error_type in CREDENTIAL_ERRORS:
|
|
214
|
+
raise HeltyCloudAuthError(f"{target}: {message}")
|
|
215
|
+
raise HeltyCloudConnectionError(f"{target}: {message}")
|
|
200
216
|
return body
|
|
201
217
|
|
|
202
218
|
def _password_key(self, username: str, password: str, srp_b_hex: str, salt_hex: str) -> bytes:
|
|
@@ -51,9 +51,16 @@ REFRESH_TIMEOUT = 15.0
|
|
|
51
51
|
#: always find something without asking the cloud to scan a long window.
|
|
52
52
|
DEFAULT_HISTORY_HOURS = 2
|
|
53
53
|
|
|
54
|
-
#:
|
|
55
|
-
#:
|
|
56
|
-
|
|
54
|
+
#: How long to wait before reading the history once more to confirm a
|
|
55
|
+
#: command. The two telemetries that answer a command can share a second
|
|
56
|
+
#: and need not become visible together: a second read lets the late one
|
|
57
|
+
#: show up.
|
|
58
|
+
CONFIRM_SETTLE_DELAY = 2.0
|
|
59
|
+
|
|
60
|
+
#: Rows to ask for. Only the newest second is used, but it can hold the
|
|
61
|
+
#: acks of the commands next to two telemetries: a command sent seconds
|
|
62
|
+
#: after another left five rows in a row on a live board.
|
|
63
|
+
HISTORY_PAGE_SIZE = 10
|
|
57
64
|
|
|
58
65
|
|
|
59
66
|
class HeltyCloud:
|
|
@@ -85,6 +92,7 @@ class HeltyCloud:
|
|
|
85
92
|
)
|
|
86
93
|
self._tokens: Tokens | None = None
|
|
87
94
|
self._lock = asyncio.Lock()
|
|
95
|
+
self._command_locks: dict[str, asyncio.Lock] = {}
|
|
88
96
|
|
|
89
97
|
def _ensure_started(self) -> tuple[aiohttp.ClientSession, CognitoSrpAuth]:
|
|
90
98
|
"""Session and auth, created lazily inside the current event loop."""
|
|
@@ -203,6 +211,20 @@ class HeltyCloud:
|
|
|
203
211
|
self, serial_number: str, hours: int = DEFAULT_HISTORY_HOURS
|
|
204
212
|
) -> HeltyState:
|
|
205
213
|
"""Last state from the raw history: includes fans and alarms."""
|
|
214
|
+
return (await self._newest_telemetry(serial_number, hours))[0]
|
|
215
|
+
|
|
216
|
+
async def _newest_telemetry(
|
|
217
|
+
self, serial_number: str, hours: int = DEFAULT_HISTORY_HOURS
|
|
218
|
+
) -> list[HeltyState]:
|
|
219
|
+
"""Every telemetry that carries the newest timestamp, in cloud order.
|
|
220
|
+
|
|
221
|
+
The cloud stamps the history to the second and gives the rows no id,
|
|
222
|
+
and the board answers a command with two telemetries, one of its own
|
|
223
|
+
and one for the refresh that follows: when they land in the same
|
|
224
|
+
second there is no telling which came last. They normally agree, but
|
|
225
|
+
a board that drops a command can leave one with the new mode and one
|
|
226
|
+
with the old, so callers that confirm a command must see them all.
|
|
227
|
+
"""
|
|
206
228
|
now = int(time.time() * 1000)
|
|
207
229
|
page = await self._post(
|
|
208
230
|
EP_COMMAND_LOGS,
|
|
@@ -226,8 +248,12 @@ class HeltyCloud:
|
|
|
226
248
|
# The cloud happens to return the newest first, but nothing documents
|
|
227
249
|
# it, and update() decides whether the board answered by comparing
|
|
228
250
|
# timestamps: pick the newest explicitly.
|
|
229
|
-
newest = max(
|
|
230
|
-
return
|
|
251
|
+
newest = max(row.get("timestamp") or 0 for row in rows)
|
|
252
|
+
return [
|
|
253
|
+
HeltyState.from_telemetry(row["data"], row.get("timestamp"))
|
|
254
|
+
for row in rows
|
|
255
|
+
if (row.get("timestamp") or 0) == newest
|
|
256
|
+
]
|
|
231
257
|
|
|
232
258
|
async def update(self, device: HeltyDevice) -> HeltyState:
|
|
233
259
|
"""Fetch the current state: ask the board to report, then read.
|
|
@@ -239,23 +265,13 @@ class HeltyCloud:
|
|
|
239
265
|
to read at all.
|
|
240
266
|
"""
|
|
241
267
|
try:
|
|
242
|
-
last = await self.get_last_telemetry(device.serial_number)
|
|
268
|
+
last: HeltyState | None = await self.get_last_telemetry(device.serial_number)
|
|
243
269
|
except HeltyCloudNoDataError:
|
|
244
270
|
last = None
|
|
245
271
|
|
|
246
|
-
await self.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
deadline = loop.time() + REFRESH_TIMEOUT
|
|
250
|
-
while loop.time() < deadline:
|
|
251
|
-
await asyncio.sleep(REFRESH_POLL_INTERVAL)
|
|
252
|
-
try:
|
|
253
|
-
state = await self.get_last_telemetry(device.serial_number)
|
|
254
|
-
except HeltyCloudNoDataError:
|
|
255
|
-
continue
|
|
256
|
-
if last is None or (state.timestamp or 0) > (last.timestamp or 0):
|
|
257
|
-
return state
|
|
258
|
-
|
|
272
|
+
states = await self._fresh_telemetry(device, after=_timestamp(last))
|
|
273
|
+
if states is not None:
|
|
274
|
+
return states[0]
|
|
259
275
|
if last is not None:
|
|
260
276
|
_LOGGER.debug(
|
|
261
277
|
"Board %s did not answer the refresh: using the last known data",
|
|
@@ -266,6 +282,28 @@ class HeltyCloud:
|
|
|
266
282
|
f"No telemetry for {device.serial_number}, even after a refresh"
|
|
267
283
|
)
|
|
268
284
|
|
|
285
|
+
async def _fresh_telemetry(
|
|
286
|
+
self, device: HeltyDevice, after: int | None
|
|
287
|
+
) -> list[HeltyState] | None:
|
|
288
|
+
"""Ask the board to report and wait for a second newer than `after`.
|
|
289
|
+
|
|
290
|
+
`after` None accepts any telemetry. Returns None if nothing newer
|
|
291
|
+
arrives within REFRESH_TIMEOUT.
|
|
292
|
+
"""
|
|
293
|
+
await self.refresh(device.board_serial)
|
|
294
|
+
|
|
295
|
+
loop = asyncio.get_running_loop()
|
|
296
|
+
deadline = loop.time() + REFRESH_TIMEOUT
|
|
297
|
+
while loop.time() < deadline:
|
|
298
|
+
await asyncio.sleep(REFRESH_POLL_INTERVAL)
|
|
299
|
+
try:
|
|
300
|
+
states = await self._newest_telemetry(device.serial_number)
|
|
301
|
+
except HeltyCloudNoDataError:
|
|
302
|
+
continue
|
|
303
|
+
if after is None or (_timestamp(states[0]) or 0) > after:
|
|
304
|
+
return states
|
|
305
|
+
return None
|
|
306
|
+
|
|
269
307
|
# -- writing -----------------------------------------------------------
|
|
270
308
|
|
|
271
309
|
async def send_command(self, board_serial: str, command: Command) -> None:
|
|
@@ -300,17 +338,47 @@ class HeltyCloud:
|
|
|
300
338
|
|
|
301
339
|
Returns the state reflecting the requested mode. Raises
|
|
302
340
|
HeltyCloudError if after `attempts` sends the VMC has not applied it.
|
|
341
|
+
|
|
342
|
+
Only telemetry newer than what the history held before the send
|
|
343
|
+
counts, and every telemetry of its second must carry the mode, on
|
|
344
|
+
two reads CONFIRM_SETTLE_DELAY apart: an old report, or one half of
|
|
345
|
+
a pair that disagrees, says nothing about this command. Calls for
|
|
346
|
+
the same board run one at a time, so the retries of an older call
|
|
347
|
+
never land after a newer command.
|
|
303
348
|
"""
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
device.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
349
|
+
lock = self._command_locks.setdefault(device.board_serial, asyncio.Lock())
|
|
350
|
+
async with lock:
|
|
351
|
+
try:
|
|
352
|
+
seen = _timestamp(await self.get_last_telemetry(device.serial_number))
|
|
353
|
+
except HeltyCloudNoDataError:
|
|
354
|
+
seen = None
|
|
355
|
+
|
|
356
|
+
for attempt in range(1, attempts + 1):
|
|
357
|
+
await self.set_mode(device.board_serial, mode)
|
|
358
|
+
states = await self._fresh_telemetry(device, after=seen)
|
|
359
|
+
if states is not None:
|
|
360
|
+
seen = _timestamp(states[0])
|
|
361
|
+
if _all_in_mode(states, mode):
|
|
362
|
+
await asyncio.sleep(CONFIRM_SETTLE_DELAY)
|
|
363
|
+
states = await self._newest_telemetry(device.serial_number)
|
|
364
|
+
seen = _timestamp(states[0])
|
|
365
|
+
if _all_in_mode(states, mode):
|
|
366
|
+
return states[0]
|
|
367
|
+
_LOGGER.debug(
|
|
368
|
+
"Command %s not confirmed by %s (attempt %d/%d)",
|
|
369
|
+
mode.name,
|
|
370
|
+
device.serial_number,
|
|
371
|
+
attempt,
|
|
372
|
+
attempts,
|
|
373
|
+
)
|
|
316
374
|
raise HeltyCloudError(f"The VMC did not apply mode {mode.name} after {attempts} attempts")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _timestamp(state: HeltyState | None) -> int | None:
|
|
378
|
+
"""Timestamp of a state, None when there is no state."""
|
|
379
|
+
return None if state is None else state.timestamp or 0
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _all_in_mode(states: list[HeltyState], mode: VmcMode) -> bool:
|
|
383
|
+
"""Whether every telemetry of a second reports the mode."""
|
|
384
|
+
return all(state.mode is mode for state in states)
|
|
@@ -94,6 +94,38 @@ async def test_wrong_password_is_auth_error(auth: CognitoSrpAuth, cloud: FakeClo
|
|
|
94
94
|
await auth.authenticate("user@example.com", "wrong")
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
@pytest.mark.parametrize(
|
|
98
|
+
("status", "error_type"),
|
|
99
|
+
[
|
|
100
|
+
(400, "TooManyRequestsException"),
|
|
101
|
+
(400, "LimitExceededException"),
|
|
102
|
+
(500, "InternalErrorException"),
|
|
103
|
+
(500, "com.amazonaws.cognito#InternalErrorException"),
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
async def test_passing_cognito_failure_is_not_an_auth_error(
|
|
107
|
+
auth: CognitoSrpAuth, cloud: FakeCloud, status: int, error_type: str
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Throttling or an outage must not look like a wrong password."""
|
|
110
|
+
cloud.reply_sequence(
|
|
111
|
+
COGNITO_PATH, [(200, CHALLENGE), (status, {"__type": error_type, "message": "busy"})]
|
|
112
|
+
)
|
|
113
|
+
with pytest.raises(HeltyCloudConnectionError, match="busy"):
|
|
114
|
+
await auth.authenticate("user@example.com", "secret")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.parametrize(
|
|
118
|
+
"error_type",
|
|
119
|
+
["UserNotFoundException", "PasswordResetRequiredException", "UserNotConfirmedException"],
|
|
120
|
+
)
|
|
121
|
+
async def test_refused_account_is_auth_error(
|
|
122
|
+
auth: CognitoSrpAuth, cloud: FakeCloud, error_type: str
|
|
123
|
+
) -> None:
|
|
124
|
+
cloud.reply(COGNITO_PATH, {"__type": error_type, "message": "refused"}, status=400)
|
|
125
|
+
with pytest.raises(HeltyCloudAuthError, match="refused"):
|
|
126
|
+
await auth.authenticate("user@example.com", "secret")
|
|
127
|
+
|
|
128
|
+
|
|
97
129
|
async def test_unexpected_challenge(auth: CognitoSrpAuth, cloud: FakeCloud) -> None:
|
|
98
130
|
"""Should they ever enable MFA, better a clear error."""
|
|
99
131
|
cloud.reply(COGNITO_PATH, {"ChallengeName": "SMS_MFA", "ChallengeParameters": {}})
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import time
|
|
7
|
+
from collections.abc import Callable
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
8
10
|
import pytest
|
|
@@ -16,7 +18,7 @@ from pyheltycloud import (
|
|
|
16
18
|
HeltyCloudNoDataError,
|
|
17
19
|
VmcMode,
|
|
18
20
|
)
|
|
19
|
-
from pyheltycloud.const import EP_COMMAND_LOGS, EP_INSTALLATIONS, EP_LAST_STATUS
|
|
21
|
+
from pyheltycloud.const import EP_COMMAND_LOGS, EP_INSTALLATIONS, EP_LAST_STATUS, MODE_COMMANDS
|
|
20
22
|
|
|
21
23
|
from .conftest import (
|
|
22
24
|
BOARD_SERIAL,
|
|
@@ -231,52 +233,267 @@ async def test_update_without_any_telemetry(
|
|
|
231
233
|
await client.update(device)
|
|
232
234
|
|
|
233
235
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
236
|
+
class FakeBoard:
|
|
237
|
+
"""A panel behind the fake cloud, with a history that moves.
|
|
238
|
+
|
|
239
|
+
Every mode command leaves its ack and then its telemetries in the
|
|
240
|
+
history the way a live board does, one clock tick (a second for the
|
|
241
|
+
cloud) per command, and a refresh adds a telemetry with the current
|
|
242
|
+
mode. Tests script the misbehaviour through `on_mode`, which returns
|
|
243
|
+
the modes of the telemetries a mode command leaves (empty: the command
|
|
244
|
+
is lost), through `answers_refresh`, and through `delayed`: rows that
|
|
245
|
+
become visible only on the read after the one they were added for.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def __init__(self, cloud: FakeCloud, telemetry: list[Any], mode: VmcMode) -> None:
|
|
249
|
+
"""Take over the command and history paths of the fake cloud."""
|
|
250
|
+
self.telemetry = telemetry
|
|
251
|
+
self.mode = mode
|
|
252
|
+
self.clock = 0
|
|
253
|
+
self.rows: list[dict[str, Any]] = []
|
|
254
|
+
self.delayed: list[dict[str, Any]] = []
|
|
255
|
+
self.answers_refresh = True
|
|
256
|
+
self.on_mode: Callable[[VmcMode], list[VmcMode]] = self.apply
|
|
257
|
+
self.sent: list[int] = []
|
|
258
|
+
cloud.reply_with(SEND_PATH, self._command)
|
|
259
|
+
cloud.reply_with("/log/commandlogs/search", self._search)
|
|
260
|
+
|
|
261
|
+
def apply(self, mode: VmcMode) -> list[VmcMode]:
|
|
262
|
+
"""Default behaviour: switch mode and report it once."""
|
|
263
|
+
self.mode = mode
|
|
264
|
+
return [mode]
|
|
265
|
+
|
|
266
|
+
def row(self, mode: VmcMode) -> dict[str, Any]:
|
|
267
|
+
data = self.telemetry.copy()
|
|
268
|
+
data[1] = int(mode)
|
|
269
|
+
return {"commandId": 0, "timestamp": self.clock, "data": data}
|
|
270
|
+
|
|
271
|
+
def _command(self, body: Any) -> tuple[int, Any]:
|
|
272
|
+
command = body["commandId"]
|
|
273
|
+
self.sent.append(command)
|
|
274
|
+
if command == Command.GET_STATUS:
|
|
275
|
+
if self.answers_refresh:
|
|
276
|
+
self.rows.append(self.row(self.mode))
|
|
277
|
+
return (200, {})
|
|
278
|
+
self.clock += 1
|
|
279
|
+
self.rows.append({"commandId": command, "timestamp": self.clock, "data": []})
|
|
280
|
+
mode = next(m for m, c in MODE_COMMANDS.items() if c == command)
|
|
281
|
+
self.rows.extend(self.row(m) for m in self.on_mode(mode))
|
|
282
|
+
return (200, {})
|
|
283
|
+
|
|
284
|
+
def _search(self, body: Any) -> tuple[int, Any]:
|
|
285
|
+
visible = list(self.rows)
|
|
286
|
+
# Rows held back on this read join the history for the next one.
|
|
287
|
+
self.rows.extend(self.delayed)
|
|
288
|
+
self.delayed = []
|
|
289
|
+
newest_first = sorted(visible, key=lambda row: -row["timestamp"])
|
|
290
|
+
return (200, {"data": newest_first[: body["pageSize"]]})
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _lose_first(board: FakeBoard) -> Callable[[VmcMode], list[VmcMode]]:
|
|
294
|
+
"""Drop the first mode command and apply the others."""
|
|
295
|
+
lost = [True]
|
|
296
|
+
|
|
297
|
+
def on_mode(mode: VmcMode) -> list[VmcMode]:
|
|
298
|
+
if lost:
|
|
299
|
+
lost.pop()
|
|
300
|
+
return []
|
|
301
|
+
return board.apply(mode)
|
|
302
|
+
|
|
303
|
+
return on_mode
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def test_get_last_telemetry_with_tied_rows_keeps_cloud_order(
|
|
307
|
+
client: HeltyCloud, cloud: FakeCloud, telemetry: list[Any]
|
|
240
308
|
) -> None:
|
|
241
|
-
"""
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
ignored = telemetry.copy()
|
|
246
|
-
ignored[1] = int(VmcMode.SPEED_2)
|
|
247
|
-
cloud.reply(EP_INSTALLATIONS, [installation])
|
|
248
|
-
cloud.reply_sequence(
|
|
309
|
+
"""Polling still reads the first of the telemetries sharing the newest second."""
|
|
310
|
+
other = telemetry.copy()
|
|
311
|
+
other[1] = int(VmcMode.SPEED_4)
|
|
312
|
+
cloud.reply(
|
|
249
313
|
"/log/commandlogs/search",
|
|
250
|
-
[
|
|
314
|
+
{"data": [{"commandId": 0, "timestamp": 7, "data": data} for data in (telemetry, other)]},
|
|
251
315
|
)
|
|
252
|
-
|
|
316
|
+
state = await client.get_last_telemetry(SERIAL)
|
|
317
|
+
assert state.mode is VmcMode.NIGHT
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.fixture
|
|
321
|
+
def fast(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
322
|
+
"""No real waits, and a short deadline for a board that stays silent."""
|
|
323
|
+
monkeypatch.setattr("pyheltycloud.client.REFRESH_POLL_INTERVAL", 0)
|
|
324
|
+
monkeypatch.setattr("pyheltycloud.client.CONFIRM_SETTLE_DELAY", 0)
|
|
325
|
+
monkeypatch.setattr("pyheltycloud.client.REFRESH_TIMEOUT", 0.05)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def _device(client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any]) -> Any:
|
|
329
|
+
cloud.reply(EP_INSTALLATIONS, [installation])
|
|
330
|
+
return (await client.get_devices())[0]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@pytest.mark.usefixtures("fast")
|
|
334
|
+
async def test_set_mode_verified_confirms_at_once(
|
|
335
|
+
client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any], telemetry: list[Any]
|
|
336
|
+
) -> None:
|
|
337
|
+
board = FakeBoard(cloud, telemetry, VmcMode.SPEED_1)
|
|
338
|
+
board.rows.append(board.row(VmcMode.SPEED_1))
|
|
339
|
+
device = await _device(client, cloud, installation)
|
|
340
|
+
|
|
341
|
+
state = await client.set_mode_verified(device, VmcMode.NIGHT)
|
|
342
|
+
|
|
343
|
+
assert state.mode is VmcMode.NIGHT
|
|
344
|
+
assert board.sent.count(Command.NIGHT) == 1
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pytest.mark.usefixtures("fast")
|
|
348
|
+
async def test_set_mode_verified_with_empty_history(
|
|
349
|
+
client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any], telemetry: list[Any]
|
|
350
|
+
) -> None:
|
|
351
|
+
"""With nothing in the history yet, the first report confirms."""
|
|
352
|
+
board = FakeBoard(cloud, telemetry, VmcMode.SPEED_1)
|
|
353
|
+
device = await _device(client, cloud, installation)
|
|
354
|
+
|
|
355
|
+
state = await client.set_mode_verified(device, VmcMode.NIGHT)
|
|
356
|
+
|
|
357
|
+
assert state.mode is VmcMode.NIGHT
|
|
358
|
+
assert board.sent.count(Command.NIGHT) == 1
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@pytest.mark.usefixtures("fast")
|
|
362
|
+
async def test_set_mode_verified_retries_lost_command(
|
|
363
|
+
client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any], telemetry: list[Any]
|
|
364
|
+
) -> None:
|
|
365
|
+
"""A command lost on the way is resent until the board reports it."""
|
|
366
|
+
board = FakeBoard(cloud, telemetry, VmcMode.SPEED_2)
|
|
367
|
+
board.rows.append(board.row(VmcMode.SPEED_2))
|
|
368
|
+
board.on_mode = _lose_first(board)
|
|
369
|
+
device = await _device(client, cloud, installation)
|
|
370
|
+
|
|
253
371
|
state = await client.set_mode_verified(device, VmcMode.NIGHT)
|
|
254
372
|
|
|
255
373
|
assert state.mode is VmcMode.NIGHT
|
|
256
|
-
|
|
257
|
-
r for r in cloud.requests if r.path == SEND_PATH and r.json["commandId"] == Command.NIGHT
|
|
258
|
-
]
|
|
259
|
-
assert len(night_commands) == 2
|
|
374
|
+
assert board.sent.count(Command.NIGHT) == 2
|
|
260
375
|
|
|
261
376
|
|
|
377
|
+
@pytest.mark.usefixtures("fast")
|
|
262
378
|
async def test_set_mode_verified_gives_up(
|
|
263
|
-
client: HeltyCloud,
|
|
264
|
-
cloud: FakeCloud,
|
|
265
|
-
installation: dict[str, Any],
|
|
266
|
-
telemetry: list[Any],
|
|
267
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
379
|
+
client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any], telemetry: list[Any]
|
|
268
380
|
) -> None:
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
"/log/commandlogs/search",
|
|
275
|
-
{"data": [{"commandId": 0, "timestamp": 1, "data": ignored}]},
|
|
276
|
-
)
|
|
277
|
-
device = (await client.get_devices())[0]
|
|
381
|
+
board = FakeBoard(cloud, telemetry, VmcMode.SPEED_2)
|
|
382
|
+
board.rows.append(board.row(VmcMode.SPEED_2))
|
|
383
|
+
board.on_mode = lambda mode: []
|
|
384
|
+
device = await _device(client, cloud, installation)
|
|
385
|
+
|
|
278
386
|
with pytest.raises(HeltyCloudError, match="NIGHT"):
|
|
279
387
|
await client.set_mode_verified(device, VmcMode.NIGHT, attempts=2)
|
|
388
|
+
assert board.sent.count(Command.NIGHT) == 2
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@pytest.mark.usefixtures("fast")
|
|
392
|
+
async def test_set_mode_verified_ignores_stale_history(
|
|
393
|
+
client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any], telemetry: list[Any]
|
|
394
|
+
) -> None:
|
|
395
|
+
"""A history that already shows the mode does not confirm a lost command."""
|
|
396
|
+
board = FakeBoard(cloud, telemetry, VmcMode.SPEED_4)
|
|
397
|
+
board.rows.append(board.row(VmcMode.SPEED_1))
|
|
398
|
+
board.on_mode = lambda mode: []
|
|
399
|
+
board.answers_refresh = False
|
|
400
|
+
device = await _device(client, cloud, installation)
|
|
401
|
+
|
|
402
|
+
with pytest.raises(HeltyCloudError, match="SPEED_1"):
|
|
403
|
+
await client.set_mode_verified(device, VmcMode.SPEED_1, attempts=2)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@pytest.mark.usefixtures("fast")
|
|
407
|
+
async def test_set_mode_verified_resends_on_disagreeing_tie(
|
|
408
|
+
client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any], telemetry: list[Any]
|
|
409
|
+
) -> None:
|
|
410
|
+
"""A tie between the new mode and the old one does not confirm the command.
|
|
411
|
+
|
|
412
|
+
Seen on a live board: asked for speed 1 seconds after speed 4, it kept
|
|
413
|
+
speed 4 but left two telemetries in the same second, the first with the
|
|
414
|
+
new mode and the second with the old, and then stayed silent.
|
|
415
|
+
"""
|
|
416
|
+
board = FakeBoard(cloud, telemetry, VmcMode.SPEED_4)
|
|
417
|
+
board.rows.append(board.row(VmcMode.SPEED_4))
|
|
418
|
+
dropped = [True]
|
|
419
|
+
|
|
420
|
+
def drop_first(mode: VmcMode) -> list[VmcMode]:
|
|
421
|
+
if dropped:
|
|
422
|
+
dropped.pop()
|
|
423
|
+
return [mode, board.mode]
|
|
424
|
+
board.mode = mode
|
|
425
|
+
return [mode, mode]
|
|
426
|
+
|
|
427
|
+
board.on_mode = drop_first
|
|
428
|
+
board.answers_refresh = False
|
|
429
|
+
device = await _device(client, cloud, installation)
|
|
430
|
+
|
|
431
|
+
state = await client.set_mode_verified(device, VmcMode.SPEED_1)
|
|
432
|
+
|
|
433
|
+
assert state.mode is VmcMode.SPEED_1
|
|
434
|
+
assert board.sent.count(Command.SPEED_1) == 2
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@pytest.mark.usefixtures("fast")
|
|
438
|
+
async def test_set_mode_verified_waits_for_the_late_half_of_a_tie(
|
|
439
|
+
client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any], telemetry: list[Any]
|
|
440
|
+
) -> None:
|
|
441
|
+
"""The old-mode twin of a telemetry can show up a read later: no early yes."""
|
|
442
|
+
board = FakeBoard(cloud, telemetry, VmcMode.SPEED_4)
|
|
443
|
+
board.rows.append(board.row(VmcMode.SPEED_4))
|
|
444
|
+
dropped = [True]
|
|
445
|
+
|
|
446
|
+
def drop_first_late(mode: VmcMode) -> list[VmcMode]:
|
|
447
|
+
if dropped:
|
|
448
|
+
dropped.pop()
|
|
449
|
+
board.delayed.append(board.row(board.mode))
|
|
450
|
+
return [mode]
|
|
451
|
+
return board.apply(mode)
|
|
452
|
+
|
|
453
|
+
board.on_mode = drop_first_late
|
|
454
|
+
board.answers_refresh = False
|
|
455
|
+
device = await _device(client, cloud, installation)
|
|
456
|
+
|
|
457
|
+
state = await client.set_mode_verified(device, VmcMode.SPEED_1)
|
|
458
|
+
|
|
459
|
+
assert state.mode is VmcMode.SPEED_1
|
|
460
|
+
assert board.sent.count(Command.SPEED_1) == 2
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@pytest.mark.usefixtures("fast")
|
|
464
|
+
async def test_set_mode_verified_accepts_agreeing_tie(
|
|
465
|
+
client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any], telemetry: list[Any]
|
|
466
|
+
) -> None:
|
|
467
|
+
"""The usual pair of telemetries in the same second confirms at once."""
|
|
468
|
+
board = FakeBoard(cloud, telemetry, VmcMode.SPEED_1)
|
|
469
|
+
board.rows.append(board.row(VmcMode.SPEED_1))
|
|
470
|
+
board.on_mode = lambda mode: [*board.apply(mode), mode]
|
|
471
|
+
device = await _device(client, cloud, installation)
|
|
472
|
+
|
|
473
|
+
await client.set_mode_verified(device, VmcMode.NIGHT)
|
|
474
|
+
|
|
475
|
+
assert board.sent.count(Command.NIGHT) == 1
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@pytest.mark.usefixtures("fast")
|
|
479
|
+
async def test_set_mode_verified_serializes_commands_per_board(
|
|
480
|
+
client: HeltyCloud, cloud: FakeCloud, installation: dict[str, Any], telemetry: list[Any]
|
|
481
|
+
) -> None:
|
|
482
|
+
"""The retries of an older call never land after a newer command."""
|
|
483
|
+
board = FakeBoard(cloud, telemetry, VmcMode.SPEED_1)
|
|
484
|
+
board.rows.append(board.row(VmcMode.SPEED_1))
|
|
485
|
+
board.on_mode = _lose_first(board)
|
|
486
|
+
device = await _device(client, cloud, installation)
|
|
487
|
+
|
|
488
|
+
speed_4 = asyncio.create_task(client.set_mode_verified(device, VmcMode.SPEED_4))
|
|
489
|
+
await asyncio.sleep(0)
|
|
490
|
+
speed_1 = asyncio.create_task(client.set_mode_verified(device, VmcMode.SPEED_1))
|
|
491
|
+
await asyncio.gather(speed_4, speed_1)
|
|
492
|
+
|
|
493
|
+
first_speed_1 = board.sent.index(Command.SPEED_1)
|
|
494
|
+
assert Command.SPEED_4 not in board.sent[first_speed_1:]
|
|
495
|
+
assert board.sent.count(Command.SPEED_4) == 2
|
|
496
|
+
assert board.mode is VmcMode.SPEED_1
|
|
280
497
|
|
|
281
498
|
|
|
282
499
|
def test_constructor_needs_no_event_loop() -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|