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 ADDED
@@ -0,0 +1,3 @@
1
+ """Unofficial Python client + CLI for the Tech Sterowniki eModul.pl API."""
2
+
3
+ __version__ = "0.1.0"
emodul/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from emodul.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
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