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.
- package/README.md +256 -27
- package/package.json +7 -1
- package/pyproject.toml +4 -1
- package/src/tolpo/__init__.py +1 -1
- package/src/tolpo/api.py +111 -3
- package/src/tolpo/areas/__init__.py +187 -0
- package/src/tolpo/areas/automation.py +227 -0
- package/src/tolpo/areas/camera_detection.py +893 -0
- package/src/tolpo/areas/camera_media.py +745 -0
- package/src/tolpo/areas/camera_system.py +866 -0
- package/src/tolpo/areas/common.py +25 -0
- package/src/tolpo/areas/device_admin.py +650 -0
- package/src/tolpo/areas/doorbell.py +674 -0
- package/src/tolpo/areas/home_plan.py +229 -0
- package/src/tolpo/areas/hub.py +995 -0
- package/src/tolpo/areas/lights.py +798 -0
- package/src/tolpo/areas/lock.py +367 -0
- package/src/tolpo/areas/network.py +114 -0
- package/src/tolpo/areas/overview.py +21 -0
- package/src/tolpo/areas/pairing.py +127 -0
- package/src/tolpo/areas/plugs.py +660 -0
- package/src/tolpo/areas/vacuum.py +649 -0
- package/src/tolpo/automation/__init__.py +8 -0
- package/src/tolpo/automation/engine.py +456 -0
- package/src/tolpo/automation/events.py +81 -0
- package/src/tolpo/automation/graph.py +26 -0
- package/src/tolpo/automation/models.py +194 -0
- package/src/tolpo/automation/store.py +194 -0
- package/src/tolpo/automation/sun.py +28 -0
- package/src/tolpo/automation/tools.py +61 -0
- package/src/tolpo/calls.py +92 -0
- package/src/tolpo/cli.py +162 -3
- package/src/tolpo/config.py +48 -3
- package/src/tolpo/home.py +147 -0
- package/src/tolpo/mcp_server.py +37 -6
- package/src/tolpo/media.py +630 -0
- package/src/tolpo/network/__init__.py +5 -0
- package/src/tolpo/network/catalog.py +80 -0
- package/src/tolpo/network/config.py +123 -0
- package/src/tolpo/network/enforcement.py +184 -0
- package/src/tolpo/network/models.py +223 -0
- package/src/tolpo/network/providers.py +452 -0
- package/src/tolpo/network/service.py +422 -0
- package/src/tolpo/network/storage.py +132 -0
- package/src/tolpo/policy.py +88 -6
- package/src/tolpo/provision.py +729 -0
- package/src/tolpo/registry.py +362 -20
- package/src/tolpo/snapshot.py +441 -0
- package/src/tolpo/tsvideo.py +93 -0
- package/src/tolpo/views.py +4 -4
- package/src/tolpo/web/home.css +253 -0
- package/src/tolpo/web/home.html +56 -0
- package/src/tolpo/web/home.js +1413 -0
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
"""Camera detection, alarm actions and local event metadata. No media or cloud calls."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Annotated, Any, Literal
|
|
7
|
+
|
|
8
|
+
from kasa.exceptions import AuthenticationError
|
|
9
|
+
from pydantic import Field, StrictInt, StrictStr
|
|
10
|
+
|
|
11
|
+
from .. import policy
|
|
12
|
+
from ..policy import Tier
|
|
13
|
+
from ..registry import BadRequest, DeviceUnavailable, failure_code
|
|
14
|
+
from ..views import clean
|
|
15
|
+
|
|
16
|
+
KINDS = ["camera", "doorbell"]
|
|
17
|
+
# component, module, section, method stem, preferred sensitivity field
|
|
18
|
+
DETECTIONS = {
|
|
19
|
+
"motion": ("detection", "motion_detection", "motion_det", "Detection", "digital_sensitivity"),
|
|
20
|
+
"person": ("personDetection", "people_detection", "detection", "PersonDetection", "sensitivity"),
|
|
21
|
+
"pet": ("petDetection", "pet_detection", "detection", "PetDetection", "sensitivity"),
|
|
22
|
+
"vehicle": ("vehicleDetection", "vehicle_detection", "detection", "VehicleDetection", "sensitivity"),
|
|
23
|
+
"line_crossing": ("linecrossingDetection", "linecrossing_detection", "detection", "LinecrossingDetection", "sensitivity"),
|
|
24
|
+
"intrusion": ("intrusionDetection", "intrusion_detection", "detection", "IntrusionDetection", "sensitivity"),
|
|
25
|
+
"tamper": ("tamperDetection", "tamper_detection", "tamper_det", "TamperDetection", "digital_sensitivity"),
|
|
26
|
+
"baby_cry": ("babyCryDetection", "sound_detection", "bcd", "BCD", "sensitivity_v2"),
|
|
27
|
+
"bark": ("barkDetection", "bark_detection", "detection", "BarkDetection", "sensitivity"),
|
|
28
|
+
"meow": ("meowDetection", "meow_detection", "detection", "MeowDetection", "sensitivity"),
|
|
29
|
+
"glass_break": ("glassDetection", "glass_detection", "detection", "GlassDetection", "sensitivity"),
|
|
30
|
+
"package": ("packageDetection", "package_detection", "detection", "PackageDetection", "sensitivity"),
|
|
31
|
+
}
|
|
32
|
+
Detection = Literal["motion", "person", "pet", "vehicle", "line_crossing", "intrusion", "tamper", "baby_cry", "bark", "meow", "glass_break", "package"]
|
|
33
|
+
ZONES = {
|
|
34
|
+
"motion": ("getDetectionConfig", "addDetectionRegion", "add_md_regions"),
|
|
35
|
+
"line_crossing": ("getLinecrossingDetectionRegion", "addLinecrossingDetectionRegion", "add_regions"),
|
|
36
|
+
"intrusion": ("getIntrusionDetectionRegion", "addIntrusionDetectionRegion", "add_regions"),
|
|
37
|
+
"package": ("getPackageDetectionRegion", "addPackageDetectionRegion", "add_pkgd_regions"),
|
|
38
|
+
}
|
|
39
|
+
DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
|
40
|
+
EVENT_TYPES = {
|
|
41
|
+
1: "timing", 2: "motion", 3: "tamper", 4: "line_crossing", 5: "intrusion", 6: "person",
|
|
42
|
+
7: "baby_cry", 8: "vehicle", 9: "pet", 10: "ring", 11: "bark", 12: "meow", 13: "glass_break",
|
|
43
|
+
14: "smoke_alarm", 15: "package_delivered", 16: "package_picked_up", 17: "doorbell_missed",
|
|
44
|
+
18: "doorbell_answered", 19: "anti_theft", 23: "baby_left_bed", 24: "caregiver", 25: "baby_asleep",
|
|
45
|
+
26: "baby_awake", 27: "face_cover", 28: "safe_fence_out", 30: "baby_motion", 31: "panoramic_video",
|
|
46
|
+
33: "animal", 36: "co_alarm",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
METHODS = []
|
|
50
|
+
for _component, _module, _section, _stem, _sensitivity in DETECTIONS.values():
|
|
51
|
+
METHODS.extend([
|
|
52
|
+
policy.method(f"get{_stem}Config", Tier.READ, KINDS, "Read a camera detection setting."),
|
|
53
|
+
policy.method(f"set{_stem}Config", Tier.SETTING, KINDS, "Change camera detection behavior."),
|
|
54
|
+
])
|
|
55
|
+
for _kind, (_get, _add, _section) in ZONES.items():
|
|
56
|
+
if _get != "getDetectionConfig":
|
|
57
|
+
METHODS.append(policy.method(_get, Tier.READ, KINDS, "Read camera detection zones."))
|
|
58
|
+
METHODS.append(policy.method(_add, Tier.SETTING, KINDS, "Replace camera detection zones."))
|
|
59
|
+
for _stem in ("LinecrossingDetection", "IntrusionDetection"):
|
|
60
|
+
METHODS.append(policy.method(f"set{_stem}Schedule", Tier.SETTING, KINDS, "Change camera detection arming windows."))
|
|
61
|
+
for _method in ("getPersonDetectionCapability", "getBCDCapability", "getAlertConfig", "getAlertEventType",
|
|
62
|
+
"getManualAlarmStatus", "getWhitelampCapability", "getWhitelampConfig", "getFloodlightCapability",
|
|
63
|
+
"getFloodlightConfig", "getLastAlarmInfo", "searchDetectionList", "getMsgPushEventList"):
|
|
64
|
+
METHODS.append(policy.method(_method, Tier.READ, KINDS, "Read camera detection or alert information."))
|
|
65
|
+
for _method in ("setWhitelampConfig", "setFloodlightConfig"):
|
|
66
|
+
METHODS.append(policy.method(_method, Tier.SETTING, KINDS, "Change camera light brightness or on time."))
|
|
67
|
+
# Level 3: these sound or flash in the real world, or decide whether the owner is warned at all.
|
|
68
|
+
METHODS.extend([
|
|
69
|
+
policy.method("setAlertConfig", Tier.SECURITY, KINDS, "Change whether and how the camera sounds or flashes when it detects something."),
|
|
70
|
+
policy.method("setAlertEventType", Tier.SECURITY, KINDS, "Choose which detections set off the camera's sound and light alarm."),
|
|
71
|
+
policy.method("setMsgPushEventList", Tier.SECURITY, KINDS, "Choose which detections make the camera notify the owner."),
|
|
72
|
+
policy.method("doManualAlarm", Tier.SECURITY, KINDS, "Start or stop the camera's siren and alarm light now."),
|
|
73
|
+
policy.method("manualFloodlightOp", Tier.SECURITY, KINDS, "Switch the camera's floodlight on or off now."),
|
|
74
|
+
policy.method("setForceWhitelampState", Tier.SECURITY, KINDS, "Switch the camera's spotlight on or off now."),
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _camera(registry, device):
|
|
79
|
+
entry = registry.resolve_one(device)
|
|
80
|
+
if entry.kind not in KINDS:
|
|
81
|
+
raise BadRequest(f"{clean(entry.slug)} cannot use camera detection or alarm tools")
|
|
82
|
+
return entry
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _call(caller, entry, method, params, *, confirm=None):
|
|
86
|
+
try:
|
|
87
|
+
result = await caller.call(entry, method, params, confirm=confirm)
|
|
88
|
+
except DeviceUnavailable as exc:
|
|
89
|
+
code = failure_code(exc)
|
|
90
|
+
reason = "rejected the account credentials" if isinstance(exc.__cause__, AuthenticationError) else (
|
|
91
|
+
"did not answer in time" if isinstance(exc.__cause__, TimeoutError) else (f"reported an error ({code})" if code else "reported an error"))
|
|
92
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} {reason} for {method}") from None
|
|
93
|
+
# Some canned or older protocol responses retain the envelope. Do not return raw errors.
|
|
94
|
+
if isinstance(result, dict) and result.get("error_code", 0) != 0:
|
|
95
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} reported an error for {method}")
|
|
96
|
+
if isinstance(result, dict) and "result" in result:
|
|
97
|
+
result = result["result"]
|
|
98
|
+
if not isinstance(result, dict):
|
|
99
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} returned an invalid response for {method}")
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _child_components(caller, entry):
|
|
104
|
+
"""A camera or doorbell behind a hub does not answer for itself: the hub lists what its children support."""
|
|
105
|
+
start = 0
|
|
106
|
+
for _ in range(32):
|
|
107
|
+
result = await _call(caller, entry.root, "getChildDeviceComponentList", {"childControl": {"start_index": start}})
|
|
108
|
+
rows = result.get("child_component_list")
|
|
109
|
+
if not isinstance(rows, list):
|
|
110
|
+
raise DeviceUnavailable(f"{clean(entry.slug)}: the hub returned an unexpected component response")
|
|
111
|
+
for row in rows:
|
|
112
|
+
if isinstance(row, dict) and row.get("device_id") == entry.device.device_id:
|
|
113
|
+
return row.get("component_list")
|
|
114
|
+
total = result.get("sum")
|
|
115
|
+
if type(total) is not int or total < 0 or not rows or start + len(rows) >= total:
|
|
116
|
+
break
|
|
117
|
+
start += len(rows)
|
|
118
|
+
raise BadRequest(f"{clean(entry.slug)}: the hub did not report this camera's components")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _components(caller, entry):
|
|
122
|
+
if entry.root is not None and entry.root.kind == "hub":
|
|
123
|
+
items = await _child_components(caller, entry)
|
|
124
|
+
else:
|
|
125
|
+
data = await _call(caller, entry, "getAppComponentList", {"app_component": {"name": "app_component_list"}})
|
|
126
|
+
items = _module(data, "app_component", entry).get("app_component_list")
|
|
127
|
+
if not isinstance(items, list):
|
|
128
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} did not report camera components")
|
|
129
|
+
return {item["name"] for item in items if isinstance(item, dict) and isinstance(item.get("name"), str)
|
|
130
|
+
and type(item.get("version")) is int and item["version"] > 0}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _require(entry, components, component, action):
|
|
134
|
+
if component not in components:
|
|
135
|
+
raise BadRequest(f"{clean(entry.slug)} does not support {action}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _module(data, module, entry):
|
|
139
|
+
value = data.get(module)
|
|
140
|
+
if not isinstance(value, dict):
|
|
141
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} did not report {module}")
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _section(data, module, section, entry):
|
|
146
|
+
value = _module(data, module, entry).get(section)
|
|
147
|
+
if not isinstance(value, dict):
|
|
148
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} did not report {section}")
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _compact(data, fields):
|
|
153
|
+
"""Select known fields only. Flatten strings and bound lists. Never expose raw sections."""
|
|
154
|
+
out = {}
|
|
155
|
+
for key in fields:
|
|
156
|
+
value = data.get(key)
|
|
157
|
+
if value is None or value == "" or value == [] or value == {}:
|
|
158
|
+
continue
|
|
159
|
+
if isinstance(value, str):
|
|
160
|
+
out[key] = clean(value, 128)
|
|
161
|
+
elif isinstance(value, (bool, int, float)):
|
|
162
|
+
out[key] = value
|
|
163
|
+
elif isinstance(value, list):
|
|
164
|
+
out[key] = [clean(v, 64) if isinstance(v, str) else v for v in value[:32]
|
|
165
|
+
if isinstance(v, str) and v or key.endswith("_range") and type(v) is int]
|
|
166
|
+
if not out[key]:
|
|
167
|
+
del out[key]
|
|
168
|
+
elif isinstance(value, dict) and key.endswith("_range"):
|
|
169
|
+
bounds = {k: v for k, v in value.items() if k in ("min", "max") and (
|
|
170
|
+
type(v) is int or isinstance(v, str) and re.fullmatch(r"\d{1,12}", v))}
|
|
171
|
+
if len(bounds) == 2:
|
|
172
|
+
out[key] = bounds
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _integer(value, low, high, label):
|
|
177
|
+
if type(value) is not int or not low <= value <= high:
|
|
178
|
+
raise BadRequest(f"{label} must be an integer from {low} to {high}")
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _boolean(value, label):
|
|
183
|
+
if type(value) is not bool:
|
|
184
|
+
raise BadRequest(f"{label} must be true or false")
|
|
185
|
+
return "on" if value else "off"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _in_range(value, advertised, label):
|
|
189
|
+
"""Accept explicit numeric min/max, min-max text, or a list of allowed values. Fail closed."""
|
|
190
|
+
valid = False
|
|
191
|
+
if isinstance(advertised, dict) and set(advertised) >= {"min", "max"}:
|
|
192
|
+
try:
|
|
193
|
+
valid = type(value) is int and int(advertised["min"]) <= value <= int(advertised["max"])
|
|
194
|
+
except (ValueError, TypeError):
|
|
195
|
+
pass
|
|
196
|
+
elif isinstance(advertised, list):
|
|
197
|
+
valid = type(value) in (int, str) and str(value) in [str(v) for v in advertised if type(v) in (int, str)]
|
|
198
|
+
elif isinstance(advertised, str):
|
|
199
|
+
match = re.fullmatch(r"(\d+)-(\d+)", advertised)
|
|
200
|
+
if match:
|
|
201
|
+
valid = type(value) is int and int(match[1]) <= value <= int(match[2])
|
|
202
|
+
else:
|
|
203
|
+
valid = type(value) in (int, str) and str(value) in advertised.split(",")
|
|
204
|
+
if not valid:
|
|
205
|
+
raise BadRequest(f"{label} is outside the reported range or the camera did not report a usable range")
|
|
206
|
+
return str(value)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def _detection(caller, entry, detection, probe_ranges=False):
|
|
210
|
+
if detection not in DETECTIONS:
|
|
211
|
+
raise BadRequest("Unknown camera detection type")
|
|
212
|
+
component, module, section, stem, preferred = DETECTIONS[detection]
|
|
213
|
+
components = await _components(caller, entry)
|
|
214
|
+
_require(entry, components, component, f"{detection} detection")
|
|
215
|
+
names = [section] + (["arming_schedule"] if detection in ("line_crossing", "intrusion") else [])
|
|
216
|
+
params = {module: {"name": names}}
|
|
217
|
+
data = await _call(caller, entry, f"get{stem}Config", params)
|
|
218
|
+
config = _section(data, module, section, entry)
|
|
219
|
+
capability = _module(data, module, entry).get("capability", {})
|
|
220
|
+
if not isinstance(capability, dict):
|
|
221
|
+
capability = {}
|
|
222
|
+
if probe_ranges and detection in ("person", "baby_cry"):
|
|
223
|
+
method = "getPersonDetectionCapability" if detection == "person" else "getBCDCapability"
|
|
224
|
+
# UNVERIFIED: these method names are audited, but their module/name parameters and response section
|
|
225
|
+
# have no fixture or surviving app builder. Check people_detection/sound_detection.capability on hardware.
|
|
226
|
+
probe = await _call(caller, entry, method, {module: {"name": ["capability"]}})
|
|
227
|
+
capability = _module(probe, module, entry).get("capability", {})
|
|
228
|
+
if not isinstance(capability, dict):
|
|
229
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} returned an invalid detection capability")
|
|
230
|
+
return (module, section, stem, preferred, config, capability, data)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def detection_get(registry, caller, device, detection, include_ranges=False):
|
|
234
|
+
entry = _camera(registry, device)
|
|
235
|
+
module, section, stem, preferred, config, capability, data = await _detection(caller, entry, detection, include_ranges)
|
|
236
|
+
out = {"device": clean(entry.slug), "detection": detection}
|
|
237
|
+
selected = _compact(config, ("enabled", "sensitivity", "digital_sensitivity", "sensitivity_v2", "det_sensitivity", "enhanced", "target", "type",
|
|
238
|
+
"people_enabled", "vehicle_enabled", "non_vehicle_enabled"))
|
|
239
|
+
if selected:
|
|
240
|
+
out["settings"] = selected
|
|
241
|
+
ranges = _compact(capability, ("sensitivity_range",))
|
|
242
|
+
if ranges:
|
|
243
|
+
out["capability"] = ranges
|
|
244
|
+
schedule = _module(data, module, entry).get("arming_schedule")
|
|
245
|
+
if isinstance(schedule, dict) and (selected := _compact(schedule, DAYS)):
|
|
246
|
+
out["schedule"] = selected
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def detection_set(registry, caller, device, detection, enabled=None, sensitivity=None):
|
|
251
|
+
if enabled is None and sensitivity is None:
|
|
252
|
+
raise BadRequest("Choose enabled or sensitivity for the detection change")
|
|
253
|
+
patch = {}
|
|
254
|
+
if enabled is not None:
|
|
255
|
+
patch["enabled"] = _boolean(enabled, "enabled")
|
|
256
|
+
if sensitivity is not None and type(sensitivity) not in (int, str):
|
|
257
|
+
raise BadRequest("sensitivity must be an integer or an advertised choice")
|
|
258
|
+
entry = _camera(registry, device)
|
|
259
|
+
module, section, stem, preferred, config, capability, data = await _detection(caller, entry, detection, sensitivity is not None)
|
|
260
|
+
if sensitivity is not None:
|
|
261
|
+
candidates = (preferred, "digital_sensitivity", "sensitivity") if detection == "baby_cry" else (preferred, "sensitivity")
|
|
262
|
+
field = next((key for key in candidates if key in config and config[key] not in (None, "")), None)
|
|
263
|
+
if field is None:
|
|
264
|
+
raise BadRequest(f"{clean(entry.slug)} does not support {detection} sensitivity tuning")
|
|
265
|
+
patch[field] = _in_range(sensitivity, capability.get("sensitivity_range", config.get("sensitivity_range")), "sensitivity")
|
|
266
|
+
await _call(caller, entry, f"set{stem}Config", {module: {section: patch}})
|
|
267
|
+
await registry.refresh(entry, force=True)
|
|
268
|
+
return {"device": clean(entry.slug), "detection": detection, "changed": patch}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _zone_fields(detection):
|
|
272
|
+
if detection == "motion":
|
|
273
|
+
return ("x_coor", "y_coor", "width", "height")
|
|
274
|
+
count = 2 if detection == "line_crossing" else 4 if detection == "intrusion" else 8
|
|
275
|
+
return tuple(f"pt{i}_{axis}" for i in range(1, count + 1) for axis in ("x", "y")) + (
|
|
276
|
+
("direction", "sensitivity") if detection == "line_crossing" else
|
|
277
|
+
("sensitivity", "threshold", "percentage") if detection == "intrusion" else ("point_num",))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def zones_get(registry, caller, device, detection):
|
|
281
|
+
if detection not in ZONES:
|
|
282
|
+
raise BadRequest(f"{detection} does not have separate zones in this area")
|
|
283
|
+
entry = _camera(registry, device)
|
|
284
|
+
component, module, section, stem, preferred = DETECTIONS[detection]
|
|
285
|
+
_require(entry, await _components(caller, entry), component, f"{detection} zones")
|
|
286
|
+
method = ZONES[detection][0]
|
|
287
|
+
# UNVERIFIED: the `table` read parameter. The D230 fixture shows motion rows under motion_detection.region_info,
|
|
288
|
+
# each wrapped as region_info_N, but that came from python-kasa's plain motion_det read.
|
|
289
|
+
data = await _call(caller, entry, method, {module: {"table": ["region_info"]}})
|
|
290
|
+
raw = _module(data, module, entry).get("region_info")
|
|
291
|
+
if not isinstance(raw, list):
|
|
292
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} did not report detection zones")
|
|
293
|
+
zones = []
|
|
294
|
+
for item in raw[:16]:
|
|
295
|
+
if not isinstance(item, dict):
|
|
296
|
+
continue
|
|
297
|
+
# Older cameras name each table row, for example region_info_1.
|
|
298
|
+
if not any(key in item for key in _zone_fields(detection)):
|
|
299
|
+
item = next((v for v in item.values() if isinstance(v, dict)), {})
|
|
300
|
+
if selected := _compact(item, _zone_fields(detection)):
|
|
301
|
+
zones.append(selected)
|
|
302
|
+
out = {"device": clean(entry.slug), "detection": detection, "zone_count": len(raw)}
|
|
303
|
+
if zones:
|
|
304
|
+
out["zones"] = zones
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# UNVERIFIED: the 0..10000 scale is seen only in the D230 motion rectangle. The scale of line, area and package points,
|
|
309
|
+
# the 0..100 bounds of zone sensitivity, threshold and percentage, the 3..8 package points and the four zone limit
|
|
310
|
+
# are local guesses that keep inputs small. They must be checked on a camera before they are relied on.
|
|
311
|
+
def _validate_zones(detection, zones):
|
|
312
|
+
if detection not in ZONES:
|
|
313
|
+
raise BadRequest("This detection does not have separate zones in this area")
|
|
314
|
+
if not isinstance(zones, list) or len(zones) > 4:
|
|
315
|
+
raise BadRequest("zones must be a list of at most four zones")
|
|
316
|
+
wire = []
|
|
317
|
+
for zone in zones:
|
|
318
|
+
if not isinstance(zone, dict) or set(zone) - set(_zone_fields(detection)):
|
|
319
|
+
raise BadRequest("Zone contains unsupported fields")
|
|
320
|
+
coords = _zone_fields(detection)[:4] if detection == "motion" else tuple(
|
|
321
|
+
f"pt{i}_{axis}" for i in range(1, (2 if detection == "line_crossing" else 4 if detection == "intrusion"
|
|
322
|
+
else _integer(zone.get("point_num"), 3, 8, "point_num")) + 1)
|
|
323
|
+
for axis in ("x", "y"))
|
|
324
|
+
allowed = set(coords) | ({"direction", "sensitivity"} if detection == "line_crossing" else
|
|
325
|
+
{"sensitivity", "threshold", "percentage"} if detection == "intrusion" else
|
|
326
|
+
{"point_num"} if detection == "package" else set())
|
|
327
|
+
if set(zone) != allowed:
|
|
328
|
+
raise BadRequest("Zone must include exactly its coordinates and detection fields")
|
|
329
|
+
row = {key: str(_integer(zone.get(key), 0, 10000, key)) for key in coords}
|
|
330
|
+
if detection == "motion":
|
|
331
|
+
if zone["width"] <= 0 or zone["height"] <= 0 or zone["x_coor"] + zone["width"] > 10000 or zone["y_coor"] + zone["height"] > 10000:
|
|
332
|
+
raise BadRequest("Motion rectangle must have positive size and fit inside the image")
|
|
333
|
+
elif detection == "line_crossing":
|
|
334
|
+
if (zone["pt1_x"], zone["pt1_y"]) == (zone["pt2_x"], zone["pt2_y"]):
|
|
335
|
+
raise BadRequest("Line crossing needs two distinct points")
|
|
336
|
+
if zone["direction"] not in ("both", "AtoB", "BtoA"):
|
|
337
|
+
raise BadRequest("direction must be both, AtoB or BtoA")
|
|
338
|
+
row["direction"] = zone["direction"]
|
|
339
|
+
row["sensitivity"] = str(_integer(zone["sensitivity"], 0, 100, "sensitivity"))
|
|
340
|
+
elif detection == "intrusion":
|
|
341
|
+
for key in ("sensitivity", "threshold", "percentage"):
|
|
342
|
+
row[key] = str(_integer(zone[key], 0, 100, key))
|
|
343
|
+
elif detection == "package":
|
|
344
|
+
row["point_num"] = str(zone["point_num"])
|
|
345
|
+
wire.append(row)
|
|
346
|
+
return wire
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async def zones_set(registry, caller, device, detection, zones):
|
|
350
|
+
wire = _validate_zones(detection, zones)
|
|
351
|
+
entry = _camera(registry, device)
|
|
352
|
+
component, module, section, stem, preferred = DETECTIONS[detection]
|
|
353
|
+
_require(entry, await _components(caller, entry), component, f"{detection} zones")
|
|
354
|
+
method, write_section = ZONES[detection][1:]
|
|
355
|
+
# UNVERIFIED: whether add*Region replaces or appends, what an empty list means, and these wrappers.
|
|
356
|
+
# Motion, intrusion and package wrap their rows in region_info; line crossing sends the list directly.
|
|
357
|
+
payload = wire if detection == "line_crossing" else {"region_info": wire}
|
|
358
|
+
await _call(caller, entry, method, {module: {write_section: payload}})
|
|
359
|
+
await registry.refresh(entry, force=True)
|
|
360
|
+
return {"device": clean(entry.slug), "detection": detection, "changed": {"zones": wire}}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def schedule_set(registry, caller, device, detection, schedule):
|
|
364
|
+
if detection not in ("line_crossing", "intrusion"):
|
|
365
|
+
raise BadRequest("Only line crossing and intrusion have detection arming schedules")
|
|
366
|
+
if not isinstance(schedule, dict) or not schedule or set(schedule) - set(DAYS):
|
|
367
|
+
raise BadRequest("schedule must name weekdays from monday to sunday")
|
|
368
|
+
patch = {}
|
|
369
|
+
for day, windows in schedule.items():
|
|
370
|
+
if not isinstance(windows, list) or len(windows) > 6:
|
|
371
|
+
raise BadRequest("Each day accepts up to six HHMM-HHMM windows")
|
|
372
|
+
last = -1
|
|
373
|
+
for window in windows:
|
|
374
|
+
if not isinstance(window, str) or not re.fullmatch(r"\d{4}-\d{4}", window):
|
|
375
|
+
raise BadRequest("Arming windows must use HHMM-HHMM")
|
|
376
|
+
start, end = window.split("-")
|
|
377
|
+
if not (int(start[:2]) < 24 and int(start[2:]) < 60 and
|
|
378
|
+
(end == "2400" or int(end[:2]) < 24 and int(end[2:]) < 60)) or not last <= int(start) < int(end):
|
|
379
|
+
raise BadRequest("Arming windows must be valid, ordered and not overlap")
|
|
380
|
+
last = int(end)
|
|
381
|
+
# UNVERIFIED: each weekday as a JSON list of HHMM-HHMM strings. The audit says plans may carry a suffix.
|
|
382
|
+
patch[day] = json.dumps(windows, separators=(",", ":"))
|
|
383
|
+
entry = _camera(registry, device)
|
|
384
|
+
module, section, stem, preferred, config, capability, data = await _detection(caller, entry, detection)
|
|
385
|
+
current = _module(data, module, entry).get("arming_schedule")
|
|
386
|
+
if not isinstance(current, dict) or not all(isinstance(current.get(day), str) for day in DAYS):
|
|
387
|
+
raise BadRequest(f"{clean(entry.slug)} did not report a complete arming schedule")
|
|
388
|
+
merged = {day: patch.get(day, current[day]) for day in DAYS}
|
|
389
|
+
await _call(caller, entry, f"set{stem}Schedule", {module: {"arming_schedule": merged}})
|
|
390
|
+
await registry.refresh(entry, force=True)
|
|
391
|
+
return {"device": clean(entry.slug), "detection": detection, "changed": {"schedule": patch}}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def tools(ctx):
|
|
395
|
+
_other_tools(ctx)
|
|
396
|
+
@ctx.tool(title="Camera detection settings")
|
|
397
|
+
async def camera_detect_get(
|
|
398
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
399
|
+
detection: Annotated[Detection, Field(description="Detection type to read")],
|
|
400
|
+
include_ranges: Annotated[bool, Field(description="Also probe sensitivity capabilities for person and baby cry", strict=True)] = False,
|
|
401
|
+
) -> dict[str, Any]:
|
|
402
|
+
"""Read a supported detection switch, sensitivity range and arming schedule."""
|
|
403
|
+
await ctx.ready_for([device])
|
|
404
|
+
with ctx.translated():
|
|
405
|
+
return await detection_get(ctx.registry, ctx.caller, device, detection, include_ranges)
|
|
406
|
+
|
|
407
|
+
@ctx.tool(title="Change camera detection", tier=Tier.SETTING)
|
|
408
|
+
async def camera_detect_set(
|
|
409
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
410
|
+
detection: Annotated[Detection, Field(description="Detection type to change")],
|
|
411
|
+
enabled: Annotated[bool | None, Field(description="Enable or disable this detection", strict=True)] = None,
|
|
412
|
+
sensitivity: Annotated[StrictInt | StrictStr | None, Field(description="Sensitivity inside the range reported by this model")] = None,
|
|
413
|
+
) -> dict[str, Any]:
|
|
414
|
+
"""Change detection behavior at level 2. Missing fields and ranges are refused."""
|
|
415
|
+
await ctx.ready_for([device])
|
|
416
|
+
with ctx.translated():
|
|
417
|
+
return await detection_set(ctx.registry, ctx.caller, device, detection, enabled, sensitivity)
|
|
418
|
+
|
|
419
|
+
@ctx.tool(title="Camera detection zones")
|
|
420
|
+
async def camera_detect_zones_get(
|
|
421
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
422
|
+
detection: Annotated[Detection, Field(description="Motion, line_crossing, intrusion or package")],
|
|
423
|
+
) -> dict[str, Any]:
|
|
424
|
+
"""Read the zones for a detection that supports separate regions."""
|
|
425
|
+
await ctx.ready_for([device])
|
|
426
|
+
with ctx.translated():
|
|
427
|
+
return await zones_get(ctx.registry, ctx.caller, device, detection)
|
|
428
|
+
|
|
429
|
+
@ctx.tool(title="Replace camera detection zones", tier=Tier.SETTING)
|
|
430
|
+
async def camera_detect_zones_set(
|
|
431
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
432
|
+
detection: Annotated[Detection, Field(description="Motion, line_crossing, intrusion or package")],
|
|
433
|
+
zones: Annotated[list[dict[str, Any]], Field(description="Up to four zones with integer coordinates 0..10000. See camera detection docs for fields. Empty clears zones.")],
|
|
434
|
+
) -> dict[str, Any]:
|
|
435
|
+
"""Replace supported detection zones at level 2. Extra fields are refused."""
|
|
436
|
+
await ctx.ready_for([device])
|
|
437
|
+
with ctx.translated():
|
|
438
|
+
return await zones_set(ctx.registry, ctx.caller, device, detection, zones)
|
|
439
|
+
|
|
440
|
+
@ctx.tool(title="Change camera arming windows", tier=Tier.SETTING)
|
|
441
|
+
async def camera_detect_schedule_set(
|
|
442
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
443
|
+
detection: Annotated[Detection, Field(description="Only line_crossing or intrusion")],
|
|
444
|
+
schedule: Annotated[dict[str, list[str]], Field(description="Weekday names mapped to ordered HHMM-HHMM windows. Empty day disables it. Other days stay unchanged.")],
|
|
445
|
+
) -> dict[str, Any]:
|
|
446
|
+
"""Change arming windows at level 2 for line crossing or intrusion."""
|
|
447
|
+
await ctx.ready_for([device])
|
|
448
|
+
with ctx.translated():
|
|
449
|
+
return await schedule_set(ctx.registry, ctx.caller, device, detection, schedule)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
ALARM_FIELDS = ("enabled", "alarm_mode", "alarm_type", "alarm_volume", "alarm_duration", "light_type",
|
|
453
|
+
"light_alarm_enabled", "sound_alarm_enabled", "light_rbl_enabled")
|
|
454
|
+
ALARM_CAPS = ("alarm_func", "alarm_type", "alarm_duration_support", "alarm_volume_support",
|
|
455
|
+
"red_blue_light_alarm_support", "alert_event_type_support", "alarm_volume_range", "light_type_range")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def _alarm(caller, entry):
|
|
459
|
+
_require(entry, await _components(caller, entry), "alert", "camera alarm actions")
|
|
460
|
+
# UNVERIFIED: asking for `capability` in the same read. The owner's cameras answered the plain chn1_msg_alarm_info
|
|
461
|
+
# read; the audit says the capability table rides along. Without it, capability-gated writes are refused.
|
|
462
|
+
data = await _call(caller, entry, "getAlertConfig", {"msg_alarm": {"name": ["chn1_msg_alarm_info", "capability"]}})
|
|
463
|
+
config = _section(data, "msg_alarm", "chn1_msg_alarm_info", entry)
|
|
464
|
+
capability = _module(data, "msg_alarm", entry).get("capability", {})
|
|
465
|
+
if not isinstance(capability, dict):
|
|
466
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} returned an invalid alarm capability")
|
|
467
|
+
return config, capability
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
async def alarm_get(registry, caller, device):
|
|
471
|
+
entry = _camera(registry, device)
|
|
472
|
+
config, capability = await _alarm(caller, entry)
|
|
473
|
+
out = {"device": clean(entry.slug)}
|
|
474
|
+
if selected := _compact(config, ALARM_FIELDS):
|
|
475
|
+
out["settings"] = selected
|
|
476
|
+
if selected := _compact(capability, ALARM_CAPS):
|
|
477
|
+
out["capability"] = selected
|
|
478
|
+
return out
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _supported_flag(capability, key, entry, action):
|
|
482
|
+
if capability.get(key) not in ("on", "1", 1, True):
|
|
483
|
+
raise BadRequest(f"{clean(entry.slug)} does not support {action}")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
async def alarm_set(registry, caller, device, settings, confirm=None):
|
|
487
|
+
if not isinstance(settings, dict) or not settings or set(settings) - set(ALARM_FIELDS):
|
|
488
|
+
raise BadRequest("Choose only supported camera alarm fields")
|
|
489
|
+
entry = _camera(registry, device)
|
|
490
|
+
config, capability = await _alarm(caller, entry)
|
|
491
|
+
patch = {}
|
|
492
|
+
for key, value in settings.items():
|
|
493
|
+
if key not in config:
|
|
494
|
+
raise BadRequest(f"{clean(entry.slug)} does not report alarm field {key}")
|
|
495
|
+
if key == "enabled" or key.endswith("enabled"):
|
|
496
|
+
patch[key] = _boolean(value, key)
|
|
497
|
+
if key == "light_rbl_enabled":
|
|
498
|
+
_supported_flag(capability, "red_blue_light_alarm_support", entry, "red and blue alarm lights")
|
|
499
|
+
if key in ("sound_alarm_enabled", "light_alarm_enabled"):
|
|
500
|
+
mode = "sound" if key.startswith("sound") else "light"
|
|
501
|
+
if mode not in capability.get("alarm_func", []):
|
|
502
|
+
raise BadRequest(f"{clean(entry.slug)} does not support {mode} alarm actions")
|
|
503
|
+
elif key == "alarm_mode":
|
|
504
|
+
choices = capability.get("alarm_func", [])
|
|
505
|
+
if not isinstance(value, list) or not value or any(v not in ("sound", "light") or v not in choices for v in value) or len(value) != len(set(value)):
|
|
506
|
+
raise BadRequest("alarm_mode must select supported sound and light actions")
|
|
507
|
+
patch[key] = value
|
|
508
|
+
elif key == "alarm_type":
|
|
509
|
+
patch[key] = _in_range(value, capability.get("alarm_type"), "alarm_type")
|
|
510
|
+
elif key == "alarm_volume":
|
|
511
|
+
_supported_flag(capability, "alarm_volume_support", entry, "alarm volume")
|
|
512
|
+
# UNVERIFIED: numeric camera volume encoding is not established by the audit.
|
|
513
|
+
# Refuse unless the device supplies explicit alarm_volume_range choices or bounds.
|
|
514
|
+
patch[key] = _in_range(value, capability.get("alarm_volume_range"), key)
|
|
515
|
+
elif key == "alarm_duration":
|
|
516
|
+
_supported_flag(capability, "alarm_duration_support", entry, "alarm duration")
|
|
517
|
+
# UNVERIFIED: seconds from 1 to 600 is a local bound. The owner's cameras report "0", whose meaning is unknown.
|
|
518
|
+
patch[key] = str(_integer(value, 1, 600, key))
|
|
519
|
+
elif key == "light_type":
|
|
520
|
+
if "light" not in capability.get("alarm_func", []):
|
|
521
|
+
raise BadRequest(f"{clean(entry.slug)} does not support light alarm actions")
|
|
522
|
+
# UNVERIFIED: fixtures return numeric light_type "1", while the audit's newer enum
|
|
523
|
+
# uses flicker/lighting. Do not translate numeric values without a reported range.
|
|
524
|
+
choices = ("flicker", "lighting") if config[key] in ("flicker", "lighting") else capability.get("light_type_range")
|
|
525
|
+
patch[key] = _in_range(value, list(choices) if isinstance(choices, tuple) else choices, key)
|
|
526
|
+
await _call(caller, entry, "setAlertConfig", {"msg_alarm": {"chn1_msg_alarm_info": patch}}, confirm=confirm)
|
|
527
|
+
await registry.refresh(entry, force=True)
|
|
528
|
+
return {"device": clean(entry.slug), "changed": patch}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
async def manual_alarm(registry, caller, device, action=None, confirm=None):
|
|
532
|
+
if action is not None and action not in ("start", "stop"):
|
|
533
|
+
raise BadRequest("Manual alarm action must be start or stop")
|
|
534
|
+
entry = _camera(registry, device)
|
|
535
|
+
_require(entry, await _components(caller, entry), "manualAlarm", "manual alarm")
|
|
536
|
+
# UNVERIFIED: the status read parameter and the start/stop body. No owner camera reports the manualAlarm component.
|
|
537
|
+
if action is None:
|
|
538
|
+
result = await _call(caller, entry, "getManualAlarmStatus", {"msg_alarm": {"get_manual_alarm_status": ""}})
|
|
539
|
+
status = _section(result, "msg_alarm", "get_manual_alarm_status", entry)
|
|
540
|
+
out = {"device": clean(entry.slug)}
|
|
541
|
+
if selected := _compact(status, ("status", "rest_time")):
|
|
542
|
+
out["status"] = selected
|
|
543
|
+
return out
|
|
544
|
+
await _call(caller, entry, "doManualAlarm", {"msg_alarm": {"manual_alarm": {"action": action}}}, confirm=confirm)
|
|
545
|
+
await registry.refresh(entry, force=True)
|
|
546
|
+
return {"device": clean(entry.slug), "changed": {"manual_alarm": action}}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
async def _event_table(caller, entry, notifications):
|
|
550
|
+
component = "msgPush" if notifications else "alert"
|
|
551
|
+
_require(entry, await _components(caller, entry), component, "notification choices" if notifications else "alarm triggers")
|
|
552
|
+
module, section, method = ("msg_push", "msg_push_event", "getMsgPushEventList") if notifications else ("msg_alarm", "msg_alarm_type", "getAlertEventType")
|
|
553
|
+
data = await _call(caller, entry, method, {module: {"table": [section]}})
|
|
554
|
+
rows = _module(data, module, entry).get(section)
|
|
555
|
+
if not isinstance(rows, list) or any(not isinstance(row, dict) or not isinstance(row.get("name"), str) for row in rows):
|
|
556
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} returned an invalid event choice table")
|
|
557
|
+
return module, section, rows
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
async def choices_get(registry, caller, device, notifications=False):
|
|
561
|
+
entry = _camera(registry, device)
|
|
562
|
+
module, section, rows = await _event_table(caller, entry, notifications)
|
|
563
|
+
out = {"device": clean(entry.slug), "choice_count": len(rows)}
|
|
564
|
+
if rows:
|
|
565
|
+
out["choices"] = [_compact(row, ("name", "enabled", "interruption_level") if notifications else ("name", "enabled")) for row in rows[:32]]
|
|
566
|
+
return out
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
async def choices_set(registry, caller, device, event, enabled, interruption_level=None, notifications=False, confirm=None):
|
|
570
|
+
if not isinstance(event, str) or not re.fullmatch(r"[A-Za-z0-9_]{1,64}", event):
|
|
571
|
+
raise BadRequest("event must be an identifier from the device choice table")
|
|
572
|
+
switch = _boolean(enabled, "enabled")
|
|
573
|
+
if interruption_level is not None and (not notifications or interruption_level not in ("none", "general", "critical")):
|
|
574
|
+
raise BadRequest("Notification interruption_level must be none, general or critical")
|
|
575
|
+
entry = _camera(registry, device)
|
|
576
|
+
module, section, rows = await _event_table(caller, entry, notifications)
|
|
577
|
+
found = [row for row in rows if row["name"] == event]
|
|
578
|
+
if len(found) != 1:
|
|
579
|
+
raise BadRequest(f"{clean(entry.slug)} does not report event choice {event}")
|
|
580
|
+
fields = ("name", "enabled", "interruption_level") if notifications else ("name", "enabled")
|
|
581
|
+
# Preserve all other choices, without forwarding arbitrary device fields.
|
|
582
|
+
payload = [{key: row[key] for key in fields if key in row} for row in rows]
|
|
583
|
+
for row in payload:
|
|
584
|
+
if not re.fullmatch(r"[A-Za-z0-9_]{1,64}", row["name"]) or row.get("enabled") not in ("on", "off"):
|
|
585
|
+
raise BadRequest(f"{clean(entry.slug)} reported an unsupported event choice value")
|
|
586
|
+
if notifications and row.get("interruption_level", "none") not in ("none", "general", "critical"):
|
|
587
|
+
raise BadRequest(f"{clean(entry.slug)} reported an unsupported notification level")
|
|
588
|
+
if row["name"] == event:
|
|
589
|
+
row["enabled"] = switch
|
|
590
|
+
if interruption_level is not None:
|
|
591
|
+
if "interruption_level" not in found[0]:
|
|
592
|
+
raise BadRequest(f"{clean(entry.slug)} does not report notification interruption levels")
|
|
593
|
+
row["interruption_level"] = interruption_level
|
|
594
|
+
method = "setMsgPushEventList" if notifications else "setAlertEventType"
|
|
595
|
+
await _call(caller, entry, method, {module: {section: payload}}, confirm=confirm)
|
|
596
|
+
await registry.refresh(entry, force=True)
|
|
597
|
+
changed = {"event": event, "enabled": switch}
|
|
598
|
+
if interruption_level is not None:
|
|
599
|
+
changed["interruption_level"] = interruption_level
|
|
600
|
+
return {"device": clean(entry.slug), "changed": changed}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
async def _lighting(caller, entry, light):
|
|
604
|
+
if light not in ("spotlight", "floodlight"):
|
|
605
|
+
raise BadRequest("light must be spotlight or floodlight")
|
|
606
|
+
_require(entry, await _components(caller, entry), "whiteLamp" if light == "spotlight" else "floodlight", light)
|
|
607
|
+
# UNVERIFIED: the `name` read parameters below. Module, section and field names come from the audit and app beans.
|
|
608
|
+
if light == "spotlight":
|
|
609
|
+
data = await _call(caller, entry, "getWhitelampCapability", {"image_capability": {"name": ["white_lamp"]}})
|
|
610
|
+
capability = _section(data, "image_capability", "white_lamp", entry)
|
|
611
|
+
data = await _call(caller, entry, "getWhitelampConfig", {"image": {"name": ["switch"]}})
|
|
612
|
+
config = _section(data, "image", "switch", entry)
|
|
613
|
+
else:
|
|
614
|
+
data = await _call(caller, entry, "getFloodlightCapability", {"floodlight": {"name": ["capability"]}})
|
|
615
|
+
capability = _section(data, "floodlight", "capability", entry)
|
|
616
|
+
data = await _call(caller, entry, "getFloodlightConfig", {"floodlight": {"name": ["config"]}})
|
|
617
|
+
config = _section(data, "floodlight", "config", entry)
|
|
618
|
+
return config, capability
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
async def lighting_get(registry, caller, device, light):
|
|
622
|
+
entry = _camera(registry, device)
|
|
623
|
+
config, capability = await _lighting(caller, entry, light)
|
|
624
|
+
fields = ("wtl_intensity_level", "wtl_force_time") if light == "spotlight" else (
|
|
625
|
+
"intensity_level", "auto_off_enabled", "manual_duration", "trigger_duration", "img_det_tri_enabled")
|
|
626
|
+
caps = ("wtl_intensity_range", "time_limit_max_sec", "wtl_intensity_support", "time_limit_support") if light == "spotlight" else (
|
|
627
|
+
"min_intensity", "intensity_level_max", "manual_duration_max", "trigger_duration_max")
|
|
628
|
+
out = {"device": clean(entry.slug), "light": light}
|
|
629
|
+
if selected := _compact(config, fields):
|
|
630
|
+
out["settings"] = selected
|
|
631
|
+
if selected := _compact(capability, caps):
|
|
632
|
+
out["capability"] = selected
|
|
633
|
+
return out
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
async def lighting_set(registry, caller, device, light, brightness=None, duration=None):
|
|
637
|
+
if brightness is None and duration is None:
|
|
638
|
+
raise BadRequest("Choose lighting brightness or duration")
|
|
639
|
+
if brightness is not None:
|
|
640
|
+
_integer(brightness, 0, 100, "brightness")
|
|
641
|
+
if duration is not None:
|
|
642
|
+
_integer(duration, 1, 86400, "duration")
|
|
643
|
+
entry = _camera(registry, device)
|
|
644
|
+
config, capability = await _lighting(caller, entry, light)
|
|
645
|
+
patch = {}
|
|
646
|
+
if brightness is not None:
|
|
647
|
+
if light == "spotlight":
|
|
648
|
+
_supported_flag(capability, "wtl_intensity_support", entry, "spotlight brightness")
|
|
649
|
+
patch["wtl_intensity_level"] = _in_range(brightness, capability.get("wtl_intensity_range"), "brightness")
|
|
650
|
+
else:
|
|
651
|
+
patch["intensity_level"] = _in_range(brightness, {"min": capability.get("min_intensity"), "max": capability.get("intensity_level_max")}, "brightness")
|
|
652
|
+
if duration is not None:
|
|
653
|
+
if light == "spotlight":
|
|
654
|
+
_supported_flag(capability, "time_limit_support", entry, "spotlight duration")
|
|
655
|
+
cap = capability.get("time_limit_max_sec" if light == "spotlight" else "manual_duration_max")
|
|
656
|
+
patch["wtl_force_time" if light == "spotlight" else "manual_duration"] = _in_range(duration, {"min": 1, "max": cap}, "duration")
|
|
657
|
+
if any(key not in config for key in patch):
|
|
658
|
+
raise BadRequest(f"{clean(entry.slug)} does not report those lighting settings")
|
|
659
|
+
method, module, section = ("setWhitelampConfig", "image", "switch") if light == "spotlight" else ("setFloodlightConfig", "floodlight", "config")
|
|
660
|
+
await _call(caller, entry, method, {module: {section: patch}})
|
|
661
|
+
await registry.refresh(entry, force=True)
|
|
662
|
+
return {"device": clean(entry.slug), "light": light, "changed": patch}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
async def lighting_switch(registry, caller, device, light, enabled, confirm=None):
|
|
666
|
+
switch = _boolean(enabled, "enabled")
|
|
667
|
+
entry = _camera(registry, device)
|
|
668
|
+
await _lighting(caller, entry, light)
|
|
669
|
+
# UNVERIFIED: force_wtl_state on/off and floodlight action start/stop values. The field names are audited.
|
|
670
|
+
if light == "spotlight":
|
|
671
|
+
method, params = "setForceWhitelampState", {"image": {"switch": {"force_wtl_state": switch}}}
|
|
672
|
+
else:
|
|
673
|
+
method, params = "manualFloodlightOp", {"floodlight": {"manual_floodlight_op": {"action": "start" if enabled else "stop"}}}
|
|
674
|
+
await _call(caller, entry, method, params, confirm=confirm)
|
|
675
|
+
await registry.refresh(entry, force=True)
|
|
676
|
+
return {"device": clean(entry.slug), "light": light, "changed": {"enabled": enabled}}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _timestamp(value):
|
|
680
|
+
if isinstance(value, datetime):
|
|
681
|
+
try:
|
|
682
|
+
number = int(value.timestamp())
|
|
683
|
+
return number if 0 < number <= 253402300799 else None
|
|
684
|
+
except (ValueError, OverflowError, OSError):
|
|
685
|
+
return None
|
|
686
|
+
if type(value) in (int, str):
|
|
687
|
+
try:
|
|
688
|
+
number = int(value)
|
|
689
|
+
return number if 0 < number <= 253402300799 else None
|
|
690
|
+
except (ValueError, OverflowError):
|
|
691
|
+
pass
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _event(value):
|
|
696
|
+
if type(value) is int or isinstance(value, str):
|
|
697
|
+
try:
|
|
698
|
+
code = int(value)
|
|
699
|
+
# A device-supplied number stays small in the answer; anything outside a plausible code is just unknown.
|
|
700
|
+
return {"code": code, "type": EVENT_TYPES.get(code, "unknown")} if 0 <= code <= 9999 else {"type": "unknown"}
|
|
701
|
+
except ValueError:
|
|
702
|
+
if value in EVENT_TYPES.values():
|
|
703
|
+
return {"type": str(value)}
|
|
704
|
+
return {"type": "unknown"}
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
async def events_recent(registry, caller, device, start_time=None, end_time=None, limit=20, start_index=0):
|
|
708
|
+
_integer(limit, 1, 100, "limit")
|
|
709
|
+
_integer(start_index, 0, 100000, "start_index")
|
|
710
|
+
if (start_time is None) != (end_time is None):
|
|
711
|
+
raise BadRequest("Supply both start_time and end_time for SD events")
|
|
712
|
+
if start_time is not None:
|
|
713
|
+
_integer(start_time, 1, 253402300799, "start_time")
|
|
714
|
+
_integer(end_time, 1, 253402300799, "end_time")
|
|
715
|
+
if start_time >= end_time or end_time - start_time > 86400:
|
|
716
|
+
raise BadRequest("SD event window must be ordered and no longer than one day")
|
|
717
|
+
entry = _camera(registry, device)
|
|
718
|
+
components = await _components(caller, entry)
|
|
719
|
+
out = {"device": clean(entry.slug)}
|
|
720
|
+
if "detection" in components:
|
|
721
|
+
data = await _call(caller, entry, "getLastAlarmInfo", {"system": {"name": ["last_alarm_info"]}})
|
|
722
|
+
last = _section(data, "system", "last_alarm_info", entry)
|
|
723
|
+
if timestamp := _timestamp(last.get("last_alarm_time")):
|
|
724
|
+
out["last"] = {"timestamp": timestamp, **_event(last.get("last_alarm_type"))}
|
|
725
|
+
elif start_time is None:
|
|
726
|
+
raise BadRequest(f"{clean(entry.slug)} does not support last-alert polling")
|
|
727
|
+
# python-kasa's last alert features are cached values, labeled as such. Raw type names never escape.
|
|
728
|
+
features = entry.device.features
|
|
729
|
+
if "last_alert_timestamp" in features and (timestamp := _timestamp(features["last_alert_timestamp"].value)):
|
|
730
|
+
value = features["last_alert_type"].value if "last_alert_type" in features else None
|
|
731
|
+
out["cached_last"] = {"timestamp": timestamp, **_event(value)}
|
|
732
|
+
if start_time is not None:
|
|
733
|
+
_require(entry, components, "playback", "SD event metadata")
|
|
734
|
+
_require(entry, components, "sdCard", "SD event metadata")
|
|
735
|
+
# UNVERIFIED: channel 0, inclusive start/end indices and seconds. The reply fields match the app's bean.
|
|
736
|
+
params = {"playback": {"search_detection_list": {"start_time": start_time, "end_time": end_time,
|
|
737
|
+
"channel": 0, "start_index": start_index, "end_index": start_index + limit - 1}}}
|
|
738
|
+
data = await _call(caller, entry, "searchDetectionList", params)
|
|
739
|
+
playback = _module(data, "playback", entry)
|
|
740
|
+
rows = playback.get("search_detection_list")
|
|
741
|
+
if not isinstance(rows, list):
|
|
742
|
+
raise DeviceUnavailable(f"{clean(entry.slug)} did not report SD event metadata")
|
|
743
|
+
events = []
|
|
744
|
+
for row in rows[:limit]:
|
|
745
|
+
if not isinstance(row, dict) or not (timestamp := _timestamp(row.get("start_time"))):
|
|
746
|
+
continue
|
|
747
|
+
if not start_time <= timestamp <= end_time:
|
|
748
|
+
continue
|
|
749
|
+
event = {"timestamp": timestamp, **_event(row.get("alarm_type"))}
|
|
750
|
+
if end := _timestamp(row.get("end_time")):
|
|
751
|
+
event["end_time"] = end
|
|
752
|
+
events.append(event)
|
|
753
|
+
events.sort(key=lambda event: event["timestamp"], reverse=True)
|
|
754
|
+
out["event_count"] = len(events)
|
|
755
|
+
if events:
|
|
756
|
+
out["events"] = events
|
|
757
|
+
# The list is one bounded page, sorted locally, not necessarily the newest page on the device.
|
|
758
|
+
out["window"] = {"start_time": start_time, "end_time": end_time, "start_index": start_index}
|
|
759
|
+
if playback.get("to_be_continued") in (1, "1", True):
|
|
760
|
+
out["next_index"] = start_index + len(rows)
|
|
761
|
+
return out
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _other_tools(ctx):
|
|
765
|
+
@ctx.tool(title="Camera alarm settings")
|
|
766
|
+
async def camera_alarm_get(
|
|
767
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
768
|
+
) -> dict[str, Any]:
|
|
769
|
+
"""Read detection-triggered sound and light alarm settings and capabilities."""
|
|
770
|
+
await ctx.ready_for([device])
|
|
771
|
+
with ctx.translated():
|
|
772
|
+
return await alarm_get(ctx.registry, ctx.caller, device)
|
|
773
|
+
|
|
774
|
+
@ctx.tool(title="Change camera alarm actions", tier=Tier.SECURITY)
|
|
775
|
+
async def camera_alarm_set(
|
|
776
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
777
|
+
settings: Annotated[dict[str, Any], Field(description="Alarm fields: enabled, alarm_mode, alarm_type, alarm_volume, alarm_duration, light_type, light_alarm_enabled, sound_alarm_enabled, light_rbl_enabled. Switches are booleans.")],
|
|
778
|
+
confirm: Annotated[str | None, Field(description="Required phrase: setAlertConfig <device id>")] = None,
|
|
779
|
+
) -> dict[str, Any]:
|
|
780
|
+
"""Change supported alarm actions at level 3 with confirmation. Alarms can sound or flash."""
|
|
781
|
+
await ctx.ready_for([device])
|
|
782
|
+
with ctx.translated():
|
|
783
|
+
return await alarm_set(ctx.registry, ctx.caller, device, settings, confirm)
|
|
784
|
+
|
|
785
|
+
@ctx.tool(title="Camera alarm detection triggers")
|
|
786
|
+
async def camera_alarm_triggers_get(
|
|
787
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
788
|
+
) -> dict[str, Any]:
|
|
789
|
+
"""Read which device-reported event types trigger the camera alarm."""
|
|
790
|
+
await ctx.ready_for([device])
|
|
791
|
+
with ctx.translated():
|
|
792
|
+
return await choices_get(ctx.registry, ctx.caller, device)
|
|
793
|
+
|
|
794
|
+
@ctx.tool(title="Change camera alarm trigger", tier=Tier.SECURITY)
|
|
795
|
+
async def camera_alarm_triggers_set(
|
|
796
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
797
|
+
event: Annotated[str, Field(description="Exact event name from camera_alarm_triggers_get")],
|
|
798
|
+
enabled: Annotated[bool, Field(description="Whether this detection triggers the alarm", strict=True)],
|
|
799
|
+
confirm: Annotated[str | None, Field(description="Required phrase: setAlertEventType <device id>")] = None,
|
|
800
|
+
) -> dict[str, Any]:
|
|
801
|
+
"""Change one alarm trigger at level 3. Preserve the other event choices."""
|
|
802
|
+
await ctx.ready_for([device])
|
|
803
|
+
with ctx.translated():
|
|
804
|
+
return await choices_set(ctx.registry, ctx.caller, device, event, enabled, confirm=confirm)
|
|
805
|
+
|
|
806
|
+
@ctx.tool(title="Camera manual alarm status")
|
|
807
|
+
async def camera_alarm_status(
|
|
808
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
809
|
+
) -> dict[str, Any]:
|
|
810
|
+
"""Read the manual alarm status and remaining time."""
|
|
811
|
+
await ctx.ready_for([device])
|
|
812
|
+
with ctx.translated():
|
|
813
|
+
return await manual_alarm(ctx.registry, ctx.caller, device)
|
|
814
|
+
|
|
815
|
+
@ctx.tool(title="Start or stop camera alarm", tier=Tier.SECURITY)
|
|
816
|
+
async def camera_alarm_manual(
|
|
817
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
818
|
+
action: Annotated[Literal["start", "stop"], Field(description="Start the audible and bright alarm now, or stop it")],
|
|
819
|
+
confirm: Annotated[str | None, Field(description="Required phrase for either action: doManualAlarm <device id>")] = None,
|
|
820
|
+
) -> dict[str, Any]:
|
|
821
|
+
"""Start or stop the manual alarm at level 3 with confirmation for this device."""
|
|
822
|
+
await ctx.ready_for([device])
|
|
823
|
+
with ctx.translated():
|
|
824
|
+
return await manual_alarm(ctx.registry, ctx.caller, device, action, confirm)
|
|
825
|
+
|
|
826
|
+
@ctx.tool(title="Camera light settings")
|
|
827
|
+
async def camera_alarm_light_get(
|
|
828
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
829
|
+
light: Annotated[Literal["spotlight", "floodlight"], Field(description="Which physical camera light to read")],
|
|
830
|
+
) -> dict[str, Any]:
|
|
831
|
+
"""Read spotlight or floodlight brightness, duration and model limits."""
|
|
832
|
+
await ctx.ready_for([device])
|
|
833
|
+
with ctx.translated():
|
|
834
|
+
return await lighting_get(ctx.registry, ctx.caller, device, light)
|
|
835
|
+
|
|
836
|
+
@ctx.tool(title="Change camera light settings", tier=Tier.SETTING)
|
|
837
|
+
async def camera_alarm_light_set(
|
|
838
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
839
|
+
light: Annotated[Literal["spotlight", "floodlight"], Field(description="Which physical camera light to configure")],
|
|
840
|
+
brightness: Annotated[int | None, Field(description="Brightness within the reported model range", ge=0, le=100, strict=True)] = None,
|
|
841
|
+
duration: Annotated[int | None, Field(description="On duration in seconds within the reported model limit", ge=1, le=86400, strict=True)] = None,
|
|
842
|
+
) -> dict[str, Any]:
|
|
843
|
+
"""Change supported camera light brightness or duration at level 2."""
|
|
844
|
+
await ctx.ready_for([device])
|
|
845
|
+
with ctx.translated():
|
|
846
|
+
return await lighting_set(ctx.registry, ctx.caller, device, light, brightness, duration)
|
|
847
|
+
|
|
848
|
+
@ctx.tool(title="Switch camera light now", tier=Tier.SECURITY)
|
|
849
|
+
async def camera_alarm_light_switch(
|
|
850
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
851
|
+
light: Annotated[Literal["spotlight", "floodlight"], Field(description="Physical camera light to switch")],
|
|
852
|
+
enabled: Annotated[bool, Field(description="Turn the light on or off now", strict=True)],
|
|
853
|
+
confirm: Annotated[str | None, Field(description="Required phrase: setForceWhitelampState <device id> for spotlight, manualFloodlightOp <device id> for floodlight")] = None,
|
|
854
|
+
) -> dict[str, Any]:
|
|
855
|
+
"""Switch a bright physical light now at level 3 with confirmation."""
|
|
856
|
+
await ctx.ready_for([device])
|
|
857
|
+
with ctx.translated():
|
|
858
|
+
return await lighting_switch(ctx.registry, ctx.caller, device, light, enabled, confirm)
|
|
859
|
+
|
|
860
|
+
@ctx.tool(title="Recent local camera events")
|
|
861
|
+
async def camera_events_recent(
|
|
862
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
863
|
+
start_time: Annotated[int | None, Field(description="Optional Unix seconds for SD window start. Supply end_time too.", strict=True)] = None,
|
|
864
|
+
end_time: Annotated[int | None, Field(description="Optional Unix seconds for SD window end. At most one day.", strict=True)] = None,
|
|
865
|
+
limit: Annotated[int, Field(description="Maximum SD events in one page", ge=1, le=100, strict=True)] = 20,
|
|
866
|
+
start_index: Annotated[int, Field(description="SD page start index. Use next_index to continue.", ge=0, le=100000, strict=True)] = 0,
|
|
867
|
+
) -> dict[str, Any]:
|
|
868
|
+
"""Poll the last local alert and cached python-kasa alert. Optionally read one SD metadata page. No clips or thumbnails."""
|
|
869
|
+
await ctx.ready_for([device])
|
|
870
|
+
with ctx.translated():
|
|
871
|
+
return await events_recent(ctx.registry, ctx.caller, device, start_time, end_time, limit, start_index)
|
|
872
|
+
|
|
873
|
+
@ctx.tool(title="Camera notification choices")
|
|
874
|
+
async def camera_detect_notifications_get(
|
|
875
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
876
|
+
) -> dict[str, Any]:
|
|
877
|
+
"""Read device-side notification switches and interruption levels by event type. Push delivery is cloud."""
|
|
878
|
+
await ctx.ready_for([device])
|
|
879
|
+
with ctx.translated():
|
|
880
|
+
return await choices_get(ctx.registry, ctx.caller, device, notifications=True)
|
|
881
|
+
|
|
882
|
+
@ctx.tool(title="Change camera notification choice", tier=Tier.SECURITY)
|
|
883
|
+
async def camera_detect_notifications_set(
|
|
884
|
+
device: Annotated[str, Field(description="Camera or doorbell id or name")],
|
|
885
|
+
event: Annotated[str, Field(description="Exact event name from camera_detect_notifications_get")],
|
|
886
|
+
enabled: Annotated[bool, Field(description="Enable or disable the device-side notification for this event", strict=True)],
|
|
887
|
+
interruption_level: Annotated[Literal["none", "general", "critical"] | None, Field(description="Optional device-reported notification urgency")] = None,
|
|
888
|
+
confirm: Annotated[str | None, Field(description="Required phrase: setMsgPushEventList <device id>")] = None,
|
|
889
|
+
) -> dict[str, Any]:
|
|
890
|
+
"""Change one device-side notification choice at level 3 with confirmation, because it decides whether the owner is warned. Preserve other types. No cloud push calls."""
|
|
891
|
+
await ctx.ready_for([device])
|
|
892
|
+
with ctx.translated():
|
|
893
|
+
return await choices_set(ctx.registry, ctx.caller, device, event, enabled, interruption_level, notifications=True, confirm=confirm)
|