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.
Files changed (21) hide show
  1. {pyheltycloud-0.2.0/src/pyheltycloud.egg-info → pyheltycloud-0.2.2}/PKG-INFO +1 -1
  2. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/pyproject.toml +1 -1
  3. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/auth.py +18 -2
  4. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/client.py +99 -31
  5. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2/src/pyheltycloud.egg-info}/PKG-INFO +1 -1
  6. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/tests/test_auth_flow.py +32 -0
  7. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/tests/test_client.py +252 -35
  8. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/LICENSE +0 -0
  9. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/README.md +0 -0
  10. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/setup.cfg +0 -0
  11. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/__init__.py +0 -0
  12. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/const.py +0 -0
  13. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/exceptions.py +0 -0
  14. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/models.py +0 -0
  15. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud/py.typed +0 -0
  16. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud.egg-info/SOURCES.txt +0 -0
  17. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud.egg-info/dependency_links.txt +0 -0
  18. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud.egg-info/requires.txt +0 -0
  19. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/src/pyheltycloud.egg-info/top_level.txt +0 -0
  20. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/tests/test_auth.py +0 -0
  21. {pyheltycloud-0.2.0 → pyheltycloud-0.2.2}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyheltycloud
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Async client for the Helty cloud (hcloud) of Helty Flow VMC units
5
5
  Author: Ermanno Baschiera
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyheltycloud"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Async client for the Helty cloud (hcloud) of Helty Flow VMC units"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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
- message = body.get("message") or body.get("__type") or str(body)
199
- raise HeltyCloudAuthError(f"{target}: {message}")
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
- #: Rows to ask for. Only the newest is used; a few spare ones cover the
55
- #: acks and other commands that share the history.
56
- HISTORY_PAGE_SIZE = 5
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(rows, key=lambda row: row.get("timestamp") or 0)
230
- return HeltyState.from_telemetry(newest["data"], newest.get("timestamp"))
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.refresh(device.board_serial)
247
-
248
- loop = asyncio.get_running_loop()
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
- for attempt in range(1, attempts + 1):
305
- await self.set_mode(device.board_serial, mode)
306
- state = await self.update(device)
307
- if state.mode is mode:
308
- return state
309
- _LOGGER.debug(
310
- "Command %s not applied by %s (attempt %d/%d)",
311
- mode.name,
312
- device.serial_number,
313
- attempt,
314
- attempts,
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyheltycloud
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Async client for the Helty cloud (hcloud) of Helty Flow VMC units
5
5
  Author: Ermanno Baschiera
6
6
  License-Expression: MIT
@@ -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
- async def test_set_mode_verified_retries_lost_command(
235
- client: HeltyCloud,
236
- cloud: FakeCloud,
237
- installation: dict[str, Any],
238
- telemetry: list[Any],
239
- monkeypatch: pytest.MonkeyPatch,
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
- """A command lost by the cloud is resent until the state reflects it."""
242
- # Timeout 0: update() only reads the history, without waiting, so the
243
- # test controls exactly which telemetry each attempt sees.
244
- monkeypatch.setattr("pyheltycloud.client.REFRESH_TIMEOUT", 0)
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
- [_search_reply(1, ignored), _search_reply(2, telemetry)],
314
+ {"data": [{"commandId": 0, "timestamp": 7, "data": data} for data in (telemetry, other)]},
251
315
  )
252
- device = (await client.get_devices())[0]
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
- night_commands = [
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
- monkeypatch.setattr("pyheltycloud.client.REFRESH_TIMEOUT", 0)
270
- ignored = telemetry.copy()
271
- ignored[1] = int(VmcMode.SPEED_2)
272
- cloud.reply(EP_INSTALLATIONS, [installation])
273
- cloud.reply(
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