emodul 0.1.0__py3-none-any.whl
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.
- emodul/__init__.py +3 -0
- emodul/__main__.py +4 -0
- emodul/api.py +494 -0
- emodul/auth.py +79 -0
- emodul/cli.py +148 -0
- emodul/commands/__init__.py +0 -0
- emodul/commands/alarms.py +65 -0
- emodul/commands/auth.py +121 -0
- emodul/commands/menu.py +154 -0
- emodul/commands/misc.py +161 -0
- emodul/commands/modules.py +97 -0
- emodul/commands/schedules.py +136 -0
- emodul/commands/settings.py +334 -0
- emodul/commands/stats.py +313 -0
- emodul/commands/watch.py +417 -0
- emodul/commands/zones.py +529 -0
- emodul/config.py +69 -0
- emodul/format.py +149 -0
- emodul/i18n.py +42 -0
- emodul/settings_map.py +231 -0
- emodul/storage.py +102 -0
- emodul-0.1.0.dist-info/METADATA +496 -0
- emodul-0.1.0.dist-info/RECORD +26 -0
- emodul-0.1.0.dist-info/WHEEL +4 -0
- emodul-0.1.0.dist-info/entry_points.txt +2 -0
- emodul-0.1.0.dist-info/licenses/LICENSE +29 -0
emodul/__init__.py
ADDED
emodul/__main__.py
ADDED
emodul/api.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Thin httpx wrapper around the eModul.pl REST API.
|
|
2
|
+
|
|
3
|
+
Endpoints reverse-engineered from the Angular SPA bundle (see README for the map).
|
|
4
|
+
All temperatures on the wire are integer tenths of °C — this layer keeps them
|
|
5
|
+
as integers; conversion lives in `emodul.format`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_changing(value: Any) -> bool:
|
|
17
|
+
"""TECH `duringChange` flag — sometimes bool, sometimes 't'/'f' string."""
|
|
18
|
+
if value is None:
|
|
19
|
+
return False
|
|
20
|
+
if isinstance(value, bool):
|
|
21
|
+
return value
|
|
22
|
+
if isinstance(value, str):
|
|
23
|
+
return value.lower() in ("t", "true", "1")
|
|
24
|
+
return bool(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EmodulApiError(RuntimeError):
|
|
28
|
+
def __init__(self, status: int, body: Any, path: str) -> None:
|
|
29
|
+
super().__init__(f"API {status} on {path}: {body!r}")
|
|
30
|
+
self.status = status
|
|
31
|
+
self.body = body
|
|
32
|
+
self.path = path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ApiClient:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
base_url: str,
|
|
39
|
+
token: str | None = None,
|
|
40
|
+
user_id: int | None = None,
|
|
41
|
+
timeout: float = 30.0,
|
|
42
|
+
refresher: Callable[["ApiClient"], None] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.base_url = base_url.rstrip("/")
|
|
45
|
+
self.token = token
|
|
46
|
+
self.user_id = user_id
|
|
47
|
+
self.refresher = refresher
|
|
48
|
+
self._refreshing = False # guard against recursion during re-auth
|
|
49
|
+
self._client = httpx.Client(
|
|
50
|
+
base_url=self.base_url,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
headers={
|
|
53
|
+
"Accept": "application/json",
|
|
54
|
+
"Accept-Encoding": "gzip",
|
|
55
|
+
"User-Agent": "emodul-cli/0.1",
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def close(self) -> None:
|
|
60
|
+
self._client.close()
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> "ApiClient":
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, *exc: Any) -> None:
|
|
66
|
+
self.close()
|
|
67
|
+
|
|
68
|
+
def _headers(self) -> dict[str, str]:
|
|
69
|
+
return {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
|
70
|
+
|
|
71
|
+
def _request(
|
|
72
|
+
self,
|
|
73
|
+
method: str,
|
|
74
|
+
path: str,
|
|
75
|
+
*,
|
|
76
|
+
json_body: Any = None,
|
|
77
|
+
parse_json: bool = True,
|
|
78
|
+
) -> Any:
|
|
79
|
+
r = self._client.request(method, path, headers=self._headers(), json=json_body)
|
|
80
|
+
if (
|
|
81
|
+
r.status_code == 401
|
|
82
|
+
and self.refresher is not None
|
|
83
|
+
and not self._refreshing
|
|
84
|
+
):
|
|
85
|
+
self._refreshing = True
|
|
86
|
+
try:
|
|
87
|
+
self.refresher(self) # mutates self.token on success; raises otherwise
|
|
88
|
+
finally:
|
|
89
|
+
self._refreshing = False
|
|
90
|
+
r = self._client.request(
|
|
91
|
+
method, path, headers=self._headers(), json=json_body
|
|
92
|
+
)
|
|
93
|
+
if r.status_code >= 400:
|
|
94
|
+
try:
|
|
95
|
+
body = r.json()
|
|
96
|
+
except Exception:
|
|
97
|
+
body = r.text
|
|
98
|
+
raise EmodulApiError(r.status_code, body, path)
|
|
99
|
+
if not parse_json:
|
|
100
|
+
return r.text
|
|
101
|
+
if "application/json" in r.headers.get("content-type", ""):
|
|
102
|
+
return r.json()
|
|
103
|
+
return r.text
|
|
104
|
+
|
|
105
|
+
# -- shorthand --
|
|
106
|
+
def get(self, path: str, **kw: Any) -> Any:
|
|
107
|
+
return self._request("GET", path, **kw)
|
|
108
|
+
|
|
109
|
+
def post(self, path: str, body: Any = None, **kw: Any) -> Any:
|
|
110
|
+
return self._request("POST", path, json_body=body if body is not None else {}, **kw)
|
|
111
|
+
|
|
112
|
+
def put(self, path: str, body: Any = None, **kw: Any) -> Any:
|
|
113
|
+
return self._request("PUT", path, json_body=body if body is not None else {}, **kw)
|
|
114
|
+
|
|
115
|
+
def delete(self, path: str, **kw: Any) -> Any:
|
|
116
|
+
return self._request("DELETE", path, **kw)
|
|
117
|
+
|
|
118
|
+
def options(self, path: str, **kw: Any) -> Any:
|
|
119
|
+
# eModul uses OPTIONS as a CSV download verb (yes, really).
|
|
120
|
+
r = self._client.request("OPTIONS", path, headers=self._headers())
|
|
121
|
+
if r.status_code >= 400:
|
|
122
|
+
try:
|
|
123
|
+
body = r.json()
|
|
124
|
+
except Exception:
|
|
125
|
+
body = r.text
|
|
126
|
+
raise EmodulApiError(r.status_code, body, path)
|
|
127
|
+
return r.text
|
|
128
|
+
|
|
129
|
+
# ---------------- Auth ----------------
|
|
130
|
+
def authenticate(self, username: str, password: str, language_id: int = 18) -> dict:
|
|
131
|
+
return self.post(
|
|
132
|
+
"/api/v1/authentication",
|
|
133
|
+
{
|
|
134
|
+
"username": username,
|
|
135
|
+
"password": password,
|
|
136
|
+
"rememberMe": True,
|
|
137
|
+
"languageId": language_id,
|
|
138
|
+
"remote": False,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# ---------------- User ----------------
|
|
143
|
+
def user_info(self) -> dict:
|
|
144
|
+
return self.get(f"/api/v1/users/{self.user_id}/info")
|
|
145
|
+
|
|
146
|
+
def last_modification(self) -> str:
|
|
147
|
+
return self.get(f"/api/v1/users/{self.user_id}/last_modification", parse_json=False)
|
|
148
|
+
|
|
149
|
+
# ---------------- Modules ----------------
|
|
150
|
+
def list_modules(self) -> list[dict]:
|
|
151
|
+
return self.get(f"/api/v1/users/{self.user_id}/modules")
|
|
152
|
+
|
|
153
|
+
def get_module(self, udid: str) -> dict:
|
|
154
|
+
return self.get(f"/api/v1/users/{self.user_id}/modules/{udid}")
|
|
155
|
+
|
|
156
|
+
def get_tiles(self, udid: str) -> dict:
|
|
157
|
+
return self.get(f"/api/v1/users/{self.user_id}/modules/{udid}/tiles")
|
|
158
|
+
|
|
159
|
+
def force_sync(self, udid: str) -> dict:
|
|
160
|
+
return self.post(f"/api/v1/users/{self.user_id}/modules/{udid}/force_data_sync")
|
|
161
|
+
|
|
162
|
+
def update_module_info(self, udid: str, name: str, additional_information: str = "") -> dict:
|
|
163
|
+
return self.put(
|
|
164
|
+
f"/api/v1/users/{self.user_id}/modules/{udid}/info/data",
|
|
165
|
+
{"module_name": name, "additional_information": additional_information},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def set_tile_order(self, udid: str, order: str, type_: str = "tiles") -> dict:
|
|
169
|
+
return self.post(
|
|
170
|
+
f"/api/v1/users/{self.user_id}/modules/{udid}/tiles/order",
|
|
171
|
+
{"order": order, "type": type_},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# ---------------- Zones ----------------
|
|
175
|
+
def _zones_path(self, udid: str) -> str:
|
|
176
|
+
return f"/api/v1/users/{self.user_id}/modules/{udid}/zones"
|
|
177
|
+
|
|
178
|
+
def set_zone_constant_temp(
|
|
179
|
+
self, udid: str, *, mode_id: int, zone_id: int, set_temperature_int10: int
|
|
180
|
+
) -> dict:
|
|
181
|
+
return self.post(
|
|
182
|
+
self._zones_path(udid),
|
|
183
|
+
{
|
|
184
|
+
"mode": {
|
|
185
|
+
"id": mode_id,
|
|
186
|
+
"parentId": zone_id,
|
|
187
|
+
"mode": "constantTemp",
|
|
188
|
+
"constTempTime": 60,
|
|
189
|
+
"setTemperature": set_temperature_int10,
|
|
190
|
+
"scheduleIndex": 0,
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def set_zone_time_limit(
|
|
196
|
+
self,
|
|
197
|
+
udid: str,
|
|
198
|
+
*,
|
|
199
|
+
mode_id: int,
|
|
200
|
+
zone_id: int,
|
|
201
|
+
set_temperature_int10: int,
|
|
202
|
+
minutes: int,
|
|
203
|
+
) -> dict:
|
|
204
|
+
return self.post(
|
|
205
|
+
self._zones_path(udid),
|
|
206
|
+
{
|
|
207
|
+
"mode": {
|
|
208
|
+
"id": mode_id,
|
|
209
|
+
"parentId": zone_id,
|
|
210
|
+
"mode": "timeLimit",
|
|
211
|
+
"constTempTime": minutes,
|
|
212
|
+
"setTemperature": set_temperature_int10,
|
|
213
|
+
"scheduleIndex": 0,
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def attach_global_schedule(
|
|
219
|
+
self,
|
|
220
|
+
udid: str,
|
|
221
|
+
*,
|
|
222
|
+
zone_id: int,
|
|
223
|
+
mode_id: int,
|
|
224
|
+
schedule_element: dict,
|
|
225
|
+
) -> dict:
|
|
226
|
+
"""Switch a zone to a globalSchedule by re-POSTing the schedule with
|
|
227
|
+
this zone in `setInZones`. Live-confirmed against the API (POST /zones
|
|
228
|
+
with mode=globalSchedule returns 422 'Invalid JSON data'; this endpoint
|
|
229
|
+
is the right one)."""
|
|
230
|
+
p0 = [i for i in (schedule_element.get("p0Intervals") or []) if i.get("start", 9999) <= 1440]
|
|
231
|
+
p1 = [i for i in (schedule_element.get("p1Intervals") or []) if i.get("start", 9999) <= 1440]
|
|
232
|
+
body = {
|
|
233
|
+
"modeId": mode_id,
|
|
234
|
+
"schedule": {
|
|
235
|
+
"id": schedule_element["id"],
|
|
236
|
+
"index": schedule_element["index"],
|
|
237
|
+
"p0Days": schedule_element.get("p0Days") or [],
|
|
238
|
+
"p0Intervals": p0,
|
|
239
|
+
"p0SetbackTemp": schedule_element.get("p0SetbackTemp", 200),
|
|
240
|
+
"p1Days": schedule_element.get("p1Days") or [],
|
|
241
|
+
"p1Intervals": p1,
|
|
242
|
+
"p1SetbackTemp": schedule_element.get("p1SetbackTemp", 200),
|
|
243
|
+
},
|
|
244
|
+
"scheduleName": schedule_element.get("name") or "",
|
|
245
|
+
"setInZones": [{"modeId": mode_id, "zoneId": zone_id}],
|
|
246
|
+
}
|
|
247
|
+
return self.post(f"{self._zones_path(udid)}/{zone_id}/global_schedule", body)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def set_zone_state(self, udid: str, zone_id: int, state: str) -> dict:
|
|
251
|
+
if state not in ("zoneOn", "zoneOff"):
|
|
252
|
+
raise ValueError("state must be zoneOn or zoneOff")
|
|
253
|
+
return self.post(self._zones_path(udid), {"zone": {"id": zone_id, "zoneState": state}})
|
|
254
|
+
|
|
255
|
+
def rename_zone(
|
|
256
|
+
self,
|
|
257
|
+
udid: str,
|
|
258
|
+
*,
|
|
259
|
+
zone_id: int,
|
|
260
|
+
description_id: int,
|
|
261
|
+
name: str,
|
|
262
|
+
icon_id: int = 0,
|
|
263
|
+
) -> dict:
|
|
264
|
+
return self.put(
|
|
265
|
+
f"{self._zones_path(udid)}/{zone_id}",
|
|
266
|
+
{"description_id": description_id, "name": name, "icons_id": icon_id},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def set_local_schedule(
|
|
270
|
+
self, udid: str, *, zone_id: int, mode_id: int, schedule: dict
|
|
271
|
+
) -> dict:
|
|
272
|
+
return self.post(
|
|
273
|
+
f"{self._zones_path(udid)}/{zone_id}/local_schedule",
|
|
274
|
+
{"modeId": mode_id, "schedule": schedule},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def set_global_schedule(
|
|
278
|
+
self,
|
|
279
|
+
udid: str,
|
|
280
|
+
*,
|
|
281
|
+
zone_id: int,
|
|
282
|
+
mode_id: int,
|
|
283
|
+
schedule: dict,
|
|
284
|
+
schedule_name: str,
|
|
285
|
+
set_in_zones: list[dict],
|
|
286
|
+
) -> dict:
|
|
287
|
+
return self.post(
|
|
288
|
+
f"{self._zones_path(udid)}/{zone_id}/global_schedule",
|
|
289
|
+
{
|
|
290
|
+
"modeId": mode_id,
|
|
291
|
+
"schedule": schedule,
|
|
292
|
+
"scheduleName": schedule_name,
|
|
293
|
+
"setInZones": set_in_zones,
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# ---------------- Menu (MU / MI / MS / MP) ----------------
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _format_pin_chain(pin_chain: list[tuple[int, str]] | None) -> str:
|
|
300
|
+
if not pin_chain:
|
|
301
|
+
return ""
|
|
302
|
+
return "/" + ",".join(f"{int(i)}:{p}" for i, p in pin_chain)
|
|
303
|
+
|
|
304
|
+
def get_menu(
|
|
305
|
+
self,
|
|
306
|
+
udid: str,
|
|
307
|
+
menu_type: str,
|
|
308
|
+
pin_chain: list[tuple[int, str]] | None = None,
|
|
309
|
+
) -> dict:
|
|
310
|
+
base = f"/api/v1/users/{self.user_id}/modules/{udid}/menu/{menu_type}"
|
|
311
|
+
return self.get(base + self._format_pin_chain(pin_chain))
|
|
312
|
+
|
|
313
|
+
def set_menu_param(self, udid: str, menu_type: str, ido: int, body: dict) -> dict:
|
|
314
|
+
return self.post(
|
|
315
|
+
f"/api/v1/users/{self.user_id}/modules/{udid}/menu/{menu_type}/ido/{ido}",
|
|
316
|
+
body,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# ---------------- Statistics (no /users/ prefix!) ----------------
|
|
320
|
+
def stats_available(self, udid: str) -> dict:
|
|
321
|
+
return self.get(f"/api/v1/modules/{udid}/statistics/available")
|
|
322
|
+
|
|
323
|
+
def stats_linear(
|
|
324
|
+
self,
|
|
325
|
+
udid: str,
|
|
326
|
+
*,
|
|
327
|
+
period: str = "day",
|
|
328
|
+
month: int | None = None,
|
|
329
|
+
year: int | None = None,
|
|
330
|
+
) -> dict:
|
|
331
|
+
if month and year:
|
|
332
|
+
return self.get(
|
|
333
|
+
f"/api/v1/modules/{udid}/statistics/linear/range/month/{month}/year/{year}"
|
|
334
|
+
)
|
|
335
|
+
return self.get(f"/api/v1/modules/{udid}/statistics/linear/range/{period}")
|
|
336
|
+
|
|
337
|
+
def stats_column(
|
|
338
|
+
self,
|
|
339
|
+
udid: str,
|
|
340
|
+
*,
|
|
341
|
+
state: str,
|
|
342
|
+
period: str = "day",
|
|
343
|
+
month: int | None = None,
|
|
344
|
+
year: int | None = None,
|
|
345
|
+
) -> dict:
|
|
346
|
+
if month and year:
|
|
347
|
+
return self.get(
|
|
348
|
+
f"/api/v1/modules/{udid}/statistics/{state}/range/month/{month}/year/{year}"
|
|
349
|
+
)
|
|
350
|
+
return self.get(f"/api/v1/modules/{udid}/statistics/{state}/range/{period}")
|
|
351
|
+
|
|
352
|
+
def stats_csv(
|
|
353
|
+
self,
|
|
354
|
+
udid: str,
|
|
355
|
+
*,
|
|
356
|
+
state: str,
|
|
357
|
+
period: str = "day",
|
|
358
|
+
language: str = "pl",
|
|
359
|
+
month: int | None = None,
|
|
360
|
+
year: int | None = None,
|
|
361
|
+
) -> str:
|
|
362
|
+
if month and year:
|
|
363
|
+
path = (
|
|
364
|
+
f"/api/v1/modules/{udid}/statistics/{state}/range/month/{month}/"
|
|
365
|
+
f"year/{year}/language/{language}"
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
path = f"/api/v1/modules/{udid}/statistics/{state}/range/{period}/language/{language}"
|
|
369
|
+
return self.options(path)
|
|
370
|
+
|
|
371
|
+
# ---------------- Alarms ----------------
|
|
372
|
+
def alarm_history(
|
|
373
|
+
self, udid: str, *, from_date: str, to_date: str, alarm_type: str = "all"
|
|
374
|
+
) -> dict:
|
|
375
|
+
return self.get(
|
|
376
|
+
f"/api/v1/users/{self.user_id}/modules/{udid}"
|
|
377
|
+
f"/alarm_history/from/{from_date}/to/{to_date}/type/{alarm_type}"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def acknowledge_alarm(self, udid: str, alarm_id: int) -> dict:
|
|
381
|
+
return self.post(
|
|
382
|
+
f"/api/v1/users/{self.user_id}/modules/{udid}/alarm_history",
|
|
383
|
+
{"id": alarm_id},
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# ---------------- I18n ----------------
|
|
387
|
+
def i18n(self, lang: str = "pl") -> dict:
|
|
388
|
+
return self.get(f"/api/v1/i18n/{lang}")
|
|
389
|
+
|
|
390
|
+
# ---------------- Polling ----------------
|
|
391
|
+
def update_data(
|
|
392
|
+
self,
|
|
393
|
+
udid: str,
|
|
394
|
+
*,
|
|
395
|
+
parents: list[str] | None = None,
|
|
396
|
+
alarm_ids: list[int] | None = None,
|
|
397
|
+
last_update: int | None = None,
|
|
398
|
+
) -> dict:
|
|
399
|
+
parents_str = json.dumps(parents or [], separators=(",", ":"))
|
|
400
|
+
alarms_str = json.dumps(alarm_ids or [], separators=(",", ":"))
|
|
401
|
+
path = (
|
|
402
|
+
f"/api/v1/users/{self.user_id}/modules/{udid}"
|
|
403
|
+
f"/update/data/parents/{parents_str}/alarm_ids/{alarms_str}"
|
|
404
|
+
)
|
|
405
|
+
if last_update is not None:
|
|
406
|
+
path += f"/last_update/{last_update}"
|
|
407
|
+
return self.get(path)
|
|
408
|
+
|
|
409
|
+
# ---------------- Notifications ----------------
|
|
410
|
+
def get_notification_times(self, udid: str) -> dict:
|
|
411
|
+
return self.get(f"/api/v1/users/{self.user_id}/modules/{udid}/notifications/time")
|
|
412
|
+
|
|
413
|
+
def set_notification_time(
|
|
414
|
+
self,
|
|
415
|
+
udid: str,
|
|
416
|
+
*,
|
|
417
|
+
notification_type: str,
|
|
418
|
+
start: str,
|
|
419
|
+
end: str,
|
|
420
|
+
enable: bool,
|
|
421
|
+
id_: int,
|
|
422
|
+
) -> dict:
|
|
423
|
+
return self.put(
|
|
424
|
+
f"/api/v1/users/{self.user_id}/modules/{udid}/notifications/time",
|
|
425
|
+
{
|
|
426
|
+
"data": {
|
|
427
|
+
"notification_type": notification_type,
|
|
428
|
+
"start": start,
|
|
429
|
+
"end": end,
|
|
430
|
+
"enable": enable,
|
|
431
|
+
"id": id_,
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# ---------------- Write-settle helpers (`duringChange:"t"` window) ----------------
|
|
437
|
+
def wait_until_settled(
|
|
438
|
+
self,
|
|
439
|
+
check: Callable[[], bool],
|
|
440
|
+
*,
|
|
441
|
+
timeout: float = 30.0,
|
|
442
|
+
interval: float = 2.0,
|
|
443
|
+
) -> bool:
|
|
444
|
+
"""Poll `check()` until it returns True (settled) or timeout elapses.
|
|
445
|
+
|
|
446
|
+
Borrowed from HA tech-controllers issue #184: after any write the API
|
|
447
|
+
keeps reporting the OLD value with `duringChange:"t"` for ~30s. Caller
|
|
448
|
+
provides a closure that re-reads and decides "settled?" — typically by
|
|
449
|
+
inspecting `duringChange` on the relevant zone/menu element.
|
|
450
|
+
|
|
451
|
+
Returns True if settled, False on timeout.
|
|
452
|
+
"""
|
|
453
|
+
deadline = time.monotonic() + timeout
|
|
454
|
+
while True:
|
|
455
|
+
try:
|
|
456
|
+
if check():
|
|
457
|
+
return True
|
|
458
|
+
except EmodulApiError:
|
|
459
|
+
# Transient errors during settle are normal; just retry.
|
|
460
|
+
pass
|
|
461
|
+
if time.monotonic() >= deadline:
|
|
462
|
+
return False
|
|
463
|
+
time.sleep(interval)
|
|
464
|
+
|
|
465
|
+
def is_menu_item_settled(
|
|
466
|
+
self, udid: str, menu_type: str, ido: int, *, pin_chain=None
|
|
467
|
+
) -> bool:
|
|
468
|
+
"""True when `duringChange` on the menu element is no longer set."""
|
|
469
|
+
from emodul.settings_map import find_item # local import to avoid cycle
|
|
470
|
+
|
|
471
|
+
menu = self.get_menu(udid, menu_type, pin_chain=pin_chain)
|
|
472
|
+
item = find_item(menu, ido)
|
|
473
|
+
if item is None:
|
|
474
|
+
return True
|
|
475
|
+
return not _is_changing(item.get("duringChange"))
|
|
476
|
+
|
|
477
|
+
def is_zone_settled(self, udid: str, zone_id: int) -> bool:
|
|
478
|
+
"""True when zone/mode/description all report `duringChange` clear."""
|
|
479
|
+
snap = self.get_module(udid)
|
|
480
|
+
elements = (snap.get("zones") or {}).get("elements") or []
|
|
481
|
+
for el in elements:
|
|
482
|
+
if not el:
|
|
483
|
+
continue
|
|
484
|
+
zone = el.get("zone") or {}
|
|
485
|
+
if zone.get("id") != zone_id:
|
|
486
|
+
continue
|
|
487
|
+
if _is_changing(zone.get("duringChange")):
|
|
488
|
+
return False
|
|
489
|
+
for sub in ("mode", "description", "schedule"):
|
|
490
|
+
s = el.get(sub) or {}
|
|
491
|
+
if _is_changing(s.get("duringChange")):
|
|
492
|
+
return False
|
|
493
|
+
return True
|
|
494
|
+
return True # zone vanished — settled (or moved)
|
emodul/auth.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Keychain-backed auto-refresh: on 401, re-authenticate and retry once.
|
|
2
|
+
|
|
3
|
+
Uses the cross-platform `keyring` library: macOS Keychain, GNOME Keyring,
|
|
4
|
+
KWallet, or Windows Credential Locker — whichever the OS provides.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
import keyring
|
|
11
|
+
import keyring.errors
|
|
12
|
+
|
|
13
|
+
from emodul.api import ApiClient
|
|
14
|
+
from emodul.config import Config
|
|
15
|
+
from emodul.format import err_console
|
|
16
|
+
|
|
17
|
+
KEYRING_SERVICE = "emodul"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RefreshUnavailable(RuntimeError):
|
|
21
|
+
"""Raised when auto-refresh can't proceed (no email, no password, etc.)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def set_password(email: str, password: str) -> None:
|
|
25
|
+
keyring.set_password(KEYRING_SERVICE, email, password)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_password(email: str) -> str | None:
|
|
29
|
+
try:
|
|
30
|
+
return keyring.get_password(KEYRING_SERVICE, email)
|
|
31
|
+
except keyring.errors.KeyringError:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def delete_password(email: str) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
keyring.delete_password(KEYRING_SERVICE, email)
|
|
38
|
+
return True
|
|
39
|
+
except keyring.errors.PasswordDeleteError:
|
|
40
|
+
return False
|
|
41
|
+
except keyring.errors.KeyringError:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_refresher(
|
|
46
|
+
config: Config,
|
|
47
|
+
persist: Callable[[str, int], None],
|
|
48
|
+
) -> Callable[[ApiClient], None]:
|
|
49
|
+
"""Return a refresher closure suitable for ApiClient(refresher=...).
|
|
50
|
+
|
|
51
|
+
`persist(token, user_id)` is called after a successful re-auth so the new
|
|
52
|
+
token is saved to disk for the next process.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def refresh(client: ApiClient) -> None:
|
|
56
|
+
if not config.email:
|
|
57
|
+
raise RefreshUnavailable(
|
|
58
|
+
"No email in config; run `emodul auth login` to enable auto-refresh."
|
|
59
|
+
)
|
|
60
|
+
password = get_password(config.email)
|
|
61
|
+
if not password:
|
|
62
|
+
raise RefreshUnavailable(
|
|
63
|
+
f"No password in keyring for {config.email!r}; "
|
|
64
|
+
"run `emodul auth login` to store it."
|
|
65
|
+
)
|
|
66
|
+
err_console.print(
|
|
67
|
+
f"[yellow]token rejected; re-authenticating as {config.email}…[/yellow]"
|
|
68
|
+
)
|
|
69
|
+
body = client.authenticate(config.email, password)
|
|
70
|
+
token = body.get("token")
|
|
71
|
+
user_id = body.get("user_id")
|
|
72
|
+
if not token or not user_id:
|
|
73
|
+
raise RefreshUnavailable(f"unexpected auth response: {body}")
|
|
74
|
+
client.token = token
|
|
75
|
+
client.user_id = int(user_id)
|
|
76
|
+
persist(token, int(user_id))
|
|
77
|
+
err_console.print("[green]auto-refresh ok.[/green]")
|
|
78
|
+
|
|
79
|
+
return refresh
|