python-aidot 0.3.57__tar.gz → 0.3.59__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.
- {python_aidot-0.3.57/python_aidot.egg-info → python_aidot-0.3.59}/PKG-INFO +1 -1
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/client.py +96 -4
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/const.py +7 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/device_client.py +19 -2
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/models/device_model.py +1 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59/python_aidot.egg-info}/PKG-INFO +1 -1
- {python_aidot-0.3.57 → python_aidot-0.3.59}/python_aidot.egg-info/SOURCES.txt +2 -1
- {python_aidot-0.3.57 → python_aidot-0.3.59}/setup.py +1 -1
- python_aidot-0.3.59/tests/test_client.py +384 -0
- python_aidot-0.3.59/tests/test_device_client.py +65 -0
- python_aidot-0.3.57/tests/test_client.py +0 -147
- {python_aidot-0.3.57 → python_aidot-0.3.59}/LICENSE +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/README.md +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/__init__.py +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/aes_utils.py +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/discover.py +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/exceptions.py +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/login_const.py +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/models/__init__.py +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/models/device_client_model.py +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/models/discover_model.py +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/python_aidot.egg-info/dependency_links.txt +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/python_aidot.egg-info/requires.txt +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/python_aidot.egg-info/top_level.txt +0 -0
- {python_aidot-0.3.57 → python_aidot-0.3.59}/setup.cfg +0 -0
|
@@ -24,6 +24,11 @@ from .const import (
|
|
|
24
24
|
CONF_CODE,
|
|
25
25
|
CONF_COUNTRY,
|
|
26
26
|
CONF_DEVICE_LIST,
|
|
27
|
+
CONF_DIYS,
|
|
28
|
+
CONF_EFFECTS,
|
|
29
|
+
CONF_EFFECT_SELECTION,
|
|
30
|
+
CONF_EFFECT_SOURCE,
|
|
31
|
+
CONF_FAV_PRESETS,
|
|
27
32
|
CONF_ID,
|
|
28
33
|
CONF_IPADDRESS,
|
|
29
34
|
CONF_PASSWORD,
|
|
@@ -48,6 +53,9 @@ from .const import (
|
|
|
48
53
|
CONF_AES_KEY,
|
|
49
54
|
CONF_FIRMWARE_VERSION,
|
|
50
55
|
CONF_PRIMITIVE,
|
|
56
|
+
DEFAULT_EFFECT_SOURCE,
|
|
57
|
+
EFFECT_SOURCE_MANUAL,
|
|
58
|
+
EFFECT_SOURCE_RECOMMENDED,
|
|
51
59
|
)
|
|
52
60
|
|
|
53
61
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -76,9 +84,13 @@ class AidotClient:
|
|
|
76
84
|
username: str | None = None,
|
|
77
85
|
password: str | None = None,
|
|
78
86
|
token: dict | None = None,
|
|
87
|
+
options: dict[str, Any] | None = None,
|
|
88
|
+
effect_source: str = DEFAULT_EFFECT_SOURCE,
|
|
79
89
|
) -> None:
|
|
80
|
-
_LOGGER.info("Client Version: v0.3.
|
|
90
|
+
_LOGGER.info("Client Version: v0.3.59")
|
|
81
91
|
self.session = session
|
|
92
|
+
self.options = options.copy() if options is not None else {}
|
|
93
|
+
self.effect_source = self.options.get(CONF_EFFECT_SOURCE, effect_source)
|
|
82
94
|
self.username = username
|
|
83
95
|
self.password = password
|
|
84
96
|
self.country_code = country_code or DEFAULT_COUNTRY_CODE
|
|
@@ -394,7 +406,7 @@ class AidotClient:
|
|
|
394
406
|
async def async_get_all_effects(
|
|
395
407
|
self, device: dict[str, Any]
|
|
396
408
|
) -> dict[str, FavoriteEffectPrimitive]:
|
|
397
|
-
"""Get all effects list."""
|
|
409
|
+
"""Get all effects list and store raw effect sources on the device."""
|
|
398
410
|
diy_list: list[FavoriteEffectPrimitive] = []
|
|
399
411
|
fav_preset: list[FavoriteEffectPrimitive] = []
|
|
400
412
|
preset_list: list[FavoriteEffectPrimitive] = []
|
|
@@ -430,7 +442,87 @@ class AidotClient:
|
|
|
430
442
|
preset_list = [
|
|
431
443
|
item for item in preset_list if item.primitiveEffectId not in fav_preset_ids
|
|
432
444
|
]
|
|
433
|
-
|
|
445
|
+
|
|
446
|
+
device[CONF_DIYS] = diy_list
|
|
447
|
+
device[CONF_FAV_PRESETS] = fav_preset
|
|
448
|
+
device[CONF_PRESETS] = preset_list
|
|
449
|
+
return self.get_filtered_effects(device)
|
|
450
|
+
|
|
451
|
+
def get_filtered_effects(
|
|
452
|
+
self, device: dict[str, Any]
|
|
453
|
+
) -> dict[str, FavoriteEffectPrimitive]:
|
|
454
|
+
"""Get final effects for Home Assistant from raw device effect sources."""
|
|
455
|
+
diy_list = device.get(CONF_DIYS, [])
|
|
456
|
+
fav_preset = device.get(CONF_FAV_PRESETS, [])
|
|
457
|
+
preset_list = device.get(CONF_PRESETS, [])
|
|
458
|
+
|
|
459
|
+
if self.effect_source == EFFECT_SOURCE_MANUAL:
|
|
460
|
+
return self._merge_effects_by_unique_name(
|
|
461
|
+
self._filter_manual_effects(device, diy_list),
|
|
462
|
+
self._filter_manual_effects(device, fav_preset),
|
|
463
|
+
self._filter_manual_effects(device, preset_list),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
return self._merge_effects_by_unique_name(
|
|
467
|
+
diy_list,
|
|
468
|
+
fav_preset,
|
|
469
|
+
self._filter_preset_list(preset_list),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def get_all_cached_effects(
|
|
473
|
+
self, device: dict[str, Any]
|
|
474
|
+
) -> dict[str, FavoriteEffectPrimitive]:
|
|
475
|
+
"""Get all effects from cached raw device effect sources."""
|
|
476
|
+
return self._merge_effects_by_unique_name(
|
|
477
|
+
device.get(CONF_DIYS, []),
|
|
478
|
+
device.get(CONF_FAV_PRESETS, []),
|
|
479
|
+
device.get(CONF_PRESETS, []),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def _filter_manual_effects(
|
|
483
|
+
self, device: dict[str, Any], effect_list: list[FavoriteEffectPrimitive]
|
|
484
|
+
) -> list[FavoriteEffectPrimitive]:
|
|
485
|
+
"""Filter effect list by manual device selection."""
|
|
486
|
+
selected_ids = self._get_manual_effect_ids(device)
|
|
487
|
+
return [
|
|
488
|
+
effect
|
|
489
|
+
for effect in effect_list
|
|
490
|
+
if self._get_effect_ids(effect) & selected_ids
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
def _get_manual_effect_ids(self, device: dict[str, Any]) -> set[str]:
|
|
494
|
+
"""Get manually selected effect ids for a device."""
|
|
495
|
+
effect_selection = self.options.get(CONF_EFFECT_SELECTION, {})
|
|
496
|
+
selected_ids = effect_selection.get(device.get(CONF_ID), [])
|
|
497
|
+
return {effect_id for effect_id in selected_ids if effect_id}
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def _get_effect_ids(effect: FavoriteEffectPrimitive) -> set[str]:
|
|
501
|
+
"""Get selectable ids for an effect."""
|
|
502
|
+
return {
|
|
503
|
+
effect_id
|
|
504
|
+
for effect_id in (effect.primitiveEffectId, effect.favoriteId)
|
|
505
|
+
if effect_id
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
def _filter_preset_list(
|
|
509
|
+
self, preset_list: list[FavoriteEffectPrimitive]
|
|
510
|
+
) -> list[FavoriteEffectPrimitive]:
|
|
511
|
+
"""Filter preset effects by configured source."""
|
|
512
|
+
if self.effect_source != EFFECT_SOURCE_RECOMMENDED:
|
|
513
|
+
return preset_list
|
|
514
|
+
|
|
515
|
+
recommended = [
|
|
516
|
+
effect for effect in preset_list if self._is_recommended_effect(effect)
|
|
517
|
+
]
|
|
518
|
+
if recommended:
|
|
519
|
+
return recommended
|
|
520
|
+
return preset_list[:10]
|
|
521
|
+
|
|
522
|
+
@staticmethod
|
|
523
|
+
def _is_recommended_effect(effect: FavoriteEffectPrimitive) -> bool:
|
|
524
|
+
"""Return whether an effect should be included in recommended presets."""
|
|
525
|
+
return effect.tag not in (None, "") and effect.tag.strip() != "New"
|
|
434
526
|
|
|
435
527
|
@staticmethod
|
|
436
528
|
def _merge_effects_by_unique_name(
|
|
@@ -485,7 +577,7 @@ class AidotClient:
|
|
|
485
577
|
and CONF_AES_KEY in device
|
|
486
578
|
and device[CONF_AES_KEY][0] is not None
|
|
487
579
|
):
|
|
488
|
-
device[
|
|
580
|
+
device[CONF_EFFECTS] = await self.async_get_all_effects(device)
|
|
489
581
|
|
|
490
582
|
except Exception as e:
|
|
491
583
|
raise e
|
|
@@ -215,6 +215,13 @@ CONF_SET_DEV_ATTR_REQ = "setDevAttrReq"
|
|
|
215
215
|
CONF_DIYS = "diys"
|
|
216
216
|
CONF_PRESETS = "presets"
|
|
217
217
|
CONF_FAV_PRESETS = "favoritePresets"
|
|
218
|
+
CONF_EFFECTS = "effects"
|
|
219
|
+
CONF_EFFECT_SOURCE = "effect_source"
|
|
220
|
+
CONF_EFFECT_SELECTION = "effect_selection"
|
|
221
|
+
EFFECT_SOURCE_ALL = "all"
|
|
222
|
+
EFFECT_SOURCE_RECOMMENDED = "recommended"
|
|
223
|
+
EFFECT_SOURCE_MANUAL = "manual"
|
|
224
|
+
DEFAULT_EFFECT_SOURCE = EFFECT_SOURCE_ALL
|
|
218
225
|
CONF_VERSION = "version"
|
|
219
226
|
CONF_LIGHT_SCRIPT_FLAGS = "lightScriptFlags"
|
|
220
227
|
CONF_PRIMITIVE = "primitive"
|
|
@@ -7,6 +7,7 @@ import time
|
|
|
7
7
|
import json
|
|
8
8
|
import asyncio
|
|
9
9
|
import logging
|
|
10
|
+
from ipaddress import ip_address
|
|
10
11
|
from typing import TYPE_CHECKING, Any
|
|
11
12
|
|
|
12
13
|
from .aes_utils import aes_encrypt, aes_decrypt_to_json
|
|
@@ -21,6 +22,7 @@ from .models.device_client_model import (
|
|
|
21
22
|
from .const import (
|
|
22
23
|
CONF_AES_KEY,
|
|
23
24
|
CONF_CCT,
|
|
25
|
+
CONF_EFFECTS,
|
|
24
26
|
CONF_HARDWARE_VERSION,
|
|
25
27
|
CONF_ID,
|
|
26
28
|
CONF_IDENTITY,
|
|
@@ -106,7 +108,7 @@ class DeviceInformation:
|
|
|
106
108
|
self.model_id = device.get(CONF_MODEL_ID)
|
|
107
109
|
self.name = device.get(CONF_NAME)
|
|
108
110
|
self.hw_version = device.get(CONF_HARDWARE_VERSION)
|
|
109
|
-
self.presets = device.get(CONF_PRESETS, {})
|
|
111
|
+
self.presets = device.get(CONF_EFFECTS, device.get(CONF_PRESETS, {}))
|
|
110
112
|
self.preset_names = list(self.presets)
|
|
111
113
|
if CONF_PRODUCT in device and CONF_SERVICE_MODULES in device[CONF_PRODUCT]:
|
|
112
114
|
for service in device[CONF_PRODUCT][CONF_SERVICE_MODULES]:
|
|
@@ -174,13 +176,28 @@ class DeviceClient(object):
|
|
|
174
176
|
self._simpleVersion = device.get("simpleVersion")
|
|
175
177
|
self._TAG = f"{self.device_id}"
|
|
176
178
|
self.syncProperties = []
|
|
177
|
-
if self.info.model_id == "lk.WIFI-RGBWLight-D0006":
|
|
179
|
+
if self.info.model_id == "lk.WIFI-RGBWLight-D0006": # 一代灯
|
|
178
180
|
self.syncProperties = [CONF_ON_OFF, CONF_DIMMING, CONF_RGBW, CONF_CCT]
|
|
179
181
|
self.ping_data = None
|
|
180
182
|
self.heart_time = 10
|
|
183
|
+
else:
|
|
184
|
+
properties = device.get(CONF_PROPERTIES)
|
|
185
|
+
if properties is not None:
|
|
186
|
+
ip_address = self._get_valid_ip(properties.get("ipAddress"))
|
|
187
|
+
self.update_ip_address(ip_address)
|
|
181
188
|
|
|
182
189
|
# _LOGGER.warning(f"{self._TAG}:{device}")
|
|
183
190
|
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _get_valid_ip(ip: str | None) -> str | None:
|
|
193
|
+
"""Return a valid IP address string."""
|
|
194
|
+
if not ip:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
return str(ip_address(ip))
|
|
198
|
+
except ValueError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
184
201
|
async def connect(self, ip_address) -> None:
|
|
185
202
|
_LOGGER.warning(f"{self._TAG}:connect device: {ip_address}")
|
|
186
203
|
self.reader = self.writer = None
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Tests for the AiDot client."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from typing import Any
|
|
5
|
+
from unittest.mock import AsyncMock
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
|
|
9
|
+
from aidot.client import AidotClient
|
|
10
|
+
from aidot.const import (
|
|
11
|
+
CONF_ACCESS_TOKEN,
|
|
12
|
+
CONF_COUNTRY,
|
|
13
|
+
CONF_DIYS,
|
|
14
|
+
CONF_EFFECTS,
|
|
15
|
+
CONF_EFFECT_SELECTION,
|
|
16
|
+
CONF_EFFECT_SOURCE,
|
|
17
|
+
CONF_FAV_PRESETS,
|
|
18
|
+
CONF_ID,
|
|
19
|
+
CONF_PASSWORD,
|
|
20
|
+
CONF_PRESETS,
|
|
21
|
+
CONF_REFRESH_TOKEN,
|
|
22
|
+
CONF_REGION,
|
|
23
|
+
CONF_USERNAME,
|
|
24
|
+
EFFECT_SOURCE_MANUAL,
|
|
25
|
+
EFFECT_SOURCE_RECOMMENDED,
|
|
26
|
+
)
|
|
27
|
+
from aidot.models.device_model import FavoriteEffectPrimitive
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeResponse:
|
|
31
|
+
"""Minimal aiohttp response test double."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, data: dict[str, Any]) -> None:
|
|
34
|
+
self._data = data
|
|
35
|
+
|
|
36
|
+
async def json(self) -> dict[str, Any]:
|
|
37
|
+
"""Return the response payload."""
|
|
38
|
+
return self._data
|
|
39
|
+
|
|
40
|
+
def raise_for_status(self) -> None:
|
|
41
|
+
"""Represent a successful response."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FakeSession:
|
|
45
|
+
"""Record requests made by one client."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, response: dict[str, Any]) -> None:
|
|
48
|
+
self._response = response
|
|
49
|
+
self.post_calls: list[tuple[str, dict[str, str], dict[str, Any]]] = []
|
|
50
|
+
|
|
51
|
+
async def post(
|
|
52
|
+
self,
|
|
53
|
+
url: str,
|
|
54
|
+
*,
|
|
55
|
+
headers: dict[str, str],
|
|
56
|
+
json: dict[str, Any],
|
|
57
|
+
) -> FakeResponse:
|
|
58
|
+
"""Record a POST request."""
|
|
59
|
+
self.post_calls.append((url, headers, json))
|
|
60
|
+
return FakeResponse(self._response)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FailingSession:
|
|
64
|
+
"""Raise a typed connection error for every request."""
|
|
65
|
+
|
|
66
|
+
async def post(
|
|
67
|
+
self,
|
|
68
|
+
url: str,
|
|
69
|
+
*,
|
|
70
|
+
headers: dict[str, str],
|
|
71
|
+
json: dict[str, Any],
|
|
72
|
+
) -> FakeResponse:
|
|
73
|
+
"""Simulate a network failure."""
|
|
74
|
+
raise aiohttp.ClientConnectionError
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_token(
|
|
78
|
+
*, username: str, region: str, access_token: str, refresh_token: str
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
"""Create stored login data for a client."""
|
|
81
|
+
return {
|
|
82
|
+
CONF_ID: None,
|
|
83
|
+
CONF_USERNAME: username,
|
|
84
|
+
CONF_PASSWORD: "password",
|
|
85
|
+
CONF_COUNTRY: "United States",
|
|
86
|
+
CONF_REGION: region,
|
|
87
|
+
CONF_ACCESS_TOKEN: access_token,
|
|
88
|
+
CONF_REFRESH_TOKEN: refresh_token,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AidotClientTest(unittest.IsolatedAsyncioTestCase):
|
|
93
|
+
"""Verify client state is isolated by account."""
|
|
94
|
+
|
|
95
|
+
async def test_multiple_accounts_keep_state_isolated(self) -> None:
|
|
96
|
+
"""Refreshing one account does not update another account."""
|
|
97
|
+
first_session = FakeSession(
|
|
98
|
+
{
|
|
99
|
+
CONF_ACCESS_TOKEN: "first-new-access",
|
|
100
|
+
CONF_REFRESH_TOKEN: "first-new-refresh",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
second_session = FakeSession(
|
|
104
|
+
{
|
|
105
|
+
CONF_ACCESS_TOKEN: "second-new-access",
|
|
106
|
+
CONF_REFRESH_TOKEN: "second-new-refresh",
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
first_client = AidotClient(
|
|
110
|
+
session=first_session,
|
|
111
|
+
token=create_token(
|
|
112
|
+
username="first@example.com",
|
|
113
|
+
region="us",
|
|
114
|
+
access_token="first-access",
|
|
115
|
+
refresh_token="first-refresh",
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
second_client = AidotClient(
|
|
119
|
+
session=second_session,
|
|
120
|
+
token=create_token(
|
|
121
|
+
username="second@example.com",
|
|
122
|
+
region="eu",
|
|
123
|
+
access_token="second-access",
|
|
124
|
+
refresh_token="second-refresh",
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
refreshed: list[str] = []
|
|
128
|
+
first_client.set_token_fresh_cb(lambda: refreshed.append("first"))
|
|
129
|
+
second_client.set_token_fresh_cb(lambda: refreshed.append("second"))
|
|
130
|
+
|
|
131
|
+
await first_client.async_refresh_token()
|
|
132
|
+
|
|
133
|
+
self.assertIsNot(first_client.login_info, second_client.login_info)
|
|
134
|
+
self.assertEqual(first_client.login_info[CONF_ACCESS_TOKEN], "first-new-access")
|
|
135
|
+
self.assertEqual(
|
|
136
|
+
first_client.login_info[CONF_REFRESH_TOKEN], "first-new-refresh"
|
|
137
|
+
)
|
|
138
|
+
self.assertEqual(second_client.login_info[CONF_ACCESS_TOKEN], "second-access")
|
|
139
|
+
self.assertEqual(second_client.login_info[CONF_REFRESH_TOKEN], "second-refresh")
|
|
140
|
+
self.assertEqual(second_session.post_calls, [])
|
|
141
|
+
self.assertEqual(refreshed, ["first"])
|
|
142
|
+
|
|
143
|
+
async def test_login_preserves_connection_error(self) -> None:
|
|
144
|
+
"""Login keeps aiohttp connection errors typed for callers."""
|
|
145
|
+
client = AidotClient(
|
|
146
|
+
session=FailingSession(),
|
|
147
|
+
country_code="US",
|
|
148
|
+
username="user@example.com",
|
|
149
|
+
password="password",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
with self.assertRaises(aiohttp.ClientConnectionError):
|
|
153
|
+
await client.async_post_login()
|
|
154
|
+
|
|
155
|
+
def test_recommended_effect_source_keeps_tagged_presets(self) -> None:
|
|
156
|
+
"""Recommended effect source keeps tagged presets except New."""
|
|
157
|
+
client = AidotClient(session=None, effect_source=EFFECT_SOURCE_RECOMMENDED)
|
|
158
|
+
presets = [
|
|
159
|
+
FavoriteEffectPrimitive(name="Calm", primitiveEffectId="p_1"),
|
|
160
|
+
FavoriteEffectPrimitive(
|
|
161
|
+
name="Spark",
|
|
162
|
+
primitiveEffectId="p_3",
|
|
163
|
+
tag=" New ",
|
|
164
|
+
),
|
|
165
|
+
FavoriteEffectPrimitive(
|
|
166
|
+
name="Wave",
|
|
167
|
+
primitiveEffectId="p_4",
|
|
168
|
+
tag="Hot",
|
|
169
|
+
),
|
|
170
|
+
FavoriteEffectPrimitive(
|
|
171
|
+
name="Blank tag",
|
|
172
|
+
primitiveEffectId="p_5",
|
|
173
|
+
tag="",
|
|
174
|
+
),
|
|
175
|
+
FavoriteEffectPrimitive(
|
|
176
|
+
name="Space tag",
|
|
177
|
+
primitiveEffectId="p_6",
|
|
178
|
+
tag=" ",
|
|
179
|
+
),
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
filtered = client._filter_preset_list(presets)
|
|
183
|
+
|
|
184
|
+
self.assertEqual(
|
|
185
|
+
[effect.name for effect in filtered],
|
|
186
|
+
["Wave", "Space tag"],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def test_options_effect_source_takes_precedence(self) -> None:
|
|
190
|
+
"""Options effect source takes precedence over legacy argument."""
|
|
191
|
+
client = AidotClient(
|
|
192
|
+
session=None,
|
|
193
|
+
options={CONF_EFFECT_SOURCE: EFFECT_SOURCE_RECOMMENDED},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
self.assertEqual(client.effect_source, EFFECT_SOURCE_RECOMMENDED)
|
|
197
|
+
|
|
198
|
+
def test_recommended_effect_source_falls_back_to_top_ten(self) -> None:
|
|
199
|
+
"""Recommended effect source falls back to the first ten presets."""
|
|
200
|
+
client = AidotClient(session=None, effect_source=EFFECT_SOURCE_RECOMMENDED)
|
|
201
|
+
presets = [
|
|
202
|
+
FavoriteEffectPrimitive(
|
|
203
|
+
name=f"Effect {index}", primitiveEffectId=f"p_{index}"
|
|
204
|
+
)
|
|
205
|
+
for index in range(12)
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
filtered = client._filter_preset_list(presets)
|
|
209
|
+
|
|
210
|
+
self.assertEqual(len(filtered), 10)
|
|
211
|
+
self.assertEqual(filtered[-1].name, "Effect 9")
|
|
212
|
+
|
|
213
|
+
def test_all_effect_source_keeps_all_presets(self) -> None:
|
|
214
|
+
"""All effect source keeps all preset effects."""
|
|
215
|
+
client = AidotClient(session=None)
|
|
216
|
+
device = {
|
|
217
|
+
CONF_DIYS: [
|
|
218
|
+
FavoriteEffectPrimitive(name="DIY", primitiveEffectId="d_1")
|
|
219
|
+
],
|
|
220
|
+
CONF_FAV_PRESETS: [
|
|
221
|
+
FavoriteEffectPrimitive(name="Favorite", primitiveEffectId="f_1")
|
|
222
|
+
],
|
|
223
|
+
CONF_PRESETS: [
|
|
224
|
+
FavoriteEffectPrimitive(name="Hot", primitiveEffectId="p_1", tag=" "),
|
|
225
|
+
FavoriteEffectPrimitive(
|
|
226
|
+
name="New", primitiveEffectId="p_2", tag="New"
|
|
227
|
+
),
|
|
228
|
+
FavoriteEffectPrimitive(name="Plain", primitiveEffectId="p_3"),
|
|
229
|
+
],
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
effects = client.get_filtered_effects(device)
|
|
233
|
+
|
|
234
|
+
self.assertEqual(list(effects), ["DIY", "Favorite", "Hot", "New", "Plain"])
|
|
235
|
+
|
|
236
|
+
def test_manual_effect_source_filters_selected_device_effects(self) -> None:
|
|
237
|
+
"""Manual effect source keeps selected effects for one device."""
|
|
238
|
+
client = AidotClient(
|
|
239
|
+
session=None,
|
|
240
|
+
options={
|
|
241
|
+
CONF_EFFECT_SOURCE: EFFECT_SOURCE_MANUAL,
|
|
242
|
+
CONF_EFFECT_SELECTION: {
|
|
243
|
+
"device_id_1": ["d_1", "f_1", "p_2"],
|
|
244
|
+
"device_id_2": ["p_3"],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
device = {
|
|
249
|
+
CONF_ID: "device_id_1",
|
|
250
|
+
CONF_DIYS: [
|
|
251
|
+
FavoriteEffectPrimitive(name="DIY", primitiveEffectId="d_1"),
|
|
252
|
+
FavoriteEffectPrimitive(name="Other DIY", primitiveEffectId="d_2"),
|
|
253
|
+
],
|
|
254
|
+
CONF_FAV_PRESETS: [
|
|
255
|
+
FavoriteEffectPrimitive(
|
|
256
|
+
name="Favorite",
|
|
257
|
+
primitiveEffectId="p_1",
|
|
258
|
+
favoriteId="f_1",
|
|
259
|
+
)
|
|
260
|
+
],
|
|
261
|
+
CONF_PRESETS: [
|
|
262
|
+
FavoriteEffectPrimitive(name="Preset 2", primitiveEffectId="p_2"),
|
|
263
|
+
FavoriteEffectPrimitive(name="Preset 3", primitiveEffectId="p_3"),
|
|
264
|
+
],
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
effects = client.get_filtered_effects(device)
|
|
268
|
+
|
|
269
|
+
self.assertEqual(list(effects), ["DIY", "Favorite", "Preset 2"])
|
|
270
|
+
|
|
271
|
+
def test_manual_effect_source_returns_empty_without_selection(self) -> None:
|
|
272
|
+
"""Manual effect source returns no effects when device has no selection."""
|
|
273
|
+
client = AidotClient(
|
|
274
|
+
session=None,
|
|
275
|
+
options={
|
|
276
|
+
CONF_EFFECT_SOURCE: EFFECT_SOURCE_MANUAL,
|
|
277
|
+
CONF_EFFECT_SELECTION: {
|
|
278
|
+
"other_device_id": ["p_1"],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
device = {
|
|
283
|
+
CONF_ID: "device_id",
|
|
284
|
+
CONF_PRESETS: [
|
|
285
|
+
FavoriteEffectPrimitive(name="Preset", primitiveEffectId="p_1"),
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
effects = client.get_filtered_effects(device)
|
|
290
|
+
|
|
291
|
+
self.assertEqual(effects, {})
|
|
292
|
+
|
|
293
|
+
def test_get_all_cached_effects_ignores_effect_source(self) -> None:
|
|
294
|
+
"""All cached effects ignores the configured effect source."""
|
|
295
|
+
client = AidotClient(
|
|
296
|
+
session=None,
|
|
297
|
+
options={
|
|
298
|
+
CONF_EFFECT_SOURCE: EFFECT_SOURCE_MANUAL,
|
|
299
|
+
CONF_EFFECT_SELECTION: {"device_id": ["p_1"]},
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
device = {
|
|
303
|
+
CONF_ID: "device_id",
|
|
304
|
+
CONF_PRESETS: [
|
|
305
|
+
FavoriteEffectPrimitive(name="Selected", primitiveEffectId="p_1"),
|
|
306
|
+
FavoriteEffectPrimitive(name="Available", primitiveEffectId="p_2"),
|
|
307
|
+
],
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
effects = client.get_all_cached_effects(device)
|
|
311
|
+
|
|
312
|
+
self.assertEqual(list(effects), ["Selected", "Available"])
|
|
313
|
+
|
|
314
|
+
async def test_get_all_effects_stores_raw_lists_before_filtering(self) -> None:
|
|
315
|
+
"""All effect sources are stored on the device before final filtering."""
|
|
316
|
+
client = AidotClient(session=None, effect_source=EFFECT_SOURCE_RECOMMENDED)
|
|
317
|
+
diy = [FavoriteEffectPrimitive(name="DIY", primitiveEffectId="d_1")]
|
|
318
|
+
favorite = [
|
|
319
|
+
FavoriteEffectPrimitive(name="Favorite", primitiveEffectId="p_1")
|
|
320
|
+
]
|
|
321
|
+
presets = [
|
|
322
|
+
FavoriteEffectPrimitive(
|
|
323
|
+
name=f"Preset {index}", primitiveEffectId=f"p_{index}"
|
|
324
|
+
)
|
|
325
|
+
for index in range(12)
|
|
326
|
+
]
|
|
327
|
+
device = {CONF_ID: "device_id"}
|
|
328
|
+
client.async_get_diy_list = AsyncMock(return_value=diy)
|
|
329
|
+
client.async_get_fav_presets = AsyncMock(return_value=favorite)
|
|
330
|
+
client.async_get_presets = AsyncMock(return_value=presets)
|
|
331
|
+
|
|
332
|
+
effects = await client.async_get_all_effects(device)
|
|
333
|
+
|
|
334
|
+
self.assertIs(device[CONF_DIYS], diy)
|
|
335
|
+
self.assertIs(device[CONF_FAV_PRESETS], favorite)
|
|
336
|
+
self.assertEqual(
|
|
337
|
+
[effect.primitiveEffectId for effect in device[CONF_PRESETS]],
|
|
338
|
+
["p_0", *[f"p_{index}" for index in range(2, 12)]],
|
|
339
|
+
)
|
|
340
|
+
self.assertEqual(
|
|
341
|
+
list(effects),
|
|
342
|
+
[
|
|
343
|
+
"DIY",
|
|
344
|
+
"Favorite",
|
|
345
|
+
"Preset 0",
|
|
346
|
+
"Preset 2",
|
|
347
|
+
"Preset 3",
|
|
348
|
+
"Preset 4",
|
|
349
|
+
"Preset 5",
|
|
350
|
+
"Preset 6",
|
|
351
|
+
"Preset 7",
|
|
352
|
+
"Preset 8",
|
|
353
|
+
"Preset 9",
|
|
354
|
+
"Preset 10",
|
|
355
|
+
],
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def test_device_information_reads_final_effects(self) -> None:
|
|
359
|
+
"""Device information uses final effects instead of raw preset list."""
|
|
360
|
+
from aidot.device_client import DeviceInformation
|
|
361
|
+
|
|
362
|
+
device = {
|
|
363
|
+
CONF_ID: "device_id",
|
|
364
|
+
"mac": "00:11:22:33:44:55",
|
|
365
|
+
"modelId": "model",
|
|
366
|
+
"name": "Light",
|
|
367
|
+
"hardwareVersion": "1",
|
|
368
|
+
CONF_PRESETS: [
|
|
369
|
+
FavoriteEffectPrimitive(name="Raw", primitiveEffectId="p_raw")
|
|
370
|
+
],
|
|
371
|
+
CONF_EFFECTS: {
|
|
372
|
+
"Final": FavoriteEffectPrimitive(
|
|
373
|
+
name="Final", primitiveEffectId="p_final"
|
|
374
|
+
)
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
info = DeviceInformation(device)
|
|
379
|
+
|
|
380
|
+
self.assertEqual(info.preset_names, ["Final"])
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
if __name__ == "__main__":
|
|
384
|
+
unittest.main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for the AiDot device client."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest.mock import AsyncMock, patch
|
|
6
|
+
|
|
7
|
+
from aidot.const import (
|
|
8
|
+
CONF_AES_KEY,
|
|
9
|
+
CONF_ID,
|
|
10
|
+
CONF_MODEL_ID,
|
|
11
|
+
CONF_NAME,
|
|
12
|
+
CONF_PASSWORD,
|
|
13
|
+
CONF_PROPERTIES,
|
|
14
|
+
)
|
|
15
|
+
from aidot.device_client import DeviceClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DeviceClientTest(unittest.TestCase):
|
|
19
|
+
"""Test device client helpers."""
|
|
20
|
+
|
|
21
|
+
def test_get_valid_ip(self) -> None:
|
|
22
|
+
"""Validate IP address strings."""
|
|
23
|
+
self.assertEqual(DeviceClient._get_valid_ip("192.168.1.10"), "192.168.1.10")
|
|
24
|
+
self.assertEqual(DeviceClient._get_valid_ip("2001:db8::1"), "2001:db8::1")
|
|
25
|
+
self.assertIsNone(DeviceClient._get_valid_ip(""))
|
|
26
|
+
self.assertIsNone(DeviceClient._get_valid_ip(None))
|
|
27
|
+
self.assertIsNone(DeviceClient._get_valid_ip("999.168.1.10"))
|
|
28
|
+
self.assertIsNone(DeviceClient._get_valid_ip("not-an-ip"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncDeviceClientTest(unittest.IsolatedAsyncioTestCase):
|
|
32
|
+
"""Test async device client behavior."""
|
|
33
|
+
|
|
34
|
+
def _create_device(self, ip_address: str) -> dict:
|
|
35
|
+
"""Create a test device."""
|
|
36
|
+
return {
|
|
37
|
+
CONF_ID: "device-id",
|
|
38
|
+
CONF_NAME: "Test Light",
|
|
39
|
+
CONF_MODEL_ID: "aidot.light.rgbw",
|
|
40
|
+
CONF_AES_KEY: ["mock_aes_key"],
|
|
41
|
+
CONF_PASSWORD: "password",
|
|
42
|
+
CONF_PROPERTIES: {"ipAddress": ip_address},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async def test_init_with_valid_ip_schedules_login(self) -> None:
|
|
46
|
+
"""A valid IP from cloud properties triggers an initial login attempt."""
|
|
47
|
+
with patch.object(DeviceClient, "async_login", new=AsyncMock()) as mock_login:
|
|
48
|
+
device_client = DeviceClient(self._create_device("192.168.1.10"), {}, None)
|
|
49
|
+
await asyncio.sleep(0)
|
|
50
|
+
|
|
51
|
+
self.assertEqual(device_client._ip_address, "192.168.1.10")
|
|
52
|
+
mock_login.assert_awaited_once()
|
|
53
|
+
|
|
54
|
+
async def test_init_with_invalid_ip_does_not_schedule_login(self) -> None:
|
|
55
|
+
"""An invalid IP from cloud properties is ignored."""
|
|
56
|
+
with patch.object(DeviceClient, "async_login", new=AsyncMock()) as mock_login:
|
|
57
|
+
device_client = DeviceClient(self._create_device("not-an-ip"), {}, None)
|
|
58
|
+
await asyncio.sleep(0)
|
|
59
|
+
|
|
60
|
+
self.assertIsNone(device_client._ip_address)
|
|
61
|
+
mock_login.assert_not_awaited()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
unittest.main()
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
"""Tests for the AiDot client."""
|
|
2
|
-
|
|
3
|
-
import unittest
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
import aiohttp
|
|
7
|
-
|
|
8
|
-
from aidot.client import AidotClient
|
|
9
|
-
from aidot.const import (
|
|
10
|
-
CONF_ACCESS_TOKEN,
|
|
11
|
-
CONF_COUNTRY,
|
|
12
|
-
CONF_ID,
|
|
13
|
-
CONF_PASSWORD,
|
|
14
|
-
CONF_REFRESH_TOKEN,
|
|
15
|
-
CONF_REGION,
|
|
16
|
-
CONF_USERNAME,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class FakeResponse:
|
|
21
|
-
"""Minimal aiohttp response test double."""
|
|
22
|
-
|
|
23
|
-
def __init__(self, data: dict[str, Any]) -> None:
|
|
24
|
-
self._data = data
|
|
25
|
-
|
|
26
|
-
async def json(self) -> dict[str, Any]:
|
|
27
|
-
"""Return the response payload."""
|
|
28
|
-
return self._data
|
|
29
|
-
|
|
30
|
-
def raise_for_status(self) -> None:
|
|
31
|
-
"""Represent a successful response."""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class FakeSession:
|
|
35
|
-
"""Record requests made by one client."""
|
|
36
|
-
|
|
37
|
-
def __init__(self, response: dict[str, Any]) -> None:
|
|
38
|
-
self._response = response
|
|
39
|
-
self.post_calls: list[tuple[str, dict[str, str], dict[str, Any]]] = []
|
|
40
|
-
|
|
41
|
-
async def post(
|
|
42
|
-
self,
|
|
43
|
-
url: str,
|
|
44
|
-
*,
|
|
45
|
-
headers: dict[str, str],
|
|
46
|
-
json: dict[str, Any],
|
|
47
|
-
) -> FakeResponse:
|
|
48
|
-
"""Record a POST request."""
|
|
49
|
-
self.post_calls.append((url, headers, json))
|
|
50
|
-
return FakeResponse(self._response)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class FailingSession:
|
|
54
|
-
"""Raise a typed connection error for every request."""
|
|
55
|
-
|
|
56
|
-
async def post(
|
|
57
|
-
self,
|
|
58
|
-
url: str,
|
|
59
|
-
*,
|
|
60
|
-
headers: dict[str, str],
|
|
61
|
-
json: dict[str, Any],
|
|
62
|
-
) -> FakeResponse:
|
|
63
|
-
"""Simulate a network failure."""
|
|
64
|
-
raise aiohttp.ClientConnectionError
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def create_token(
|
|
68
|
-
*, username: str, region: str, access_token: str, refresh_token: str
|
|
69
|
-
) -> dict[str, Any]:
|
|
70
|
-
"""Create stored login data for a client."""
|
|
71
|
-
return {
|
|
72
|
-
CONF_ID: None,
|
|
73
|
-
CONF_USERNAME: username,
|
|
74
|
-
CONF_PASSWORD: "password",
|
|
75
|
-
CONF_COUNTRY: "United States",
|
|
76
|
-
CONF_REGION: region,
|
|
77
|
-
CONF_ACCESS_TOKEN: access_token,
|
|
78
|
-
CONF_REFRESH_TOKEN: refresh_token,
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class AidotClientTest(unittest.IsolatedAsyncioTestCase):
|
|
83
|
-
"""Verify client state is isolated by account."""
|
|
84
|
-
|
|
85
|
-
async def test_multiple_accounts_keep_state_isolated(self) -> None:
|
|
86
|
-
"""Refreshing one account does not update another account."""
|
|
87
|
-
first_session = FakeSession(
|
|
88
|
-
{
|
|
89
|
-
CONF_ACCESS_TOKEN: "first-new-access",
|
|
90
|
-
CONF_REFRESH_TOKEN: "first-new-refresh",
|
|
91
|
-
}
|
|
92
|
-
)
|
|
93
|
-
second_session = FakeSession(
|
|
94
|
-
{
|
|
95
|
-
CONF_ACCESS_TOKEN: "second-new-access",
|
|
96
|
-
CONF_REFRESH_TOKEN: "second-new-refresh",
|
|
97
|
-
}
|
|
98
|
-
)
|
|
99
|
-
first_client = AidotClient(
|
|
100
|
-
session=first_session,
|
|
101
|
-
token=create_token(
|
|
102
|
-
username="first@example.com",
|
|
103
|
-
region="us",
|
|
104
|
-
access_token="first-access",
|
|
105
|
-
refresh_token="first-refresh",
|
|
106
|
-
),
|
|
107
|
-
)
|
|
108
|
-
second_client = AidotClient(
|
|
109
|
-
session=second_session,
|
|
110
|
-
token=create_token(
|
|
111
|
-
username="second@example.com",
|
|
112
|
-
region="eu",
|
|
113
|
-
access_token="second-access",
|
|
114
|
-
refresh_token="second-refresh",
|
|
115
|
-
),
|
|
116
|
-
)
|
|
117
|
-
refreshed: list[str] = []
|
|
118
|
-
first_client.set_token_fresh_cb(lambda: refreshed.append("first"))
|
|
119
|
-
second_client.set_token_fresh_cb(lambda: refreshed.append("second"))
|
|
120
|
-
|
|
121
|
-
await first_client.async_refresh_token()
|
|
122
|
-
|
|
123
|
-
self.assertIsNot(first_client.login_info, second_client.login_info)
|
|
124
|
-
self.assertEqual(first_client.login_info[CONF_ACCESS_TOKEN], "first-new-access")
|
|
125
|
-
self.assertEqual(
|
|
126
|
-
first_client.login_info[CONF_REFRESH_TOKEN], "first-new-refresh"
|
|
127
|
-
)
|
|
128
|
-
self.assertEqual(second_client.login_info[CONF_ACCESS_TOKEN], "second-access")
|
|
129
|
-
self.assertEqual(second_client.login_info[CONF_REFRESH_TOKEN], "second-refresh")
|
|
130
|
-
self.assertEqual(second_session.post_calls, [])
|
|
131
|
-
self.assertEqual(refreshed, ["first"])
|
|
132
|
-
|
|
133
|
-
async def test_login_preserves_connection_error(self) -> None:
|
|
134
|
-
"""Login keeps aiohttp connection errors typed for callers."""
|
|
135
|
-
client = AidotClient(
|
|
136
|
-
session=FailingSession(),
|
|
137
|
-
country_code="US",
|
|
138
|
-
username="user@example.com",
|
|
139
|
-
password="password",
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
with self.assertRaises(aiohttp.ClientConnectionError):
|
|
143
|
-
await client.async_post_login()
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if __name__ == "__main__":
|
|
147
|
-
unittest.main()
|
|
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
|