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,674 @@
1
+ """Local doorbell observations and chime controls. All hardware behavior is UNVERIFIED."""
2
+
3
+ import re
4
+ from typing import Annotated, Any, Literal
5
+
6
+ from pydantic import Field
7
+ from kasa.protocols.smartcamprotocol import SmartCamProtocol
8
+
9
+ from .. import policy
10
+ from ..policy import Tier
11
+ from ..registry import BadRequest, DeviceUnavailable, Forbidden, RegistryError, failure_code
12
+ from ..views import clean
13
+
14
+ METHODS = [
15
+ policy.method("getLastAlarmInfo", Tier.READ, ["doorbell"], "Read the last doorbell alarm observation."),
16
+ policy.method("getCallStateInfo", Tier.READ, ["doorbell"], "Read the doorbell call state without answering it."),
17
+ policy.method("get_trigger_logs", Tier.READ, ["doorbell"], "Read hub trigger observations through the doorbell child path."),
18
+ ]
19
+
20
+ METHODS += [
21
+ policy.method("get_chime_alarm_configure", Tier.READ, ["chime", "hub"], "Read chime settings for a paired doorbell."),
22
+ policy.method("get_support_alarm_type_list", Tier.READ, ["chime", "hub"], "Read the available chime tones."),
23
+ policy.method("set_chime_alarm_configure", Tier.SETTING, ["chime", "hub"], "Change chime tone, volume and duration for a doorbell."),
24
+ policy.method("play_alarm", Tier.SECURITY, ["chime", "hub"], "Play an audible chime or hub sound."),
25
+ policy.method("stop_alarm", Tier.SECURITY, ["chime", "hub"], "Stop a chime or hub sound."),
26
+ ]
27
+
28
+ Volume = Literal["mute", "low", "normal", "high"]
29
+
30
+
31
+ def _choice(value, choices, name):
32
+ if value not in choices:
33
+ raise BadRequest(f"Unsupported {name}")
34
+ return value
35
+
36
+
37
+ def _boolean(value, name):
38
+ if type(value) is not bool:
39
+ raise BadRequest(f"{name} must be a boolean")
40
+ return value
41
+
42
+
43
+ def _mac(entry):
44
+ # Read the owner's already authenticated device metadata. Never expose this address.
45
+ value = getattr(entry.device, "mac", None)
46
+ if not isinstance(value, str) or not re.fullmatch(r"(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}", value):
47
+ raise BadRequest(f"{clean(entry.slug)} has no usable cached pairing address")
48
+ # UNVERIFIED: chime firmware must be checked for colon versus hyphen address formatting.
49
+ return value
50
+
51
+
52
+ async def _write(registry, caller, entry, method, params=None, confirm=None, changed=None):
53
+ await _call(caller, entry, method, params, confirm)
54
+ await registry.refresh(entry, force=True)
55
+ return {"device": clean(entry.slug), "changed": changed or method, "unverified": True}
56
+
57
+
58
+ async def chime_get(registry, caller, device, doorbell):
59
+ entry = _entry(registry, device, ["chime", "hub"])
60
+ bell = _entry(registry, doorbell, ["doorbell"])
61
+ params = {"mac": _mac(bell)}
62
+ if _smartcam_hub(entry):
63
+ params = {"chime": params}
64
+ value = await _call(caller, entry, "get_chime_alarm_configure", params)
65
+ config = _compact(_section(value, "chime", "config"), ("func_type", "type", "volume", "duration", "on_off"))
66
+ tones = await _available_tones(caller, entry)
67
+ out = {"device": clean(entry.slug), "doorbell": clean(bell.slug), "unverified": True}
68
+ if config:
69
+ out["settings"] = config
70
+ if tones:
71
+ out["tones"] = tones
72
+ return out
73
+
74
+
75
+ def _tone_list(value, field):
76
+ values = value.get(field, []) if isinstance(value, dict) else value
77
+ return [clean(tone) for tone in values[:50] if isinstance(tone, str) and clean(tone)] if isinstance(values, list) else []
78
+
79
+
80
+ async def _check_tone(caller, entry, tone):
81
+ _text(tone, "tone")
82
+ if tone not in await _available_tones(caller, entry):
83
+ raise BadRequest("The chime does not advertise this tone")
84
+
85
+
86
+ async def chime_set(registry, caller, device, doorbell, tone, volume, duration, enabled):
87
+ _choice(volume, ("mute", "low", "normal", "high"), "volume")
88
+ _integer(duration, 1, 600, "duration")
89
+ _boolean(enabled, "enabled")
90
+ _text(tone, "tone")
91
+ entry = _entry(registry, device, ["chime", "hub"])
92
+ bell = _entry(registry, doorbell, ["doorbell"])
93
+ mac = _mac(bell)
94
+ await _check_tone(caller, entry, tone)
95
+ # UNVERIFIED: the audit/model confirms these fields; per-model duration limits need fixtures.
96
+ params = {"mac": mac, "type": tone, "volume": volume, "duration": duration, "on_off": int(enabled)}
97
+ return await _write(registry, caller, entry, "set_chime_alarm_configure", {"chime": params} if _smartcam_hub(entry) else params, changed={k: v for k, v in params.items() if k != "mac"})
98
+
99
+
100
+ async def chime_sound_action(registry, caller, device, action, tone=None, volume="normal", duration=1, confirm=None):
101
+ _choice(action, ("play", "stop"), "action")
102
+ entry = _entry(registry, device, ["chime", "hub"])
103
+ if _smartcam_hub(entry):
104
+ raise BadRequest("This hub plays its siren through hub_siren")
105
+ if action == "stop":
106
+ return await _write(registry, caller, entry, "stop_alarm", confirm=confirm, changed="sound stopped")
107
+ _choice(volume, ("mute", "low", "normal", "high"), "volume")
108
+ _integer(duration, 1, 600, "duration")
109
+ await _check_tone(caller, entry, tone)
110
+ params = {"alarm_type": tone, "alarm_volume": volume, "alarm_duration": duration}
111
+ return await _write(registry, caller, entry, "play_alarm", params, confirm, "sound played")
112
+
113
+ # These module/section names come from the local protocol catalog and request wrappers.
114
+ # UNVERIFIED: firmware versions use different wrappers and the H200 may not forward all of them.
115
+ READS = {
116
+ "ring": [("getRingStatus", "ring", "status", ("enabled", "status")), ("getRingSoundConfig", "ring", "config", ("ring_type", "volume")), ("getRingTypeList", "ring", "ring_type_list", ()), ("getRingPlay", "ring", "play", ("status", "ring_type", "time_left"))],
117
+ "hub_ring": [("getHubRingStatus", "hub_ring", "config", ("enabled", "status")), ("getHubRingSoundConfig", "hub_ring", "config", ("ring_type", "volume")), ("getHubRingTypeList", "hub_ring", "ring_type_list", ()), ("getHubRingPlay", "hub_ring", "play", ("status", "ring_type", "time_left"))],
118
+ "hub_plan": [("getHubRingPlan", "hub_ring_plan", "chn1_hub_ring_plan", ("enabled", "ring_plan_1"))],
119
+ "chime_plan": [("getChimeRingPlan", "chime_ring_plan", "chn1_chime_ring_plan", ("enabled", "ring_plan_1"))],
120
+ "existing_chime": [("getExistingChimeCfg", "existing_chime", "config", ("type", "ring_length"))],
121
+ "auto_response": [("getSmartRespStatus", "quick_response", "smart_resp_status", ("enabled",)), ("getSmartRespConfig", "quick_response", "smart_resp_config", ("id", "play_interval"))],
122
+ "anti_theft": [("getAntiTheftEnable", "anti_theft", "config", ("enabled",)), ("getAntiTheftStatus", "anti_theft", "status", ("status",))],
123
+ "wake_up": [("getWakeUpConfig", "wake_up", "config", ("wake_up_type",))],
124
+ "battery_mode": [("getBatteryOperatingMode", "battery", "operating", ("mode", "follow_config"))],
125
+ "button_led": [("getLedColor", "led", "color", ("name",))],
126
+ }
127
+ Setting = Literal["ring", "hub_ring", "hub_plan", "chime_plan", "existing_chime", "quick_replies", "auto_response", "anti_theft", "wake_up", "battery_mode", "button_led"]
128
+ RingTarget = Literal["doorbell", "hub"]
129
+ BatteryMode = Literal["monitor_performance_first", "battery_life_first", "super_power_saving"]
130
+ METHODS += [policy.method(method, Tier.READ, ["doorbell"], "Read a doorbell setting.") for specs in READS.values() for method, _, _, _ in specs]
131
+ METHODS += [
132
+ policy.method("getQuickRespList", Tier.READ, ["doorbell"], "List stored doorbell voice replies."),
133
+ *[policy.method(method, Tier.SETTING, ["doorbell"], summary) for method, summary in [
134
+ ("setRingSoundConfig", "Change the doorbell ring tone and volume."),
135
+ ("setHubRingSoundConfig", "Change the connected hub ring tone and volume."),
136
+ ("setRingStatus", "Change whether the doorbell rings on a press."),
137
+ ("setHubRingStatus", "Change whether the connected hub rings on a press."),
138
+ ("setHubRingPlan", "Change the connected hub ring schedule."),
139
+ ("setChimeRingPlan", "Change the paired chime ring schedule."),
140
+ ("setSmartRespConfig", "Change the automatic voice reply and delay."),
141
+ ("setSmartRespStatus", "Enable or disable automatic voice replies."),
142
+ ("setWakeUpConfig", "Change the battery doorbell wake-up trigger."),
143
+ ("setBatteryOperatingMode", "Change the doorbell battery operating mode."),
144
+ ("setLedColor", "Change the doorbell button LED color."),
145
+ ]],
146
+ policy.method("setPreWakeup", Tier.SETTING, ["hub"], "Wake a battery doorbell child in advance through its hub."),
147
+ *[policy.method(method, Tier.SECURITY, ["doorbell"], summary) for method, summary in [
148
+ ("setRingPlay", "Preview or stop an audible doorbell ring."),
149
+ ("setHubRingPlay", "Preview or stop an audible connected hub ring."),
150
+ ("setExistingChimeCfg", "Change the electrical configuration of an in-wall chime."),
151
+ # Anti-theft is an alarm with an arm state. Disarming lowers a protection; arming blocks a factory reset
152
+ # by button. Both change what a person can do at the door, so it is level 3.
153
+ ("setAntiTheftEnable", "Arm or disarm the anti-theft alarm. Armed, it blocks a factory reset by button."),
154
+ ("playQuickResp", "Play an audible voice reply to a visitor."),
155
+ ("stopQuickResp", "Stop a doorbell voice reply."),
156
+ ]],
157
+ ]
158
+
159
+
160
+ async def read_setting(registry, caller, device, setting, legacy=False):
161
+ _choice(setting, (*READS, "quick_replies"), "setting")
162
+ _boolean(legacy, "legacy")
163
+ if setting == "quick_replies":
164
+ return {**await quick_replies(registry, caller, device), "setting": setting}
165
+ entry = _entry(registry, device, ["doorbell"])
166
+ data = {}
167
+ for method, module, section, fields in READS[setting]:
168
+ # The camera fixtures read sections as a list: {"system": {"name": ["last_alarm_info"]}}.
169
+ params = {module: {"name": [section]}}
170
+ if module == "hub_ring" or legacy and setting in ("ring", "anti_theft", "wake_up", "hub_plan"):
171
+ params = {module: {}}
172
+ value = _section(await _call(caller, entry, method, params), module, section)
173
+ if method.endswith("TypeList"):
174
+ item = _tone_list(value, "ring_type_list")
175
+ else:
176
+ item = _compact(value, fields)
177
+ if item:
178
+ data[method] = item
179
+ out = {"device": clean(entry.slug), "setting": setting, "unverified": True}
180
+ if data:
181
+ out["values"] = data
182
+ return out
183
+
184
+
185
+ async def ring_configure(registry, caller, device, target, tone, volume):
186
+ _choice(target, ("doorbell", "hub"), "ring target")
187
+ _text(tone, "tone")
188
+ # UNVERIFIED: older devices use 0/1/2; newer hub firmware has more volume gears.
189
+ _integer(volume, 0, 100, "volume")
190
+ entry = _entry(registry, device, ["doorbell"])
191
+ module, method = ("ring", "setRingSoundConfig") if target == "doorbell" else ("hub_ring", "setHubRingSoundConfig")
192
+ config = {"ring_type": tone, "volume": str(volume)}
193
+ return await _write(registry, caller, entry, method, {module: {"config": config}}, changed=config)
194
+
195
+
196
+ async def ring_enable(registry, caller, device, target, enabled, legacy=False):
197
+ _choice(target, ("doorbell", "hub"), "ring target")
198
+ _boolean(enabled, "enabled")
199
+ _boolean(legacy, "legacy")
200
+ entry = _entry(registry, device, ["doorbell"])
201
+ module, method = ("ring", "setRingStatus") if target == "doorbell" else ("hub_ring", "setHubRingStatus")
202
+ section = "status" if target == "doorbell" and not legacy else "config"
203
+ config = {"enabled": "on" if enabled else "off"}
204
+ return await _write(registry, caller, entry, method, {module: {section: config}}, changed=config)
205
+
206
+
207
+ async def ring_preview(registry, caller, device, target, action, tone, confirm=None, legacy=False):
208
+ _choice(target, ("doorbell", "hub"), "ring target")
209
+ _choice(action, ("play", "stop"), "action")
210
+ _boolean(legacy, "legacy")
211
+ if action == "play":
212
+ _text(tone, "tone")
213
+ entry = _entry(registry, device, ["doorbell"])
214
+ module, method = ("ring", "setRingPlay") if target == "doorbell" else ("hub_ring", "setHubRingPlay")
215
+ value = {"status": "on" if action == "play" else "off"}
216
+ if action == "play":
217
+ value["ring_type"] = tone
218
+ params = {module: {"play": value}} if target == "doorbell" and not legacy else {module: value}
219
+ return await _write(registry, caller, entry, method, params, confirm, f"{target} ring {action}")
220
+
221
+
222
+ async def ring_plan(registry, caller, device, target, enabled, schedule):
223
+ _choice(target, ("hub", "chime"), "ring plan target")
224
+ _boolean(enabled, "enabled")
225
+ # UNVERIFIED: one observed default establishes the syntax, but weekday bit ordering needs hardware.
226
+ _text(schedule, "schedule", 256)
227
+ match = re.fullmatch(r"(\d{2})(\d{2})-(\d{2})(\d{2}),(\d{1,3})", schedule)
228
+ if not match or any(int(match[i]) > maximum for i, maximum in ((1, 23), (2, 59), (3, 23), (4, 59), (5, 127))) or int(match[5]) == 0:
229
+ raise BadRequest("schedule must be HHMM-HHMM,WEEKDAY_MASK with a mask from 1 to 127")
230
+ entry = _entry(registry, device, ["doorbell"])
231
+ module, method = ("hub_ring_plan", "setHubRingPlan") if target == "hub" else ("chime_ring_plan", "setChimeRingPlan")
232
+ section = "chn1_hub_ring_plan" if target == "hub" else "chn1_chime_ring_plan"
233
+ config = {"enabled": "on" if enabled else "off", "ring_plan_1": schedule}
234
+ return await _write(registry, caller, entry, method, {module: {section: config}}, changed=config)
235
+
236
+
237
+ async def existing_chime(registry, caller, device, chime_type, ring_length, confirm=None):
238
+ _choice(chime_type, ("mechanical", "digital"), "in-wall chime type")
239
+ # UNVERIFIED: the electrical duration limit varies by wired doorbell/chime model. Zero is used in app setup.
240
+ _integer(ring_length, 0, 10, "ring_length")
241
+ entry = _entry(registry, device, ["doorbell"])
242
+ config = {"type": chime_type, "ring_length": ring_length}
243
+ return await _write(registry, caller, entry, "setExistingChimeCfg", {"existing_chime": {"config": config}}, confirm, config)
244
+
245
+
246
+ async def quick_replies(registry, caller, device):
247
+ entry = _entry(registry, device, ["doorbell"])
248
+ value = _section(await _call(caller, entry, "getQuickRespList", {"quick_response": {"table": ["quick_resp_audio"]}}), "quick_response", "quick_resp_audio")
249
+ rows = value.get("quick_resp_audio", []) if isinstance(value, dict) else value
250
+ replies = []
251
+ if isinstance(rows, list):
252
+ for row in rows[:50]:
253
+ if not isinstance(row, dict):
254
+ continue
255
+ items = [row] if "id" in row else list(row.values())[:1]
256
+ for item in items:
257
+ reply = _compact(item, ("id", "index", "name", "duration", "read_only"))
258
+ if reply:
259
+ replies.append(reply)
260
+ out = {"device": clean(entry.slug), "unverified": True}
261
+ if replies:
262
+ out["replies"] = replies
263
+ return out
264
+
265
+
266
+ async def quick_reply_action(registry, caller, device, action, reply_id=None, confirm=None):
267
+ _choice(action, ("play", "stop"), "action")
268
+ entry = _entry(registry, device, ["doorbell"])
269
+ if action == "stop":
270
+ return await _write(registry, caller, entry, "stopQuickResp", {"quick_response": {"stop_quick_resp_audio": {}}}, confirm, "voice reply stopped")
271
+ _text(reply_id, "reply_id")
272
+ replies = await quick_replies(registry, caller, device)
273
+ if reply_id not in [reply.get("id") for reply in replies.get("replies", [])]:
274
+ raise BadRequest("The doorbell does not list this voice reply")
275
+ params = {"quick_response": {"play_quick_resp_audio": {"id": reply_id, "force": "0"}}}
276
+ return await _write(registry, caller, entry, "playQuickResp", params, confirm, {"voice_reply_played": reply_id})
277
+
278
+
279
+ async def auto_response_configure(registry, caller, device, reply_id, delay):
280
+ _text(reply_id, "reply_id")
281
+ # UNVERIFIED: actual model interval range needs fixtures. This is a conservative API bound in seconds.
282
+ _integer(delay, 0, 600, "delay")
283
+ entry = _entry(registry, device, ["doorbell"])
284
+ replies = await quick_replies(registry, caller, device)
285
+ if reply_id not in [reply.get("id") for reply in replies.get("replies", [])]:
286
+ raise BadRequest("The doorbell does not list this voice reply")
287
+ config = {"id": reply_id, "play_interval": str(delay)}
288
+ return await _write(registry, caller, entry, "setSmartRespConfig", {"quick_response": {"smart_resp_config": config}}, changed=config)
289
+
290
+
291
+ async def auto_response(registry, caller, device, enabled=None, reply_id=None, delay=None):
292
+ """One write per call: switch auto response on or off, or choose its reply and delay. Never both."""
293
+ if enabled is None:
294
+ if reply_id is None or delay is None:
295
+ raise BadRequest("Give enabled alone, or reply_id with delay")
296
+ return await auto_response_configure(registry, caller, device, reply_id, delay)
297
+ _boolean(enabled, "enabled")
298
+ if reply_id is not None or delay is not None:
299
+ raise BadRequest("Give enabled alone, or reply_id with delay. One change per call")
300
+ entry = _entry(registry, device, ["doorbell"])
301
+ config = {"enabled": "on" if enabled else "off"}
302
+ return await _write(registry, caller, entry, "setSmartRespStatus", {"quick_response": {"smart_resp_status": config}}, changed={"auto_response": enabled})
303
+
304
+
305
+ async def anti_theft(registry, caller, device, enabled, confirm=None):
306
+ _boolean(enabled, "enabled")
307
+ entry = _entry(registry, device, ["doorbell"])
308
+ config = {"enabled": "on" if enabled else "off"}
309
+ return await _write(registry, caller, entry, "setAntiTheftEnable", {"anti_theft": {"config": config}}, confirm, {"anti_theft": enabled})
310
+
311
+
312
+ async def wake_up(registry, caller, device, trigger):
313
+ _choice(trigger, ("detection", "doorbell"), "wake-up trigger")
314
+ entry = _entry(registry, device, ["doorbell"])
315
+ config = {"wake_up_type": trigger}
316
+ return await _write(registry, caller, entry, "setWakeUpConfig", {"wake_up": {"config": config}}, changed=config)
317
+
318
+
319
+ async def pre_wake(registry, caller, device):
320
+ entry = _entry(registry, device, ["doorbell"])
321
+ if not entry.root or entry.root.kind != "hub":
322
+ raise BadRequest("Pre-wake needs a doorbell connected through a hub")
323
+ # UNVERIFIED: the app sends this child list to a hub; verify it wakes a D230 through H200.
324
+ params = {"preWakeUp": {"child_device_list": [{"device_id": entry.device.device_id}]}}
325
+ return await _write(registry, caller, entry.root, "setPreWakeup", params, changed={"pre_wake_requested": clean(entry.slug)})
326
+
327
+
328
+ async def battery_mode(registry, caller, device, mode, follow_config):
329
+ _choice(mode, ("monitor_performance_first", "battery_life_first", "super_power_saving"), "battery mode")
330
+ _boolean(follow_config, "follow_config")
331
+ entry = _entry(registry, device, ["doorbell"])
332
+ config = {"mode": mode, "follow_config": follow_config}
333
+ return await _write(registry, caller, entry, "setBatteryOperatingMode", {"battery": {"operating": config}}, changed=config)
334
+
335
+
336
+ async def button_color(registry, caller, device, color):
337
+ _text(color, "color", 32)
338
+ entry = _entry(registry, device, ["doorbell"])
339
+ # UNVERIFIED: the name wrapper is known, but available color names vary by model.
340
+ return await _write(registry, caller, entry, "setLedColor", {"led": {"color": {"name": color}}}, changed={"button_led_color": color})
341
+
342
+ # The H200 lists its tones with getSirenTypeList (same level as the hub area). Its siren itself is the hub area's
343
+ # job (hub_alarm_config, hub_alarm_set, hub_siren), so this area does not send setSirenConfig or setSirenStatus.
344
+ METHODS += [
345
+ policy.method("getSirenTypeList", Tier.READ, ["hub"], "Read the hub's available ring and alarm tones."),
346
+ ]
347
+
348
+
349
+ def _smartcam_hub(entry):
350
+ return entry.kind == "hub" and isinstance(getattr(entry.device, "protocol", None), SmartCamProtocol)
351
+
352
+
353
+ async def _available_tones(caller, entry):
354
+ if _smartcam_hub(entry):
355
+ result = await _call(caller, entry, "getSirenTypeList", {"siren": {}})
356
+ return _tone_list(result, "siren_type_list")
357
+ result = await _call(caller, entry, "get_support_alarm_type_list")
358
+ return _tone_list(result, "alarm_type_list")
359
+
360
+
361
+ Device = Annotated[str, Field(description="Device id or name")]
362
+ Confirm = Annotated[str | None, Field(description="Exact method and device id confirmation phrase")]
363
+
364
+
365
+ def _entry(registry, ref, kinds):
366
+ entry = registry.resolve_one(ref)
367
+ if entry.kind not in kinds:
368
+ raise BadRequest(f"{clean(entry.slug)} is not a {' or '.join(kinds)}")
369
+ return entry
370
+
371
+
372
+ def _integer(value, low, high, name):
373
+ if type(value) is not int or not low <= value <= high:
374
+ raise BadRequest(f"{name} must be an integer from {low} to {high}")
375
+ return value
376
+
377
+
378
+ def _text(value, name, limit=64):
379
+ if not isinstance(value, str) or not value or clean(value, limit) != value:
380
+ raise BadRequest(f"{name} must be nonempty plain text of at most {limit} characters")
381
+ return value
382
+
383
+
384
+ def _section(value, module, section):
385
+ if not isinstance(value, dict):
386
+ return {}
387
+ value = value.get(module, value)
388
+ return value.get(section, value) if isinstance(value, dict) else {}
389
+
390
+
391
+ def _compact(value, fields):
392
+ """Only named scalar fields cross the model boundary. Unknown nested data is discarded."""
393
+ if not isinstance(value, dict):
394
+ return {}
395
+ out = {}
396
+ for field in fields:
397
+ item = value.get(field)
398
+ if isinstance(item, str):
399
+ item = clean(item)
400
+ if type(item) in (str, int, bool) and item != "":
401
+ out[field] = item
402
+ return out
403
+
404
+
405
+ async def _call(caller, entry, method, params=None, confirm=None):
406
+ try:
407
+ return await caller.call(entry, method, params, confirm=confirm)
408
+ except DeviceUnavailable as exc:
409
+ # Device error text can echo pairing keys or credentials. Never forward it.
410
+ code = failure_code(exc)
411
+ reason = f"device rejected or could not complete the request ({code})" if code else "device rejected or could not complete the request"
412
+ if "UNSUPPORTED_METHOD" in str(exc) or "-40106" in str(exc):
413
+ reason = "device does not support this method"
414
+ elif "did not answer in time" in str(exc):
415
+ reason = "device did not answer in time"
416
+ elif "rejected the account credentials" in str(exc):
417
+ reason = "device rejected the account credentials"
418
+ raise DeviceUnavailable(f"{clean(entry.slug)}: {method}: {reason}") from None
419
+
420
+
421
+ def _timestamp(value):
422
+ try:
423
+ return int(value) if not isinstance(value, bool) and int(value) >= 0 else None
424
+ except (ValueError, TypeError, OverflowError):
425
+ return None
426
+
427
+
428
+ async def events_recent(registry, caller, device, since=0, limit=10, hub=None):
429
+ """One bounded poll. Call state is a snapshot, never a synthesized ring event."""
430
+ _integer(since, 0, 2**63 - 1, "since")
431
+ _integer(limit, 1, 50, "limit")
432
+ entry = _entry(registry, device, ["doorbell"])
433
+ parent = _entry(registry, hub, ["hub"]) if hub else entry.root
434
+ if parent and parent is not entry.root:
435
+ raise BadRequest("The selected hub is not this doorbell's parent")
436
+ requests = [
437
+ # Request and reply shapes of getLastAlarmInfo match the C225 and C610 fixtures. The D230 has no fixture for it.
438
+ (entry, "getLastAlarmInfo", {"system": {"name": ["last_alarm_info"]}}),
439
+ # UNVERIFIED: the call_manager module and call_state_info section come from the app catalog, not a fixture.
440
+ (entry, "getCallStateInfo", {"call_manager": {"name": ["call_state_info"]}}),
441
+ ]
442
+ if parent:
443
+ # Sensor fixtures confirm this child-scoped request. Direct H200 requests are rejected (UNSUPPORTED_METHOD).
444
+ # UNVERIFIED: the D230 fixture lists no trigger_log component, so this read may well fail on a D230.
445
+ # A failure lands in "errors" and does not hide the other observations.
446
+ requests.append((entry, "get_trigger_logs", {"start_id": 0, "page_size": limit}))
447
+ events, errors, state = [], [], {}
448
+ for target, method, params in requests:
449
+ try:
450
+ result = await _call(caller, target, method, params)
451
+ except Forbidden:
452
+ raise
453
+ except RegistryError as exc:
454
+ errors.append({"device": clean(target.slug), "method": method, "reason": clean(str(exc), 160)})
455
+ continue
456
+ if method == "getLastAlarmInfo":
457
+ value = _compact(_section(result, "system", "last_alarm_info"), ("last_alarm_time", "last_alarm_type"))
458
+ timestamp = _timestamp(value.get("last_alarm_time"))
459
+ if timestamp is not None and timestamp > since:
460
+ events.append({"source": "last_alarm", "timestamp": timestamp, **{k: v for k, v in value.items() if k != "last_alarm_time"}})
461
+ elif method == "getCallStateInfo":
462
+ state = _compact(_section(result, "call_manager", "call_state_info"), ("call_state", "video_call_camera_state"))
463
+ elif isinstance(result, dict) and isinstance(result.get("logs"), list):
464
+ for log in result["logs"][:limit]:
465
+ value = _compact(log, ("id", "timestamp", "event"))
466
+ timestamp = _timestamp(value.get("timestamp"))
467
+ if timestamp is not None and timestamp > since:
468
+ events.append({**value, "source": "hub_trigger", "timestamp": timestamp})
469
+ # UNVERIFIED: neither alarm types nor call states are known to prove a physical ring press.
470
+ unique = {tuple(sorted(event.items())): event for event in events}
471
+ events = sorted(unique.values(), key=lambda event: event["timestamp"])[:limit]
472
+ out = {"device": clean(entry.slug), "unverified": True, "cursor": max([since, *(event["timestamp"] for event in events)])}
473
+ if events:
474
+ out["events"] = events
475
+ if state:
476
+ out["call_state"] = state
477
+ if errors:
478
+ out["errors"] = errors
479
+ return out
480
+
481
+
482
+ def tools(ctx):
483
+ @ctx.tool(title="Recent doorbell observations")
484
+ async def doorbell_events_recent(
485
+ device: Device,
486
+ since: Annotated[int, Field(description="Only observations after this Unix timestamp", ge=0, strict=True)] = 0,
487
+ limit: Annotated[int, Field(description="Maximum observations in one bounded poll", ge=1, le=50, strict=True)] = 10,
488
+ hub: Annotated[str | None, Field(description="Optional hub id or name; defaults to the parent hub")] = None,
489
+ ) -> dict[str, Any]:
490
+ """Poll last alarm, call state and parent hub logs. UNVERIFIED: observations do not prove a ring press. Use cursor as since; equal-second events may be missed."""
491
+ await ctx.ready_for([device, *([hub] if hub else [])])
492
+ with ctx.translated():
493
+ return await events_recent(ctx.registry, ctx.caller, device, since, limit, hub)
494
+
495
+ @ctx.tool(title="Chime settings")
496
+ async def chime_settings_get(device: Device, doorbell: Annotated[str, Field(description="Paired doorbell id or name")]) -> dict[str, Any]:
497
+ """Read per-doorbell chime settings and available tones, including an H100 or H200 as chime. UNVERIFIED on hardware."""
498
+ await ctx.ready_for([device, doorbell])
499
+ with ctx.translated():
500
+ return await chime_get(ctx.registry, ctx.caller, device, doorbell)
501
+
502
+ @ctx.tool(title="Change chime settings", tier=Tier.SETTING)
503
+ async def chime_settings_set(
504
+ device: Device,
505
+ doorbell: Annotated[str, Field(description="Paired doorbell id or name")],
506
+ tone: Annotated[str, Field(description="Tone advertised by chime_settings_get", min_length=1, max_length=64)],
507
+ volume: Annotated[Volume, Field(description="Chime sound volume")],
508
+ duration: Annotated[int, Field(description="Ring duration in seconds", ge=1, le=600, strict=True)],
509
+ enabled: Annotated[bool, Field(description="Whether this chime rings for the doorbell", strict=True)],
510
+ ) -> dict[str, Any]:
511
+ """Change chime tone, volume, duration and ring enable. Level 2. UNVERIFIED per-model duration limits."""
512
+ await ctx.ready_for([device, doorbell])
513
+ with ctx.translated():
514
+ return await chime_set(ctx.registry, ctx.caller, device, doorbell, tone, volume, duration, enabled)
515
+
516
+ @ctx.tool(title="Play or stop chime", tier=Tier.SECURITY)
517
+ async def chime_sound(
518
+ device: Device,
519
+ action: Annotated[Literal["play", "stop"], Field(description="Play an audible sound or stop it")],
520
+ tone: Annotated[str | None, Field(description="Advertised tone; required for play")] = None,
521
+ volume: Annotated[Volume, Field(description="Sound volume")] = "normal",
522
+ duration: Annotated[int, Field(description="Sound duration in seconds", ge=1, le=600, strict=True)] = 1,
523
+ confirm: Confirm = None,
524
+ ) -> dict[str, Any]:
525
+ """Play or stop sound on a chime or an H100. An H200 uses hub_siren. Level 3. Confirm play_alarm DEVICE or stop_alarm DEVICE. UNVERIFIED on hardware."""
526
+ await ctx.ready_for([device])
527
+ with ctx.translated():
528
+ return await chime_sound_action(ctx.registry, ctx.caller, device, action, tone, volume, duration, confirm)
529
+
530
+ @ctx.tool(title="Doorbell settings")
531
+ async def doorbell_settings_get(
532
+ device: Device,
533
+ setting: Annotated[Setting, Field(description="Which doorbell setting to read")],
534
+ legacy: Annotated[bool, Field(description="Use older read wrappers for ring, anti-theft and wake-up", strict=True)] = False,
535
+ ) -> dict[str, Any]:
536
+ """Read ring, hub/chime plan, in-wall chime, stored voice replies, auto response, anti-theft, wake-up, battery mode or button LED. UNVERIFIED on hardware."""
537
+ await ctx.ready_for([device])
538
+ with ctx.translated():
539
+ return await read_setting(ctx.registry, ctx.caller, device, setting, legacy)
540
+
541
+ @ctx.tool(title="Change ring sound", tier=Tier.SETTING)
542
+ async def doorbell_ring_sound_set(
543
+ device: Device,
544
+ target: Annotated[RingTarget, Field(description="Sound at the doorbell or its connected hub")],
545
+ tone: Annotated[str, Field(description="Ring type returned by doorbell_settings_get")],
546
+ volume: Annotated[int, Field(description="Device volume gear. UNVERIFIED range varies by model", ge=0, le=100, strict=True)],
547
+ ) -> dict[str, Any]:
548
+ """Set ring tone and volume at level 2. Use the model's supported volume gear. UNVERIFIED on hardware."""
549
+ await ctx.ready_for([device])
550
+ with ctx.translated():
551
+ return await ring_configure(ctx.registry, ctx.caller, device, target, tone, volume)
552
+
553
+ @ctx.tool(title="Enable doorbell or hub ring", tier=Tier.SETTING)
554
+ async def doorbell_ring_enabled_set(
555
+ device: Device,
556
+ target: Annotated[RingTarget, Field(description="Ring at the doorbell or connected hub")],
557
+ enabled: Annotated[bool, Field(description="Whether a press rings this target", strict=True)],
558
+ legacy: Annotated[bool, Field(description="Use the older doorbell ring config wrapper", strict=True)] = False,
559
+ ) -> dict[str, Any]:
560
+ """Enable or disable the doorbell or hub ring at level 2. UNVERIFIED on hardware."""
561
+ await ctx.ready_for([device])
562
+ with ctx.translated():
563
+ return await ring_enable(ctx.registry, ctx.caller, device, target, enabled, legacy)
564
+
565
+ @ctx.tool(title="Preview doorbell ring", tier=Tier.SECURITY)
566
+ async def doorbell_ring_preview(
567
+ device: Device,
568
+ target: Annotated[RingTarget, Field(description="Preview at the doorbell or connected hub")],
569
+ action: Annotated[Literal["play", "stop"], Field(description="Audible preview or stop")],
570
+ tone: Annotated[str | None, Field(description="Ring type; required for play")] = None,
571
+ confirm: Confirm = None,
572
+ legacy: Annotated[bool, Field(description="Use the older doorbell preview wrapper", strict=True)] = False,
573
+ ) -> dict[str, Any]:
574
+ """Play or stop an audible ring at level 3. Confirm setRingPlay DEVICE or setHubRingPlay DEVICE. UNVERIFIED on hardware."""
575
+ await ctx.ready_for([device])
576
+ with ctx.translated():
577
+ return await ring_preview(ctx.registry, ctx.caller, device, target, action, tone, confirm, legacy)
578
+
579
+ @ctx.tool(title="Change ring plan", tier=Tier.SETTING)
580
+ async def doorbell_ring_plan_set(
581
+ device: Device,
582
+ target: Annotated[Literal["hub", "chime"], Field(description="Schedule the hub or paired chimes")],
583
+ enabled: Annotated[bool, Field(description="Whether the plan is active", strict=True)],
584
+ schedule: Annotated[str, Field(description="Single app-format window HHMM-HHMM,WEEKDAY_MASK; UNVERIFIED weekday bits")],
585
+ ) -> dict[str, Any]:
586
+ """Set one hub or chime ring window at level 2. UNVERIFIED weekday bits; preserve a schedule read from the device."""
587
+ await ctx.ready_for([device])
588
+ with ctx.translated():
589
+ return await ring_plan(ctx.registry, ctx.caller, device, target, enabled, schedule)
590
+
591
+ @ctx.tool(title="Configure in-wall chime", tier=Tier.SECURITY)
592
+ async def doorbell_existing_chime_set(
593
+ device: Device,
594
+ chime_type: Annotated[Literal["mechanical", "digital"], Field(description="Installed in-wall chime type")],
595
+ ring_length: Annotated[int, Field(description="Electrical ring length. UNVERIFIED units and model limits", ge=0, le=10, strict=True)],
596
+ confirm: Confirm = None,
597
+ ) -> dict[str, Any]:
598
+ """Change electrical chime configuration at level 3. Wrong values can strain its coil. Confirm setExistingChimeCfg DEVICE. UNVERIFIED duration limits."""
599
+ await ctx.ready_for([device])
600
+ with ctx.translated():
601
+ return await existing_chime(ctx.registry, ctx.caller, device, chime_type, ring_length, confirm)
602
+
603
+ @ctx.tool(title="Play or stop quick reply", tier=Tier.SECURITY)
604
+ async def doorbell_quick_reply(
605
+ device: Device,
606
+ action: Annotated[Literal["play", "stop"], Field(description="Audible voice reply or stop")],
607
+ reply_id: Annotated[str | None, Field(description="Listed reply id; required for play")] = None,
608
+ confirm: Confirm = None,
609
+ ) -> dict[str, Any]:
610
+ """Play or stop a visitor voice reply at level 3. Confirm playQuickResp DEVICE or stopQuickResp DEVICE. UNVERIFIED on hardware."""
611
+ await ctx.ready_for([device])
612
+ with ctx.translated():
613
+ return await quick_reply_action(ctx.registry, ctx.caller, device, action, reply_id, confirm)
614
+
615
+ @ctx.tool(title="Configure auto response", tier=Tier.SETTING)
616
+ async def doorbell_auto_response_set(
617
+ device: Device,
618
+ enabled: Annotated[bool | None, Field(description="Switch automatic replies on or off. Give it alone", strict=True)] = None,
619
+ reply_id: Annotated[str | None, Field(description="Listed voice reply id. Give it with delay")] = None,
620
+ delay: Annotated[int | None, Field(description="Response delay. UNVERIFIED seconds and per-model range", ge=0, le=600, strict=True)] = None,
621
+ ) -> dict[str, Any]:
622
+ """Switch automatic voice replies on or off, or choose the reply and its delay, at level 2. One change per call. UNVERIFIED on hardware."""
623
+ await ctx.ready_for([device])
624
+ with ctx.translated():
625
+ return await auto_response(ctx.registry, ctx.caller, device, enabled, reply_id, delay)
626
+
627
+ @ctx.tool(title="Arm or disarm anti-theft", tier=Tier.SECURITY)
628
+ async def doorbell_anti_theft_set(
629
+ device: Device,
630
+ enabled: Annotated[bool, Field(description="True arms the anti-theft alarm, false disarms it", strict=True)],
631
+ confirm: Confirm = None,
632
+ ) -> dict[str, Any]:
633
+ """Arm or disarm the anti-theft alarm at level 3. Armed, it blocks a factory reset by button. Confirm setAntiTheftEnable DEVICE. UNVERIFIED on hardware."""
634
+ await ctx.ready_for([device])
635
+ with ctx.translated():
636
+ return await anti_theft(ctx.registry, ctx.caller, device, enabled, confirm)
637
+
638
+ @ctx.tool(title="Change doorbell wake-up", tier=Tier.SETTING)
639
+ async def doorbell_wake_up_set(
640
+ device: Device,
641
+ trigger: Annotated[Literal["detection", "doorbell"], Field(description="Wake for detection events or the ring button")],
642
+ ) -> dict[str, Any]:
643
+ """Change the battery doorbell wake-up trigger at level 2. UNVERIFIED on hardware."""
644
+ await ctx.ready_for([device])
645
+ with ctx.translated():
646
+ return await wake_up(ctx.registry, ctx.caller, device, trigger)
647
+
648
+ @ctx.tool(title="Pre-wake doorbell", tier=Tier.SETTING)
649
+ async def doorbell_pre_wake(device: Device) -> dict[str, Any]:
650
+ """Request pre-wake of a battery doorbell through its parent hub at level 2. UNVERIFIED on H200/D230."""
651
+ await ctx.ready_for([device])
652
+ with ctx.translated():
653
+ return await pre_wake(ctx.registry, ctx.caller, device)
654
+
655
+ @ctx.tool(title="Change battery operating mode", tier=Tier.SETTING)
656
+ async def doorbell_battery_mode_set(
657
+ device: Device,
658
+ mode: Annotated[BatteryMode, Field(description="Favor performance, battery life or maximum power saving")],
659
+ follow_config: Annotated[bool, Field(description="Use the existing per-mode configuration", strict=True)] = False,
660
+ ) -> dict[str, Any]:
661
+ """Change the doorbell battery operating mode at level 2. UNVERIFIED support and follow_config meaning on hardware."""
662
+ await ctx.ready_for([device])
663
+ with ctx.translated():
664
+ return await battery_mode(ctx.registry, ctx.caller, device, mode, follow_config)
665
+
666
+ @ctx.tool(title="Change button LED color", tier=Tier.SETTING)
667
+ async def doorbell_button_color_set(
668
+ device: Device,
669
+ color: Annotated[str, Field(description="Button color name supported by this doorbell", min_length=1, max_length=32)],
670
+ ) -> dict[str, Any]:
671
+ """Change the button LED color at level 2. UNVERIFIED available color names; unsupported names may be refused by the device."""
672
+ await ctx.ready_for([device])
673
+ with ctx.translated():
674
+ return await button_color(ctx.registry, ctx.caller, device, color)