tolpo 0.1.0 → 0.2.0

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 (53) hide show
  1. package/README.md +256 -27
  2. package/package.json +7 -1
  3. package/pyproject.toml +4 -1
  4. package/src/tolpo/__init__.py +1 -1
  5. package/src/tolpo/api.py +111 -3
  6. package/src/tolpo/areas/__init__.py +187 -0
  7. package/src/tolpo/areas/automation.py +227 -0
  8. package/src/tolpo/areas/camera_detection.py +893 -0
  9. package/src/tolpo/areas/camera_media.py +745 -0
  10. package/src/tolpo/areas/camera_system.py +866 -0
  11. package/src/tolpo/areas/common.py +25 -0
  12. package/src/tolpo/areas/device_admin.py +650 -0
  13. package/src/tolpo/areas/doorbell.py +674 -0
  14. package/src/tolpo/areas/home_plan.py +229 -0
  15. package/src/tolpo/areas/hub.py +995 -0
  16. package/src/tolpo/areas/lights.py +798 -0
  17. package/src/tolpo/areas/lock.py +367 -0
  18. package/src/tolpo/areas/network.py +114 -0
  19. package/src/tolpo/areas/overview.py +21 -0
  20. package/src/tolpo/areas/pairing.py +127 -0
  21. package/src/tolpo/areas/plugs.py +660 -0
  22. package/src/tolpo/areas/vacuum.py +649 -0
  23. package/src/tolpo/automation/__init__.py +8 -0
  24. package/src/tolpo/automation/engine.py +456 -0
  25. package/src/tolpo/automation/events.py +81 -0
  26. package/src/tolpo/automation/graph.py +26 -0
  27. package/src/tolpo/automation/models.py +194 -0
  28. package/src/tolpo/automation/store.py +194 -0
  29. package/src/tolpo/automation/sun.py +28 -0
  30. package/src/tolpo/automation/tools.py +61 -0
  31. package/src/tolpo/calls.py +92 -0
  32. package/src/tolpo/cli.py +162 -3
  33. package/src/tolpo/config.py +48 -3
  34. package/src/tolpo/home.py +147 -0
  35. package/src/tolpo/mcp_server.py +37 -6
  36. package/src/tolpo/media.py +630 -0
  37. package/src/tolpo/network/__init__.py +5 -0
  38. package/src/tolpo/network/catalog.py +80 -0
  39. package/src/tolpo/network/config.py +123 -0
  40. package/src/tolpo/network/enforcement.py +184 -0
  41. package/src/tolpo/network/models.py +223 -0
  42. package/src/tolpo/network/providers.py +452 -0
  43. package/src/tolpo/network/service.py +422 -0
  44. package/src/tolpo/network/storage.py +132 -0
  45. package/src/tolpo/policy.py +88 -6
  46. package/src/tolpo/provision.py +729 -0
  47. package/src/tolpo/registry.py +362 -20
  48. package/src/tolpo/snapshot.py +441 -0
  49. package/src/tolpo/tsvideo.py +93 -0
  50. package/src/tolpo/views.py +4 -4
  51. package/src/tolpo/web/home.css +253 -0
  52. package/src/tolpo/web/home.html +56 -0
  53. package/src/tolpo/web/home.js +1413 -0
@@ -0,0 +1,650 @@
1
+ """Device settings, SMART firmware and tolpo's own rooms and groups."""
2
+
3
+ import base64
4
+ import json
5
+ import math
6
+ import os
7
+ import re
8
+ import tempfile
9
+ from contextlib import contextmanager
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Annotated, Any, Literal
13
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
14
+
15
+ from kasa import Module
16
+ from kasa.smart import SmartDevice
17
+ from kasa.smartcam import SmartCamDevice
18
+ from pydantic import Field
19
+
20
+ from .. import policy
21
+ from ..calls import NeedsConfirmation
22
+ from ..policy import Tier
23
+ from ..registry import DeviceUnavailable, Forbidden, NotFound, RegistryError, Unsupported, failure_code
24
+ from ..views import clean
25
+ from .common import CAMERA_KINDS, SMART_KINDS
26
+
27
+ CAM_DIALECT_KINDS = [*CAMERA_KINDS, "hub"]
28
+
29
+ METHODS = [
30
+ policy.method("component_nego", Tier.READ, SMART_KINDS, "Read SMART device components."),
31
+ policy.method("setDeviceAlias", Tier.SETTING, CAM_DIALECT_KINDS, "Rename a camera, doorbell or camera protocol hub."),
32
+ policy.method("get_device_time", Tier.READ, SMART_KINDS, "Read the device clock and time zone."),
33
+ policy.method("set_device_time", Tier.SETTING, SMART_KINDS, "Change the device clock and time zone."),
34
+ policy.method("getTimezone", Tier.READ, CAM_DIALECT_KINDS, "Read the camera time zone."),
35
+ policy.method("getClockStatus", Tier.READ, CAM_DIALECT_KINDS, "Read the camera clock."),
36
+ policy.method("setTimezone", Tier.SETTING, CAM_DIALECT_KINDS, "Change the camera time zone."),
37
+ policy.method("set_led_info", Tier.SETTING, SMART_KINDS, "Change the indicator LED rule or schedule."),
38
+ policy.method("getLedStatus", Tier.READ, CAM_DIALECT_KINDS, "Read the camera indicator LED state."),
39
+ policy.method("setLedStatus", Tier.SECURITY, CAM_DIALECT_KINDS, "Change the camera indicator light. Off hides that the camera is recording."),
40
+ policy.method("get_latest_fw", Tier.CONTROL, SMART_KINDS, "Ask the device to check the vendor cloud for a firmware update."),
41
+ policy.method("get_auto_update_info", Tier.READ, SMART_KINDS, "Read automatic firmware update settings."),
42
+ # Turning automatic updates on lets the device install firmware and reboot unattended, and turning them off stops
43
+ # security fixes. The audit groups both with firmware installation, and the camera firmware switch is level 4 too.
44
+ policy.method("set_auto_update_info", Tier.LIFECYCLE, SMART_KINDS, "Change automatic firmware updates and their window."),
45
+ policy.method("get_fw_download_state", Tier.READ, SMART_KINDS, "Read firmware download and upgrade progress."),
46
+ policy.method("fw_download", Tier.LIFECYCLE, SMART_KINDS, "Install firmware fetched by the device and reboot it."),
47
+ ]
48
+
49
+ DeviceRef = Annotated[str, Field(description="Device slug or exact name")]
50
+ Name = Annotated[str, Field(description="Local room or group name", min_length=1, max_length=64)]
51
+ Minute = Annotated[int, Field(description="Minutes after local midnight", ge=0, le=1439, strict=True)]
52
+ Confirm = Annotated[str | None, Field(description="Exact phrase: fw_download <device slug>")]
53
+ AutoConfirm = Annotated[str | None, Field(description="Exact phrase: set_auto_update_info <device slug>")]
54
+
55
+
56
+ def _name(value: str) -> str:
57
+ if not isinstance(value, str):
58
+ raise RegistryError("The name must be text.")
59
+ result = clean(value)
60
+ if not result or len(value) > 64:
61
+ raise RegistryError("The name must contain 1 to 64 characters.")
62
+ return result
63
+
64
+
65
+ def _camera_dialect(entry):
66
+ return entry.kind in CAMERA_KINDS or isinstance(entry.device, SmartCamDevice) or "getDeviceInfo" in getattr(entry.device, "internal_state", {})
67
+
68
+
69
+ def _smart(entry):
70
+ # IOT devices have a different request envelope, even for matching kinds.
71
+ native = isinstance(entry.device, SmartDevice) and not isinstance(entry.device, SmartCamDevice)
72
+ if entry.kind not in SMART_KINDS or not (native or "get_device_info" in getattr(entry.device, "internal_state", {})):
73
+ raise Unsupported(f"{clean(entry.slug)}: this operation requires the SMART protocol.")
74
+
75
+
76
+ def _module(entry, name, *, smart=False):
77
+ if smart:
78
+ _smart(entry)
79
+ module = entry.device.modules.get(name)
80
+ if module is None or getattr(module, "disabled", False):
81
+ raise Unsupported(f"{clean(entry.slug)}: the {name} module is unavailable.")
82
+ return module
83
+
84
+
85
+ def _pick(data, fields, limit=64):
86
+ """Only known scalar fields reach the agent. Unknown and nested values stay private."""
87
+ if not isinstance(data, dict):
88
+ return {}
89
+ out = {}
90
+ for key in fields:
91
+ value = data.get(key)
92
+ if isinstance(value, str):
93
+ value = clean(value, limit)
94
+ if value is not None and value != "" and isinstance(value, (str, bool, int, float)):
95
+ if not isinstance(value, float) or math.isfinite(value):
96
+ out[key] = value
97
+ return out
98
+
99
+
100
+ async def _call(caller, entry, method, params=None, *, confirm=None):
101
+ try:
102
+ return await caller.call(entry, method, params, confirm=confirm)
103
+ except DeviceUnavailable as exc:
104
+ # The shared caller may include raw library text. Keep device values out of errors too.
105
+ text = str(exc)
106
+ code = failure_code(exc)
107
+ reason = f"device reported an error ({code})" if code else "device reported an error"
108
+ if "rejected the account" in text:
109
+ reason = "account credentials were rejected"
110
+ elif "did not answer" in text:
111
+ reason = "device did not answer in time"
112
+ raise DeviceUnavailable(f"{clean(entry.slug)}: {method} failed: {reason}.") from None
113
+
114
+
115
+ async def _write(registry, caller, entry, method, params=None, *, changed, confirm=None):
116
+ await _call(caller, entry, method, params, confirm=confirm)
117
+ await registry.refresh(entry, force=True)
118
+ out = {"device": clean(entry.slug), "changed": changed}
119
+ if not entry.online:
120
+ out["refresh"] = "Device is unavailable after the write; state was not verified."
121
+ return out
122
+
123
+
124
+ async def rename_device(registry, caller, device, name):
125
+ entry = registry.resolve_one(device)
126
+ name = _name(name)
127
+ if _camera_dialect(entry):
128
+ method, params = "setDeviceAlias", {"system": {"sys": {"dev_alias": name}}}
129
+ else:
130
+ _smart(entry)
131
+ method, params = "set_device_info", {"nickname": base64.b64encode(name.encode()).decode()}
132
+ return await _write(registry, caller, entry, method, params, changed={"name": name})
133
+
134
+
135
+ async def set_metadata(registry, caller, device, icon=None, latitude=None, longitude=None):
136
+ # UNVERIFIED: python-kasa writes neither field. The audit names `avatar` as a set_device_info field, and the real
137
+ # get_device_info answers carry `avatar`, `latitude` and `longitude`. That set_device_info accepts the coordinates
138
+ # as integer degrees times 10000 (the scale python-kasa reads them with) is not confirmed on a device.
139
+ entry = registry.resolve_one(device)
140
+ _smart(entry)
141
+ params, changed = {}, {}
142
+ if icon is not None:
143
+ if not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", icon):
144
+ raise RegistryError("Icon must be a built-in avatar identifier.")
145
+ params["avatar"] = changed["icon"] = icon
146
+ if (latitude is None) != (longitude is None):
147
+ raise RegistryError("Latitude and longitude must be provided together.")
148
+ if latitude is not None:
149
+ if not math.isfinite(latitude) or not math.isfinite(longitude) or not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
150
+ raise RegistryError("Coordinates are outside their valid range.")
151
+ params.update(latitude=int(latitude * 10000), longitude=int(longitude * 10000))
152
+ changed["location"] = "updated"
153
+ if not params:
154
+ raise RegistryError("Provide an icon or coordinates.")
155
+ return await _write(registry, caller, entry, "set_device_info", params, changed=changed)
156
+
157
+
158
+ async def device_clock(registry, caller, device, timezone=None, timestamp=None):
159
+ entry = registry.resolve_one(device)
160
+ _module(entry, Module.Time, smart=not _camera_dialect(entry))
161
+ camera = _camera_dialect(entry)
162
+ if timezone is None:
163
+ if timestamp is not None:
164
+ raise RegistryError("A time zone is required when setting the clock.")
165
+ if not camera:
166
+ data = await _call(caller, entry, "get_device_time")
167
+ return {"device": clean(entry.slug), **_pick(data, ("timestamp", "time_diff", "region"))}
168
+ zone = await _call(caller, entry, "getTimezone", {"system": {"name": "basic"}})
169
+ clock = await _call(caller, entry, "getClockStatus", {"system": {"name": "clock_status"}})
170
+ return {"device": clean(entry.slug), **_pick(zone.get("system", {}).get("basic", {}), ("timezone", "zone_id")),
171
+ **_pick(clock.get("system", {}).get("clock_status", {}), ("seconds_from_1970",))}
172
+ try:
173
+ tz = ZoneInfo(timezone)
174
+ if timestamp is not None and (type(timestamp) is not int or not 0 <= timestamp <= 253402214400):
175
+ raise ValueError
176
+ dt = datetime.now(tz) if timestamp is None else datetime.fromtimestamp(timestamp, tz)
177
+ except (ZoneInfoNotFoundError, ValueError, OverflowError, OSError):
178
+ raise RegistryError("Provide a valid IANA time zone and Unix timestamp.") from None
179
+ offset = int(dt.utcoffset().total_seconds() / 60)
180
+ if camera:
181
+ if timestamp is not None:
182
+ raise Unsupported(f"{clean(entry.slug)}: camera clock writes are unsupported; only its time zone can be set.")
183
+ sign = "+" if offset >= 0 else "-"
184
+ hours, minutes = divmod(abs(offset), 60)
185
+ params = {"system": {"basic": {"timezone": f"UTC{sign}{hours:02d}:{minutes:02d}", "zone_id": timezone}}}
186
+ method, changed = "setTimezone", {"timezone": clean(timezone)}
187
+ else:
188
+ params = {"timestamp": int(dt.timestamp()), "time_diff": offset, "region": timezone}
189
+ method, changed = "set_device_time", params
190
+ return await _write(registry, caller, entry, method, params, changed=changed)
191
+
192
+
193
+ _LED_FIELDS = ("led_rule", "led_status", "start_time", "end_time", "night_mode_type", "sunrise_offset", "sunset_offset")
194
+
195
+
196
+ async def device_led(registry, caller, device, rule=None, start=None, end=None, confirm=None):
197
+ entry = registry.resolve_one(device)
198
+ module = _module(entry, Module.Led, smart=not _camera_dialect(entry))
199
+ if rule not in (None, "always", "never", "night_mode"):
200
+ raise RegistryError("LED rule must be always, never or night_mode.")
201
+ schedule = start is not None or end is not None
202
+ if schedule and (rule != "night_mode" or type(start) is not int or type(end) is not int or not 0 <= start <= 1439 or not 0 <= end <= 1439 or start == end):
203
+ raise RegistryError("A night schedule needs distinct start and end minutes from 0 to 1439.")
204
+ if _camera_dialect(entry):
205
+ if rule == "night_mode" or schedule:
206
+ raise Unsupported(f"{clean(entry.slug)}: camera LED schedules are unsupported.")
207
+ if rule is None:
208
+ data = await _call(caller, entry, "getLedStatus", {"led": {"name": "config"}})
209
+ return {"device": clean(entry.slug), **_pick(data.get("led", {}).get("config", {}), ("enabled",))}
210
+ params = {"led": {"config": {"enabled": "on" if rule == "always" else "off"}}}
211
+ return await _write(registry, caller, entry, "setLedStatus", params, changed={"led_rule": rule}, confirm=confirm)
212
+ # python-kasa rate-limits get_led_info because repeated queries can crash P100.
213
+ # Use the module's existing response rather than issuing another LED read.
214
+ data = dict(module.data)
215
+ visible = _pick(data, _LED_FIELDS)
216
+ if isinstance(data.get("night_mode"), dict):
217
+ night = _pick(data["night_mode"], _LED_FIELDS[2:])
218
+ if night:
219
+ visible["night_mode"] = night
220
+ if rule is None:
221
+ return {"device": clean(entry.slug), **visible}
222
+ params = {**visible, "led_rule": rule}
223
+ if schedule:
224
+ night = {"night_mode_type": "custom", "start_time": start, "end_time": end}
225
+ # Match the device's existing layout: old SMART flat fields, newer nested night_mode.
226
+ if "night_mode" in data:
227
+ params["night_mode"] = {**params.get("night_mode", {}), **night}
228
+ elif "night_mode_type" in data:
229
+ params.update(night)
230
+ else:
231
+ raise Unsupported(f"{clean(entry.slug)}: LED schedule layout is unavailable.")
232
+ elif rule == "night_mode" and "night_mode" not in data and "night_mode_type" not in data:
233
+ raise Unsupported(f"{clean(entry.slug)}: no LED night schedule is available.")
234
+ return await _write(registry, caller, entry, "set_led_info", params, changed=params)
235
+
236
+
237
+ def _firmware(entry, auto=False):
238
+ module = _module(entry, Module.Firmware, smart=True)
239
+ if auto and module.supported_version <= 1:
240
+ raise Unsupported(f"{clean(entry.slug)}: automatic firmware updates are unsupported.")
241
+ return module
242
+
243
+
244
+ def _latest(data):
245
+ out = _pick(data, ("type", "need_to_upgrade", "fw_ver", "release_date", "fw_size"))
246
+ out.update(_pick(data, ("release_note",), limit=1200))
247
+ return out
248
+
249
+
250
+ async def firmware_info(registry, caller, device, check=False, progress=False):
251
+ entry = registry.resolve_one(device)
252
+ module = _firmware(entry)
253
+ if check:
254
+ return {"device": clean(entry.slug), **_latest(await _call(caller, entry, "get_latest_fw"))}
255
+ if progress:
256
+ data = await _call(caller, entry, "get_fw_download_state")
257
+ return {"device": clean(entry.slug), **_pick(data, ("status", "download_progress", "upgrade_time", "reboot_time", "auto_upgrade"))}
258
+ info = await _call(caller, entry, "get_device_info")
259
+ out = {"device": clean(entry.slug), **_pick(info, ("fw_ver", "hw_ver"))}
260
+ if module.supported_version > 1:
261
+ data = await _call(caller, entry, "get_auto_update_info")
262
+ auto = _pick(data, ("enable", "time", "random_range"))
263
+ if auto:
264
+ out["auto_update"] = auto
265
+ cached = getattr(module, "firmware_update_info", None)
266
+ if cached:
267
+ out["latest_cached"] = _latest({"type": cached.status, "need_to_upgrade": cached.needs_upgrade,
268
+ "fw_ver": cached.version, "release_note": cached.release_notes,
269
+ "release_date": cached.release_date.isoformat() if cached.release_date else None})
270
+ return out
271
+
272
+
273
+ async def firmware_auto_update(registry, caller, device, enabled, time=None, random_range=None, confirm=None):
274
+ entry = registry.resolve_one(device)
275
+ _firmware(entry, auto=True)
276
+ if type(enabled) is not bool:
277
+ raise RegistryError("Enabled must be a boolean.")
278
+ for value in (time, random_range):
279
+ if value is not None and (type(value) is not int or not 0 <= value <= 1439):
280
+ raise RegistryError("Window values must be minutes from 0 to 1439.")
281
+ # Refuse before the read, so a blocked or unconfirmed change sends nothing.
282
+ plan = caller.plan(entry, "set_auto_update_info")
283
+ if not plan["allowed"]:
284
+ raise Forbidden("Changing automatic firmware updates is a level 4 action and the owner's policy blocks it.")
285
+ if confirm != plan.get("confirm"):
286
+ raise NeedsConfirmation(f"Changing automatic firmware updates requires confirm='set_auto_update_info {clean(entry.slug)}'.")
287
+ current = await _call(caller, entry, "get_auto_update_info")
288
+ params = _pick(current, ("enable", "time", "random_range"))
289
+ params["enable"] = enabled
290
+ if time is not None:
291
+ params["time"] = time
292
+ if random_range is not None:
293
+ params["random_range"] = random_range
294
+ return await _write(registry, caller, entry, "set_auto_update_info", params, changed={"auto_update": params}, confirm=confirm)
295
+
296
+
297
+ async def firmware_install(registry, caller, device, version, confirm=None):
298
+ entry = registry.resolve_one(device)
299
+ _firmware(entry)
300
+ plan = caller.plan(entry, "fw_download")
301
+ if not plan["allowed"]:
302
+ raise Forbidden("Firmware installation is a level 4 action and the owner's policy blocks it.")
303
+ if confirm != plan.get("confirm"):
304
+ raise NeedsConfirmation(f"Firmware installation requires confirm='fw_download {clean(entry.slug)}'.")
305
+ latest = await _call(caller, entry, "get_latest_fw")
306
+ if not isinstance(version, str) or not version or len(version) > 128:
307
+ raise RegistryError("Provide the expected firmware version.")
308
+ if not latest.get("type") or latest.get("fw_ver") != version:
309
+ raise RegistryError("The available firmware does not match the requested version, or no update is available.")
310
+ result = await _write(registry, caller, entry, "fw_download", changed={"firmware_install": "started", "version": clean(version, 128)}, confirm=confirm)
311
+ result["status"] = "Start request accepted. Use firmware_progress to check completion."
312
+ return result
313
+
314
+
315
+ async def diagnostics(registry, caller, device):
316
+ entry = registry.resolve_one(device)
317
+ if _camera_dialect(entry):
318
+ if entry.root is not None:
319
+ # A child camera gets normalized basic info and components from its parent.
320
+ out = _pick(getattr(entry.device, "sys_info", {}), ("model", "fw_ver", "hw_ver"))
321
+ for field in ("rssi", "signal_level", "uptime", "cam_uptime"):
322
+ value = getattr(entry.device, "sys_info", {}).get(field)
323
+ if type(value) in (int, float):
324
+ out.update(_pick({field: value}, (field,)))
325
+ components = getattr(entry.device, "_components_raw", {})
326
+ safe = [_pick(item, ("name", "version")) for item in components.get("app_component_list", [])[:100] if isinstance(item, dict)]
327
+ safe = [item for item in safe if item]
328
+ return {"device": clean(entry.slug), "source": "cached parent state", **out, **({"components": safe} if safe else {})}
329
+ if entry.kind == "hub":
330
+ # Common camera reads are not declared for hubs in the shared catalog.
331
+ # The negotiated responses are already available, without another wire call.
332
+ raw = entry.device.internal_state.get("getDeviceInfo", {})
333
+ else:
334
+ raw = await _call(caller, entry, "getDeviceInfo", {"device_info": {"name": ["basic_info", "info"]}})
335
+ info = raw.get("device_info", {}).get("basic_info", {})
336
+ out = _pick(info, ("device_model", "sw_version", "hw_version"))
337
+ if entry.kind == "hub":
338
+ components = entry.device.internal_state.get("getAppComponentList", {})
339
+ out["source"] = "cached negotiation"
340
+ else:
341
+ components = await _call(caller, entry, "getAppComponentList", {"app_component": {"name": "app_component_list"}})
342
+ items = components.get("app_component", {}).get("app_component_list", [])
343
+ else:
344
+ _smart(entry)
345
+ info = await _call(caller, entry, "get_device_info")
346
+ out = _pick(info, ("model", "fw_ver", "hw_ver"))
347
+ for field in ("rssi", "signal_level", "on_time", "uptime"):
348
+ if type(info.get(field)) in (int, float):
349
+ out.update(_pick(info, (field,)))
350
+ if entry.root is not None:
351
+ # Children receive component negotiation through their parent, not component_nego.
352
+ components = getattr(entry.device, "_components_raw", {})
353
+ else:
354
+ components = await _call(caller, entry, "component_nego")
355
+ items = components.get("component_list", [])
356
+ safe = [_pick(item, ("id", "ver_code", "name", "version")) for item in items[:100] if isinstance(item, dict)]
357
+ safe = [item for item in safe if item]
358
+ if safe:
359
+ out["components"] = safe
360
+ return {"device": clean(entry.slug), **out}
361
+
362
+
363
+ def compatibility_info() -> dict[str, Any]:
364
+ return {"detectable_locally": False, "account_switch": "homeAssistantCompatibility",
365
+ "status": "unknown", "note": "Third-Party Compatibility may gate local access. Its account setting cannot be read locally. Local authentication failure alone does not identify this switch."}
366
+
367
+
368
+ # These collections are local data, never cloud families or device-side group ids.
369
+ def _state_path() -> Path:
370
+ return Path(os.environ.get("TOLPO_STATE_DIR", str(Path.home() / ".local/state/tolpo"))) / "rooms.json"
371
+
372
+
373
+ def _load_map(path):
374
+ try:
375
+ if path.stat().st_size > 1024 * 1024:
376
+ raise ValueError
377
+ data = json.loads(path.read_text(encoding="utf-8"))
378
+ if not isinstance(data, dict) or data.get("version") != 1 or set(data) != {"version", "rooms", "groups"}:
379
+ raise ValueError
380
+ for collection in ("rooms", "groups"):
381
+ if not isinstance(data[collection], dict) or len(data[collection]) > 256:
382
+ raise ValueError
383
+ seen = set()
384
+ for name, members in data[collection].items():
385
+ if _name(name) != name or name.casefold() in seen or not isinstance(members, list) or len(members) > 256:
386
+ raise ValueError
387
+ seen.add(name.casefold())
388
+ if len(set(members)) != len(members) or any(not isinstance(s, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", s) for s in members):
389
+ raise ValueError
390
+ return data
391
+ except FileNotFoundError:
392
+ return {"version": 1, "rooms": {}, "groups": {}}
393
+ except (OSError, ValueError, TypeError, UnicodeDecodeError, RegistryError):
394
+ raise RegistryError("The local rooms file is unreadable or invalid; it was not replaced.") from None
395
+
396
+
397
+ def _find(collection, name):
398
+ wanted = _name(name).casefold()
399
+ for saved in collection:
400
+ if saved.casefold() == wanted:
401
+ return saved
402
+ raise NotFound("No local room or group has that name.")
403
+
404
+
405
+ def resolve_room(name: str) -> list[str]:
406
+ """Resolve a tolpo room to saved slugs. No discovery or device action."""
407
+ rooms = _load_map(_state_path())["rooms"]
408
+ return list(rooms[_find(rooms, name)])
409
+
410
+
411
+ @contextmanager
412
+ def _locked_map():
413
+ # flock coordinates separate tolpo processes; os.replace makes readers see a complete file.
414
+ try:
415
+ import fcntl
416
+ except ImportError: # Windows has no flock. The replace stays atomic, only the lock is skipped.
417
+ fcntl = None
418
+
419
+ path = _state_path()
420
+ try:
421
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
422
+ with os.fdopen(os.open(path.with_suffix(".lock"), os.O_RDWR | os.O_CREAT, 0o600), "r+") as lock:
423
+ if fcntl is not None:
424
+ fcntl.flock(lock, fcntl.LOCK_EX)
425
+ data = _load_map(path)
426
+ yield data
427
+ if any(len(data[k]) > 256 for k in ("rooms", "groups")):
428
+ raise RegistryError("At most 256 rooms and 256 groups can be stored.")
429
+ fd, temporary = tempfile.mkstemp(prefix=".rooms-", dir=path.parent)
430
+ try:
431
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
432
+ json.dump(data, stream, ensure_ascii=False)
433
+ stream.flush()
434
+ os.fsync(stream.fileno())
435
+ os.replace(temporary, path)
436
+ finally:
437
+ if os.path.exists(temporary):
438
+ os.unlink(temporary)
439
+ except OSError:
440
+ raise RegistryError("The local rooms file could not be saved.") from None
441
+
442
+
443
+ def local_collection(registry, collection, action, name=None, new_name=None, devices=None):
444
+ if collection not in ("rooms", "groups") or action not in ("list", "create", "rename", "delete", "assign"):
445
+ raise RegistryError("Unknown local collection operation.")
446
+ if action == "list":
447
+ return {collection: [{"name": n, "devices": members} for n, members in sorted(_load_map(_state_path())[collection].items())]}
448
+ if registry.cfg.tier < Tier.CONTROL:
449
+ raise Forbidden("Changing local rooms or groups is a level 1 action and the owner's policy blocks it.")
450
+ name = _name(name)
451
+ members = None
452
+ if action == "assign":
453
+ if not isinstance(devices, list) or len(devices) > 256:
454
+ raise RegistryError("Provide a list of at most 256 devices.")
455
+ members = list(dict.fromkeys(registry.resolve_one(ref).slug for ref in devices))
456
+ with _locked_map() as data:
457
+ items = data[collection]
458
+ if action == "create":
459
+ if any(n.casefold() == name.casefold() for n in items):
460
+ raise RegistryError("A local room or group already has that name.")
461
+ items[name] = []
462
+ else:
463
+ name = _find(items, name)
464
+ if action == "delete":
465
+ del items[name]
466
+ elif action == "rename":
467
+ new_name = _name(new_name)
468
+ if any(n != name and n.casefold() == new_name.casefold() for n in items):
469
+ raise RegistryError("A local room or group already has that name.")
470
+ items[new_name] = items.pop(name)
471
+ else:
472
+ items[name] = members
473
+ if collection == "rooms":
474
+ for other in items:
475
+ if other != name:
476
+ items[other] = [slug for slug in items[other] if slug not in members]
477
+ return {"collection": collection, "action": action, "name": new_name if action == "rename" else name,
478
+ **({"devices": members} if members is not None else {})}
479
+
480
+
481
+ async def room_power(registry, caller, name, on):
482
+ if registry.cfg.tier < Tier.CONTROL:
483
+ raise Forbidden("Room power is a level 1 action and the owner's policy blocks it.")
484
+ if type(on) is not bool:
485
+ raise RegistryError("Power state must be a boolean.")
486
+ slugs = resolve_room(name)
487
+ entries = [registry.by_slug(slug) for slug in slugs]
488
+ # Check the whole room first. Camera state is privacy, never a power relay.
489
+ for entry in entries:
490
+ if entry.kind not in policy.POWER_KINDS:
491
+ raise Unsupported(f"{clean(entry.slug)}: room power supports only lights, plugs and switches.")
492
+ registry._require_write(entry, "state")
493
+ results = []
494
+ for entry in entries:
495
+ try:
496
+ await registry.set_power(entry, on)
497
+ results.append({"device": clean(entry.slug), "ok": True, "on": on,
498
+ **({"refresh": "Device state was not verified."} if not entry.online else {})})
499
+ except RegistryError:
500
+ results.append({"device": clean(entry.slug), "ok": False, "error": "Power write failed; device is unavailable."})
501
+ return {"room": _name(name), "results": results}
502
+
503
+
504
+ def tools(ctx):
505
+ async def run(fn, device, *args, **kwargs):
506
+ await ctx.ready_for([device])
507
+ with ctx.translated():
508
+ return await fn(ctx.registry, ctx.caller, device, *args, **kwargs)
509
+
510
+ @ctx.tool(title="Rename device", tier=Tier.SETTING)
511
+ async def device_rename(device: DeviceRef, name: Annotated[str, Field(description="New device alias", min_length=1, max_length=64)]) -> dict[str, Any]:
512
+ """Rename a SMART device, camera or camera protocol hub. Its current tolpo slug stays usable."""
513
+ return await run(rename_device, device, name)
514
+
515
+ @ctx.tool(title="Device icon and location", tier=Tier.SETTING)
516
+ async def device_set_metadata(device: DeviceRef, icon: Annotated[str | None, Field(description="Built-in avatar identifier")] = None,
517
+ latitude: Annotated[float | None, Field(description="Latitude in degrees", ge=-90, le=90)] = None,
518
+ longitude: Annotated[float | None, Field(description="Longitude in degrees", ge=-180, le=180)] = None) -> dict[str, Any]:
519
+ """Set a SMART device icon or sunrise location. Both coordinates are required together."""
520
+ return await run(set_metadata, device, icon, latitude, longitude)
521
+
522
+ @ctx.tool(title="Read device clock")
523
+ async def device_get_clock(device: DeviceRef) -> dict[str, Any]:
524
+ """Read device time and time zone."""
525
+ return await run(device_clock, device)
526
+
527
+ @ctx.tool(title="Set device clock", tier=Tier.SETTING)
528
+ async def device_set_clock(device: DeviceRef, timezone: Annotated[str, Field(description="IANA time zone, for example Europe/Berlin")],
529
+ timestamp: Annotated[int | None, Field(description="Unix seconds; omitted means now on SMART. Cameras support only the zone.", ge=0, strict=True)] = None) -> dict[str, Any]:
530
+ """Set SMART time and zone. Cameras accept only the zone, with timestamp omitted."""
531
+ return await run(device_clock, device, timezone, timestamp)
532
+
533
+ @ctx.tool(title="Read device LED")
534
+ async def device_get_led(device: DeviceRef) -> dict[str, Any]:
535
+ """Read the indicator rule. SMART uses cached LED data to avoid repeated device queries."""
536
+ return await run(device_led, device)
537
+
538
+ @ctx.tool(title="Set device LED", tier=Tier.SECURITY)
539
+ async def device_set_led(device: DeviceRef, rule: Annotated[Literal["always", "never", "night_mode"], Field(description="Indicator LED rule")],
540
+ start: Minute | None = None, end: Minute | None = None,
541
+ confirm: Annotated[str | None, Field(description="Exact phrase: setLedStatus <device slug>. Needed for cameras only")] = None) -> dict[str, Any]:
542
+ """Set an LED rule or custom night schedule. Cameras support always and never only. Turning a camera light off
543
+ hides that it is recording, so on a camera this is a level 3 action that needs the confirmation phrase."""
544
+ return await run(device_led, device, rule, start, end, confirm)
545
+
546
+ @ctx.tool(title="Read firmware")
547
+ async def firmware_get(device: DeviceRef) -> dict[str, Any]:
548
+ """Read SMART versions, automatic update settings and locally cached release notes."""
549
+ return await run(firmware_info, device)
550
+
551
+ @ctx.tool(title="Check firmware", tier=Tier.CONTROL)
552
+ async def firmware_check(device: DeviceRef) -> dict[str, Any]:
553
+ """Ask a SMART device to check the vendor cloud for an update and return local release notes. The device makes
554
+ the request, not tolpo, and it can take longer than a local read."""
555
+ return await run(firmware_info, device, check=True)
556
+
557
+ @ctx.tool(title="Firmware progress")
558
+ async def firmware_progress(device: DeviceRef) -> dict[str, Any]:
559
+ """Read SMART firmware download and install progress. No polling or retries."""
560
+ return await run(firmware_info, device, progress=True)
561
+
562
+ @ctx.tool(title="Automatic firmware updates", tier=Tier.LIFECYCLE)
563
+ async def firmware_set_auto_update(device: DeviceRef, enabled: Annotated[bool, Field(description="Enable automatic updates", strict=True)],
564
+ time: Minute | None = None,
565
+ random_range: Annotated[int | None, Field(description="Random spread in minutes", ge=0, le=1439, strict=True)] = None,
566
+ confirm: AutoConfirm = None) -> dict[str, Any]:
567
+ """Change SMART automatic updates with confirmation. When on, the device installs firmware and reboots by itself.
568
+ Omitted window fields keep their current values."""
569
+ return await run(firmware_auto_update, device, enabled, time, random_range, confirm)
570
+
571
+ @ctx.tool(title="Install firmware", tier=Tier.LIFECYCLE)
572
+ async def firmware_install_update(device: DeviceRef, version: Annotated[str, Field(description="Exact available firmware version to install", min_length=1, max_length=128)],
573
+ confirm: Confirm = None) -> dict[str, Any]:
574
+ """Start one SMART firmware install with confirmation. The device fetches the image and reboots."""
575
+ return await run(firmware_install, device, version, confirm)
576
+
577
+ @ctx.tool(title="Device diagnostics")
578
+ async def device_diagnostics(device: DeviceRef) -> dict[str, Any]:
579
+ """Read versions, available signal and runtime counters and components. No secrets or log export."""
580
+ return await run(diagnostics, device)
581
+
582
+ @ctx.tool(title="Third-Party Compatibility awareness")
583
+ async def device_third_party_compatibility() -> dict[str, Any]:
584
+ """Explain the account switch that may gate local access. Its value cannot be detected locally."""
585
+ return compatibility_info()
586
+
587
+ def local(kind, action, name=None, new_name=None, devices=None):
588
+ with ctx.translated():
589
+ return local_collection(ctx.registry, kind, action, name, new_name, devices)
590
+
591
+ @ctx.tool(title="List local rooms")
592
+ async def room_list() -> dict[str, Any]:
593
+ """List tolpo's local rooms and their device slugs."""
594
+ return local("rooms", "list")
595
+
596
+ @ctx.tool(title="Create local room", tier=Tier.CONTROL)
597
+ async def room_create(name: Name) -> dict[str, Any]:
598
+ """Create an empty room in tolpo's local file."""
599
+ return local("rooms", "create", name)
600
+
601
+ @ctx.tool(title="Rename local room", tier=Tier.CONTROL)
602
+ async def room_rename(name: Name, new_name: Name) -> dict[str, Any]:
603
+ """Rename a local room and keep its device assignments."""
604
+ return local("rooms", "rename", name, new_name)
605
+
606
+ @ctx.tool(title="Delete local room", tier=Tier.CONTROL)
607
+ async def room_delete(name: Name) -> dict[str, Any]:
608
+ """Delete a local room without changing any device."""
609
+ return local("rooms", "delete", name)
610
+
611
+ @ctx.tool(title="Assign local room devices", tier=Tier.CONTROL)
612
+ async def room_assign(name: Name, devices: Annotated[list[str], Field(description="Exact device refs replacing room membership; empty clears it", max_length=256)]) -> dict[str, Any]:
613
+ """Replace room membership. A device belongs to at most one local room."""
614
+ await ctx.ready_for(devices)
615
+ return local("rooms", "assign", name, devices=devices)
616
+
617
+ @ctx.tool(title="Set room power", tier=Tier.CONTROL)
618
+ async def room_set_power(name: Name, on: Annotated[bool, Field(description="Power state for all room lights, plugs and switches", strict=True)]) -> dict[str, Any]:
619
+ """Set room power after checking every member. Report each failed device separately."""
620
+ with ctx.translated():
621
+ slugs = resolve_room(name)
622
+ await ctx.ready_for(slugs)
623
+ with ctx.translated():
624
+ return await room_power(ctx.registry, ctx.caller, name, on)
625
+
626
+ @ctx.tool(title="List local groups")
627
+ async def group_list() -> dict[str, Any]:
628
+ """List tolpo's local groups and their device slugs."""
629
+ return local("groups", "list")
630
+
631
+ @ctx.tool(title="Create local group", tier=Tier.CONTROL)
632
+ async def group_create(name: Name) -> dict[str, Any]:
633
+ """Create an empty local group. It does not change Tapo app groups."""
634
+ return local("groups", "create", name)
635
+
636
+ @ctx.tool(title="Rename local group", tier=Tier.CONTROL)
637
+ async def group_rename(name: Name, new_name: Name) -> dict[str, Any]:
638
+ """Rename a local group and keep its members."""
639
+ return local("groups", "rename", name, new_name)
640
+
641
+ @ctx.tool(title="Delete local group", tier=Tier.CONTROL)
642
+ async def group_delete(name: Name) -> dict[str, Any]:
643
+ """Delete a local group without changing any device."""
644
+ return local("groups", "delete", name)
645
+
646
+ @ctx.tool(title="Assign local group devices", tier=Tier.CONTROL)
647
+ async def group_assign(name: Name, devices: Annotated[list[str], Field(description="Exact device refs replacing group membership; empty clears it", max_length=256)]) -> dict[str, Any]:
648
+ """Replace local group members. Devices can belong to several groups."""
649
+ await ctx.ready_for(devices)
650
+ return local("groups", "assign", name, devices=devices)