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.
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/PKG-INFO +1 -1
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/__init__.py +1 -1
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/const.py +4 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/device.py +41 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/ha_device.py +38 -35
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/openapi.py +28 -1
- pyimouapi-1.3.0/pyimouapi/sensor.py +95 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/PKG-INFO +1 -1
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/SOURCES.txt +4 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyproject.toml +1 -1
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/setup.py +1 -1
- pyimouapi-1.3.0/tests/test_device_summaries.py +89 -0
- pyimouapi-1.3.0/tests/test_message_callback.py +50 -0
- pyimouapi-1.3.0/tests/test_sensor_normalize.py +78 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/LICENSE +0 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/README.md +0 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi/exceptions.py +0 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/dependency_links.txt +0 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/requires.txt +0 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/pyimouapi.egg-info/top_level.txt +0 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/setup.cfg +0 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/tests/test_collect_property_entities.py +0 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/tests/test_lookup_property.py +0 -0
- {pyimouapi-1.2.8 → pyimouapi-1.3.0}/tests/test_update_from_detail.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
|
503
|
+
apply_sensor_state(device.sensors, PARAM_STORAGE_USED, percentage_used)
|
|
496
504
|
else:
|
|
497
|
-
device.sensors
|
|
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
|
|
509
|
+
apply_sensor_state(device.sensors, PARAM_STORAGE_USED, "e1")
|
|
502
510
|
else:
|
|
503
|
-
device.sensors
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
|
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
|
-
|
|
1394
|
-
electricity[PARAM_LITELEC]
|
|
1395
|
-
)
|
|
1403
|
+
battery_level = electricity[PARAM_LITELEC]
|
|
1396
1404
|
elif PARAM_ALKELEC in electricity:
|
|
1397
|
-
|
|
1398
|
-
electricity[PARAM_ALKELEC]
|
|
1399
|
-
)
|
|
1405
|
+
battery_level = electricity[PARAM_ALKELEC]
|
|
1400
1406
|
elif PARAM_ELECTRIC in electricity:
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|