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.
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/PKG-INFO +1 -1
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/__init__.py +1 -1
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/const.py +2 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/device.py +86 -8
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/ha_device.py +14 -4
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/PKG-INFO +1 -1
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/SOURCES.txt +2 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyproject.toml +1 -1
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/setup.py +1 -1
- pyimouapi-1.3.2/tests/test_channel_id_and_api_guards.py +97 -0
- pyimouapi-1.3.2/tests/test_event_identifier_map.py +94 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/LICENSE +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/README.md +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/exceptions.py +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/openapi.py +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi/sensor.py +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/dependency_links.txt +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/requires.txt +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/pyimouapi.egg-info/top_level.txt +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/setup.cfg +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_collect_property_entities.py +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_device_summaries.py +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_lookup_property.py +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_message_callback.py +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_sensor_normalize.py +0 -0
- {pyimouapi-1.3.0 → pyimouapi-1.3.2}/tests/test_update_from_detail.py +0 -0
|
@@ -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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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 =
|
|
596
|
-
|
|
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] ==
|
|
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"
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
@@ -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
|
|
@@ -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
|
|
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
|