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.
Files changed (25) hide show
  1. {python_aidot-0.3.57/python_aidot.egg-info → python_aidot-0.3.59}/PKG-INFO +1 -1
  2. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/client.py +96 -4
  3. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/const.py +7 -0
  4. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/device_client.py +19 -2
  5. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/models/device_model.py +1 -0
  6. {python_aidot-0.3.57 → python_aidot-0.3.59/python_aidot.egg-info}/PKG-INFO +1 -1
  7. {python_aidot-0.3.57 → python_aidot-0.3.59}/python_aidot.egg-info/SOURCES.txt +2 -1
  8. {python_aidot-0.3.57 → python_aidot-0.3.59}/setup.py +1 -1
  9. python_aidot-0.3.59/tests/test_client.py +384 -0
  10. python_aidot-0.3.59/tests/test_device_client.py +65 -0
  11. python_aidot-0.3.57/tests/test_client.py +0 -147
  12. {python_aidot-0.3.57 → python_aidot-0.3.59}/LICENSE +0 -0
  13. {python_aidot-0.3.57 → python_aidot-0.3.59}/README.md +0 -0
  14. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/__init__.py +0 -0
  15. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/aes_utils.py +0 -0
  16. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/discover.py +0 -0
  17. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/exceptions.py +0 -0
  18. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/login_const.py +0 -0
  19. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/models/__init__.py +0 -0
  20. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/models/device_client_model.py +0 -0
  21. {python_aidot-0.3.57 → python_aidot-0.3.59}/aidot/models/discover_model.py +0 -0
  22. {python_aidot-0.3.57 → python_aidot-0.3.59}/python_aidot.egg-info/dependency_links.txt +0 -0
  23. {python_aidot-0.3.57 → python_aidot-0.3.59}/python_aidot.egg-info/requires.txt +0 -0
  24. {python_aidot-0.3.57 → python_aidot-0.3.59}/python_aidot.egg-info/top_level.txt +0 -0
  25. {python_aidot-0.3.57 → python_aidot-0.3.59}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot
3
- Version: 0.3.57
3
+ Version: 0.3.59
4
4
  Summary: aidot control wifi lights
5
5
  Home-page: https://github.com/Aidot-Development-Team/python-aidot
6
6
  Author: aidotdev2024
@@ -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.57")
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
- return self._merge_effects_by_unique_name(diy_list, fav_preset, preset_list)
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[CONF_PRESETS] = await self.async_get_all_effects(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
@@ -100,6 +100,7 @@ class FavoriteEffectPrimitive:
100
100
  icon: str = None
101
101
  libraryId: str = "aidot.preset"
102
102
  name: str = None
103
+ tag: str = None
103
104
  tags: Optional[FavoriteEffectTags] = None
104
105
  scriptSize: int = None
105
106
  secondShareFlag: bool = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot
3
- Version: 0.3.57
3
+ Version: 0.3.59
4
4
  Summary: aidot control wifi lights
5
5
  Home-page: https://github.com/Aidot-Development-Team/python-aidot
6
6
  Author: aidotdev2024
@@ -19,4 +19,5 @@ python_aidot.egg-info/SOURCES.txt
19
19
  python_aidot.egg-info/dependency_links.txt
20
20
  python_aidot.egg-info/requires.txt
21
21
  python_aidot.egg-info/top_level.txt
22
- tests/test_client.py
22
+ tests/test_client.py
23
+ tests/test_device_client.py
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name="python-aidot",
8
- version="0.3.57",
8
+ version="0.3.59",
9
9
  author="aidotdev2024",
10
10
  url="https://github.com/Aidot-Development-Team/python-aidot",
11
11
  description="aidot control wifi lights",
@@ -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