pyimouapi 1.3.3__tar.gz → 1.3.4__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.3 → pyimouapi-1.3.4}/PKG-INFO +1 -1
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi/__init__.py +1 -1
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi/const.py +21 -9
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi/ha_device.py +15 -47
- pyimouapi-1.3.4/pyimouapi/select_option.py +123 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi.egg-info/PKG-INFO +1 -1
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi.egg-info/SOURCES.txt +2 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyproject.toml +1 -1
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/setup.py +1 -1
- pyimouapi-1.3.4/tests/test_select_option.py +111 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_update_from_detail.py +6 -2
- pyimouapi-1.3.4/tests/test_write_optimistic_state.py +127 -0
- pyimouapi-1.3.3/tests/test_write_optimistic_state.py +0 -60
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/LICENSE +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/README.md +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi/collection_point.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi/device.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi/exceptions.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi/openapi.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi/sensor.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi/siren.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi.egg-info/dependency_links.txt +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi.egg-info/requires.txt +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/pyimouapi.egg-info/top_level.txt +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/setup.cfg +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_channel_id_and_api_guards.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_collect_property_entities.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_collection_point.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_device_summaries.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_event_identifier_map.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_lookup_property.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_message_callback.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_sensor_normalize.py +0 -0
- {pyimouapi-1.3.3 → pyimouapi-1.3.4}/tests/test_siren.py +0 -0
|
@@ -113,6 +113,7 @@ PARAM_MOTION_DETECT = "motion_detect"
|
|
|
113
113
|
PARAM_STORAGE_USED = "storage_used"
|
|
114
114
|
PARAM_RESTART_DEVICE = "restart_device"
|
|
115
115
|
PARAM_NIGHT_VISION_MODE = "night_vision_mode"
|
|
116
|
+
PARAM_DEVICE_VOLUME = "device_volume"
|
|
116
117
|
PARAM_COLLECTION_POINT = "collection_point"
|
|
117
118
|
PARAM_COLLECTION_POINT_PROMPT = "select_collection_point"
|
|
118
119
|
PARAM_SIREN_START = "siren_start"
|
|
@@ -408,31 +409,42 @@ SELECT_TYPE_REF = {
|
|
|
408
409
|
"night_vision_mode": [
|
|
409
410
|
{
|
|
410
411
|
"ref": "17400",
|
|
411
|
-
"default": "
|
|
412
|
-
"options": ["
|
|
412
|
+
"default": "intelligent",
|
|
413
|
+
"options": ["intelligent", "fullcolor", "infrared", "off"],
|
|
413
414
|
"value_type": "int",
|
|
414
415
|
},
|
|
415
416
|
{
|
|
416
417
|
"ref": "139700",
|
|
417
|
-
"default": "
|
|
418
|
-
"options": [
|
|
418
|
+
"default": "intelligent",
|
|
419
|
+
"options": [
|
|
420
|
+
"intelligent",
|
|
421
|
+
"fullcolor",
|
|
422
|
+
"infrared",
|
|
423
|
+
"off",
|
|
424
|
+
"custom",
|
|
425
|
+
],
|
|
426
|
+
"value_type": "int",
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"ref": "112400",
|
|
430
|
+
"default": "infrared",
|
|
431
|
+
"options": ["infrared", "off"],
|
|
419
432
|
"value_type": "int",
|
|
420
433
|
},
|
|
421
|
-
{"ref": "112400", "default": "2", "options": ["2", "3"], "value_type": "int"},
|
|
422
434
|
],
|
|
423
435
|
"mode": [
|
|
424
436
|
{
|
|
425
437
|
"ref": "15200",
|
|
426
|
-
"default": "
|
|
427
|
-
"options": ["
|
|
438
|
+
"default": "home",
|
|
439
|
+
"options": ["home", "away", "disarm"],
|
|
428
440
|
"value_type": "int",
|
|
429
441
|
}
|
|
430
442
|
],
|
|
431
443
|
"device_volume": [
|
|
432
444
|
{
|
|
433
445
|
"ref": "15400",
|
|
434
|
-
"default": "
|
|
435
|
-
"options": ["
|
|
446
|
+
"default": "low",
|
|
447
|
+
"options": ["mute", "low", "medium", "high"],
|
|
436
448
|
"value_type": "int",
|
|
437
449
|
}
|
|
438
450
|
],
|
|
@@ -79,6 +79,7 @@ from .const import (
|
|
|
79
79
|
)
|
|
80
80
|
from .device import ImouDevice, ImouDeviceManager
|
|
81
81
|
from .exceptions import RequestFailedException
|
|
82
|
+
from .select_option import normalize_options, to_friendly, to_raw
|
|
82
83
|
from .sensor import apply_sensor_state
|
|
83
84
|
from .siren import build_siren_start_iot_content
|
|
84
85
|
|
|
@@ -358,16 +359,13 @@ class ImouHaDeviceManager:
|
|
|
358
359
|
meta: dict[str, Any],
|
|
359
360
|
raw_value: Any,
|
|
360
361
|
) -> None:
|
|
361
|
-
ref = meta[PARAM_REF]
|
|
362
362
|
if kind == "switch":
|
|
363
363
|
device.switches[key][PARAM_STATE] = raw_value == 1
|
|
364
364
|
elif kind == "binary_sensor":
|
|
365
365
|
device.binary_sensors[key][PARAM_STATE] = raw_value == 1
|
|
366
366
|
elif kind == "select":
|
|
367
367
|
value = str(raw_value) if isinstance(raw_value, int) else raw_value
|
|
368
|
-
device.selects[key][PARAM_CURRENT_OPTION] = value
|
|
369
|
-
if ref == "15400" and value == "-1":
|
|
370
|
-
device.selects[key][PARAM_CURRENT_OPTION] = "99"
|
|
368
|
+
device.selects[key][PARAM_CURRENT_OPTION] = to_friendly(key, value)
|
|
371
369
|
elif kind in ("sensor", "text"):
|
|
372
370
|
if meta.get(PARAM_EXPRESSION) and isinstance(raw_value, dict | list):
|
|
373
371
|
state = self.get_expression_value(meta[PARAM_EXPRESSION], raw_value)
|
|
@@ -819,6 +817,7 @@ class ImouHaDeviceManager:
|
|
|
819
817
|
await self.delegate.async_set_iot_device_properties(
|
|
820
818
|
device.device_id, None, device.product_id, {ref_id: value}
|
|
821
819
|
)
|
|
820
|
+
device.texts[text_type][PARAM_STATE] = str(text_value)
|
|
822
821
|
|
|
823
822
|
async def async_switch_operation(
|
|
824
823
|
self, device: ImouHaDevice, switch_type: str, enable: bool
|
|
@@ -865,9 +864,9 @@ class ImouHaDeviceManager:
|
|
|
865
864
|
if device.selects[select_type].get(PARAM_REF):
|
|
866
865
|
ref_id = device.selects[select_type].get(PARAM_REF)
|
|
867
866
|
value_type = device.selects[select_type].get(PARAM_VALUE_TYPE)
|
|
868
|
-
write_option = option
|
|
867
|
+
write_option = to_raw(select_type, option)
|
|
869
868
|
# 兼容下音量15400值为-1的情况
|
|
870
|
-
if ref_id == "15400" and
|
|
869
|
+
if ref_id == "15400" and write_option == "99":
|
|
871
870
|
write_option = "-1"
|
|
872
871
|
await self._async_select_option_by_ref(
|
|
873
872
|
device, write_option, ref_id, value_type
|
|
@@ -877,8 +876,8 @@ class ImouHaDeviceManager:
|
|
|
877
876
|
await self.delegate.async_set_device_night_vision_mode(
|
|
878
877
|
device.device_id, device.channel_id, option
|
|
879
878
|
)
|
|
880
|
-
device.selects[PARAM_NIGHT_VISION_MODE][PARAM_CURRENT_OPTION] = (
|
|
881
|
-
option
|
|
879
|
+
device.selects[PARAM_NIGHT_VISION_MODE][PARAM_CURRENT_OPTION] = to_friendly(
|
|
880
|
+
PARAM_NIGHT_VISION_MODE, option
|
|
882
881
|
)
|
|
883
882
|
|
|
884
883
|
async def _async_get_device_switch_status_by_ability(
|
|
@@ -922,13 +921,14 @@ class ImouHaDeviceManager:
|
|
|
922
921
|
if PARAM_MODE not in data or PARAM_MODES not in data:
|
|
923
922
|
raise RequestFailedException("get_device_night_vision fail")
|
|
924
923
|
if data[PARAM_MODE] is not None:
|
|
925
|
-
device.selects[PARAM_NIGHT_VISION_MODE][PARAM_CURRENT_OPTION] =
|
|
926
|
-
PARAM_MODE
|
|
927
|
-
|
|
924
|
+
device.selects[PARAM_NIGHT_VISION_MODE][PARAM_CURRENT_OPTION] = to_friendly(
|
|
925
|
+
PARAM_NIGHT_VISION_MODE, data[PARAM_MODE]
|
|
926
|
+
)
|
|
928
927
|
if data[PARAM_MODES] is not None:
|
|
929
|
-
device.selects[PARAM_NIGHT_VISION_MODE][PARAM_OPTIONS] =
|
|
930
|
-
|
|
931
|
-
|
|
928
|
+
device.selects[PARAM_NIGHT_VISION_MODE][PARAM_OPTIONS] = normalize_options(
|
|
929
|
+
PARAM_NIGHT_VISION_MODE,
|
|
930
|
+
[item.lower() for item in data[PARAM_MODES]],
|
|
931
|
+
)
|
|
932
932
|
|
|
933
933
|
@staticmethod
|
|
934
934
|
def configure_device_by_ability(
|
|
@@ -1337,34 +1337,6 @@ class ImouHaDeviceManager:
|
|
|
1337
1337
|
except Exception as e:
|
|
1338
1338
|
_LOGGER.error(f"_async_update_device_switch_status_by_ref fail:{e}")
|
|
1339
1339
|
|
|
1340
|
-
async def _async_update_device_select_status_by_ref(
|
|
1341
|
-
self, device: ImouHaDevice, select_type: str, ref: str
|
|
1342
|
-
):
|
|
1343
|
-
try:
|
|
1344
|
-
device_id = self._resolve_device_id(device)
|
|
1345
|
-
data = await self.delegate.async_get_iot_device_properties(
|
|
1346
|
-
device_id, device.channel_id, device.product_id, [ref]
|
|
1347
|
-
)
|
|
1348
|
-
if ref in data[PARAM_PROPERTIES]:
|
|
1349
|
-
value = (
|
|
1350
|
-
str(data[PARAM_PROPERTIES][ref])
|
|
1351
|
-
if isinstance(data[PARAM_PROPERTIES][ref], int)
|
|
1352
|
-
else data[PARAM_PROPERTIES][ref]
|
|
1353
|
-
)
|
|
1354
|
-
device.selects[select_type][PARAM_CURRENT_OPTION] = value
|
|
1355
|
-
if ref == "15400" and value == "-1":
|
|
1356
|
-
device.selects[select_type][PARAM_CURRENT_OPTION] = "99"
|
|
1357
|
-
else:
|
|
1358
|
-
self._debug_missing_property_ref(
|
|
1359
|
-
device,
|
|
1360
|
-
ref,
|
|
1361
|
-
source="getIotDeviceProperties",
|
|
1362
|
-
kind="select",
|
|
1363
|
-
key=select_type,
|
|
1364
|
-
)
|
|
1365
|
-
except Exception as e:
|
|
1366
|
-
_LOGGER.error(f"Error while updating device select status: {e}")
|
|
1367
|
-
|
|
1368
1340
|
async def _async_update_device_sensor_status_by_ref(
|
|
1369
1341
|
self,
|
|
1370
1342
|
device: ImouHaDevice,
|
|
@@ -1661,11 +1633,7 @@ class ImouHaDeviceManager:
|
|
|
1661
1633
|
await self.delegate.async_iot_device_control(
|
|
1662
1634
|
device_id, device.product_id, "28600", param
|
|
1663
1635
|
)
|
|
1664
|
-
|
|
1665
|
-
await asyncio.sleep(1)
|
|
1666
|
-
await self._async_update_device_text_status_by_ref(
|
|
1667
|
-
device, "count_down_switch", device.texts["count_down_switch"]
|
|
1668
|
-
)
|
|
1636
|
+
device.texts["count_down_switch"][PARAM_STATE] = str(text_value)
|
|
1669
1637
|
|
|
1670
1638
|
|
|
1671
1639
|
class DeviceStatus(Enum):
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Select option key 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_COLLECTION_POINT,
|
|
10
|
+
PARAM_DEVICE_VOLUME,
|
|
11
|
+
PARAM_MODE,
|
|
12
|
+
PARAM_NIGHT_VISION_MODE,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
MAPPED_SELECT_TYPES = frozenset(
|
|
18
|
+
{PARAM_MODE, PARAM_DEVICE_VOLUME, PARAM_NIGHT_VISION_MODE}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# raw string -> friendly
|
|
22
|
+
_VOLUME_RAW_TO_FRIENDLY = {
|
|
23
|
+
"99": "mute",
|
|
24
|
+
"-1": "mute",
|
|
25
|
+
"0": "low",
|
|
26
|
+
"1": "medium",
|
|
27
|
+
"2": "high",
|
|
28
|
+
}
|
|
29
|
+
_VOLUME_FRIENDLY_TO_RAW = {
|
|
30
|
+
"mute": "99",
|
|
31
|
+
"low": "0",
|
|
32
|
+
"medium": "1",
|
|
33
|
+
"high": "2",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_MODE_RAW_TO_FRIENDLY = {"0": "home", "1": "away", "2": "disarm"}
|
|
37
|
+
_MODE_FRIENDLY_TO_RAW = {v: k for k, v in _MODE_RAW_TO_FRIENDLY.items()}
|
|
38
|
+
|
|
39
|
+
_NIGHT_VISION_RAW_TO_FRIENDLY = {
|
|
40
|
+
"0": "intelligent",
|
|
41
|
+
"1": "fullcolor",
|
|
42
|
+
"2": "infrared",
|
|
43
|
+
"3": "off",
|
|
44
|
+
"4": "custom",
|
|
45
|
+
}
|
|
46
|
+
_NIGHT_VISION_FRIENDLY_TO_RAW = {
|
|
47
|
+
"intelligent": "0",
|
|
48
|
+
"fullcolor": "1",
|
|
49
|
+
"infrared": "2",
|
|
50
|
+
"off": "3",
|
|
51
|
+
"custom": "4",
|
|
52
|
+
"lowlight": "lowlight",
|
|
53
|
+
"smartlowlight": "smartlowlight",
|
|
54
|
+
}
|
|
55
|
+
# PaaS already-friendly keys also accepted as raw (identity after lower)
|
|
56
|
+
for _k in (
|
|
57
|
+
"intelligent",
|
|
58
|
+
"fullcolor",
|
|
59
|
+
"infrared",
|
|
60
|
+
"off",
|
|
61
|
+
"custom",
|
|
62
|
+
"lowlight",
|
|
63
|
+
"smartlowlight",
|
|
64
|
+
):
|
|
65
|
+
_NIGHT_VISION_RAW_TO_FRIENDLY.setdefault(_k, _k)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _as_raw_str(raw: Any) -> str:
|
|
69
|
+
if raw is None:
|
|
70
|
+
return ""
|
|
71
|
+
return str(raw).strip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def to_friendly(select_type: str, raw: Any) -> str:
|
|
75
|
+
"""Map vendor/API raw option to a stable friendly key."""
|
|
76
|
+
if select_type == PARAM_COLLECTION_POINT or select_type not in MAPPED_SELECT_TYPES:
|
|
77
|
+
return _as_raw_str(raw)
|
|
78
|
+
|
|
79
|
+
key = _as_raw_str(raw)
|
|
80
|
+
if select_type == PARAM_DEVICE_VOLUME:
|
|
81
|
+
table = _VOLUME_RAW_TO_FRIENDLY
|
|
82
|
+
elif select_type == PARAM_MODE:
|
|
83
|
+
table = _MODE_RAW_TO_FRIENDLY
|
|
84
|
+
else:
|
|
85
|
+
key_l = key.lower()
|
|
86
|
+
mapped = _NIGHT_VISION_RAW_TO_FRIENDLY.get(key_l)
|
|
87
|
+
if mapped is not None:
|
|
88
|
+
return mapped
|
|
89
|
+
_LOGGER.debug("unknown night_vision raw option %r", raw)
|
|
90
|
+
return key_l
|
|
91
|
+
|
|
92
|
+
if key in table:
|
|
93
|
+
return table[key]
|
|
94
|
+
_LOGGER.debug("unknown %s raw option %r", select_type, raw)
|
|
95
|
+
return key
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def to_raw(select_type: str, friendly: str) -> str:
|
|
99
|
+
"""Map friendly key to vendor/API raw string for IoT writes.
|
|
100
|
+
|
|
101
|
+
For PaaS-only night vision keys (`lowlight`, `smartlowlight`), returns the
|
|
102
|
+
same lowercase string (caller uses NIGHT_VISION_MODE_MAP).
|
|
103
|
+
Raises ValueError for unknown keys on mapped select types.
|
|
104
|
+
"""
|
|
105
|
+
if select_type == PARAM_COLLECTION_POINT or select_type not in MAPPED_SELECT_TYPES:
|
|
106
|
+
return friendly
|
|
107
|
+
|
|
108
|
+
key = friendly.strip().lower()
|
|
109
|
+
if select_type == PARAM_DEVICE_VOLUME:
|
|
110
|
+
table = _VOLUME_FRIENDLY_TO_RAW
|
|
111
|
+
elif select_type == PARAM_MODE:
|
|
112
|
+
table = _MODE_FRIENDLY_TO_RAW
|
|
113
|
+
else:
|
|
114
|
+
table = _NIGHT_VISION_FRIENDLY_TO_RAW
|
|
115
|
+
|
|
116
|
+
if key not in table:
|
|
117
|
+
raise ValueError(f"unknown {select_type} option: {friendly!r}")
|
|
118
|
+
return table[key]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalize_options(select_type: str, options: list[str]) -> list[str]:
|
|
122
|
+
"""Return options list with each entry passed through to_friendly."""
|
|
123
|
+
return [to_friendly(select_type, item) for item in options]
|
|
@@ -9,6 +9,7 @@ pyimouapi/device.py
|
|
|
9
9
|
pyimouapi/exceptions.py
|
|
10
10
|
pyimouapi/ha_device.py
|
|
11
11
|
pyimouapi/openapi.py
|
|
12
|
+
pyimouapi/select_option.py
|
|
12
13
|
pyimouapi/sensor.py
|
|
13
14
|
pyimouapi/siren.py
|
|
14
15
|
pyimouapi.egg-info/PKG-INFO
|
|
@@ -23,6 +24,7 @@ tests/test_device_summaries.py
|
|
|
23
24
|
tests/test_event_identifier_map.py
|
|
24
25
|
tests/test_lookup_property.py
|
|
25
26
|
tests/test_message_callback.py
|
|
27
|
+
tests/test_select_option.py
|
|
26
28
|
tests/test_sensor_normalize.py
|
|
27
29
|
tests/test_siren.py
|
|
28
30
|
tests/test_update_from_detail.py
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tests for select option friendly-key normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pyimouapi.const import PARAM_DEVICE_VOLUME, PARAM_MODE, PARAM_NIGHT_VISION_MODE
|
|
7
|
+
from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager
|
|
8
|
+
from pyimouapi.select_option import normalize_options, to_friendly, to_raw
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize(
|
|
12
|
+
("select_type", "raw", "friendly"),
|
|
13
|
+
[
|
|
14
|
+
(PARAM_DEVICE_VOLUME, "99", "mute"),
|
|
15
|
+
(PARAM_DEVICE_VOLUME, "-1", "mute"),
|
|
16
|
+
(PARAM_DEVICE_VOLUME, -1, "mute"),
|
|
17
|
+
(PARAM_DEVICE_VOLUME, "0", "low"),
|
|
18
|
+
(PARAM_DEVICE_VOLUME, "1", "medium"),
|
|
19
|
+
(PARAM_DEVICE_VOLUME, "2", "high"),
|
|
20
|
+
(PARAM_MODE, "0", "home"),
|
|
21
|
+
(PARAM_MODE, "1", "away"),
|
|
22
|
+
(PARAM_MODE, "2", "disarm"),
|
|
23
|
+
(PARAM_NIGHT_VISION_MODE, "0", "intelligent"),
|
|
24
|
+
(PARAM_NIGHT_VISION_MODE, "1", "fullcolor"),
|
|
25
|
+
(PARAM_NIGHT_VISION_MODE, "2", "infrared"),
|
|
26
|
+
(PARAM_NIGHT_VISION_MODE, "3", "off"),
|
|
27
|
+
(PARAM_NIGHT_VISION_MODE, "4", "custom"),
|
|
28
|
+
(PARAM_NIGHT_VISION_MODE, "intelligent", "intelligent"),
|
|
29
|
+
(PARAM_NIGHT_VISION_MODE, "FullColor", "fullcolor"),
|
|
30
|
+
(PARAM_NIGHT_VISION_MODE, "lowlight", "lowlight"),
|
|
31
|
+
(PARAM_NIGHT_VISION_MODE, "smartlowlight", "smartlowlight"),
|
|
32
|
+
],
|
|
33
|
+
)
|
|
34
|
+
def test_to_friendly(select_type: str, raw: str | int, friendly: str) -> None:
|
|
35
|
+
assert to_friendly(select_type, raw) == friendly
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize(
|
|
39
|
+
("select_type", "friendly", "raw"),
|
|
40
|
+
[
|
|
41
|
+
(PARAM_DEVICE_VOLUME, "mute", "99"),
|
|
42
|
+
(PARAM_DEVICE_VOLUME, "low", "0"),
|
|
43
|
+
(PARAM_DEVICE_VOLUME, "medium", "1"),
|
|
44
|
+
(PARAM_DEVICE_VOLUME, "high", "2"),
|
|
45
|
+
(PARAM_MODE, "home", "0"),
|
|
46
|
+
(PARAM_MODE, "away", "1"),
|
|
47
|
+
(PARAM_MODE, "disarm", "2"),
|
|
48
|
+
(PARAM_NIGHT_VISION_MODE, "intelligent", "0"),
|
|
49
|
+
(PARAM_NIGHT_VISION_MODE, "fullcolor", "1"),
|
|
50
|
+
(PARAM_NIGHT_VISION_MODE, "infrared", "2"),
|
|
51
|
+
(PARAM_NIGHT_VISION_MODE, "off", "3"),
|
|
52
|
+
(PARAM_NIGHT_VISION_MODE, "custom", "4"),
|
|
53
|
+
(PARAM_NIGHT_VISION_MODE, "lowlight", "lowlight"),
|
|
54
|
+
(PARAM_NIGHT_VISION_MODE, "smartlowlight", "smartlowlight"),
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
def test_to_raw(select_type: str, friendly: str, raw: str) -> None:
|
|
58
|
+
assert to_raw(select_type, friendly) == raw
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_to_friendly_unknown_raw_passthrough() -> None:
|
|
62
|
+
assert to_friendly(PARAM_MODE, "9") == "9"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_to_raw_unknown_friendly_raises() -> None:
|
|
66
|
+
with pytest.raises(ValueError, match="unknown"):
|
|
67
|
+
to_raw(PARAM_MODE, "vacation")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_normalize_options_maps_list() -> None:
|
|
71
|
+
assert normalize_options(PARAM_MODE, ["0", "1", "2"]) == [
|
|
72
|
+
"home",
|
|
73
|
+
"away",
|
|
74
|
+
"disarm",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_collection_point_identity() -> None:
|
|
79
|
+
from pyimouapi.const import PARAM_COLLECTION_POINT
|
|
80
|
+
|
|
81
|
+
assert to_friendly(PARAM_COLLECTION_POINT, "door") == "door"
|
|
82
|
+
assert to_raw(PARAM_COLLECTION_POINT, "door") == "door"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_configure_select_by_ref_uses_friendly_defaults() -> None:
|
|
86
|
+
device = ImouHaDevice("d1", "cam", "Imou", "IPC", "1.0")
|
|
87
|
+
device.set_channel_id("0")
|
|
88
|
+
device.set_product_id("pid")
|
|
89
|
+
# refs that match SELECT_TYPE_REF entries
|
|
90
|
+
ImouHaDeviceManager.configure_select_by_ref(
|
|
91
|
+
["15200", "15400", "17400"],
|
|
92
|
+
True,
|
|
93
|
+
[],
|
|
94
|
+
device,
|
|
95
|
+
)
|
|
96
|
+
assert device.selects[PARAM_MODE]["options"] == ["home", "away", "disarm"]
|
|
97
|
+
assert device.selects[PARAM_MODE]["current_option"] == "home"
|
|
98
|
+
assert device.selects[PARAM_DEVICE_VOLUME]["options"] == [
|
|
99
|
+
"mute",
|
|
100
|
+
"low",
|
|
101
|
+
"medium",
|
|
102
|
+
"high",
|
|
103
|
+
]
|
|
104
|
+
assert device.selects[PARAM_DEVICE_VOLUME]["current_option"] == "low"
|
|
105
|
+
assert device.selects[PARAM_NIGHT_VISION_MODE]["options"] == [
|
|
106
|
+
"intelligent",
|
|
107
|
+
"fullcolor",
|
|
108
|
+
"infrared",
|
|
109
|
+
"off",
|
|
110
|
+
]
|
|
111
|
+
assert device.selects[PARAM_NIGHT_VISION_MODE]["current_option"] == "intelligent"
|
|
@@ -6,6 +6,7 @@ import pytest
|
|
|
6
6
|
from pyimouapi.const import (
|
|
7
7
|
PARAM_CHANNELS,
|
|
8
8
|
PARAM_CURRENT_OPTION,
|
|
9
|
+
PARAM_DEVICE_VOLUME,
|
|
9
10
|
PARAM_ONLINE,
|
|
10
11
|
PARAM_PROPERTIES,
|
|
11
12
|
PARAM_REF,
|
|
@@ -26,7 +27,10 @@ def _online_device() -> ImouHaDevice:
|
|
|
26
27
|
async def test_update_from_detail_applies_switch_and_select():
|
|
27
28
|
device = _online_device()
|
|
28
29
|
device.switches["relay"] = {PARAM_REF: "10001", PARAM_STATE: False}
|
|
29
|
-
device.selects[
|
|
30
|
+
device.selects[PARAM_DEVICE_VOLUME] = {
|
|
31
|
+
PARAM_REF: "15400",
|
|
32
|
+
PARAM_CURRENT_OPTION: "low",
|
|
33
|
+
}
|
|
30
34
|
|
|
31
35
|
detail = {
|
|
32
36
|
PARAM_PROPERTIES: {"10001": 1, "15400": -1},
|
|
@@ -39,7 +43,7 @@ async def test_update_from_detail_applies_switch_and_select():
|
|
|
39
43
|
await manager._async_update_properties_from_detail(device, detail)
|
|
40
44
|
|
|
41
45
|
assert device.switches["relay"][PARAM_STATE] is True
|
|
42
|
-
assert device.selects[
|
|
46
|
+
assert device.selects[PARAM_DEVICE_VOLUME][PARAM_CURRENT_OPTION] == "mute"
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
@pytest.mark.asyncio
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Tests for optimistic local state after writes (no post-write cloud read)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from pyimouapi.const import (
|
|
9
|
+
PARAM_CURRENT_OPTION,
|
|
10
|
+
PARAM_DEVICE_VOLUME,
|
|
11
|
+
PARAM_MODE,
|
|
12
|
+
PARAM_OPTIONS,
|
|
13
|
+
PARAM_REF,
|
|
14
|
+
PARAM_STATE,
|
|
15
|
+
PARAM_VALUE_TYPE,
|
|
16
|
+
)
|
|
17
|
+
from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ha_device(*, product_id: str | None = "prod1") -> ImouHaDevice:
|
|
21
|
+
device = ImouHaDevice("DEV001", "Camera", "Imou", "IPC", "1.0")
|
|
22
|
+
device.set_channel_id("0")
|
|
23
|
+
if product_id is not None:
|
|
24
|
+
device.set_product_id(product_id)
|
|
25
|
+
return device
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.asyncio
|
|
29
|
+
async def test_switch_operation_by_ref_updates_local_state_without_read() -> None:
|
|
30
|
+
"""IoT switch writes update local state without getIotDeviceProperties."""
|
|
31
|
+
device = _ha_device()
|
|
32
|
+
device.switches["motion_detect"] = {PARAM_REF: "12300", PARAM_STATE: False}
|
|
33
|
+
delegate = MagicMock()
|
|
34
|
+
delegate.async_set_iot_device_properties = AsyncMock()
|
|
35
|
+
delegate.async_get_iot_device_properties = AsyncMock()
|
|
36
|
+
manager = ImouHaDeviceManager(delegate)
|
|
37
|
+
|
|
38
|
+
await manager.async_switch_operation(device, "motion_detect", True)
|
|
39
|
+
|
|
40
|
+
assert device.switches["motion_detect"][PARAM_STATE] is True
|
|
41
|
+
delegate.async_get_iot_device_properties.assert_not_called()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_select_option_by_ref_updates_local_state_without_read() -> None:
|
|
46
|
+
"""IoT select writes update current_option without a coordinator refresh read."""
|
|
47
|
+
device = _ha_device()
|
|
48
|
+
device.selects[PARAM_MODE] = {
|
|
49
|
+
PARAM_REF: "15200",
|
|
50
|
+
PARAM_CURRENT_OPTION: "home",
|
|
51
|
+
PARAM_OPTIONS: ["home", "away", "disarm"],
|
|
52
|
+
PARAM_VALUE_TYPE: "int",
|
|
53
|
+
}
|
|
54
|
+
delegate = MagicMock()
|
|
55
|
+
delegate.async_set_iot_device_properties = AsyncMock()
|
|
56
|
+
delegate.async_get_iot_device_properties = AsyncMock()
|
|
57
|
+
manager = ImouHaDeviceManager(delegate)
|
|
58
|
+
|
|
59
|
+
await manager.async_select_option(device, PARAM_MODE, "away")
|
|
60
|
+
|
|
61
|
+
assert device.selects[PARAM_MODE][PARAM_CURRENT_OPTION] == "away"
|
|
62
|
+
props = delegate.async_set_iot_device_properties.await_args.args[3]
|
|
63
|
+
assert props == {"15200": 1}
|
|
64
|
+
delegate.async_get_iot_device_properties.assert_not_called()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_select_volume_mute_writes_minus_one() -> None:
|
|
69
|
+
device = _ha_device()
|
|
70
|
+
device.selects[PARAM_DEVICE_VOLUME] = {
|
|
71
|
+
PARAM_REF: "15400",
|
|
72
|
+
PARAM_CURRENT_OPTION: "low",
|
|
73
|
+
PARAM_OPTIONS: ["mute", "low", "medium", "high"],
|
|
74
|
+
PARAM_VALUE_TYPE: "int",
|
|
75
|
+
}
|
|
76
|
+
delegate = MagicMock()
|
|
77
|
+
delegate.async_set_iot_device_properties = AsyncMock()
|
|
78
|
+
manager = ImouHaDeviceManager(delegate)
|
|
79
|
+
|
|
80
|
+
await manager.async_select_option(device, PARAM_DEVICE_VOLUME, "mute")
|
|
81
|
+
|
|
82
|
+
assert device.selects[PARAM_DEVICE_VOLUME][PARAM_CURRENT_OPTION] == "mute"
|
|
83
|
+
props = delegate.async_set_iot_device_properties.await_args.args[3]
|
|
84
|
+
assert props == {"15400": -1}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_set_text_value_by_ref_updates_local_state_without_read() -> None:
|
|
89
|
+
"""IoT text writes update local state without getIotDeviceProperties."""
|
|
90
|
+
device = _ha_device()
|
|
91
|
+
device.texts["overcharge_switch"] = {
|
|
92
|
+
PARAM_REF: "128900",
|
|
93
|
+
PARAM_STATE: "5",
|
|
94
|
+
PARAM_VALUE_TYPE: "int",
|
|
95
|
+
}
|
|
96
|
+
delegate = MagicMock()
|
|
97
|
+
delegate.async_set_iot_device_properties = AsyncMock()
|
|
98
|
+
delegate.async_get_iot_device_properties = AsyncMock()
|
|
99
|
+
manager = ImouHaDeviceManager(delegate)
|
|
100
|
+
|
|
101
|
+
await manager.async_set_text_value(device, "overcharge_switch", "100")
|
|
102
|
+
|
|
103
|
+
assert device.texts["overcharge_switch"][PARAM_STATE] == "100"
|
|
104
|
+
delegate.async_get_iot_device_properties.assert_not_called()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.asyncio
|
|
108
|
+
async def test_set_count_down_text_updates_local_state_without_read() -> None:
|
|
109
|
+
"""Countdown text writes update local state without sleep/re-query."""
|
|
110
|
+
device = _ha_device()
|
|
111
|
+
device.switches["switch"] = {PARAM_REF: "10000", PARAM_STATE: False}
|
|
112
|
+
device.texts["count_down_switch"] = {
|
|
113
|
+
PARAM_REF: "28800",
|
|
114
|
+
PARAM_STATE: "0",
|
|
115
|
+
}
|
|
116
|
+
delegate = MagicMock()
|
|
117
|
+
delegate.async_iot_device_control = AsyncMock()
|
|
118
|
+
delegate.async_get_iot_device_properties = AsyncMock()
|
|
119
|
+
manager = ImouHaDeviceManager(delegate)
|
|
120
|
+
manager._async_update_device_switch_status_by_ref = AsyncMock()
|
|
121
|
+
|
|
122
|
+
await manager.async_set_text_value(device, "count_down_switch", "10")
|
|
123
|
+
|
|
124
|
+
assert device.texts["count_down_switch"][PARAM_STATE] == "10"
|
|
125
|
+
manager._async_update_device_switch_status_by_ref.assert_awaited_once()
|
|
126
|
+
delegate.async_iot_device_control.assert_awaited_once()
|
|
127
|
+
delegate.async_get_iot_device_properties.assert_not_called()
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
"""Tests for optimistic local state after writes (no post-write cloud read)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
from pyimouapi.const import (
|
|
9
|
-
PARAM_CURRENT_OPTION,
|
|
10
|
-
PARAM_MODE,
|
|
11
|
-
PARAM_OPTIONS,
|
|
12
|
-
PARAM_REF,
|
|
13
|
-
PARAM_STATE,
|
|
14
|
-
PARAM_VALUE_TYPE,
|
|
15
|
-
)
|
|
16
|
-
from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _ha_device(*, product_id: str | None = "prod1") -> ImouHaDevice:
|
|
20
|
-
device = ImouHaDevice("DEV001", "Camera", "Imou", "IPC", "1.0")
|
|
21
|
-
device.set_channel_id("0")
|
|
22
|
-
if product_id is not None:
|
|
23
|
-
device.set_product_id(product_id)
|
|
24
|
-
return device
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@pytest.mark.asyncio
|
|
28
|
-
async def test_switch_operation_by_ref_updates_local_state_without_read() -> None:
|
|
29
|
-
"""IoT switch writes update local state without getIotDeviceProperties."""
|
|
30
|
-
device = _ha_device()
|
|
31
|
-
device.switches["motion_detect"] = {PARAM_REF: "12300", PARAM_STATE: False}
|
|
32
|
-
delegate = MagicMock()
|
|
33
|
-
delegate.async_set_iot_device_properties = AsyncMock()
|
|
34
|
-
delegate.async_get_iot_device_properties = AsyncMock()
|
|
35
|
-
manager = ImouHaDeviceManager(delegate)
|
|
36
|
-
|
|
37
|
-
await manager.async_switch_operation(device, "motion_detect", True)
|
|
38
|
-
|
|
39
|
-
assert device.switches["motion_detect"][PARAM_STATE] is True
|
|
40
|
-
delegate.async_get_iot_device_properties.assert_not_called()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@pytest.mark.asyncio
|
|
44
|
-
async def test_select_option_by_ref_updates_local_state_without_read() -> None:
|
|
45
|
-
"""IoT select writes update current_option without a coordinator refresh read."""
|
|
46
|
-
device = _ha_device()
|
|
47
|
-
device.selects[PARAM_MODE] = {
|
|
48
|
-
PARAM_REF: "15200",
|
|
49
|
-
PARAM_CURRENT_OPTION: "0",
|
|
50
|
-
PARAM_OPTIONS: ["0", "1", "2"],
|
|
51
|
-
PARAM_VALUE_TYPE: "int",
|
|
52
|
-
}
|
|
53
|
-
delegate = MagicMock()
|
|
54
|
-
delegate.async_set_iot_device_properties = AsyncMock()
|
|
55
|
-
manager = ImouHaDeviceManager(delegate)
|
|
56
|
-
|
|
57
|
-
await manager.async_select_option(device, PARAM_MODE, "1")
|
|
58
|
-
|
|
59
|
-
assert device.selects[PARAM_MODE][PARAM_CURRENT_OPTION] == "1"
|
|
60
|
-
delegate.async_get_iot_device_properties.assert_not_called()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|