smartbox 2.1.2__tar.gz → 2.1.3__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 (78) hide show
  1. {smartbox-2.1.2 → smartbox-2.1.3}/PKG-INFO +1 -1
  2. {smartbox-2.1.2 → smartbox-2.1.3}/pyproject.toml +1 -1
  3. {smartbox-2.1.2 → smartbox-2.1.3}/src/smartbox/cmd.py +24 -0
  4. {smartbox-2.1.2 → smartbox-2.1.3}/src/smartbox/models.py +37 -4
  5. {smartbox-2.1.2 → smartbox-2.1.3}/src/smartbox/resailer.py +6 -0
  6. {smartbox-2.1.2 → smartbox-2.1.3}/src/smartbox/session.py +45 -13
  7. {smartbox-2.1.2 → smartbox-2.1.3}/src/smartbox/socket.py +8 -0
  8. {smartbox-2.1.2 → smartbox-2.1.3}/src/smartbox/update_manager.py +19 -0
  9. smartbox-2.1.3/tests/fixtures/devs/device1/connected.json +3 -0
  10. smartbox-2.1.3/tests/fixtures/devs/device1/mgr/nodes.json +31 -0
  11. smartbox-2.1.3/tests/fixtures/devs/device1/pmo/3/samples.json +16 -0
  12. smartbox-2.1.3/tests/fixtures/devs/device1/pmo/3/setup.json +5 -0
  13. smartbox-2.1.3/tests/fixtures/devs/device1/pmo/3/status.json +4 -0
  14. smartbox-2.1.3/tests/fixtures/devs/device2/connected.json +3 -0
  15. {smartbox-2.1.2 → smartbox-2.1.3}/tests/test_cmd.py +26 -0
  16. {smartbox-2.1.2 → smartbox-2.1.3}/tests/test_resailer.py +13 -1
  17. {smartbox-2.1.2 → smartbox-2.1.3}/tests/test_session.py +87 -10
  18. {smartbox-2.1.2 → smartbox-2.1.3}/tests/test_update_manager.py +18 -0
  19. {smartbox-2.1.2 → smartbox-2.1.3}/uv.lock +2 -1
  20. smartbox-2.1.2/requirements.txt +0 -2
  21. smartbox-2.1.2/tests/fixtures/devs/device1/mgr/nodes.json +0 -18
  22. {smartbox-2.1.2 → smartbox-2.1.3}/.github/FUNDING.yml +0 -0
  23. {smartbox-2.1.2 → smartbox-2.1.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {smartbox-2.1.2 → smartbox-2.1.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  25. {smartbox-2.1.2 → smartbox-2.1.3}/.github/ISSUE_TEMPLATE/new-resailer.md +0 -0
  26. {smartbox-2.1.2 → smartbox-2.1.3}/.github/dependabot.yml +0 -0
  27. {smartbox-2.1.2 → smartbox-2.1.3}/.github/workflows/publish-to-pypi.yml +0 -0
  28. {smartbox-2.1.2 → smartbox-2.1.3}/.github/workflows/python-package.yml +0 -0
  29. {smartbox-2.1.2 → smartbox-2.1.3}/.gitignore +0 -0
  30. {smartbox-2.1.2 → smartbox-2.1.3}/.pre-commit-config.yaml +0 -0
  31. {smartbox-2.1.2 → smartbox-2.1.3}/.vscode/settings.json +0 -0
  32. {smartbox-2.1.2 → smartbox-2.1.3}/CHANGELOG.md +0 -0
  33. {smartbox-2.1.2 → smartbox-2.1.3}/CODEOWNERS +0 -0
  34. {smartbox-2.1.2 → smartbox-2.1.3}/LICENSE +0 -0
  35. {smartbox-2.1.2 → smartbox-2.1.3}/README.md +0 -0
  36. {smartbox-2.1.2 → smartbox-2.1.3}/api-notes.md +0 -0
  37. {smartbox-2.1.2 → smartbox-2.1.3}/codecov.yml +0 -0
  38. {smartbox-2.1.2 → smartbox-2.1.3}/src/smartbox/__init__.py +0 -0
  39. {smartbox-2.1.2 → smartbox-2.1.3}/src/smartbox/error.py +0 -0
  40. {smartbox-2.1.2 → smartbox-2.1.3}/src/smartbox/py.typed +0 -0
  41. {smartbox-2.1.2 → smartbox-2.1.3}/tests/__init__.py +0 -0
  42. {smartbox-2.1.2 → smartbox-2.1.3}/tests/common.py +0 -0
  43. {smartbox-2.1.2 → smartbox-2.1.3}/tests/conftest.py +0 -0
  44. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device1/acm/1/samples.json +0 -0
  45. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device1/acm/1/setup.json +0 -0
  46. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device1/acm/1/status.json +0 -0
  47. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device1/htr/0/samples.json +0 -0
  48. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device1/htr/0/setup.json +0 -0
  49. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device1/htr/0/status.json +0 -0
  50. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device1/htr_system/power_limit.json +0 -0
  51. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device1/mgr/away_status.json +0 -0
  52. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/0/samples.json +0 -0
  53. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/0/setup.json +0 -0
  54. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/0/status.json +0 -0
  55. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/1/samples.json +0 -0
  56. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/1/setup.json +0 -0
  57. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/1/status.json +0 -0
  58. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/2/samples.json +0 -0
  59. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/2/setup.json +0 -0
  60. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/2/status.json +0 -0
  61. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/3/samples.json +0 -0
  62. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/3/setup.json +0 -0
  63. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/3/status.json +0 -0
  64. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/4/samples.json +0 -0
  65. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/4/setup.json +0 -0
  66. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/4/status.json +0 -0
  67. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/5/samples.json +0 -0
  68. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/5/setup.json +0 -0
  69. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_mod/5/status.json +0 -0
  70. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/htr_system/power_limit.json +0 -0
  71. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/mgr/away_status.json +0 -0
  72. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs/device2/mgr/nodes.json +0 -0
  73. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/devs.json +0 -0
  74. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/grouped_devs.json +0 -0
  75. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/groups/test_home/guest_users.json +0 -0
  76. {smartbox-2.1.2 → smartbox-2.1.3}/tests/fixtures/test_path.json +0 -0
  77. {smartbox-2.1.2 → smartbox-2.1.3}/tests/test_models.py +0 -0
  78. {smartbox-2.1.2 → smartbox-2.1.3}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartbox
3
- Version: 2.1.2
3
+ Version: 2.1.3
4
4
  Summary: Python API to control heating 'smart boxes'
5
5
  Project-URL: Homepage, https://github.com/ajtudela/smartbox
6
6
  Project-URL: Issues, https://github.com/ajtudela/smartbox/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "smartbox"
3
- version = "2.1.2"
3
+ version = "2.1.3"
4
4
  requires-python = ">=3.13"
5
5
  authors = [
6
6
  { name="Alberto Tudela", email="ajtudela@gmail.com" },
@@ -277,6 +277,21 @@ async def device_away_status(ctx) -> None:
277
277
  _pretty_print(device_away_status)
278
278
 
279
279
 
280
+ @smartbox.command(help="Show device connected status")
281
+ @click.pass_context
282
+ async def device_connected_status(ctx) -> None:
283
+ """Show device connected status."""
284
+ session = ctx.obj["session"]
285
+ devices = await session.get_devices()
286
+
287
+ for device in devices:
288
+ print(f"{device['name']} (dev_id: {device['dev_id']})")
289
+ device_away_status = await session.get_device_connected(
290
+ device["dev_id"],
291
+ )
292
+ _pretty_print(device_away_status)
293
+
294
+
280
295
  @smartbox.command(
281
296
  help="Set device away_status (pass settings as extra args, e.g. away=true)",
282
297
  )
@@ -379,6 +394,15 @@ async def health_check(ctx) -> None:
379
394
  _pretty_print(health)
380
395
 
381
396
 
397
+ @smartbox.command(help="Get version of the API.")
398
+ @click.pass_context
399
+ async def api_version(ctx) -> None:
400
+ """Get the version of the API."""
401
+ session = ctx.obj["session"]
402
+ version = await session.api_version()
403
+ _pretty_print(version)
404
+
405
+
382
406
  @smartbox.command(help="Get the availables resailers.")
383
407
  def resailers() -> None:
384
408
  """Get the availables resailers."""
@@ -47,7 +47,15 @@ class NodeExtraOptions(BaseModel):
47
47
  boost_time: int
48
48
 
49
49
 
50
- class NodeSetup(BaseModel):
50
+ class PmoSetup(BaseModel):
51
+ """Pmo node setup."""
52
+
53
+ circuit_type: int
54
+ power_limit: int
55
+ reverse: bool
56
+
57
+
58
+ class DefaultNodeSetup(BaseModel):
51
59
  """NodeSetup model."""
52
60
 
53
61
  sync_status: str
@@ -66,6 +74,16 @@ class NodeSetup(BaseModel):
66
74
  extra_options: NodeExtraOptions
67
75
 
68
76
 
77
+ class NodeSetup(RootModel[DefaultNodeSetup | PmoSetup]):
78
+ """NodeSetup model."""
79
+
80
+ root: DefaultNodeSetup | PmoSetup
81
+
82
+ def __getattr__(self, name: str) -> DefaultNodeSetup | PmoSetup:
83
+ """Get the root model directly."""
84
+ return getattr(self.root, name)
85
+
86
+
69
87
  class DefaultNodeStatus(BaseModel):
70
88
  """Default Node Status."""
71
89
 
@@ -196,17 +214,26 @@ class Homes(RootModel[list[Home]]):
196
214
 
197
215
 
198
216
  class Sample(BaseModel):
199
- """Sample model."""
217
+ """Pmo Sample model."""
200
218
 
201
219
  t: int
220
+ counter: float
202
221
  temp: str
203
- counter: int
222
+
223
+
224
+ class PmoSample(BaseModel):
225
+ """Default Sample."""
226
+
227
+ t: int
228
+ counter: float
229
+ max: int
230
+ min: int
204
231
 
205
232
 
206
233
  class Samples(BaseModel):
207
234
  """Samples model."""
208
235
 
209
- samples: list[Sample]
236
+ samples: list[PmoSample | Sample]
210
237
 
211
238
 
212
239
  class Token(BaseModel):
@@ -229,3 +256,9 @@ class Guests(BaseModel):
229
256
  """Guests model."""
230
257
 
231
258
  guest_users: list[GuestUser]
259
+
260
+
261
+ class DeviceConnected(BaseModel):
262
+ """Connected status of devices."""
263
+
264
+ connected: bool
@@ -31,6 +31,12 @@ class AvailableResailers:
31
31
  api_url="api",
32
32
  serial_id=1,
33
33
  ),
34
+ "api-ehc": SmartboxResailer(
35
+ name="Electric Heating Company",
36
+ web_url="https://control.electric-heatingcompany.co.uk/",
37
+ api_url="api-ehc",
38
+ serial_id=3,
39
+ ),
34
40
  "api-climastar": SmartboxResailer(
35
41
  name="Climastar",
36
42
  web_url="https://avantwifi.climastar.es/",
@@ -16,6 +16,7 @@ from smartbox.models import (
16
16
  AcmNodeStatus,
17
17
  DefaultNodeStatus,
18
18
  DeviceAwayStatus,
19
+ DeviceConnected,
19
20
  Devices,
20
21
  Guests,
21
22
  Home,
@@ -131,6 +132,18 @@ class AsyncSession:
131
132
  raise APIUnavailableError(e) from e
132
133
  return await response.json()
133
134
 
135
+ async def api_version(self) -> dict[str, str]:
136
+ """Check if the API is alived."""
137
+ api_url = f"{self._api_host}/version"
138
+ try:
139
+ response = await self.client.get(api_url)
140
+ except (
141
+ aiohttp.ClientConnectionError,
142
+ aiohttp.ClientConnectorError,
143
+ ) as e:
144
+ raise APIUnavailableError(e) from e
145
+ return await response.json()
146
+
134
147
  async def _authentication(self, credentials: dict[str, str]) -> None:
135
148
  """Do the authentication process to Smartbox. First one use login/mdp/basic_auth. Then the tokens."""
136
149
  token_headers = self._headers.copy()
@@ -209,6 +222,7 @@ class AsyncSession:
209
222
  try:
210
223
  _LOGGER.debug("Getting %s.", api_url)
211
224
  response = await self.client.get(api_url, headers=self._headers)
225
+ _LOGGER.debug("Response %s.", (await response.json()))
212
226
  except (
213
227
  aiohttp.ClientConnectionError,
214
228
  aiohttp.ClientConnectorError,
@@ -239,6 +253,7 @@ class AsyncSession:
239
253
  data=data_str,
240
254
  headers=self._headers,
241
255
  )
256
+ _LOGGER.debug("Response %s.", (await response.json()))
242
257
  except (
243
258
  aiohttp.ClientConnectionError,
244
259
  aiohttp.ClientConnectorError,
@@ -306,6 +321,17 @@ class AsyncSmartboxSession(AsyncSession):
306
321
  return response["nodes"]
307
322
  return Nodes.model_validate(response).nodes
308
323
 
324
+ async def get_device_connected(
325
+ self,
326
+ device_id: str,
327
+ ) -> dict[str, bool] | DeviceConnected:
328
+ """Get device away status."""
329
+ response = await self._api_request(f"devs/{device_id}/connected")
330
+ status: DeviceConnected = DeviceConnected.model_validate(response)
331
+ if self.raw_response is False:
332
+ return status
333
+ return status.model_dump(mode="json")
334
+
309
335
  async def get_device_away_status(
310
336
  self,
311
337
  device_id: str,
@@ -333,15 +359,18 @@ class AsyncSmartboxSession(AsyncSession):
333
359
  self, device_id: str, node: dict[str, Any] | None = None
334
360
  ) -> int:
335
361
  """Get device power limit."""
336
- url = f"devs/{device_id}/htr_system/power_limit"
362
+ power_param = "power_limit"
363
+ url = f"devs/{device_id}/htr_system/{power_param}"
364
+
337
365
  if node is not None and (
338
366
  (_node := Node.model_validate(node))
339
367
  and _node.type == SmartboxNodeType.PMO
340
368
  ):
341
- url = f"devs/{device_id}/{_node.type}/{_node.addr}/power"
369
+ power_param = "power"
370
+ url = f"devs/{device_id}/{_node.type}/{_node.addr}/{power_param}"
342
371
 
343
372
  resp = await self._api_request(url)
344
- return int(resp["power_limit"])
373
+ return int(resp[power_param])
345
374
 
346
375
  async def set_device_power_limit(
347
376
  self,
@@ -380,14 +409,13 @@ class AsyncSmartboxSession(AsyncSession):
380
409
  datetime.datetime.fromtimestamp(end_time, tz=datetime.UTC),
381
410
  )
382
411
  _node: Node = Node.model_validate(node)
383
- samples = Samples.model_validate(
384
- await self._api_request(
385
- f"devs/{device_id}/{_node.type}/{_node.addr}/samples?start={start_time}&end={end_time}",
386
- ),
412
+ response = await self._api_request(
413
+ f"devs/{device_id}/{_node.type}/{_node.addr}/samples?start={start_time}&end={end_time}",
387
414
  )
388
- if self.raw_response is False:
389
- return samples
390
- return samples.model_dump(mode="json")
415
+ _LOGGER.debug("Get_Device_Samples_Node: %s", response)
416
+ if self.raw_response is True:
417
+ return response
418
+ return Samples.model_validate(response)
391
419
 
392
420
  async def get_node_status(
393
421
  self,
@@ -399,6 +427,7 @@ class AsyncSmartboxSession(AsyncSession):
399
427
  | HtrNodeStatus
400
428
  | HtrModNodeStatus
401
429
  | DefaultNodeStatus
430
+ | None
402
431
  ):
403
432
  """Get a node status."""
404
433
  _node: Node = Node.model_validate(node)
@@ -461,9 +490,12 @@ class AsyncSmartboxSession(AsyncSession):
461
490
  data = {k: v for k, v in setup_args.items() if v is not None}
462
491
  # setup seems to require all settings to be re-posted, so get current
463
492
  # values and update
464
- setup_data = await self.get_node_setup(device_id, node)
465
- if isinstance(setup_data, NodeSetup):
466
- setup_data = setup_data.model_dump(mode="json")
493
+ setup_data: dict[str, Any] = {}
494
+ node_setup = await self.get_node_setup(device_id, node)
495
+ if not isinstance(node_setup, dict):
496
+ setup_data = node_setup.model_dump(mode="json")
497
+ else:
498
+ setup_data = node_setup
467
499
  setup_data.update(data)
468
500
  await self._api_post(
469
501
  data=setup_data,
@@ -143,6 +143,13 @@ class SocketSession:
143
143
 
144
144
  event_loop.add_signal_handler(signal.SIGINT, sigint_handler)
145
145
 
146
+ async def _dev_data(self) -> None:
147
+ """Send first dev data."""
148
+ if not self._api_v2_ns.connected:
149
+ _LOGGER.debug("Namespace disconnected, not sending ping")
150
+ _LOGGER.debug("Sending dev_data event")
151
+ await self._sio.emit("dev_data", namespace=_API_V2_NAMESPACE)
152
+
146
153
  async def _send_ping(self) -> None:
147
154
  """End pings to be alive."""
148
155
  _LOGGER.debug("Starting ping task every %ss", self._ping_interval)
@@ -196,6 +203,7 @@ class SocketSession:
196
203
  )
197
204
  else:
198
205
  _LOGGER.info("Successfully connected to %s", url)
206
+ await self._dev_data()
199
207
  await self._sio.wait()
200
208
  _LOGGER.info("Socket loop exited, disconnecting")
201
209
  await self._sio.disconnect()
@@ -133,6 +133,10 @@ class UpdateManager:
133
133
  """Run the socket session asynchronously, waiting for updates."""
134
134
  await self._socket_session.run()
135
135
 
136
+ async def cancel(self) -> None:
137
+ """Disconnecting and cancelling tasks."""
138
+ await self._socket_session.cancel()
139
+
136
140
  def subscribe_to_dev_data(self, jq_expr: str, callback: Callable) -> None:
137
141
  """Subscribe to receive device data."""
138
142
  sub = DevDataSubscription(jq_expr, callback)
@@ -163,6 +167,21 @@ class UpdateManager:
163
167
  callback,
164
168
  )
165
169
 
170
+ def subscribe_to_device_connected(
171
+ self,
172
+ callback: Callable[[bool], None],
173
+ ) -> None:
174
+ """Subscribe to device power limit updates."""
175
+ self.subscribe_to_dev_data(
176
+ ".connected",
177
+ lambda p: callback(bool(p)),
178
+ )
179
+ self.subscribe_to_updates(
180
+ r"^/connected",
181
+ f"{self.BODY_PATH}.connected",
182
+ lambda p: callback(bool(p)),
183
+ )
184
+
166
185
  def subscribe_to_device_power_limit(
167
186
  self,
168
187
  callback: Callable[[int], None],
@@ -0,0 +1,3 @@
1
+ {
2
+ "connected": true
3
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "nodes": [
3
+ {
4
+ "addr": 0,
5
+ "installed": true,
6
+ "lost": false,
7
+ "name": "Node 1 0",
8
+ "type": "htr"
9
+ },
10
+ {
11
+ "addr": 1,
12
+ "installed": true,
13
+ "lost": false,
14
+ "name": "Node 1 1",
15
+ "type": "acm"
16
+ },
17
+ {
18
+ "addr": 3,
19
+ "installed": true,
20
+ "level": 1,
21
+ "lost": false,
22
+ "name": "Node 1 3",
23
+ "parent": 1,
24
+ "setup": {
25
+ "counter_offset": "679.33"
26
+ },
27
+ "type": "pmo",
28
+ "uid": "003E003F0D47373436363733"
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "samples": [
3
+ {
4
+ "counter": "16612253.89",
5
+ "max": "2411",
6
+ "min": "166",
7
+ "t": 1738360800
8
+ },
9
+ {
10
+ "counter": "16612548.73",
11
+ "max": "2185",
12
+ "min": "164",
13
+ "t": 1738361700
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "circuit_type": 0,
3
+ "power_limit": 0,
4
+ "reverse": false
5
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "message": "Not Found",
3
+ "statusCode": 404
4
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "connected": false
3
+ }
@@ -28,6 +28,22 @@ async def test_health_check(runner, mock_session):
28
28
  assert "" in result.output
29
29
 
30
30
 
31
+ @pytest.mark.anyio
32
+ async def test_api_version(runner, mock_session):
33
+ devices_future = asyncio.Future()
34
+ devices_future.set_result(
35
+ {"major": "1", "minor": "53", "subminor": "2", "commit": "NULL"}
36
+ )
37
+ mock_session.return_value.api_version.return_value = devices_future
38
+
39
+ result = await runner.invoke(
40
+ smartbox,
41
+ [*DEFAULT_ARGS, "api-version"],
42
+ )
43
+ assert result.exit_code == 0
44
+ assert "subminor" in result.output
45
+
46
+
31
47
  @pytest.mark.anyio
32
48
  async def test_devices(runner, async_smartbox_session):
33
49
  result = await runner.invoke(
@@ -365,3 +381,13 @@ async def test_guests(runner, mock_session):
365
381
  mock_session.return_value.get_home_guests.assert_called_once_with(
366
382
  home_id="home1"
367
383
  )
384
+
385
+
386
+ @pytest.mark.anyio
387
+ async def test_device_connected_status(runner, async_smartbox_session):
388
+ result = await runner.invoke(
389
+ smartbox,
390
+ [*DEFAULT_ARGS, "device-connected-status"],
391
+ )
392
+ assert result.exit_code == 0
393
+ assert "connected" in result.output
@@ -1,6 +1,6 @@
1
1
  import pytest
2
2
 
3
- from smartbox import ResailerNotExistError
3
+ from smartbox import AsyncSmartboxSession, ResailerNotExistError
4
4
  from smartbox.resailer import AvailableResailers
5
5
 
6
6
 
@@ -44,3 +44,15 @@ def test_resailer_invalid_data():
44
44
  web_url="invalid-url",
45
45
  serial_id="invalid-serial-id",
46
46
  ).resailer
47
+
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_all_resailers():
51
+ for resailer in AvailableResailers.resailers.values():
52
+ _session = AsyncSmartboxSession(
53
+ username="", password="", api_name=resailer.api_url
54
+ )
55
+ check = await _session.health_check()
56
+ assert check is not None
57
+ version = await _session.api_version()
58
+ assert "major" in version
@@ -9,7 +9,7 @@ from pydantic import ValidationError
9
9
  import pytest
10
10
 
11
11
  from smartbox import APIUnavailableError, InvalidAuthError, SmartboxError
12
- from smartbox.models import NodeSetup
12
+ from smartbox.models import DefaultNodeSetup, NodeSetup
13
13
  from smartbox.session import (
14
14
  _DEFAULT_BACKOFF_FACTOR,
15
15
  _DEFAULT_RETRY_ATTEMPTS,
@@ -90,11 +90,12 @@ async def test_get_node_status(async_smartbox_session, caplog):
90
90
  mock_api_request.assert_called_with(url)
91
91
 
92
92
  async_smartbox_session.raw_response = False
93
- status_model = await async_smartbox_session.get_node_status(
94
- mock_device["dev_id"],
95
- mock_node,
96
- )
97
- assert status_model.act_duty == status["act_duty"]
93
+ if mock_node["type"] != "pmo":
94
+ status_model = await async_smartbox_session.get_node_status(
95
+ mock_device["dev_id"],
96
+ mock_node,
97
+ )
98
+ assert status_model.act_duty == status["act_duty"]
98
99
  with pytest.raises(ValidationError):
99
100
  mock_api_request.return_value = {
100
101
  "sync_status": "synced",
@@ -145,9 +146,8 @@ async def test_get_node_samples(async_smartbox_session):
145
146
  start_time=start_time,
146
147
  end_time=end_time,
147
148
  )
148
- assert (
149
- samples_model.samples[0].counter
150
- == samples["samples"][0]["counter"]
149
+ assert samples_model.samples[0].counter == float(
150
+ samples["samples"][0]["counter"]
151
151
  )
152
152
  async_smartbox_session.raw_response = True
153
153
 
@@ -223,6 +223,7 @@ async def test_get_device_power_limit(async_smartbox_session):
223
223
  "devs/test_device/htr_system/power_limit",
224
224
  )
225
225
 
226
+ mock_api_request.return_value = {"power": "100"}
226
227
  power_limit = await async_smartbox_session.get_device_power_limit(
227
228
  device_id="test_device", node=mock_node
228
229
  )
@@ -921,6 +922,54 @@ async def test_health_check_api_unavailable(async_session):
921
922
  )
922
923
 
923
924
 
925
+ @pytest.mark.asyncio
926
+ async def test_api_version_success(async_session):
927
+ with patch.object(
928
+ async_session.client,
929
+ "get",
930
+ new_callable=AsyncMock,
931
+ ) as mock_get:
932
+ mock_response = AsyncMock()
933
+ mock_response.json = AsyncMock(
934
+ return_value={
935
+ "major": "1",
936
+ "minor": "53",
937
+ "subminor": "2",
938
+ "commit": "NULL",
939
+ }
940
+ )
941
+ mock_response.raise_for_status = AsyncMock()
942
+ mock_get.return_value = mock_response
943
+
944
+ result = await async_session.api_version()
945
+ assert result == {
946
+ "major": "1",
947
+ "minor": "53",
948
+ "subminor": "2",
949
+ "commit": "NULL",
950
+ }
951
+ mock_get.assert_called_once_with(
952
+ f"{async_session._api_host}/version",
953
+ )
954
+
955
+
956
+ @pytest.mark.asyncio
957
+ async def test_api_version_unavailable(async_session):
958
+ with patch.object(
959
+ async_session.client,
960
+ "get",
961
+ new_callable=AsyncMock,
962
+ ) as mock_get:
963
+ mock_get.side_effect = aiohttp.ClientConnectionError()
964
+
965
+ with pytest.raises(APIUnavailableError):
966
+ await async_session.api_version()
967
+
968
+ mock_get.assert_called_once_with(
969
+ f"{async_session._api_host}/version",
970
+ )
971
+
972
+
924
973
  @pytest.mark.asyncio
925
974
  async def test_api_request_success(async_session):
926
975
  path = "test_path"
@@ -1256,7 +1305,8 @@ async def test_get_node_setup(async_smartbox_session, caplog):
1256
1305
  device_id=mock_device_id,
1257
1306
  node=mock_node,
1258
1307
  )
1259
- assert setup_model.away_mode == setup["away_mode"]
1308
+ if isinstance(setup_model, DefaultNodeSetup):
1309
+ assert setup_model.away_mode == setup["away_mode"]
1260
1310
  with pytest.raises(ValidationError):
1261
1311
  mock_api_request.return_value = {
1262
1312
  "sync_status": "synced",
@@ -1396,3 +1446,30 @@ async def test_get_home_guests(async_smartbox_session):
1396
1446
  )
1397
1447
  assert guests_model.guest_users[0].email == guests[0]["email"]
1398
1448
  async_smartbox_session.raw_response = True
1449
+
1450
+
1451
+ @pytest.mark.asyncio
1452
+ async def test_get_deviceconnected_status(async_smartbox_session):
1453
+ for mock_device in await async_smartbox_session.get_devices():
1454
+ with patch.object(
1455
+ async_smartbox_session,
1456
+ "_api_request",
1457
+ new_callable=AsyncMock,
1458
+ ) as mock_api_request:
1459
+ url = f"devs/{mock_device['dev_id']}/connected"
1460
+ mock_api_request.return_value = await fake_get_request(
1461
+ mock_api_request,
1462
+ url,
1463
+ )
1464
+ nodes = await async_smartbox_session.get_device_connected(
1465
+ device_id=mock_device["dev_id"],
1466
+ )
1467
+ assert nodes == mock_api_request.return_value
1468
+ mock_api_request.assert_called_with(url)
1469
+
1470
+ async_smartbox_session.raw_response = False
1471
+ nodes_model = await async_smartbox_session.get_device_connected(
1472
+ device_id=mock_device["dev_id"],
1473
+ )
1474
+ assert nodes_model.connected == nodes["connected"]
1475
+ async_smartbox_session.raw_response = True
@@ -206,3 +206,21 @@ async def test_update_manager_run(update_manager):
206
206
  ) as mock_run:
207
207
  await update_manager.run()
208
208
  mock_run.assert_awaited_once()
209
+
210
+
211
+ def test_update_manager_subscribe_to_device_connected(update_manager):
212
+ callback = MagicMock()
213
+ update_manager.subscribe_to_device_connected(callback)
214
+ assert len(update_manager._dev_data_subscriptions) == 1
215
+ assert len(update_manager._update_subscriptions) == 1
216
+
217
+ # Test dev data callback
218
+ dev_data = {"connected": True}
219
+ update_manager._dev_data_cb(dev_data)
220
+ callback.assert_called_once_with(dev_data["connected"])
221
+
222
+ # Test update callback
223
+ callback.reset_mock()
224
+ update_data = {"path": "/connected", "body": {"connected": True}}
225
+ update_manager._update_cb(update_data)
226
+ callback.assert_called_once_with(update_data["body"]["connected"])
@@ -1,4 +1,5 @@
1
1
  version = 1
2
+ revision = 1
2
3
  requires-python = ">=3.13"
3
4
 
4
5
  [[package]]
@@ -684,7 +685,7 @@ wheels = [
684
685
 
685
686
  [[package]]
686
687
  name = "smartbox"
687
- version = "2.1.2"
688
+ version = "2.1.3"
688
689
  source = { editable = "." }
689
690
  dependencies = [
690
691
  { name = "aiohttp" },
@@ -1,2 +0,0 @@
1
- # install from setup.py
2
- .
@@ -1,18 +0,0 @@
1
- {
2
- "nodes": [
3
- {
4
- "addr": 0,
5
- "name": "Node 1 0",
6
- "type": "htr",
7
- "installed": true,
8
- "lost": false
9
- },
10
- {
11
- "addr": 1,
12
- "name": "Node 1 1",
13
- "type": "acm",
14
- "installed": true,
15
- "lost": false
16
- }
17
- ]
18
- }
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
File without changes
File without changes