Skip to content

Commit 88a8c76

Browse files
committed
chore: test the individual config endpoints as well
1 parent d095b3f commit 88a8c76

File tree

13 files changed

+12488
-43
lines changed

13 files changed

+12488
-43
lines changed

dingz/rest/_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ async def get_ddi_config(self) -> DdiConfig:
190190

191191
async def get_actions_config(self) -> ActionsConfig:
192192
"""Get the actions configuration."""
193-
return await self._request("GET", URL("/api/v1/actions"))
193+
return await self._request("GET", URL("/api/v1/action"))
194194

195195
async def get_scheduler_config(self) -> list[SchedulerConfig]:
196196
"""Get the scheduler configuration."""

dingz/rest/_types/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,8 @@ class SystemConfig(TypedDict):
486486
dyn_light: _SystemConfigDynLight
487487
wifi_ps: bool
488488
new_comp_alg: bool
489+
# TODO: there are more fields when the system config is requested through its dedicated API
490+
# compared to the dump config.
489491

490492

491493
class DdiConfig(TypedDict):

dingz/rest/_types/state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ class PirSensorState(TypedDict):
105105

106106
enabled: bool
107107
motion: bool
108-
mode: Literal["idle"] # TODO: INCOMPLETE
108+
mode: Literal["auto", "idle"] # TODO: INCOMPLETE
109109
light_off_timer: int
110110
suspend_timer: int
111111

scripts/gather.py

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,17 @@
1313
import dataclasses
1414
import hashlib
1515
import logging
16-
from pathlib import Path
1716
from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar, cast
1817

1918
import dingz.discovery
2019
from dingz.rest import Device, RestClient
21-
from tests.snapshot import Endpoint, Error, Snapshot
20+
from tests.snapshot import REDACTED_VALUE, Endpoint, Error, Snapshot
2221

2322
if TYPE_CHECKING:
2423
from collections.abc import Awaitable, Iterable
2524

2625
_LOGGER = logging.getLogger(__name__)
2726

28-
_PROJECT_ROOT = Path(__file__).parent.parent
29-
_REDACTED_VALUE = "[redacted]"
30-
3127

3228
class _Args(Protocol):
3329
host: str | None
@@ -121,7 +117,9 @@ def _redact_path(container: Any, path: str) -> bool:
121117
key = segments[-1]
122118
if not (isinstance(container, dict) and key in container):
123119
return False
124-
container[key] = _REDACTED_VALUE
120+
# Preserve empty strings
121+
if container[key] != "":
122+
container[key] = REDACTED_VALUE
125123
return True
126124

127125

@@ -162,6 +160,10 @@ async def gather_snapshot(client: RestClient) -> Snapshot:
162160
config_dump=await gather_endpoint(
163161
lambda: client.get_config_dump(),
164162
name="config_dump",
163+
redact_paths=[
164+
"services.mqtt.uri",
165+
*[f"actions.{key}" for key in _REDACT_ACTIONS_CONFIG],
166+
],
165167
),
166168
state=await gather_endpoint(
167169
lambda: client.get_state(),
@@ -180,9 +182,106 @@ async def gather_snapshot(client: RestClient) -> Snapshot:
180182
name="ram",
181183
redact_paths=[],
182184
),
185+
button_config=await gather_endpoint(
186+
lambda: client.get_button_config(),
187+
name="button_config",
188+
),
189+
input_config=await gather_endpoint(
190+
lambda: client.get_input_config(),
191+
name="input_config",
192+
),
193+
pir_config=await gather_endpoint(
194+
lambda: client.get_pir_config(),
195+
name="pir_config",
196+
),
197+
lux_config=await gather_endpoint(
198+
lambda: client.get_lux_config(),
199+
name="lux_config",
200+
),
201+
output_config=await gather_endpoint(
202+
lambda: client.get_output_config(),
203+
name="output_config",
204+
),
205+
services_config=await gather_endpoint(
206+
lambda: client.get_services_config(), name="services_config", redact_paths=["mqtt.uri"]
207+
),
208+
system_config=await gather_endpoint(
209+
lambda: client.get_system_config(),
210+
name="system_config",
211+
),
212+
ddi_config=await gather_endpoint(
213+
lambda: client.get_ddi_config(),
214+
name="ddi_config",
215+
),
216+
actions_config=await gather_endpoint(
217+
lambda: client.get_actions_config(),
218+
name="actions_config",
219+
redact_paths=_REDACT_ACTIONS_CONFIG,
220+
),
221+
scheduler_config=await gather_endpoint(
222+
lambda: client.get_scheduler_config(),
223+
name="scheduler_config",
224+
),
183225
)
184226

185227

228+
_REDACT_ACTIONS_CONFIG_BASE = [
229+
"single",
230+
"double",
231+
"long",
232+
"m3",
233+
"m4",
234+
"m5",
235+
"begin",
236+
"hold_up",
237+
"hold_down",
238+
"end",
239+
"off",
240+
]
241+
242+
_REDACT_ACTIONS_CONFIG_INPUT = [
243+
*_REDACT_ACTIONS_CONFIG_BASE,
244+
"active",
245+
"inactive",
246+
]
247+
248+
_REDACT_ACTIONS_CONFIG_THERMOSTAT = [
249+
"idle",
250+
"heating",
251+
"cooling",
252+
]
253+
254+
_REDACT_ACTIONS_CONFIG_LUX = [
255+
"night",
256+
"twilight",
257+
"day",
258+
]
259+
260+
_REDACT_ACTIONS_CONFIG_PIR = [
261+
"night",
262+
"twilight",
263+
"day",
264+
"rise",
265+
"fall",
266+
"timer_off",
267+
]
268+
269+
_REDACT_ACTIONS_CONFIG = [
270+
"generic",
271+
*[f"btn1.{key}" for key in _REDACT_ACTIONS_CONFIG_BASE],
272+
*[f"btn2.{key}" for key in _REDACT_ACTIONS_CONFIG_BASE],
273+
*[f"btn3.{key}" for key in _REDACT_ACTIONS_CONFIG_BASE],
274+
*[f"btn4.{key}" for key in _REDACT_ACTIONS_CONFIG_BASE],
275+
*[f"input.{key}" for key in _REDACT_ACTIONS_CONFIG_INPUT],
276+
*[f"input2.{key}" for key in _REDACT_ACTIONS_CONFIG_INPUT],
277+
*[f"thermostat.{key}" for key in _REDACT_ACTIONS_CONFIG_THERMOSTAT],
278+
*[f"lux.{key}" for key in _REDACT_ACTIONS_CONFIG_LUX],
279+
*[f"pir1.{key}" for key in _REDACT_ACTIONS_CONFIG_PIR],
280+
*[f"pir2.{key}" for key in _REDACT_ACTIONS_CONFIG_PIR],
281+
*[f"pir3.{key}" for key in _REDACT_ACTIONS_CONFIG_PIR],
282+
]
283+
284+
186285
def _generate_device_hash(data: Device) -> str:
187286
device_hash = hashlib.sha256(usedforsecurity=False)
188287
try:

tests/snapshot.py

Lines changed: 103 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,41 @@
33
import dataclasses
44
import json
55
from pathlib import Path
6-
from typing import TYPE_CHECKING, Any, Generic, TypeVar
6+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
77

88
from typing_extensions import Self
99

1010
if TYPE_CHECKING:
1111
from collections.abc import Mapping
1212

13-
from dingz.rest import ConfigDump, Device, NetworkInfo, Ram, State
13+
from dingz.rest import (
14+
ActionsConfig,
15+
ButtonsConfig,
16+
ConfigDump,
17+
DdiConfig,
18+
Device,
19+
InputsConfig,
20+
LuxConfig,
21+
NetworkInfo,
22+
OutputsConfig,
23+
PirsConfig,
24+
Ram,
25+
SchedulerConfig,
26+
ServicesConfig,
27+
State,
28+
SystemConfig,
29+
)
1430

1531
__all__ = [
32+
"REDACTED_VALUE",
1633
"Endpoint",
1734
"Error",
1835
"Snapshot",
1936
]
2037

38+
REDACTED_VALUE = "[redacted]"
39+
40+
2141
_TESTS_DIR = Path(__file__).parent
2242
_SNAPSHOTS_DIR = _TESTS_DIR / "snapshots"
2343

@@ -60,39 +80,63 @@ class Snapshot:
6080
config_dump: Endpoint[ConfigDump]
6181
state: Endpoint[State]
6282
ram: Endpoint[Ram]
83+
button_config: Endpoint[ButtonsConfig]
84+
input_config: Endpoint[InputsConfig]
85+
pir_config: Endpoint[PirsConfig]
86+
lux_config: Endpoint[LuxConfig]
87+
output_config: Endpoint[OutputsConfig]
88+
services_config: Endpoint[ServicesConfig]
89+
system_config: Endpoint[SystemConfig]
90+
ddi_config: Endpoint[DdiConfig]
91+
actions_config: Endpoint[ActionsConfig]
92+
scheduler_config: Endpoint[list[SchedulerConfig]]
6393

6494
def endpoint_by_path(self, path: str) -> Endpoint[Any]:
65-
if path == "/api/v1/device":
66-
return self.device
67-
if path == "/api/v1/info":
68-
return self.network_info
69-
if path == "/api/v1/dump_config":
70-
return self.config_dump
71-
if path == "/api/v1/state":
72-
return self.state
73-
if path == "/api/v1/ram":
74-
return self.ram
75-
msg = f"Unknown endpoint path: {path}"
76-
raise LookupError(msg)
95+
attr = _ENDPOINT_ATTR_MAP.get(path)
96+
if not attr:
97+
msg = f"Unknown endpoint path: {path}"
98+
raise LookupError(msg)
99+
return getattr(self, attr)
77100

78101
def path(self) -> Path:
79102
return _SNAPSHOTS_DIR / f"{self.device_hash}.json"
80103

81-
def save(self) -> None:
82-
text = json.dumps(dataclasses.asdict(self), indent=2)
83-
self.path().write_text(text, encoding="utf-8")
104+
def save(self, *, force_update: bool = False) -> None:
105+
data = dataclasses.asdict(self)
106+
path = self.path()
107+
if not force_update and path.exists():
108+
old_data = json.loads(path.read_text(encoding="utf-8"))
109+
data = _preserve_old_value(old_data, data)
110+
111+
text = json.dumps(data, indent=2)
112+
path.write_text(text, encoding="utf-8")
84113

85114
@classmethod
86115
def from_dict(cls, data: Mapping[str, Any]) -> Self:
87-
return cls(
88-
device_hash=data["device_hash"],
89-
firmware_version=Endpoint.from_dict(data["firmware_version"]),
90-
device=Endpoint.from_dict(data["device"]),
91-
network_info=Endpoint.from_dict(data["network_info"]),
92-
config_dump=Endpoint.from_dict(data["config_dump"]),
93-
state=Endpoint.from_dict(data["state"]),
94-
ram=Endpoint.from_dict(data["ram"]),
95-
)
116+
kwargs: dict[str, Endpoint[Any]] = {}
117+
for attr in _ENDPOINT_ATTR_MAP.values():
118+
kwargs[attr] = Endpoint.from_dict(data[attr])
119+
return cls(device_hash=data["device_hash"], **kwargs)
120+
121+
122+
_ENDPOINT_ATTR_MAP: dict[str, str] = {
123+
"/api/v1/firmware": "firmware_version",
124+
"/api/v1/device": "device",
125+
"/api/v1/info": "network_info",
126+
"/api/v1/dump_config": "config_dump",
127+
"/api/v1/state": "state",
128+
"/api/v1/ram": "ram",
129+
"/api/v1/button_config": "button_config",
130+
"/api/v1/input_config": "input_config",
131+
"/api/v1/pir_config": "pir_config",
132+
"/api/v1/lux_config": "lux_config",
133+
"/api/v1/output_config": "output_config",
134+
"/api/v1/services_config": "services_config",
135+
"/api/v1/system_config": "system_config",
136+
"/api/v1/ddi_config": "ddi_config",
137+
"/api/v1/action": "actions_config",
138+
"/api/v1/scheduler": "scheduler_config",
139+
}
96140

97141

98142
def load_snapshots() -> list[Snapshot]:
@@ -101,3 +145,36 @@ def load_snapshots() -> list[Snapshot]:
101145
data = json.loads(path.read_text(encoding="utf-8"))
102146
snapshots.append(Snapshot.from_dict(data))
103147
return snapshots
148+
149+
150+
def _preserve_old_value(old: Any, new: Any) -> Any: # noqa: PLR0911
151+
"""Recursively preserve old values if they haven't changed type or meaning."""
152+
if isinstance(old, float) and isinstance(new, int):
153+
# Value changed from float to int. The API returns ints if the value happens to align.
154+
return old
155+
if type(old) is not type(new):
156+
return new
157+
if isinstance(old, dict):
158+
old = cast("dict[Any, Any]", old)
159+
new_dict = new.copy()
160+
for key, old_value in old.items():
161+
if key not in new_dict:
162+
continue
163+
new_value = new_dict[key]
164+
new_dict[key] = _preserve_old_value(old_value, new_value)
165+
return new_dict
166+
if isinstance(old, list):
167+
old = cast("list[Any]", old)
168+
if len(old) != len(new):
169+
return new
170+
return [_preserve_old_value(old_item, new_item) for old_item, new_item in zip(old, new)]
171+
172+
if new == REDACTED_VALUE:
173+
# Always use the redacted value even if old wasn't yet
174+
return REDACTED_VALUE
175+
176+
if (new == "") != (old == ""):
177+
# We changed from empty to non-empty string or vice versa
178+
return new
179+
180+
return old

0 commit comments

Comments
 (0)