pyimouapi 1.2.8__tar.gz → 1.3.0__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 (24) hide show
  1. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/PKG-INFO +1 -1
  2. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/__init__.py +1 -1
  3. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/const.py +4 -0
  4. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/device.py +41 -0
  5. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/ha_device.py +38 -35
  6. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/openapi.py +28 -1
  7. pyimouapi-1.3.0/pyimouapi/sensor.py +95 -0
  8. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/PKG-INFO +1 -1
  9. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/SOURCES.txt +4 -0
  10. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyproject.toml +1 -1
  11. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/setup.py +1 -1
  12. pyimouapi-1.3.0/tests/test_device_summaries.py +89 -0
  13. pyimouapi-1.3.0/tests/test_message_callback.py +50 -0
  14. pyimouapi-1.3.0/tests/test_sensor_normalize.py +78 -0
  15. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/LICENSE +0 -0
  16. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/README.md +0 -0
  17. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/exceptions.py +0 -0
  18. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/dependency_links.txt +0 -0
  19. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/requires.txt +0 -0
  20. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/top_level.txt +0 -0
  21. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/setup.cfg +0 -0
  22. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/tests/test_collect_property_entities.py +0 -0
  23. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/tests/test_lookup_property.py +0 -0
  24. {pyimouapi-1.2.8 → pyimouapi-1.3.0}/tests/test_update_from_detail.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyimouapi
3
- Version: 1.2.8
3
+ Version: 1.3.0
4
4
  Summary: A package for imou open api
5
5
  Home-page: https://github.com/Imou-OpenPlatform/Py-Imou-Open-Api
6
6
  Author: Imou-OpenPlatform
@@ -1,4 +1,4 @@
1
- __version__ = "1.2.8"
1
+ __version__ = "1.3.0"
2
2
 
3
3
  from .device import ImouChannel, ImouDevice, ImouDeviceManager
4
4
  from .exceptions import (
@@ -22,6 +22,7 @@ API_ENDPOINT_GET_DEVICE_POWER_INFO = "/openapi/getDevicePowerInfo"
22
22
  API_ENDPOINT_GET_PRODUCT_MODEL = "/openapi/getProductModel"
23
23
  API_ENDPOINT_GET_IOT_DEVICE_DETAIL_INFO = "/openapi/getIotDeviceDetailInfo"
24
24
  API_ENDPOINT_WAKE_UP_DEVICE = "/openapi/wakeUpDevice"
25
+ API_ENDPOINT_SET_MESSAGE_CALLBACK = "/openapi/setMessageCallback"
25
26
 
26
27
  # error_codes
27
28
  ERROR_CODE_SUCCESS = "0"
@@ -116,6 +117,9 @@ PARAM_LITELEC = "litElec"
116
117
  PARAM_ALKELEC = "alkElec"
117
118
  PARAM_SERVICES = "services"
118
119
  PARAM_STATE = "state"
120
+ PARAM_STATE_VARIANT = "state_variant"
121
+ STATE_VARIANT_NUMERIC = "numeric"
122
+ STATE_VARIANT_ENUM = "enum"
119
123
  PARAM_TYPE = "type"
120
124
  PARAM_EXCEPTS = "excepts"
121
125
  PARAM_ABILITY_REFS = "abilityRefs"
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
3
4
  from typing import Any
4
5
 
5
6
  from .const import (
@@ -64,6 +65,14 @@ from .const import (
64
65
  from .openapi import ImouOpenApiClient
65
66
 
66
67
 
68
+ @dataclass(frozen=True)
69
+ class ImouDeviceSummary:
70
+ device_id: str
71
+ name: str
72
+ model: str
73
+ status: str # "0" offline, "1" online
74
+
75
+
67
76
  class ImouChannel:
68
77
  def __init__(
69
78
  self,
@@ -286,6 +295,38 @@ class ImouDeviceManager:
286
295
  devices.extend(await self.async_get_devices(page + 1, page_size))
287
296
  return devices
288
297
 
298
+ async def async_get_device_summaries(
299
+ self, page: int = 1, page_size: int = 50
300
+ ) -> list[ImouDeviceSummary]:
301
+ params = {
302
+ PARAM_PAGE: page,
303
+ PARAM_PAGE_SIZE: page_size,
304
+ }
305
+ data = await self._imou_api_client.async_request_api(
306
+ API_ENDPOINT_LIST_DEVICE_DETAILS, params
307
+ )
308
+ if data[PARAM_COUNT] == 0:
309
+ return []
310
+ summaries: list[ImouDeviceSummary] = []
311
+ for device in data[PARAM_DEVICE_LIST]:
312
+ device_id = device.get(PARAM_DEVICE_ID)
313
+ if not device_id:
314
+ continue
315
+ name = device.get(PARAM_DEVICE_NAME) or device_id
316
+ model = device.get(PARAM_DEVICE_MODEL, "")
317
+ status = str(device.get(PARAM_DEVICE_STATUS, ""))
318
+ summaries.append(
319
+ ImouDeviceSummary(
320
+ device_id=device_id,
321
+ name=name,
322
+ model=model,
323
+ status=status,
324
+ )
325
+ )
326
+ if data[PARAM_COUNT] >= page_size:
327
+ summaries.extend(await self.async_get_device_summaries(page + 1, page_size))
328
+ return summaries
329
+
289
330
  async def async_control_device_ptz(
290
331
  self, device_id: str, channel_id: str, operation: int, duration: int
291
332
  ) -> None:
@@ -67,6 +67,7 @@ from .const import (
67
67
  )
68
68
  from .device import ImouDevice, ImouDeviceManager
69
69
  from .exceptions import RequestFailedException
70
+ from .sensor import apply_sensor_state
70
71
 
71
72
  _LOGGER: logging.Logger = logging.getLogger(__package__)
72
73
 
@@ -146,9 +147,8 @@ class ImouHaDevice:
146
147
  self._model = model
147
148
  self._swversion = swversion
148
149
  self._switches = {}
149
- self._sensors = {
150
- PARAM_STATUS: {PARAM_STATE: DeviceStatus.OFFLINE.value},
151
- }
150
+ self._sensors: dict[str, dict[str, Any]] = {}
151
+ apply_sensor_state(self._sensors, PARAM_STATUS, DeviceStatus.OFFLINE.value)
152
152
  self._binary_sensors = {}
153
153
  self._selects = {}
154
154
  self._buttons = {}
@@ -348,8 +348,12 @@ class ImouHaDeviceManager:
348
348
  state = self.get_expression_value(meta[PARAM_EXPRESSION], raw_value)
349
349
  else:
350
350
  state = raw_value
351
- target = device.sensors if kind == "sensor" else device.texts
352
- target[key][PARAM_STATE] = str(state) if isinstance(state, int) else state
351
+ if kind == "sensor":
352
+ apply_sensor_state(device.sensors, key, state)
353
+ else:
354
+ device.texts[key][PARAM_STATE] = (
355
+ str(state) if isinstance(state, int) else state
356
+ )
353
357
 
354
358
  async def _async_fetch_device_detail(self, device: ImouHaDevice) -> dict[str, Any]:
355
359
  return await self.delegate.async_get_iot_device_detail_info(
@@ -472,14 +476,18 @@ class ImouHaDeviceManager:
472
476
  )
473
477
  data = await self.delegate.async_get_device_online_status(device_id)
474
478
  if device.channel_id is None and device.product_id is not None:
475
- device.sensors[PARAM_STATUS][PARAM_STATE] = self.get_device_status(
476
- data[PARAM_ONLINE]
479
+ apply_sensor_state(
480
+ device.sensors,
481
+ PARAM_STATUS,
482
+ self.get_device_status(data[PARAM_ONLINE]),
477
483
  )
478
484
  else:
479
485
  for channel in data[PARAM_CHANNELS]:
480
486
  if channel[PARAM_CHANNEL_ID] == device.channel_id:
481
- device.sensors[PARAM_STATUS][PARAM_STATE] = (
482
- self.get_device_status(channel[PARAM_ONLINE])
487
+ apply_sensor_state(
488
+ device.sensors,
489
+ PARAM_STATUS,
490
+ self.get_device_status(channel[PARAM_ONLINE]),
483
491
  )
484
492
  break
485
493
  except Exception as e:
@@ -492,15 +500,15 @@ class ImouHaDeviceManager:
492
500
  percentage_used = int(
493
501
  data[PARAM_USED_BYTES] * 100 / data[PARAM_TOTAL_BYTES]
494
502
  )
495
- device.sensors[PARAM_STORAGE_USED][PARAM_STATE] = str(percentage_used)
503
+ apply_sensor_state(device.sensors, PARAM_STORAGE_USED, percentage_used)
496
504
  else:
497
- device.sensors[PARAM_STORAGE_USED][PARAM_STATE] = "e2"
505
+ apply_sensor_state(device.sensors, PARAM_STORAGE_USED, "e2")
498
506
  except RequestFailedException as exception:
499
507
  _LOGGER.error(f"_async_update_device_storage error: {exception}")
500
508
  if ERROR_CODE_NO_STORAGE_MEDIUM in exception.message:
501
- device.sensors[PARAM_STORAGE_USED][PARAM_STATE] = "e1"
509
+ apply_sensor_state(device.sensors, PARAM_STORAGE_USED, "e1")
502
510
  else:
503
- device.sensors[PARAM_STORAGE_USED][PARAM_STATE] = "e2"
511
+ apply_sensor_state(device.sensors, PARAM_STORAGE_USED, "e2")
504
512
 
505
513
  async def async_get_device_stream(
506
514
  self, device: ImouHaDevice, live_resolution: str, live_protocol: str
@@ -843,11 +851,12 @@ class ImouHaDeviceManager:
843
851
  sensor_type,
844
852
  imou_ha_device.sensors,
845
853
  ):
846
- imou_ha_device.sensors[sensor_type] = {
847
- PARAM_STATE: "unknown"
848
- if sensor_type not in NUMBER_TYPE
849
- else "0"
850
- }
854
+ imou_ha_device.sensors[sensor_type] = {}
855
+ apply_sensor_state(
856
+ imou_ha_device.sensors,
857
+ sensor_type,
858
+ "unknown" if sensor_type not in NUMBER_TYPE else "0",
859
+ )
851
860
 
852
861
  @staticmethod
853
862
  def configure_select_by_ability(
@@ -1119,10 +1128,12 @@ class ImouHaDeviceManager:
1119
1128
  ):
1120
1129
  imou_ha_device.sensors[sensor_type] = {
1121
1130
  PARAM_REF: ref[PARAM_REF],
1122
- PARAM_STATE: ref[PARAM_DEFAULT],
1123
1131
  PARAM_REF_TYPE: ref.get(PARAM_REF_TYPE),
1124
1132
  PARAM_EXPRESSION: ref.get(PARAM_EXPRESSION),
1125
1133
  }
1134
+ apply_sensor_state(
1135
+ imou_ha_device.sensors, sensor_type, ref[PARAM_DEFAULT]
1136
+ )
1126
1137
  break
1127
1138
 
1128
1139
  @staticmethod
@@ -1224,9 +1235,7 @@ class ImouHaDeviceManager:
1224
1235
  )
1225
1236
  if state is None:
1226
1237
  return
1227
- device.sensors[sensor_type][PARAM_STATE] = (
1228
- str(state) if isinstance(state, int) else state
1229
- )
1238
+ apply_sensor_state(device.sensors, sensor_type, state)
1230
1239
  except Exception as e:
1231
1240
  _LOGGER.error(f"_async_update_device_sensor_status_by_ref fail:{e}")
1232
1241
 
@@ -1387,22 +1396,16 @@ class ImouHaDeviceManager:
1387
1396
  async def _async_update_device_battery(self, device, retry: bool = False):
1388
1397
  try:
1389
1398
  data = await self.delegate.async_get_device_power_info(device.device_id)
1399
+ battery_level = "0"
1390
1400
  if data.get(PARAM_ELECTRICITYS):
1391
1401
  electricity = data[PARAM_ELECTRICITYS][0]
1392
1402
  if PARAM_LITELEC in electricity:
1393
- device.sensors[PARAM_BATTERY][PARAM_STATE] = str(
1394
- electricity[PARAM_LITELEC]
1395
- )
1403
+ battery_level = electricity[PARAM_LITELEC]
1396
1404
  elif PARAM_ALKELEC in electricity:
1397
- device.sensors[PARAM_BATTERY][PARAM_STATE] = str(
1398
- electricity[PARAM_ALKELEC]
1399
- )
1405
+ battery_level = electricity[PARAM_ALKELEC]
1400
1406
  elif PARAM_ELECTRIC in electricity:
1401
- device.sensors[PARAM_BATTERY][PARAM_STATE] = str(
1402
- electricity[PARAM_ELECTRIC]
1403
- )
1404
- else:
1405
- device.sensors[PARAM_BATTERY][PARAM_STATE] = "0"
1407
+ battery_level = electricity[PARAM_ELECTRIC]
1408
+ apply_sensor_state(device.sensors, PARAM_BATTERY, battery_level)
1406
1409
  except RequestFailedException as exception:
1407
1410
  # 如果在休眠,则唤醒设备后重试一次
1408
1411
  if ERROR_CODE_DEVICE_SLEEPING in exception.message and not retry:
@@ -1411,10 +1414,10 @@ class ImouHaDeviceManager:
1411
1414
  await self._async_update_device_battery(device, True)
1412
1415
  except RequestFailedException as e:
1413
1416
  _LOGGER.error(f"_async_update_device_battery error: {e}")
1414
- device.sensors[PARAM_BATTERY][PARAM_STATE] = "0"
1417
+ apply_sensor_state(device.sensors, PARAM_BATTERY, "0")
1415
1418
  else:
1416
1419
  _LOGGER.error(f"_async_update_device_battery error: {exception}")
1417
- device.sensors[PARAM_BATTERY][PARAM_STATE] = "0"
1420
+ apply_sensor_state(device.sensors, PARAM_BATTERY, "0")
1418
1421
 
1419
1422
  @staticmethod
1420
1423
  def configure_text_by_ref(
@@ -5,13 +5,15 @@ import logging
5
5
  import secrets
6
6
  import time
7
7
  import uuid
8
- from typing import Any
8
+ from collections.abc import Iterable
9
+ from typing import Any, Literal
9
10
  from urllib.parse import urlparse
10
11
 
11
12
  import aiohttp
12
13
 
13
14
  from .const import (
14
15
  API_ENDPOINT_ACCESS_TOKEN,
16
+ API_ENDPOINT_SET_MESSAGE_CALLBACK,
15
17
  ERROR_CODE_INVALID_APP,
16
18
  ERROR_CODE_INVALID_SIGN,
17
19
  ERROR_CODE_SUCCESS,
@@ -133,6 +135,31 @@ class ImouOpenApiClient:
133
135
  response_data = response_body[PARAM_RESULT].get(PARAM_DATA, {})
134
136
  return response_data
135
137
 
138
+ async def async_set_message_callback(
139
+ self,
140
+ *,
141
+ status: Literal["on", "off"],
142
+ callback_url: str | None = None,
143
+ callback_flag: str | Iterable[str] | None = None,
144
+ base_push: str = "2",
145
+ ) -> dict[str, Any]:
146
+ """Register or unregister Imou Open Platform message callback."""
147
+ params: dict[str, Any] = {
148
+ "status": status,
149
+ "basePush": base_push,
150
+ }
151
+ if status == "on":
152
+ if callback_url is None:
153
+ raise ValueError("callback_url is required when status is 'on'")
154
+ params["callbackUrl"] = callback_url
155
+ if callback_flag is None:
156
+ params["callbackFlag"] = "alarm,deviceStatus"
157
+ elif isinstance(callback_flag, str):
158
+ params["callbackFlag"] = callback_flag
159
+ else:
160
+ params["callbackFlag"] = ",".join(callback_flag)
161
+ return await self.async_request_api(API_ENDPOINT_SET_MESSAGE_CALLBACK, params)
162
+
136
163
  @property
137
164
  def access_token(self) -> str | None:
138
165
  return self._access_token
@@ -0,0 +1,95 @@
1
+ """Sensor state normalization for Home Assistant integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from pyimouapi.const import (
9
+ PARAM_STATE,
10
+ PARAM_STATE_VARIANT,
11
+ PARAM_STATUS,
12
+ PARAM_STORAGE_USED,
13
+ STATE_VARIANT_ENUM,
14
+ STATE_VARIANT_NUMERIC,
15
+ )
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+ NUMERIC_SENSOR_TYPES = frozenset(
20
+ {
21
+ "battery",
22
+ "temperature_current",
23
+ "humidity_current",
24
+ "power",
25
+ "voltage",
26
+ "current",
27
+ "use_electricity",
28
+ "use_time",
29
+ "switch_cnt",
30
+ }
31
+ )
32
+
33
+ INTEGER_NUMERIC_SENSOR_TYPES = frozenset(
34
+ {
35
+ "battery",
36
+ "use_time",
37
+ "switch_cnt",
38
+ }
39
+ )
40
+
41
+ ENUM_SENSOR_TYPES = frozenset({PARAM_STATUS})
42
+
43
+ STORAGE_ERROR_CODES = frozenset({"e1", "e2"})
44
+
45
+
46
+ def normalize_sensor_state(sensor_type: str, raw: Any) -> tuple[Any, str]:
47
+ """Return normalized (state, state_variant) for a sensor update."""
48
+ if sensor_type == PARAM_STORAGE_USED:
49
+ if isinstance(raw, str) and raw in STORAGE_ERROR_CODES:
50
+ return raw, STATE_VARIANT_ENUM
51
+ return _coerce_number(
52
+ raw, integer=True, sensor_type=sensor_type
53
+ ), STATE_VARIANT_NUMERIC
54
+
55
+ if sensor_type in ENUM_SENSOR_TYPES:
56
+ return str(raw), STATE_VARIANT_ENUM
57
+
58
+ if sensor_type in NUMERIC_SENSOR_TYPES:
59
+ integer = sensor_type in INTEGER_NUMERIC_SENSOR_TYPES
60
+ return _coerce_number(
61
+ raw, integer=integer, sensor_type=sensor_type
62
+ ), STATE_VARIANT_NUMERIC
63
+
64
+ if isinstance(raw, int | float) and not isinstance(raw, bool):
65
+ return raw, STATE_VARIANT_NUMERIC
66
+ return raw, STATE_VARIANT_ENUM
67
+
68
+
69
+ def _coerce_number(raw: Any, *, integer: bool, sensor_type: str) -> Any:
70
+ if isinstance(raw, bool):
71
+ return raw
72
+ if isinstance(raw, int) and not isinstance(raw, bool):
73
+ return raw
74
+ if isinstance(raw, float):
75
+ return int(raw) if integer else raw
76
+ if isinstance(raw, str):
77
+ try:
78
+ number = float(raw)
79
+ return int(number) if integer else number
80
+ except ValueError:
81
+ _LOGGER.warning(
82
+ "Could not coerce sensor %s value %r to number", sensor_type, raw
83
+ )
84
+ return raw
85
+ return raw
86
+
87
+
88
+ def apply_sensor_state(
89
+ sensors: dict[str, dict[str, Any]], sensor_type: str, raw: Any
90
+ ) -> None:
91
+ """Write PARAM_STATE and PARAM_STATE_VARIANT on a sensor entry."""
92
+ state, variant = normalize_sensor_state(sensor_type, raw)
93
+ bucket = sensors.setdefault(sensor_type, {})
94
+ bucket[PARAM_STATE] = state
95
+ bucket[PARAM_STATE_VARIANT] = variant
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyimouapi
3
- Version: 1.2.8
3
+ Version: 1.3.0
4
4
  Summary: A package for imou open api
5
5
  Home-page: https://github.com/Imou-OpenPlatform/Py-Imou-Open-Api
6
6
  Author: Imou-OpenPlatform
@@ -8,11 +8,15 @@ pyimouapi/device.py
8
8
  pyimouapi/exceptions.py
9
9
  pyimouapi/ha_device.py
10
10
  pyimouapi/openapi.py
11
+ pyimouapi/sensor.py
11
12
  pyimouapi.egg-info/PKG-INFO
12
13
  pyimouapi.egg-info/SOURCES.txt
13
14
  pyimouapi.egg-info/dependency_links.txt
14
15
  pyimouapi.egg-info/requires.txt
15
16
  pyimouapi.egg-info/top_level.txt
16
17
  tests/test_collect_property_entities.py
18
+ tests/test_device_summaries.py
17
19
  tests/test_lookup_property.py
20
+ tests/test_message_callback.py
21
+ tests/test_sensor_normalize.py
18
22
  tests/test_update_from_detail.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyimouapi"
7
- version = "1.2.8"
7
+ version = "1.3.0"
8
8
  description = "A package for imou open api"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,7 +4,7 @@ from setuptools import find_packages, setup
4
4
 
5
5
  setup(
6
6
  name="pyimouapi",
7
- version="1.2.8",
7
+ version="1.3.0",
8
8
  packages=find_packages(),
9
9
  python_requires=">=3.11",
10
10
  install_requires=[
@@ -0,0 +1,89 @@
1
+ """Tests for ImouDeviceManager.async_get_device_summaries."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock
4
+
5
+ import pytest
6
+ from pyimouapi.const import (
7
+ API_ENDPOINT_LIST_DEVICE_DETAILS,
8
+ PARAM_COUNT,
9
+ PARAM_DEVICE_ID,
10
+ PARAM_DEVICE_LIST,
11
+ PARAM_DEVICE_MODEL,
12
+ PARAM_DEVICE_NAME,
13
+ PARAM_DEVICE_STATUS,
14
+ PARAM_PAGE,
15
+ PARAM_PAGE_SIZE,
16
+ )
17
+ from pyimouapi.device import ImouDeviceManager, ImouDeviceSummary
18
+
19
+
20
+ def _make_device(
21
+ device_id: str,
22
+ name: str | None = "Camera",
23
+ model: str = "IPC-A1",
24
+ status: str | int = "1",
25
+ ) -> dict:
26
+ entry: dict = {
27
+ PARAM_DEVICE_ID: device_id,
28
+ PARAM_DEVICE_STATUS: status,
29
+ }
30
+ if name is not None:
31
+ entry[PARAM_DEVICE_NAME] = name
32
+ if model:
33
+ entry[PARAM_DEVICE_MODEL] = model
34
+ return entry
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_get_device_summaries_single_page():
39
+ client = MagicMock()
40
+ client.async_request_api = AsyncMock(
41
+ return_value={
42
+ PARAM_COUNT: 2,
43
+ PARAM_DEVICE_LIST: [
44
+ _make_device("dev1", "Front Door", "IPC-A1", "1"),
45
+ _make_device("dev2", None, "", "0"),
46
+ {},
47
+ ],
48
+ }
49
+ )
50
+ manager = ImouDeviceManager(client)
51
+
52
+ summaries = await manager.async_get_device_summaries()
53
+
54
+ client.async_request_api.assert_awaited_once_with(
55
+ API_ENDPOINT_LIST_DEVICE_DETAILS,
56
+ {PARAM_PAGE: 1, PARAM_PAGE_SIZE: 50},
57
+ )
58
+ assert summaries == [
59
+ ImouDeviceSummary(
60
+ device_id="dev1", name="Front Door", model="IPC-A1", status="1"
61
+ ),
62
+ ImouDeviceSummary(device_id="dev2", name="dev2", model="", status="0"),
63
+ ]
64
+
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_get_device_summaries_pagination():
68
+ page1_devices = [
69
+ _make_device(f"dev{i}", f"Device {i}", "IPC", "1") for i in range(50)
70
+ ]
71
+ page2_devices = [_make_device("dev50", "Device 50", "IPC", "1")]
72
+
73
+ async def mock_request(endpoint, params):
74
+ if params[PARAM_PAGE] == 1:
75
+ return {PARAM_COUNT: 50, PARAM_DEVICE_LIST: page1_devices}
76
+ if params[PARAM_PAGE] == 2:
77
+ return {PARAM_COUNT: 1, PARAM_DEVICE_LIST: page2_devices}
78
+ raise AssertionError(f"Unexpected page: {params[PARAM_PAGE]}")
79
+
80
+ client = MagicMock()
81
+ client.async_request_api = AsyncMock(side_effect=mock_request)
82
+ manager = ImouDeviceManager(client)
83
+
84
+ summaries = await manager.async_get_device_summaries()
85
+
86
+ assert client.async_request_api.await_count == 2
87
+ assert len(summaries) == 51
88
+ assert summaries[0].device_id == "dev0"
89
+ assert summaries[50].device_id == "dev50"
@@ -0,0 +1,50 @@
1
+ """Tests for ImouOpenApiClient.async_set_message_callback."""
2
+
3
+ from unittest.mock import AsyncMock
4
+
5
+ import pytest
6
+ from pyimouapi.const import API_ENDPOINT_SET_MESSAGE_CALLBACK
7
+ from pyimouapi.openapi import ImouOpenApiClient
8
+
9
+
10
+ @pytest.fixture
11
+ def client() -> ImouOpenApiClient:
12
+ return ImouOpenApiClient("app_id", "app_secret", "api.example.com")
13
+
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_set_message_callback_on(client: ImouOpenApiClient) -> None:
17
+ client.async_request_api = AsyncMock(return_value={})
18
+ await client.async_set_message_callback(
19
+ status="on",
20
+ callback_url="https://example.com/webhook",
21
+ callback_flag=["alarm", "deviceStatus"],
22
+ )
23
+ client.async_request_api.assert_awaited_once_with(
24
+ API_ENDPOINT_SET_MESSAGE_CALLBACK,
25
+ {
26
+ "status": "on",
27
+ "basePush": "2",
28
+ "callbackUrl": "https://example.com/webhook",
29
+ "callbackFlag": "alarm,deviceStatus",
30
+ },
31
+ )
32
+
33
+
34
+ @pytest.mark.asyncio
35
+ async def test_set_message_callback_off(client: ImouOpenApiClient) -> None:
36
+ client.async_request_api = AsyncMock(return_value={})
37
+ await client.async_set_message_callback(status="off")
38
+ client.async_request_api.assert_awaited_once_with(
39
+ API_ENDPOINT_SET_MESSAGE_CALLBACK,
40
+ {
41
+ "status": "off",
42
+ "basePush": "2",
43
+ },
44
+ )
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_set_message_callback_on_requires_url(client: ImouOpenApiClient) -> None:
49
+ with pytest.raises(ValueError):
50
+ await client.async_set_message_callback(status="on")
@@ -0,0 +1,78 @@
1
+ """Tests for sensor state normalization."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock
4
+
5
+ import pytest
6
+ from pyimouapi.const import (
7
+ PARAM_BATTERY,
8
+ PARAM_STATE,
9
+ PARAM_STATE_VARIANT,
10
+ PARAM_STORAGE_USED,
11
+ STATE_VARIANT_ENUM,
12
+ STATE_VARIANT_NUMERIC,
13
+ )
14
+ from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager
15
+ from pyimouapi.sensor import apply_sensor_state, normalize_sensor_state
16
+
17
+
18
+ @pytest.mark.parametrize(
19
+ ("sensor_type", "raw", "expected_state", "expected_variant"),
20
+ [
21
+ ("battery", "85", 85, STATE_VARIANT_NUMERIC),
22
+ ("battery", 85, 85, STATE_VARIANT_NUMERIC),
23
+ ("temperature_current", "23.5", 23.5, STATE_VARIANT_NUMERIC),
24
+ ("use_time", "120", 120, STATE_VARIANT_NUMERIC),
25
+ ("switch_cnt", "3", 3, STATE_VARIANT_NUMERIC),
26
+ ("use_electricity", "1.25", 1.25, STATE_VARIANT_NUMERIC),
27
+ ("storage_used", "75", 75, STATE_VARIANT_NUMERIC),
28
+ ("storage_used", "e1", "e1", STATE_VARIANT_ENUM),
29
+ ("storage_used", "e2", "e2", STATE_VARIANT_ENUM),
30
+ ("status", "online", "online", STATE_VARIANT_ENUM),
31
+ ("status", "offline", "offline", STATE_VARIANT_ENUM),
32
+ ],
33
+ )
34
+ def test_normalize_sensor_state(sensor_type, raw, expected_state, expected_variant):
35
+ """Normalize sensor values to typed state and variant metadata."""
36
+ state, variant = normalize_sensor_state(sensor_type, raw)
37
+ assert state == expected_state
38
+ assert variant == expected_variant
39
+
40
+
41
+ def test_apply_sensor_state_writes_variant():
42
+ """apply_sensor_state sets PARAM_STATE and PARAM_STATE_VARIANT."""
43
+ sensors: dict[str, dict] = {}
44
+ apply_sensor_state(sensors, "battery", "90")
45
+ assert sensors["battery"][PARAM_STATE] == 90
46
+ assert sensors["battery"][PARAM_STATE_VARIANT] == STATE_VARIANT_NUMERIC
47
+
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_update_device_battery_stores_int():
51
+ """Battery updates store normalized integer state."""
52
+ device = ImouHaDevice("d1", "cam", "Imou", "m", "1.0")
53
+ device.sensors[PARAM_BATTERY] = {}
54
+ delegate = MagicMock()
55
+ delegate.async_get_device_power_info = AsyncMock(
56
+ return_value={"electricitys": [{"litElec": "88"}]}
57
+ )
58
+ manager = ImouHaDeviceManager(delegate)
59
+ await manager._async_update_device_battery(device)
60
+ assert device.sensors[PARAM_BATTERY][PARAM_STATE] == 88
61
+ assert device.sensors[PARAM_BATTERY][PARAM_STATE_VARIANT] == STATE_VARIANT_NUMERIC
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_update_device_storage_stores_int_percentage():
66
+ """Storage percentage is stored as int with numeric variant."""
67
+ device = ImouHaDevice("d1", "cam", "Imou", "m", "1.0")
68
+ device.sensors[PARAM_STORAGE_USED] = {}
69
+ delegate = MagicMock()
70
+ delegate.async_get_device_storage = AsyncMock(
71
+ return_value={"usedBytes": 50, "totalBytes": 100}
72
+ )
73
+ manager = ImouHaDeviceManager(delegate)
74
+ await manager._async_update_device_storage(device)
75
+ assert device.sensors[PARAM_STORAGE_USED][PARAM_STATE] == 50
76
+ assert (
77
+ device.sensors[PARAM_STORAGE_USED][PARAM_STATE_VARIANT] == STATE_VARIANT_NUMERIC
78
+ )
File without changes
File without changes
File without changes