pyimouapi 1.3.0__tar.gz → 1.3.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 (26) hide show
  1. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/PKG-INFO +1 -1
  2. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/__init__.py +1 -1
  3. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/const.py +2 -0
  4. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/device.py +86 -8
  5. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/ha_device.py +14 -4
  6. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/PKG-INFO +1 -1
  7. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/SOURCES.txt +2 -0
  8. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyproject.toml +1 -1
  9. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/setup.py +1 -1
  10. pyimouapi-1.3.2/tests/test_channel_id_and_api_guards.py +97 -0
  11. pyimouapi-1.3.2/tests/test_event_identifier_map.py +94 -0
  12. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/LICENSE +0 -0
  13. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/README.md +0 -0
  14. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/exceptions.py +0 -0
  15. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/openapi.py +0 -0
  16. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/sensor.py +0 -0
  17. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/dependency_links.txt +0 -0
  18. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/requires.txt +0 -0
  19. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/top_level.txt +0 -0
  20. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/setup.cfg +0 -0
  21. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_collect_property_entities.py +0 -0
  22. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_device_summaries.py +0 -0
  23. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_lookup_property.py +0 -0
  24. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_message_callback.py +0 -0
  25. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_sensor_normalize.py +0 -0
  26. {pyimouapi-1.3.0 → pyimouapi-1.3.2}/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.3.0
3
+ Version: 1.3.2
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.3.0"
1
+ __version__ = "1.3.2"
2
2
 
3
3
  from .device import ImouChannel, ImouDevice, ImouDeviceManager
4
4
  from .exceptions import (
@@ -93,6 +93,8 @@ PARAM_URL = "url"
93
93
  PARAM_KEY = "key"
94
94
  PARAM_DEFAULT = "default"
95
95
  PARAM_REF = "ref"
96
+ PARAM_EVENTS = "events"
97
+ PARAM_IDENTIFIER = "identifier"
96
98
  PARAM_CONTENT = "content"
97
99
  PARAM_ON = "on"
98
100
  PARAM_BUTTON_TYPE_REF = "button_type_ref"
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
4
+ import logging
3
5
  from dataclasses import dataclass
4
6
  from typing import Any
5
7
 
@@ -49,6 +51,8 @@ from .const import (
49
51
  PARAM_DURATION,
50
52
  PARAM_ENABLE,
51
53
  PARAM_ENABLE_TYPE,
54
+ PARAM_EVENTS,
55
+ PARAM_IDENTIFIER,
52
56
  PARAM_MODE,
53
57
  PARAM_MULTI_FLAG,
54
58
  PARAM_OPERATION,
@@ -64,6 +68,27 @@ from .const import (
64
68
  )
65
69
  from .openapi import ImouOpenApiClient
66
70
 
71
+ _LOGGER = logging.getLogger(__name__)
72
+
73
+
74
+ def _parse_event_ref_map(data: dict[str, Any] | None) -> dict[str, str]:
75
+ """Build {event.ref: event.identifier} from a getProductModel payload."""
76
+ if not isinstance(data, dict):
77
+ return {}
78
+ events = data.get(PARAM_EVENTS)
79
+ if not isinstance(events, list):
80
+ return {}
81
+ mapping: dict[str, str] = {}
82
+ for item in events:
83
+ if not isinstance(item, dict):
84
+ continue
85
+ ref = item.get(PARAM_REF)
86
+ identifier = item.get(PARAM_IDENTIFIER)
87
+ if ref is None or identifier is None or identifier == "":
88
+ continue
89
+ mapping[str(ref)] = str(identifier)
90
+ return mapping
91
+
67
92
 
68
93
  @dataclass(frozen=True)
69
94
  class ImouDeviceSummary:
@@ -229,6 +254,54 @@ class ImouDevice:
229
254
  class ImouDeviceManager:
230
255
  def __init__(self, imou_api_client: ImouOpenApiClient):
231
256
  self._imou_api_client = imou_api_client
257
+ self._event_maps: dict[str, dict[str, str]] = {}
258
+ self._event_map_locks: dict[str, asyncio.Lock] = {}
259
+ self._event_map_locks_guard = asyncio.Lock()
260
+
261
+ async def async_close(self) -> None:
262
+ """Close the underlying Open API HTTP session."""
263
+ await self._imou_api_client.async_close()
264
+
265
+ async def _async_lock_for_product(self, product_id: str) -> asyncio.Lock:
266
+ async with self._event_map_locks_guard:
267
+ lock = self._event_map_locks.get(product_id)
268
+ if lock is None:
269
+ lock = asyncio.Lock()
270
+ self._event_map_locks[product_id] = lock
271
+ return lock
272
+
273
+ async def async_ensure_event_map(self, product_id: str) -> None:
274
+ """Fetch and cache events ref→identifier for product_id if missing."""
275
+ if not product_id:
276
+ return
277
+ if product_id in self._event_maps:
278
+ return
279
+ lock = await self._async_lock_for_product(product_id)
280
+ async with lock:
281
+ if product_id in self._event_maps:
282
+ return
283
+ try:
284
+ data = await self.async_get_product_model(product_id)
285
+ except Exception:
286
+ _LOGGER.warning(
287
+ "getProductModel failed for product_id=%s",
288
+ product_id,
289
+ exc_info=True,
290
+ )
291
+ return
292
+ self._event_maps[product_id] = _parse_event_ref_map(data)
293
+
294
+ async def async_resolve_event_identifier(
295
+ self, product_id: str, ref: str
296
+ ) -> str | None:
297
+ """Return event identifier for ref, fetching the product model if needed."""
298
+ if not product_id or ref is None or ref == "":
299
+ return None
300
+ await self.async_ensure_event_map(product_id)
301
+ mapping = self._event_maps.get(product_id)
302
+ if mapping is None:
303
+ return None
304
+ return mapping.get(str(ref))
232
305
 
233
306
  async def async_get_devices(
234
307
  self, page: int = 1, page_size: int = 10
@@ -486,11 +559,13 @@ class ImouDeviceManager:
486
559
  }
487
560
  ]
488
561
  }
489
- return (
490
- await self._imou_api_client.async_request_api(
491
- API_ENDPOINT_GET_IOT_DEVICE_PROPERTIES, params
492
- )
493
- ).get(PARAM_DEVICE_LIST, [{}])[0]
562
+ data = await self._imou_api_client.async_request_api(
563
+ API_ENDPOINT_GET_IOT_DEVICE_PROPERTIES, params
564
+ )
565
+ device_list = data.get(PARAM_DEVICE_LIST) if isinstance(data, dict) else None
566
+ if not device_list:
567
+ return {}
568
+ return device_list[0]
494
569
 
495
570
  async def async_set_iot_device_properties(
496
571
  self, device_id: str, channel_id: str | None, product_id: str, properties: dict
@@ -586,12 +661,15 @@ class ImouDeviceManager:
586
661
  if device_detail.get(PARAM_CHANNELS) and imou_device.channels:
587
662
  channels_detail = device_detail.get(PARAM_CHANNELS)
588
663
  channel_detail_map = {
589
- channel_detail.get(PARAM_CHANNEL_ID): channel_detail.get(
664
+ str(channel_detail.get(PARAM_CHANNEL_ID)): channel_detail.get(
590
665
  PARAM_ABILITY_REFS, "unknown"
591
666
  )
592
667
  for channel_detail in channels_detail
668
+ if channel_detail.get(PARAM_CHANNEL_ID) is not None
593
669
  }
594
670
  for channel in imou_device.channels:
595
- channel_id = channel.channel_id
596
- if channel_id in channel_detail_map:
671
+ channel_id = (
672
+ str(channel.channel_id) if channel.channel_id is not None else None
673
+ )
674
+ if channel_id is not None and channel_id in channel_detail_map:
597
675
  channel.set_channel_ability_refs(channel_detail_map[channel_id])
@@ -254,6 +254,10 @@ class ImouHaDeviceManager:
254
254
  def delegate(self):
255
255
  return self._delegate
256
256
 
257
+ async def async_close(self) -> None:
258
+ """Close the underlying Open API HTTP session."""
259
+ await self._delegate.async_close()
260
+
257
261
  @staticmethod
258
262
  def _resolve_device_id(device: ImouHaDevice) -> str:
259
263
  device_id = device.device_id
@@ -482,8 +486,11 @@ class ImouHaDeviceManager:
482
486
  self.get_device_status(data[PARAM_ONLINE]),
483
487
  )
484
488
  else:
489
+ device_channel_id = (
490
+ str(device.channel_id) if device.channel_id is not None else None
491
+ )
485
492
  for channel in data[PARAM_CHANNELS]:
486
- if channel[PARAM_CHANNEL_ID] == device.channel_id:
493
+ if str(channel[PARAM_CHANNEL_ID]) == device_channel_id:
487
494
  apply_sensor_state(
488
495
  device.sensors,
489
496
  PARAM_STATUS,
@@ -681,9 +688,12 @@ class ImouHaDeviceManager:
681
688
  + "_"
682
689
  + device.parent_product_id
683
690
  )
684
- if value_type == "int" and text_value.isdigit():
685
- value = int(text_value)
686
- elif value_type == "str" and not isinstance(value_type, str):
691
+ if value_type == "int":
692
+ try:
693
+ value = int(text_value)
694
+ except (TypeError, ValueError):
695
+ value = text_value
696
+ elif value_type == "str":
687
697
  value = str(text_value)
688
698
  else:
689
699
  value = text_value
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyimouapi
3
- Version: 1.3.0
3
+ Version: 1.3.2
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
@@ -14,8 +14,10 @@ pyimouapi.egg-info/SOURCES.txt
14
14
  pyimouapi.egg-info/dependency_links.txt
15
15
  pyimouapi.egg-info/requires.txt
16
16
  pyimouapi.egg-info/top_level.txt
17
+ tests/test_channel_id_and_api_guards.py
17
18
  tests/test_collect_property_entities.py
18
19
  tests/test_device_summaries.py
20
+ tests/test_event_identifier_map.py
19
21
  tests/test_lookup_property.py
20
22
  tests/test_message_callback.py
21
23
  tests/test_sensor_normalize.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyimouapi"
7
- version = "1.3.0"
7
+ version = "1.3.2"
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.3.0",
7
+ version="1.3.2",
8
8
  packages=find_packages(),
9
9
  python_requires=">=3.11",
10
10
  install_requires=[
@@ -0,0 +1,97 @@
1
+ """Guards for channelId type mismatch and empty API payloads."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock
4
+
5
+ import pytest
6
+ from pyimouapi.const import (
7
+ PARAM_ABILITY_REFS,
8
+ PARAM_CHANNEL_ID,
9
+ PARAM_CHANNELS,
10
+ PARAM_DEVICE_LIST,
11
+ PARAM_ONLINE,
12
+ PARAM_REF,
13
+ PARAM_STATE,
14
+ PARAM_STATUS,
15
+ PARAM_VALUE_TYPE,
16
+ )
17
+ from pyimouapi.device import ImouChannel, ImouDevice, ImouDeviceManager
18
+ from pyimouapi.ha_device import DeviceStatus, ImouHaDevice, ImouHaDeviceManager
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_online_status_matches_int_channel_id():
23
+ """API may return channelId as int while HA device stores str."""
24
+ device = ImouHaDevice("dev1", "Cam", "Imou", "IPC", "1.0")
25
+ device.set_channel_id("0")
26
+ device.sensors[PARAM_STATUS][PARAM_STATE] = DeviceStatus.OFFLINE.value
27
+
28
+ delegate = MagicMock()
29
+ delegate.async_get_device_online_status = AsyncMock(
30
+ return_value={
31
+ PARAM_ONLINE: "1",
32
+ PARAM_CHANNELS: [
33
+ {PARAM_CHANNEL_ID: 0, PARAM_ONLINE: "1"},
34
+ ],
35
+ }
36
+ )
37
+ manager = ImouHaDeviceManager(delegate)
38
+ await manager._async_update_status(device)
39
+
40
+ assert device.sensors[PARAM_STATUS][PARAM_STATE] == DeviceStatus.ONLINE.value
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_ability_refs_match_int_channel_id():
45
+ """Channel abilityRefs lookup must tolerate int/str channelId."""
46
+ channel = ImouChannel("0", "ch0", "1", "ability")
47
+ device = ImouDevice("dev1", "Cam", "1", "Imou", "IPC")
48
+ device.set_channels([channel])
49
+ device.set_product_id("pid1")
50
+
51
+ manager = ImouDeviceManager(MagicMock())
52
+ manager.async_get_iot_device_detail_info = AsyncMock(
53
+ return_value={
54
+ PARAM_ABILITY_REFS: "devAbility",
55
+ PARAM_CHANNELS: [
56
+ {PARAM_CHANNEL_ID: 0, PARAM_ABILITY_REFS: "chAbility"},
57
+ ],
58
+ }
59
+ )
60
+ await manager._async_update_device_ability_refs(device)
61
+
62
+ assert channel.channel_ability_refs == "chAbility"
63
+
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_get_iot_device_properties_empty_list():
67
+ """Empty deviceList must not raise IndexError."""
68
+ api = MagicMock()
69
+ api.async_request_api = AsyncMock(return_value={PARAM_DEVICE_LIST: []})
70
+ manager = ImouDeviceManager(api)
71
+
72
+ result = await manager.async_get_iot_device_properties(
73
+ "dev1", "0", "pid", ["10001"]
74
+ )
75
+ assert result == {}
76
+
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_set_text_value_coerces_str_type():
80
+ """value_type=str must stringify the payload."""
81
+ device = ImouHaDevice("dev1", "Plug", "Imou", "Plug", "1.0")
82
+ device.set_product_id("pid1")
83
+ device.texts["name"] = {
84
+ PARAM_REF: "20001",
85
+ PARAM_VALUE_TYPE: "str",
86
+ PARAM_STATE: "",
87
+ }
88
+
89
+ delegate = MagicMock()
90
+ delegate.async_set_iot_device_properties = AsyncMock()
91
+ manager = ImouHaDeviceManager(delegate)
92
+
93
+ await manager.async_set_text_value(device, "name", "hello")
94
+
95
+ delegate.async_set_iot_device_properties.assert_awaited_with(
96
+ "dev1", None, "pid1", {"20001": "hello"}
97
+ )
@@ -0,0 +1,94 @@
1
+ """Tests for product-model event ref → identifier mapping."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock
4
+
5
+ import pytest
6
+ from pyimouapi.const import API_ENDPOINT_GET_PRODUCT_MODEL, PARAM_PRODUCT_ID
7
+ from pyimouapi.device import ImouDeviceManager, _parse_event_ref_map
8
+ from pyimouapi.exceptions import RequestFailedException
9
+
10
+
11
+ def test_parse_event_ref_map_extracts_events_only():
12
+ data = {
13
+ "events": [
14
+ {"ref": "33000", "identifier": "doorOpen", "name": "Door"},
15
+ {"ref": 123900, "identifier": "electricity"},
16
+ {"identifier": "missingRef"},
17
+ {"ref": "999", "identifier": ""},
18
+ {"ref": "1"},
19
+ ],
20
+ "properties": [{"ref": "1", "identifier": "battery"}],
21
+ }
22
+ assert _parse_event_ref_map(data) == {
23
+ "33000": "doorOpen",
24
+ "123900": "electricity",
25
+ }
26
+
27
+
28
+ def test_parse_event_ref_map_missing_events():
29
+ assert _parse_event_ref_map({}) == {}
30
+ assert _parse_event_ref_map({"events": "bad"}) == {}
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_ensure_event_map_fetches_once_and_caches():
35
+ client = MagicMock()
36
+ client.async_request_api = AsyncMock(
37
+ return_value={
38
+ "events": [{"ref": "33000", "identifier": "doorOpen"}],
39
+ }
40
+ )
41
+ manager = ImouDeviceManager(client)
42
+
43
+ await manager.async_ensure_event_map("pidA")
44
+ await manager.async_ensure_event_map("pidA")
45
+
46
+ assert client.async_request_api.await_count == 1
47
+ client.async_request_api.assert_awaited_with(
48
+ API_ENDPOINT_GET_PRODUCT_MODEL,
49
+ {PARAM_PRODUCT_ID: "pidA"},
50
+ )
51
+ assert await manager.async_resolve_event_identifier("pidA", "33000") == "doorOpen"
52
+ assert await manager.async_resolve_event_identifier("pidA", "missing") is None
53
+
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_ensure_event_map_failure_does_not_poison_cache():
57
+ client = MagicMock()
58
+ client.async_request_api = AsyncMock(
59
+ side_effect=[
60
+ RequestFailedException("boom"),
61
+ {"events": [{"ref": "1", "identifier": "ok"}]},
62
+ ]
63
+ )
64
+ manager = ImouDeviceManager(client)
65
+
66
+ await manager.async_ensure_event_map("pidB")
67
+ assert await manager.async_resolve_event_identifier("pidB", "1") == "ok"
68
+ assert client.async_request_api.await_count == 2
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_ensure_event_map_single_flight():
73
+ import asyncio
74
+
75
+ client = MagicMock()
76
+ started = asyncio.Event()
77
+ release = asyncio.Event()
78
+
79
+ async def slow_request(endpoint, params):
80
+ started.set()
81
+ await release.wait()
82
+ return {"events": [{"ref": "9", "identifier": "once"}]}
83
+
84
+ client.async_request_api = AsyncMock(side_effect=slow_request)
85
+ manager = ImouDeviceManager(client)
86
+
87
+ t1 = asyncio.create_task(manager.async_ensure_event_map("pidC"))
88
+ await started.wait()
89
+ t2 = asyncio.create_task(manager.async_ensure_event_map("pidC"))
90
+ await asyncio.sleep(0)
91
+ release.set()
92
+ await asyncio.gather(t1, t2)
93
+
94
+ assert client.async_request_api.await_count == 1
File without changes
File without changes
File without changes
File without changes