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,866 @@
|
|
|
1
|
+
"""Capability-gated SMARTCAM controls. Requests use the existing authenticated caller.
|
|
2
|
+
|
|
3
|
+
Wire facts: camera-settings.md, python-kasa PanTilt/LensMask/Led/Time modules,
|
|
4
|
+
and the app's request builders (method and field names only). No app code is copied.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from typing import Annotated, Any, Literal
|
|
9
|
+
|
|
10
|
+
from kasa.exceptions import AuthenticationError
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
from .. import policy
|
|
14
|
+
from ..policy import Tier
|
|
15
|
+
from ..registry import BadRequest, DeviceUnavailable, failure_code
|
|
16
|
+
from ..views import clean
|
|
17
|
+
|
|
18
|
+
KINDS = ["camera", "doorbell"]
|
|
19
|
+
Device = Annotated[str, Field(description="Camera or doorbell id or name")]
|
|
20
|
+
Confirm = Annotated[str | None, Field(description="Exact '<method> <device id>' confirmation for level 3 or 4")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Read:
|
|
25
|
+
method: str
|
|
26
|
+
component: str
|
|
27
|
+
module: str
|
|
28
|
+
section: str
|
|
29
|
+
fields: tuple[str, ...]
|
|
30
|
+
selector: str = "name"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
READS = {
|
|
34
|
+
"presets": Read("getPresetConfig", "ptz", "preset", "preset", ("id", "name")),
|
|
35
|
+
"angles": Read("getMotorAngleRange", "ptz", "panoramic_view", "get_angle_range", ("x_angle_range", "y_angle_range"), "direct"),
|
|
36
|
+
"patrol": Read("getPatrolStatus", "patrol", "patrol", "get_patrol_status", ("value",), "direct"),
|
|
37
|
+
# UNVERIFIED: getPatrolInfo response section is inferred from its request section.
|
|
38
|
+
"patrol_points": Read("getPatrolInfo", "patrol", "patrol", "get_patrol_info", ("id", "name", "wait_time", "enabled", "index", "patrol_info"), "direct"),
|
|
39
|
+
"tracking": Read("getTargetTrackConfig", "targetTrack", "target_track", "target_track_info", ("enabled", "people_enabled", "pet_enabled", "vehicle_enabled")),
|
|
40
|
+
}
|
|
41
|
+
WRITES = {
|
|
42
|
+
"stopMove": (Tier.CONTROL, "Stop camera movement."),
|
|
43
|
+
"cruiseMove": (Tier.CONTROL, "Sweep the camera once."),
|
|
44
|
+
"cruiseStop": (Tier.CONTROL, "Stop a camera sweep."),
|
|
45
|
+
"manualCalibrate": (Tier.CONTROL, "Calibrate the camera motor."),
|
|
46
|
+
"addMotorPostion": (Tier.SETTING, "Save a camera viewpoint."),
|
|
47
|
+
"deletePreset": (Tier.SETTING, "Delete camera viewpoints."),
|
|
48
|
+
"setPatrolStatus": (Tier.SETTING, "Start or stop camera patrol."),
|
|
49
|
+
"addPatrol": (Tier.SETTING, "Add a camera patrol point."),
|
|
50
|
+
"editPatrol": (Tier.SETTING, "Edit a camera patrol point."),
|
|
51
|
+
"deletePatrol": (Tier.SETTING, "Delete a camera patrol point."),
|
|
52
|
+
"setTargetTrackConfig": (Tier.SETTING, "Change automatic camera tracking."),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _catalog():
|
|
57
|
+
reads = {r.method for r in READS.values()}
|
|
58
|
+
shared = [policy.method("getChildDeviceComponentList", Tier.READ, ["hub"], "Read component support for hub-managed cameras.")]
|
|
59
|
+
return shared + [policy.method(m, Tier.READ, KINDS, "Read camera settings or capabilities.") for m in sorted(reads)] + [
|
|
60
|
+
policy.method(m, tier, KINDS, summary) for m, (tier, summary) in WRITES.items()
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pick(value, fields):
|
|
65
|
+
"""Only named, known fields leave the house. Unknown fields are never echoed."""
|
|
66
|
+
if isinstance(value, dict):
|
|
67
|
+
out = {k: _pick(v, fields) for k, v in value.items() if k in fields}
|
|
68
|
+
return {k: v for k, v in out.items() if v is not None and v != "" and v != [] and v != {}}
|
|
69
|
+
if isinstance(value, list):
|
|
70
|
+
return [v for item in value[:32] if (v := _pick(item, fields)) is not None and v != {} and v != ""]
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
return clean(value)
|
|
73
|
+
if isinstance(value, (bool, int, float)):
|
|
74
|
+
return value
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _section(result, module, section):
|
|
79
|
+
if not isinstance(result, dict):
|
|
80
|
+
raise BadRequest("The camera returned an unexpected response shape")
|
|
81
|
+
block = result.get(module)
|
|
82
|
+
if isinstance(block, dict) and section in block:
|
|
83
|
+
return block[section]
|
|
84
|
+
# Seen on a real doorbell: getBatteryStatistic answers {"statistic": ...} and leaves the module out.
|
|
85
|
+
if module not in result and isinstance(result.get(section), dict):
|
|
86
|
+
return result[section]
|
|
87
|
+
raise BadRequest("The camera returned an unexpected section shape")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _rejected(value):
|
|
91
|
+
if isinstance(value, dict):
|
|
92
|
+
return ("error_code" in value and value["error_code"] not in (0, "0")) or any(_rejected(v) for v in value.values())
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
return any(_rejected(v) for v in value)
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _call(caller, entry, method, params, confirm=None):
|
|
99
|
+
try:
|
|
100
|
+
result = await caller.call(entry, method, params, confirm=confirm)
|
|
101
|
+
except DeviceUnavailable as exc:
|
|
102
|
+
cause = exc.__cause__
|
|
103
|
+
code = failure_code(exc)
|
|
104
|
+
reason = "did not answer in time" if isinstance(cause, TimeoutError) else "rejected the account credentials" if isinstance(cause, AuthenticationError) else (
|
|
105
|
+
f"reported a device error ({code})" if code else "reported a device error")
|
|
106
|
+
raise DeviceUnavailable(f"{entry.slug}: {method}: camera {reason}") from exc
|
|
107
|
+
if _rejected(result):
|
|
108
|
+
raise DeviceUnavailable(f"{entry.slug}: {method} was rejected by the camera")
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _response_section(entry, method, result, module, section):
|
|
113
|
+
try:
|
|
114
|
+
return _section(result, module, section)
|
|
115
|
+
except BadRequest:
|
|
116
|
+
raise DeviceUnavailable(f"{entry.slug}: {method} returned an unexpected response shape") from None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def components(registry, caller, device):
|
|
120
|
+
entry = registry.resolve_one(device)
|
|
121
|
+
if entry.kind not in KINDS:
|
|
122
|
+
raise BadRequest(f"{entry.slug} is not a camera or doorbell")
|
|
123
|
+
if entry.root is not None and entry.root.kind == "hub":
|
|
124
|
+
items = await child_components(caller, entry)
|
|
125
|
+
else:
|
|
126
|
+
items = await direct_components(caller, entry)
|
|
127
|
+
if not isinstance(items, list):
|
|
128
|
+
raise BadRequest(f"{entry.slug}: camera component list is unavailable")
|
|
129
|
+
found = {}
|
|
130
|
+
for item in items:
|
|
131
|
+
if isinstance(item, dict) and isinstance(item.get("name"), str):
|
|
132
|
+
version = item.get("version", 0)
|
|
133
|
+
if isinstance(version, int) and not isinstance(version, bool) and version > 0:
|
|
134
|
+
found[item["name"]] = version
|
|
135
|
+
return entry, found
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def direct_components(caller, entry):
|
|
139
|
+
try:
|
|
140
|
+
result = await _call(caller, entry, "getAppComponentList", {"app_component": {"name": "app_component_list"}})
|
|
141
|
+
except DeviceUnavailable as exc:
|
|
142
|
+
if "did not answer in time" in str(exc):
|
|
143
|
+
raise DeviceUnavailable(f"{exc}. A sleeping battery camera may need BLE hardware") from exc
|
|
144
|
+
raise
|
|
145
|
+
return _response_section(entry, "getAppComponentList", result, "app_component", "app_component_list")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def child_components(caller, entry):
|
|
149
|
+
"""The hub supplies the component list for camera-protocol children."""
|
|
150
|
+
start = 0
|
|
151
|
+
for _ in range(32):
|
|
152
|
+
result = await _call(caller, entry.root, "getChildDeviceComponentList", {"childControl": {"start_index": start}})
|
|
153
|
+
if not isinstance(result, dict) or not isinstance(result.get("child_component_list"), list):
|
|
154
|
+
raise DeviceUnavailable(f"{entry.slug}: the hub returned an unexpected component response")
|
|
155
|
+
rows = result["child_component_list"]
|
|
156
|
+
for row in rows:
|
|
157
|
+
if isinstance(row, dict) and row.get("device_id") == entry.device.device_id:
|
|
158
|
+
return row.get("component_list")
|
|
159
|
+
total = result.get("sum")
|
|
160
|
+
if type(total) is not int or total < 0 or not rows or start + len(rows) >= total:
|
|
161
|
+
break
|
|
162
|
+
start += len(rows)
|
|
163
|
+
raise BadRequest(f"{entry.slug}: the hub did not report this camera's components")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def require(entry, found, feature):
|
|
167
|
+
if feature == "system" and "batCamSystem" in found:
|
|
168
|
+
return
|
|
169
|
+
if feature and feature not in found:
|
|
170
|
+
raise BadRequest(f"{entry.slug} does not report support for {feature}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def read_setting(registry, caller, device, setting):
|
|
174
|
+
if setting not in READS:
|
|
175
|
+
raise BadRequest("Unknown camera setting")
|
|
176
|
+
spec = READS[setting]
|
|
177
|
+
entry, found = await components(registry, caller, device)
|
|
178
|
+
require(entry, found, spec.component)
|
|
179
|
+
check_variant(entry, found, spec)
|
|
180
|
+
if (spec.component == "clips" and found["clips"] == 1) or (spec.component == "wakeUp" and found["wakeUp"] == 1):
|
|
181
|
+
spec = replace(spec, section="*", selector="flat_dict")
|
|
182
|
+
elif spec.component == "pir" and found["pir"] == 1:
|
|
183
|
+
spec = replace(spec, module="pir_verify", section="*", selector="flat_dict")
|
|
184
|
+
result = await raw_read(caller, entry, spec)
|
|
185
|
+
return {"device": entry.slug, **({setting: value} if (value := _pick(result, spec.fields)) else {})}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def write(registry, caller, entry, method, module, section, payload, changed, confirm=None):
|
|
189
|
+
await _call(caller, entry, method, {module: payload if section == "*" else {section: payload}}, confirm)
|
|
190
|
+
try:
|
|
191
|
+
await registry.refresh(entry, force=True)
|
|
192
|
+
except DeviceUnavailable:
|
|
193
|
+
raise DeviceUnavailable(f"{entry.slug}: {method} was accepted but camera state could not be refreshed") from None
|
|
194
|
+
return {"device": entry.slug, "changed": changed}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def integer(value, minimum, maximum, label):
|
|
198
|
+
if type(value) is not int or not minimum <= value <= maximum:
|
|
199
|
+
raise BadRequest(f"{label} must be an integer from {minimum} to {maximum}")
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def switch(value):
|
|
204
|
+
if type(value) is not bool:
|
|
205
|
+
raise BadRequest("Enabled must be true or false")
|
|
206
|
+
return "on" if value else "off"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def text_input(value, label, limit=64):
|
|
210
|
+
if not isinstance(value, str) or not value.strip() or len(value) > limit or clean(value, limit) != value:
|
|
211
|
+
raise BadRequest(f"{label} must be plain text of 1 to {limit} characters")
|
|
212
|
+
return value
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def ptz_stop(registry, caller, device):
|
|
216
|
+
"""Stop must always be sent. It skips the component read, so a slow or odd component list cannot block it.
|
|
217
|
+
A camera without a motor answers with an error, which is reported."""
|
|
218
|
+
entry = registry.resolve_one(device)
|
|
219
|
+
if entry.kind not in KINDS:
|
|
220
|
+
raise BadRequest(f"{entry.slug} is not a camera or doorbell")
|
|
221
|
+
return await write(registry, caller, entry, "stopMove", "motor", "stop", "null", "movement stopped")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def ptz_action(registry, caller, device, action, **values):
|
|
225
|
+
if action == "stop":
|
|
226
|
+
return await ptz_stop(registry, caller, device)
|
|
227
|
+
entry, found = await components(registry, caller, device)
|
|
228
|
+
require(entry, found, "patrol" if action in {"patrol", "add", "edit", "delete"} else "targetTrack" if action == "tracking" else "ptz")
|
|
229
|
+
if action == "cruise":
|
|
230
|
+
axis = values["axis"]
|
|
231
|
+
if axis not in {"horizontal", "vertical", "stop"}:
|
|
232
|
+
raise BadRequest("Sweep axis must be horizontal, vertical or stop")
|
|
233
|
+
method, section, payload = ("cruiseStop", "cruise_stop", "null") if axis == "stop" else ("cruiseMove", "cruise", {"coord": "x" if axis == "horizontal" else "y"})
|
|
234
|
+
return await write(registry, caller, entry, method, "motor", section, payload, f"sweep {axis}")
|
|
235
|
+
if action == "calibrate":
|
|
236
|
+
mode = values["position"]
|
|
237
|
+
if mode not in {"factory", "saved"}:
|
|
238
|
+
raise BadRequest("Calibration position must be factory or saved")
|
|
239
|
+
return await write(registry, caller, entry, "manualCalibrate", "motor", "manual_cali", {"cal_to_resume": "0" if mode == "factory" else "1"}, f"calibration requested ({mode})")
|
|
240
|
+
if action == "save":
|
|
241
|
+
name = text_input(values["name"], "Preset name")
|
|
242
|
+
return await write(registry, caller, entry, "addMotorPostion", "preset", "set_preset", {"name": name, "save_ptz": "1"}, {"preset_saved": name})
|
|
243
|
+
if action == "remove":
|
|
244
|
+
ids = values["ids"]
|
|
245
|
+
if not isinstance(ids, list) or not 1 <= len(ids) <= 32:
|
|
246
|
+
raise BadRequest("Choose 1 to 32 preset ids")
|
|
247
|
+
ids = [str(integer(i, 0, 255, "Preset id")) for i in ids]
|
|
248
|
+
presets = _response_section(entry, "getPresetConfig", await _call(caller, entry, "getPresetConfig", {"preset": {"name": ["preset"]}}), "preset", "preset")
|
|
249
|
+
known = presets.get("id") if isinstance(presets, dict) else None
|
|
250
|
+
if not isinstance(known, list):
|
|
251
|
+
raise DeviceUnavailable(f"{entry.slug}: getPresetConfig returned an unexpected response shape")
|
|
252
|
+
if any(i not in {str(p) for p in known} for i in ids):
|
|
253
|
+
raise BadRequest("A selected preset does not exist")
|
|
254
|
+
return await write(registry, caller, entry, "deletePreset", "preset", "remove_preset", {"id": ids}, {"presets_deleted": ids})
|
|
255
|
+
if action == "patrol":
|
|
256
|
+
enabled = switch(values["enabled"])
|
|
257
|
+
return await write(registry, caller, entry, "setPatrolStatus", "patrol", "set_patrol_status", {"value": enabled}, {"patrol": enabled})
|
|
258
|
+
if action in {"add", "edit", "delete"}:
|
|
259
|
+
point = str(integer(values["preset_id"], 0, 255, "Preset id"))
|
|
260
|
+
payload = {"id": point}
|
|
261
|
+
if action != "delete":
|
|
262
|
+
# No defaults: an edit without a name would silently rename the point.
|
|
263
|
+
if values.get("name") is None or values.get("wait_seconds") is None:
|
|
264
|
+
raise BadRequest("Adding or editing a patrol point needs a name and wait_seconds")
|
|
265
|
+
payload.update(name=text_input(values["name"], "Point name"), wait_time=str(integer(values["wait_seconds"], 1, 3600, "Wait seconds")))
|
|
266
|
+
method = {"add": "addPatrol", "edit": "editPatrol", "delete": "deletePatrol"}[action]
|
|
267
|
+
return await write(registry, caller, entry, method, "patrol", f"{action}_patrol", payload, {"patrol_point": point, "action": action})
|
|
268
|
+
if action == "tracking":
|
|
269
|
+
enabled = switch(values["enabled"])
|
|
270
|
+
return await write(registry, caller, entry, "setTargetTrackConfig", "target_track", "target_track_info", {"enabled": enabled}, {"tracking": enabled})
|
|
271
|
+
raise BadRequest("Unknown PTZ action")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Image settings and their model-reported ranges.
|
|
275
|
+
READS.update({
|
|
276
|
+
"night_vision": Read("getNightVisionModeConfig", "nightVisionMode", "image", "switch", ("night_vision_mode",)),
|
|
277
|
+
"night_capability": Read("getNightVisionCapability", "nightVisionMode", "image_capability", "supplement_lamp", ("night_vision_mode_range",)),
|
|
278
|
+
"rotation": Read("getRotationStatus", "videoRotation", "image", "switch", ("flip_type", "rotate_type_by_app")),
|
|
279
|
+
"rotation_capability": Read("getImageCapability", "videoRotation", "image_capability", "flip", ("flip_type", "app_rotate_support")),
|
|
280
|
+
"style": Read("getImageStyleConfig", "imageStyle", "image", "common", ("style", "luma")),
|
|
281
|
+
"style_capability": Read("getImageStyleCapability", "imageStyle", "image_style_capability", "image", ("image_style_range", "image_style_support", "luma_support")),
|
|
282
|
+
"video_capability": Read("getVideoCapability", "video", "video_capability", "main", ("hdrs", "auto_hdr")),
|
|
283
|
+
"led": Read("getLedStatus", "led", "led", "config", ("enabled", "status")),
|
|
284
|
+
"privacy": Read("getLensMaskConfig", "lensMask", "lens_mask", "lens_mask_info", ("enabled",)),
|
|
285
|
+
"privacy_zones": Read("getCoverConfig", "blockZone", "cover", "cover", ("enabled",)),
|
|
286
|
+
"privacy_regions": Read("getCoverRegion", "blockZone", "cover", "region_info", ("x_coor", "y_coor", "width", "height", "point_num", *(f"pt{i}_{axis}" for i in range(1, 13) for axis in ("x", "y"))), "table"),
|
|
287
|
+
"osd": Read("getOsd", "osd", "OSD", "*", ("date", "logo", "label_info", "label_info_1", "enabled", "x_coor", "y_coor", "text", "is_hour12", "time_type")),
|
|
288
|
+
"osd_capability": Read("getOsdCapability", "osd", "osd_capability", "*", ("date_default", "logo_default", "label_info_default", "capability", "osd_week_support", "icon_support", "x_coor", "y_coor")),
|
|
289
|
+
})
|
|
290
|
+
WRITES.update({m: (Tier.SETTING, s) for m, s in {
|
|
291
|
+
"setNightVisionModeConfig": "Change camera night vision mode.",
|
|
292
|
+
"setRotationStatus": "Change camera image orientation.",
|
|
293
|
+
"setHDR": "Change camera HDR processing.", # UNVERIFIED module and section, see image_change
|
|
294
|
+
"setImageStyleConfig": "Change camera image style.",
|
|
295
|
+
"setOsd": "Change camera video overlays.",
|
|
296
|
+
}.items()})
|
|
297
|
+
# Turning the indicator light off hides that the camera records, and turning privacy zones off shows areas the
|
|
298
|
+
# owner blacked out. One method carries both directions, so both are level 3. setLedStatus must be level 3 in
|
|
299
|
+
# every area that declares it (camera_system, hub, device_admin), or the loader fails on merge.
|
|
300
|
+
WRITES["setLedStatus"] = (Tier.SECURITY, "Change the camera indicator light. Off hides that the camera is recording.")
|
|
301
|
+
WRITES["setCoverConfig"] = (Tier.SECURITY, "Enable or disable camera privacy zones. Off shows the blacked-out areas.")
|
|
302
|
+
# The shared method catalog has no parameter-dependent levels, so the whole lens-mask method is level 3. Closing the
|
|
303
|
+
# mask at level 1 would need, in the shared files: a MethodRule field that maps params to a level (here: enabled "on"
|
|
304
|
+
# is CONTROL, anything else SECURITY, unknown shapes the higher one); Caller.check and call_many passing params to it;
|
|
305
|
+
# Caller.plan taking params; and a confirmation phrase that names the direction, so a phrase given for ON cannot be
|
|
306
|
+
# reused for OFF. Until then failing closed (level 3 both ways) is the safe choice.
|
|
307
|
+
WRITES["setLensMaskConfig"] = (Tier.SECURITY, "Change camera privacy mode.")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def raw_read(caller, entry, spec):
|
|
311
|
+
if spec.selector == "flat_dict":
|
|
312
|
+
payload = {}
|
|
313
|
+
elif spec.selector == "flat":
|
|
314
|
+
payload = "null"
|
|
315
|
+
elif spec.selector == "direct_list":
|
|
316
|
+
payload = {spec.section: []}
|
|
317
|
+
elif spec.section == "*":
|
|
318
|
+
if spec.method == "getOsd":
|
|
319
|
+
payload = {"name": ["date", "logo"], "table": ["label_info"]}
|
|
320
|
+
else:
|
|
321
|
+
payload = {"name": ["date_default", "logo_default", "label_info_default", "capability"], "table": ["capability"]}
|
|
322
|
+
else:
|
|
323
|
+
payload = {spec.section: {}} if spec.selector == "direct" else {spec.selector: [spec.section]}
|
|
324
|
+
result = await _call(caller, entry, spec.method, {spec.module: payload})
|
|
325
|
+
if spec.selector == "direct_list":
|
|
326
|
+
if not isinstance(result, dict):
|
|
327
|
+
raise DeviceUnavailable(f"{entry.slug}: {spec.method} returned an unexpected response shape")
|
|
328
|
+
if result and not any(k in result for k in spec.fields):
|
|
329
|
+
raise DeviceUnavailable(f"{entry.slug}: {spec.method} returned an unexpected response shape")
|
|
330
|
+
return result
|
|
331
|
+
if spec.section == "*":
|
|
332
|
+
if not isinstance(result, dict) or not isinstance(result.get(spec.module), dict):
|
|
333
|
+
raise DeviceUnavailable(f"{entry.slug}: {spec.method} returned an unexpected response shape")
|
|
334
|
+
return result[spec.module]
|
|
335
|
+
return _response_section(entry, spec.method, result, spec.module, spec.section)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def capability(caller, entry, found, setting):
|
|
339
|
+
spec = READS[setting]
|
|
340
|
+
require(entry, found, spec.component)
|
|
341
|
+
check_variant(entry, found, spec)
|
|
342
|
+
result = await raw_read(caller, entry, spec)
|
|
343
|
+
if not isinstance(result, dict):
|
|
344
|
+
raise BadRequest(f"{entry.slug}: capability response is unavailable")
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def choice(value, choices, label):
|
|
349
|
+
if not isinstance(choices, list) or not choices or value not in choices:
|
|
350
|
+
raise BadRequest(f"{label} is not in the camera's reported supported values")
|
|
351
|
+
return value
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def image_change(registry, caller, device, setting, value, confirm=None):
|
|
355
|
+
entry, found = await components(registry, caller, device)
|
|
356
|
+
if "dualCam" in found:
|
|
357
|
+
raise BadRequest(f"{entry.slug}: multi-lens image changes need a lens-specific request")
|
|
358
|
+
if setting == "night_vision":
|
|
359
|
+
cap = await capability(caller, entry, found, "night_capability")
|
|
360
|
+
value = choice(value, cap.get("night_vision_mode_range"), "Night vision mode")
|
|
361
|
+
return await write(registry, caller, entry, "setNightVisionModeConfig", "image", "switch", {"night_vision_mode": value}, {setting: value})
|
|
362
|
+
if setting == "flip":
|
|
363
|
+
cap = await capability(caller, entry, found, "rotation_capability")
|
|
364
|
+
value = choice(value, cap.get("flip_type"), "Flip mode")
|
|
365
|
+
return await write(registry, caller, entry, "setRotationStatus", "image", "switch", {"flip_type": value}, {setting: value})
|
|
366
|
+
if setting == "style":
|
|
367
|
+
cap = await capability(caller, entry, found, "style_capability")
|
|
368
|
+
value = choice(value, cap.get("image_style_range"), "Image style")
|
|
369
|
+
return await write(registry, caller, entry, "setImageStyleConfig", "image", "common", {"style": value}, {setting: value})
|
|
370
|
+
if setting == "hdr":
|
|
371
|
+
# UNVERIFIED: the audit could not resolve setHDR's module and section; `video.set_hdr` is inferred.
|
|
372
|
+
flag = switch(value)
|
|
373
|
+
cap = await capability(caller, entry, found, "video_capability")
|
|
374
|
+
hdr = "1" if flag == "on" else "0"
|
|
375
|
+
choice(hdr, cap.get("hdrs"), "HDR mode")
|
|
376
|
+
return await write(registry, caller, entry, "setHDR", "video", "set_hdr", {"secname": "main", "hdr": int(hdr)}, {setting: value})
|
|
377
|
+
if setting in {"led", "privacy", "privacy_zones"}:
|
|
378
|
+
spec = READS[setting]
|
|
379
|
+
require(entry, found, spec.component)
|
|
380
|
+
flag = switch(value)
|
|
381
|
+
if setting == "led" and found["led"] != 1:
|
|
382
|
+
raise BadRequest(f"{entry.slug}: this LED version needs a different status payload")
|
|
383
|
+
method = {"led": "setLedStatus", "privacy": "setLensMaskConfig", "privacy_zones": "setCoverConfig"}[setting]
|
|
384
|
+
return await write(registry, caller, entry, method, spec.module, spec.section, {"enabled": flag}, {setting: value}, confirm)
|
|
385
|
+
raise BadRequest("Unknown image setting")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def image_mode(registry, caller, device, setting, value):
|
|
389
|
+
"""camera_image_mode: HDR takes on or off, the other modes take a value from the camera's reported range."""
|
|
390
|
+
if setting == "hdr":
|
|
391
|
+
if value not in ("on", "off"):
|
|
392
|
+
raise BadRequest("HDR must be on or off")
|
|
393
|
+
value = value == "on"
|
|
394
|
+
elif setting not in {"night_vision", "flip", "style"}:
|
|
395
|
+
raise BadRequest("Unknown image mode")
|
|
396
|
+
return await image_change(registry, caller, device, setting, value)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
async def osd_change(registry, caller, device, element, enabled, text=None):
|
|
400
|
+
if element not in {"time", "name", "logo"}:
|
|
401
|
+
raise BadRequest("OSD element must be time, name or logo")
|
|
402
|
+
flag = switch(enabled)
|
|
403
|
+
if text is not None:
|
|
404
|
+
text = text_input(text, "Overlay text")
|
|
405
|
+
if element != "name":
|
|
406
|
+
raise BadRequest("Only the name overlay accepts text")
|
|
407
|
+
entry, found = await components(registry, caller, device)
|
|
408
|
+
require(entry, found, "osd")
|
|
409
|
+
if "batCamOsd" in found or "dualCam" in found:
|
|
410
|
+
raise BadRequest(f"{entry.slug}: this model needs a different OSD payload")
|
|
411
|
+
cap = await capability(caller, entry, found, "osd_capability")
|
|
412
|
+
section = {"time": "date", "name": "label_info_1", "logo": "logo"}[element]
|
|
413
|
+
default = {"time": "date_default", "name": "label_info_default", "logo": "logo_default"}[element]
|
|
414
|
+
if not isinstance(cap.get(default), dict) or not cap[default]:
|
|
415
|
+
raise BadRequest(f"{entry.slug} does not report support for the {element} overlay")
|
|
416
|
+
current = await raw_read(caller, entry, READS["osd"])
|
|
417
|
+
old = current.get(section)
|
|
418
|
+
if element == "name" and old is None:
|
|
419
|
+
table = current.get("label_info", [])
|
|
420
|
+
if isinstance(table, list):
|
|
421
|
+
old = next((row[section] for row in table if isinstance(row, dict) and section in row), None)
|
|
422
|
+
if not isinstance(old, dict):
|
|
423
|
+
raise BadRequest(f"{entry.slug}: current overlay position is unavailable")
|
|
424
|
+
payload = _pick(old, ("x_coor", "y_coor", "text", "enabled", "is_hour12", "time_type"))
|
|
425
|
+
payload["enabled"] = flag
|
|
426
|
+
if text is not None:
|
|
427
|
+
payload["text"] = text
|
|
428
|
+
return await write(registry, caller, entry, "setOsd", "OSD", section, payload, {"overlay": element, "enabled": enabled, **({"text": text} if text is not None else {})})
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
READS.update({
|
|
432
|
+
"battery_status": Read("getBatteryStatus", "battery", "battery", "status", ("battery_percent", "battery_charging", "battery_voltage", "battery_temperature", "battery_overheated", "low_battery", "power_mode", "battery_capacity", "battery_chg_temp_status")),
|
|
433
|
+
"battery_capability": Read("getBatteryCapability", "battery", "battery", "capability", ("battery_power_mode_range", "battery_operating_mode_range", "battery_power_mode_support", "battery_operating_mode_support", "charging_privacy_mode_support", "charging_use_support", "battery_statistic_support")),
|
|
434
|
+
"power_mode": Read("getPowerMode", "battery", "battery", "power", ("mode",)),
|
|
435
|
+
"working_mode": Read("getBatteryOperatingMode", "battery", "battery", "operating", ("mode", "follow_config")),
|
|
436
|
+
"clips": Read("getClipsConfig", "clips", "clips", "config", ("clips_length", "record_buffer", "retrigger_time")),
|
|
437
|
+
"wake_up": Read("getWakeUpConfig", "wakeUp", "wake_up", "config", ("wake_up_type",)),
|
|
438
|
+
"pir": Read("getPirSensitivity", "pir", "pir", "config", ("sensitivity",)),
|
|
439
|
+
"charging": Read("getChargingMode", "battery", "battery", "charging_mode", ("adapter_mode", "charging_privacy_mode")),
|
|
440
|
+
# getChargingPrivacyMode is refused by a C465 and a C610 (PROTOCOL_FORMAT_ERROR). The app reads this switch from
|
|
441
|
+
# getChargingMode, as it sets it with setChargingMode: the C465 and C610 answer with `charging_privacy_mode` and `adapter_mode`.
|
|
442
|
+
"charging_privacy": Read("getChargingMode", "battery", "battery", "charging_mode", ("charging_privacy_mode",)),
|
|
443
|
+
# Checked on a D230: the answer is {"statistic": {"day_num", "day": [...]}}, without the module around it.
|
|
444
|
+
"statistics": Read("getBatteryStatistic", "batCamStatistics", "battery", "statistic", ("day", "day_num", "time", "bat_per", "bat_usage", "day_runtime", "hour_runtime", "pir_wakeup_with_event", "pir_only_wakeup", "solar_charge_cap", "always_on_status", "bat_status")),
|
|
445
|
+
})
|
|
446
|
+
WRITES.update({
|
|
447
|
+
"setPowerMode": (Tier.SETTING, "Change camera power source mode."),
|
|
448
|
+
"setBatteryOperatingMode": (Tier.SETTING, "Change camera battery working mode."),
|
|
449
|
+
"setClipsConfig": (Tier.SETTING, "Change camera clip duration and retrigger behavior."),
|
|
450
|
+
"setWakeUpConfig": (Tier.SETTING, "Change what wakes a battery camera."),
|
|
451
|
+
# The app switches charging privacy with setChargingMode (`battery.charging_mode`, field `charging_privacy_mode`),
|
|
452
|
+
# not with setChargingPrivacyMode. UNVERIFIED on a device: no battery camera fixture has this response yet.
|
|
453
|
+
"setChargingMode": (Tier.SECURITY, "Change camera privacy while charging. Off lets a charging camera record."),
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def check_variant(entry, found, spec):
|
|
458
|
+
if spec.module == "battery" and found.get("battery", 0) < 3:
|
|
459
|
+
raise BadRequest(f"{entry.slug}: this battery dialect requires battery component version 3 or later")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def battery_change(registry, caller, device, setting, value, confirm=None):
|
|
463
|
+
if setting not in {"power_mode", "working_mode", "wake_up", "charging_privacy"}:
|
|
464
|
+
raise BadRequest("Unknown battery setting")
|
|
465
|
+
entry, found = await components(registry, caller, device)
|
|
466
|
+
spec = READS[setting]
|
|
467
|
+
require(entry, found, spec.component)
|
|
468
|
+
check_variant(entry, found, spec)
|
|
469
|
+
if setting in {"power_mode", "working_mode", "charging_privacy"}:
|
|
470
|
+
cap = await capability(caller, entry, found, "battery_capability")
|
|
471
|
+
if setting in {"power_mode", "working_mode"}:
|
|
472
|
+
field = "battery_power_mode_range" if setting == "power_mode" else "battery_operating_mode_range"
|
|
473
|
+
value = choice(value, cap.get(field), "Battery mode")
|
|
474
|
+
payload = {"mode": value}
|
|
475
|
+
if setting == "working_mode":
|
|
476
|
+
payload["follow_config"] = True
|
|
477
|
+
method = "setPowerMode" if setting == "power_mode" else "setBatteryOperatingMode"
|
|
478
|
+
elif setting == "wake_up":
|
|
479
|
+
value = choice(value, ["detection", "doorbell"], "Wake-up type")
|
|
480
|
+
payload, method = {"wake_up_type": value}, "setWakeUpConfig"
|
|
481
|
+
else:
|
|
482
|
+
if cap.get("charging_privacy_mode_support") not in ("1", "on", True):
|
|
483
|
+
raise BadRequest(f"{entry.slug} does not report charging privacy support")
|
|
484
|
+
payload, method = {"charging_privacy_mode": switch(value)}, "setChargingMode"
|
|
485
|
+
spec = replace(spec, section="charging_mode")
|
|
486
|
+
return await write(registry, caller, entry, method, spec.module, spec.section, payload, {setting: value}, confirm)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
async def clips_change(registry, caller, device, length, buffer, retrigger):
|
|
490
|
+
payload = {"clips_length": integer(length, 1, 3600, "Clip seconds"), "record_buffer": integer(buffer, 0, 3600, "Buffer seconds"), "retrigger_time": integer(retrigger, 0, 3600, "Retrigger seconds")}
|
|
491
|
+
entry, found = await components(registry, caller, device)
|
|
492
|
+
require(entry, found, "clips")
|
|
493
|
+
check_variant(entry, found, READS["clips"])
|
|
494
|
+
return await write(registry, caller, entry, "setClipsConfig", "clips", "config" if found["clips"] >= 2 else "*", payload, payload)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def battery_statistics(registry, caller, device, days):
|
|
498
|
+
integer(days, 1, 31, "Days")
|
|
499
|
+
entry, found = await components(registry, caller, device)
|
|
500
|
+
require(entry, found, "batCamStatistics")
|
|
501
|
+
check_variant(entry, found, READS["statistics"])
|
|
502
|
+
result = await _call(caller, entry, "getBatteryStatistic", {"battery": {"statistic": {"days": days}}})
|
|
503
|
+
data = _pick(_response_section(entry, "getBatteryStatistic", result, "battery", "statistic"), READS["statistics"].fields)
|
|
504
|
+
return {"device": entry.slug, **({"statistics": data} if data else {})}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
READS.update({
|
|
508
|
+
"microphone": Read("getAudioConfig", "audio", "audio_config", "microphone", ("volume", "encode_type", "sampling_rate", "bitrate", "channels", "mute", "noise_cancelling", "echo_cancelling", "factory_noise_cancelling")),
|
|
509
|
+
"speaker": Read("getAudioConfig", "audio", "audio_config", "speaker", ("volume",)),
|
|
510
|
+
"microphone_capability": Read("getAudioConfig", "audioCapability", "audio_capability", "device_microphone", ("volume", "mute")),
|
|
511
|
+
"microphone_noise": Read("getAudioConfig", "audio", "audio_config", "microphone_algo", ("aec", "ns", "hs", "sys_aec")),
|
|
512
|
+
"speaker_noise": Read("getAudioConfig", "audio", "audio_config", "speaker_algo", ("ns", "ns_mode", "hs")),
|
|
513
|
+
"record_audio": Read("getAudioConfig", "audio", "audio_config", "record_audio", ("enabled",)),
|
|
514
|
+
"voice_enhancement": Read("getVoiceEnhancementConfig", "voiceEnhancement", "audio_config", "voice_enhancement", ("enabled",)),
|
|
515
|
+
})
|
|
516
|
+
WRITES.update({
|
|
517
|
+
"setMicrophoneVolume": (Tier.SETTING, "Change camera microphone volume or mute."),
|
|
518
|
+
"setSpeakerVolume": (Tier.SETTING, "Change camera speaker volume."),
|
|
519
|
+
"setRecordAudio": (Tier.SETTING, "Change whether camera recordings include audio."),
|
|
520
|
+
"setVoiceEnhancement": (Tier.SETTING, "Change camera voice enhancement."),
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
async def audio_change(registry, caller, device, setting, value):
|
|
525
|
+
if setting not in {"microphone", "speaker", "record_audio", "voice_enhancement"}:
|
|
526
|
+
raise BadRequest("Unknown audio setting")
|
|
527
|
+
entry, found = await components(registry, caller, device)
|
|
528
|
+
spec = READS[setting]
|
|
529
|
+
require(entry, found, spec.component)
|
|
530
|
+
if setting in {"microphone", "speaker"}:
|
|
531
|
+
integer(value, 0, 100, "Volume")
|
|
532
|
+
current = await raw_read(caller, entry, spec)
|
|
533
|
+
if not isinstance(current, dict) or "volume" not in current:
|
|
534
|
+
raise BadRequest(f"{entry.slug} does not report {setting} volume support")
|
|
535
|
+
# Keep the current microphone codec, channels, bitrate and sample rate.
|
|
536
|
+
payload = _pick(current, ("volume", "encode_type", "sampling_rate", "bitrate", "channels")) if setting == "microphone" else {}
|
|
537
|
+
payload["volume"] = str(value)
|
|
538
|
+
method = "setMicrophoneVolume" if setting == "microphone" else "setSpeakerVolume"
|
|
539
|
+
else:
|
|
540
|
+
payload = {"enabled": switch(value)}
|
|
541
|
+
method = "setRecordAudio" if setting == "record_audio" else "setVoiceEnhancement"
|
|
542
|
+
return await write(registry, caller, entry, method, spec.module, spec.section, payload, {setting: value})
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
async def audio_set(registry, caller, device, setting, volume=None, enabled=None):
|
|
546
|
+
"""camera_audio_set: volumes take `volume`, switches take `enabled`. The other argument must be left out."""
|
|
547
|
+
if setting in {"microphone", "speaker"}:
|
|
548
|
+
if volume is None or enabled is not None:
|
|
549
|
+
raise BadRequest(f"{setting} takes volume and no enabled value")
|
|
550
|
+
return await audio_change(registry, caller, device, setting, volume)
|
|
551
|
+
if setting in {"record_audio", "voice_enhancement"}:
|
|
552
|
+
if enabled is None or volume is not None:
|
|
553
|
+
raise BadRequest(f"{setting} takes enabled and no volume")
|
|
554
|
+
return await audio_change(registry, caller, device, setting, enabled)
|
|
555
|
+
raise BadRequest("Unknown audio setting")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
READS.update({
|
|
559
|
+
"timezone": Read("getTimezone", "timezone", "system", "basic", ("timezone", "zone_id", "timing_mode")),
|
|
560
|
+
"clock": Read("getClockStatus", "timezone", "system", "clock_status", ("local_time", "seconds_from_1970")),
|
|
561
|
+
"wifi": Read("getConnectionType", "", "network", "get_connection_type", ("rssi", "rssiValue", "link_type", "signal_intensity", "channel"), "direct_list"),
|
|
562
|
+
"upnp": Read("getUpnpInfo", "upnpc", "upnpc", "upnpc_info", ("enabled", "mode")),
|
|
563
|
+
"compatibility": Read("getThirdPartyCompatibility", "localCtrl", "local_ctrl", "third_party_setting", ("enabled",)),
|
|
564
|
+
"account": Read("getThirdAccount", "account", "user_management", "third_account", ("enabled",)),
|
|
565
|
+
"reboot_schedule": Read("getReboot", "reboot", "timing_reboot", "reboot", ("enabled", "day", "time", "random_range")),
|
|
566
|
+
"firmware": Read("getCloudConfig", "firmware", "cloud_config", "upgrade_info", ("fw_download_progress", "fw_download_status", "fw_verify_status", "connect_status", "reconnect_time")),
|
|
567
|
+
"firmware_status": Read("getFirmwareUpdateStatus", "firmware", "cloud_config", "upgrade_status", ("status", "lastUpgradingSuccess")),
|
|
568
|
+
# UNVERIFIED: a C225, a C465 and a C610 refuse this read with PROTOCOL_FORMAT_ERROR for every request shape tried
|
|
569
|
+
# (the app's own shape, JSON null, an empty string, an empty object, a name list and no parameters).
|
|
570
|
+
"firmware_capability": Read("getFirmwareCapability", "firmware", "auto_upgrade", "*", ("auto_upgrade_support", "upgrade_process_support"), "flat"),
|
|
571
|
+
"auto_update": Read("getFirmwareAutoUpgradeConfig", "firmware", "auto_upgrade", "common", ("enabled", "time", "random_range")),
|
|
572
|
+
})
|
|
573
|
+
WRITES.update({
|
|
574
|
+
"setTimezone": (Tier.SETTING, "Change camera time zone and use NTP."),
|
|
575
|
+
"setUpnpInfo": (Tier.SECURITY, "Change camera router port mapping. On can make the camera reachable from the internet."),
|
|
576
|
+
# Off may stop local clients, tolpo included, from reaching the camera, and only the Tapo app can turn it back on.
|
|
577
|
+
# On widens local exposure. Losing the agent's own access is lifecycle class, like a Wi-Fi change.
|
|
578
|
+
"setThirdPartyCompatibility": (Tier.LIFECYCLE, "Change the camera third-party compatibility flag. Off may lock tolpo out of the camera."),
|
|
579
|
+
"setAccountEnabled": (Tier.SECURITY, "Enable or disable an existing camera stream login."),
|
|
580
|
+
"rebootDevice": (Tier.SETTING, "Restart the camera."),
|
|
581
|
+
"setReboot": (Tier.SETTING, "Change the camera reboot schedule."),
|
|
582
|
+
"reset": (Tier.LIFECYCLE, "Factory reset the camera and lose its settings."),
|
|
583
|
+
"softReset": (Tier.LIFECYCLE, "Factory reset a newer or battery camera."),
|
|
584
|
+
"configRestore": (Tier.LIFECYCLE, "Restore camera settings to their defaults."),
|
|
585
|
+
# Not a read: the camera contacts the vendor cloud and updates its own upgrade state.
|
|
586
|
+
"checkFirmwareVersionByCloud": (Tier.CONTROL, "Ask the camera to check the vendor firmware catalogue."),
|
|
587
|
+
"startFirmwareUpgrade": (Tier.LIFECYCLE, "Install camera firmware from the vendor cloud."),
|
|
588
|
+
"setFirmwareAutoUpgradeConfig": (Tier.LIFECYCLE, "Change unattended camera firmware updates."),
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
async def system_change(registry, caller, device, setting, value=None, confirm=None):
|
|
593
|
+
entry, found = await components(registry, caller, device)
|
|
594
|
+
if setting == "timezone":
|
|
595
|
+
from datetime import datetime
|
|
596
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
597
|
+
|
|
598
|
+
require(entry, found, "timezone")
|
|
599
|
+
text_input(value, "IANA time zone")
|
|
600
|
+
try:
|
|
601
|
+
zone = ZoneInfo(value)
|
|
602
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
603
|
+
raise BadRequest("Use a valid IANA time zone") from None
|
|
604
|
+
offset = datetime.now(zone).utcoffset()
|
|
605
|
+
total = int(offset.total_seconds() // 60)
|
|
606
|
+
timezone = f"UTC{'+' if total >= 0 else '-'}{abs(total) // 60:02d}:{abs(total) % 60:02d}"
|
|
607
|
+
payload = {"timezone": timezone, "zone_id": value, "timing_mode": "ntp"}
|
|
608
|
+
return await write(registry, caller, entry, "setTimezone", "system", "basic", payload, {"zone_id": value, "timing_mode": "ntp"})
|
|
609
|
+
if setting in {"upnp", "compatibility", "account"}:
|
|
610
|
+
spec = READS[setting]
|
|
611
|
+
require(entry, found, spec.component)
|
|
612
|
+
flag = switch(value)
|
|
613
|
+
if setting == "upnp":
|
|
614
|
+
current = await raw_read(caller, entry, spec)
|
|
615
|
+
if not isinstance(current, dict) or not isinstance(current.get("mode"), str):
|
|
616
|
+
raise BadRequest(f"{entry.slug}: current UPnP mapping mode is unavailable")
|
|
617
|
+
mode = text_input(current["mode"], "UPnP mapping mode")
|
|
618
|
+
method, section, payload = "setUpnpInfo", "upnpc_info", {"enabled": flag, "mode": mode}
|
|
619
|
+
elif setting == "compatibility":
|
|
620
|
+
method, section, payload = "setThirdPartyCompatibility", "third_party_setting", {"enabled": flag}
|
|
621
|
+
else:
|
|
622
|
+
# This only toggles an existing account. Creation needs excluded encryption.
|
|
623
|
+
current = await raw_read(caller, entry, spec)
|
|
624
|
+
if not isinstance(current, dict) or "enabled" not in current or (value and not isinstance(current.get("username"), str)) or (value and not current["username"].strip()):
|
|
625
|
+
raise BadRequest(f"{entry.slug}: no existing stream login was reported")
|
|
626
|
+
method, section, payload = "setAccountEnabled", "set_account_enabled", {"secname": "third_account", "enabled": flag}
|
|
627
|
+
return await write(registry, caller, entry, method, spec.module, section, payload, {setting: value}, confirm)
|
|
628
|
+
if setting in {"reboot", "factory_reset", "restore"}:
|
|
629
|
+
require(entry, found, "system")
|
|
630
|
+
if setting == "reboot":
|
|
631
|
+
method, section, payload = "rebootDevice", "reboot", {}
|
|
632
|
+
elif setting == "restore":
|
|
633
|
+
method, section, payload = "configRestore", "config_restore", {}
|
|
634
|
+
elif found.get("system", 0) >= 4 or "battery" in found:
|
|
635
|
+
method, section, payload = "softReset", "soft_reset", {"type": "factory"}
|
|
636
|
+
else:
|
|
637
|
+
method, section, payload = "reset", "reset", {}
|
|
638
|
+
return await write(registry, caller, entry, method, "system", section, payload, f"{setting} requested", confirm)
|
|
639
|
+
if setting in {"firmware_check", "firmware_update"}:
|
|
640
|
+
require(entry, found, "firmware")
|
|
641
|
+
method, section = ("checkFirmwareVersionByCloud", "check_fw_version") if setting == "firmware_check" else ("startFirmwareUpgrade", "fw_download")
|
|
642
|
+
return await write(registry, caller, entry, method, "cloud_config", section, "null", f"{setting} requested", confirm)
|
|
643
|
+
if setting == "auto_update":
|
|
644
|
+
try:
|
|
645
|
+
cap = await capability(caller, entry, found, "firmware_capability")
|
|
646
|
+
except DeviceUnavailable:
|
|
647
|
+
cap = None # the cameras tried refuse this read; the current configuration read below is the real check
|
|
648
|
+
if cap is not None and cap.get("auto_upgrade_support") not in ("1", "on", True):
|
|
649
|
+
raise BadRequest(f"{entry.slug} does not report automatic firmware update support")
|
|
650
|
+
current = await raw_read(caller, entry, READS["auto_update"])
|
|
651
|
+
if not isinstance(current, dict) or "time" not in current or "random_range" not in current:
|
|
652
|
+
raise BadRequest(f"{entry.slug}: current firmware update window is unavailable")
|
|
653
|
+
payload = _pick(current, READS["auto_update"].fields)
|
|
654
|
+
payload["enabled"] = switch(value)
|
|
655
|
+
return await write(registry, caller, entry, "setFirmwareAutoUpgradeConfig", "auto_upgrade", "common", payload, {"auto_update": value}, confirm)
|
|
656
|
+
raise BadRequest("Unknown system setting")
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
async def reboot_schedule(registry, caller, device, enabled, time=None):
|
|
660
|
+
import re
|
|
661
|
+
|
|
662
|
+
flag = switch(enabled)
|
|
663
|
+
if time is not None and (not isinstance(time, str) or not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", time)):
|
|
664
|
+
raise BadRequest("Reboot time must be HH:MM in the camera's local time zone")
|
|
665
|
+
entry, found = await components(registry, caller, device)
|
|
666
|
+
require(entry, found, "reboot")
|
|
667
|
+
current = await raw_read(caller, entry, READS["reboot_schedule"])
|
|
668
|
+
if not isinstance(current, dict) or "day" not in current or "time" not in current:
|
|
669
|
+
raise BadRequest(f"{entry.slug}: current reboot schedule is unavailable")
|
|
670
|
+
payload = _pick(current, READS["reboot_schedule"].fields)
|
|
671
|
+
payload["enabled"] = flag
|
|
672
|
+
if time is not None:
|
|
673
|
+
payload["time"] = f"{time}:00"
|
|
674
|
+
return await write(registry, caller, entry, "setReboot", "timing_reboot", "reboot", payload, {"reboot_schedule": payload})
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def tools(ctx):
|
|
678
|
+
async def run(fn, device, *args, **kwargs):
|
|
679
|
+
await ctx.ready_for([device])
|
|
680
|
+
with ctx.translated():
|
|
681
|
+
return await fn(ctx.registry, ctx.caller, device, *args, **kwargs)
|
|
682
|
+
|
|
683
|
+
@ctx.tool(title="Camera PTZ settings")
|
|
684
|
+
async def camera_ptz_status(device: Device, setting: Annotated[Literal["presets", "angles", "patrol", "patrol_points", "tracking"], Field(description="PTZ setting to read")] = "presets") -> dict[str, Any]:
|
|
685
|
+
"""Read supported camera viewpoints, angle ranges, patrol or tracking."""
|
|
686
|
+
return await run(read_setting, device, setting)
|
|
687
|
+
|
|
688
|
+
@ctx.tool(title="Stop camera movement", tier=Tier.CONTROL)
|
|
689
|
+
async def camera_ptz_stop(device: Device) -> dict[str, Any]:
|
|
690
|
+
"""Stop pan and tilt movement now. Sent without any capability read first, so it works even when other reads fail.
|
|
691
|
+
A sweep stops with camera_ptz_cruise axis=stop and a patrol with camera_ptz_patrol enabled=false."""
|
|
692
|
+
return await run(ptz_stop, device)
|
|
693
|
+
|
|
694
|
+
@ctx.tool(title="Camera sweep", tier=Tier.CONTROL)
|
|
695
|
+
async def camera_ptz_cruise(device: Device, axis: Annotated[Literal["horizontal", "vertical", "stop"], Field(description="One sweep axis, or stop")]) -> dict[str, Any]:
|
|
696
|
+
"""Sweep the horizontal or vertical range once, or stop the sweep."""
|
|
697
|
+
return await run(ptz_action, device, "cruise", axis=axis)
|
|
698
|
+
|
|
699
|
+
@ctx.tool(title="Calibrate camera position", tier=Tier.CONTROL)
|
|
700
|
+
async def camera_ptz_calibrate(device: Device, position: Annotated[Literal["factory", "saved"], Field(description="Position to resume after calibration")] = "factory") -> dict[str, Any]:
|
|
701
|
+
"""Move the camera to its limits to calibrate its motor."""
|
|
702
|
+
return await run(ptz_action, device, "calibrate", position=position)
|
|
703
|
+
|
|
704
|
+
@ctx.tool(title="Save camera viewpoint", tier=Tier.SETTING)
|
|
705
|
+
async def camera_ptz_save_preset(device: Device, name: Annotated[str, Field(description="Plain name of the current viewpoint", min_length=1, max_length=64)]) -> dict[str, Any]:
|
|
706
|
+
"""Save the current pan and tilt position under a name."""
|
|
707
|
+
return await run(ptz_action, device, "save", name=name)
|
|
708
|
+
|
|
709
|
+
@ctx.tool(title="Delete camera viewpoints", tier=Tier.SETTING)
|
|
710
|
+
async def camera_ptz_delete_presets(device: Device, ids: Annotated[list[int], Field(description="Existing viewpoint ids", min_length=1, max_length=32)]) -> dict[str, Any]:
|
|
711
|
+
"""Delete selected saved viewpoints after checking they exist."""
|
|
712
|
+
return await run(ptz_action, device, "remove", ids=ids)
|
|
713
|
+
|
|
714
|
+
@ctx.tool(title="Camera patrol", tier=Tier.SETTING)
|
|
715
|
+
async def camera_ptz_patrol(device: Device, enabled: Annotated[bool, Field(description="Start or stop the configured patrol")]) -> dict[str, Any]:
|
|
716
|
+
"""Start or stop an existing camera patrol. Patrol uses battery power."""
|
|
717
|
+
return await run(ptz_action, device, "patrol", enabled=enabled)
|
|
718
|
+
|
|
719
|
+
@ctx.tool(title="Camera patrol point", tier=Tier.SETTING)
|
|
720
|
+
async def camera_ptz_patrol_point(device: Device, action: Annotated[Literal["add", "edit", "delete"], Field(description="Change to a patrol point")], preset_id: Annotated[int, Field(description="Viewpoint id", ge=0, le=255)], name: Annotated[str | None, Field(description="Point name, required for add and edit", max_length=64)] = None, wait_seconds: Annotated[int | None, Field(description="Seconds to wait at this point, required for add and edit", ge=1, le=3600)] = None) -> dict[str, Any]:
|
|
721
|
+
"""Add, edit or delete one point in a camera's patrol. Add and edit need both name and wait_seconds."""
|
|
722
|
+
return await run(ptz_action, device, action, preset_id=preset_id, name=name, wait_seconds=wait_seconds)
|
|
723
|
+
|
|
724
|
+
@ctx.tool(title="Camera automatic tracking", tier=Tier.SETTING)
|
|
725
|
+
async def camera_ptz_auto_tracking(device: Device, enabled: Annotated[bool, Field(description="Whether the camera follows targets automatically")]) -> dict[str, Any]:
|
|
726
|
+
"""Enable or disable basic target tracking on a supported camera."""
|
|
727
|
+
return await run(ptz_action, device, "tracking", enabled=enabled)
|
|
728
|
+
|
|
729
|
+
@ctx.tool(title="Camera image settings")
|
|
730
|
+
async def camera_image_read(device: Device, setting: Annotated[Literal["night_vision", "night_capability", "rotation", "rotation_capability", "style", "style_capability", "video_capability", "led", "privacy_zones", "privacy_regions", "osd", "osd_capability"], Field(description="Image setting or supported ranges to read")]) -> dict[str, Any]:
|
|
731
|
+
"""Read supported image settings and ranges. Private device fields are omitted."""
|
|
732
|
+
return await run(read_setting, device, setting)
|
|
733
|
+
|
|
734
|
+
@ctx.tool(title="Camera image mode", tier=Tier.SETTING)
|
|
735
|
+
async def camera_image_mode(device: Device, setting: Annotated[Literal["night_vision", "flip", "style", "hdr"], Field(description="Image mode to change")], value: Annotated[str, Field(description="Exact mode from camera_image_read capability values. For hdr: on or off", max_length=64)]) -> dict[str, Any]:
|
|
736
|
+
"""Set night vision, flip or mirror, image style or main-stream HDR within the camera's reported values."""
|
|
737
|
+
return await run(image_mode, device, setting, value)
|
|
738
|
+
|
|
739
|
+
@ctx.tool(title="Camera video overlay", tier=Tier.SETTING)
|
|
740
|
+
async def camera_image_osd(device: Device, element: Annotated[Literal["time", "name", "logo"], Field(description="Overlay to change")], enabled: Annotated[bool, Field(description="Show this overlay")], text: Annotated[str | None, Field(description="Plain name overlay text, or keep existing text", max_length=64)] = None) -> dict[str, Any]:
|
|
741
|
+
"""Show or hide an overlay while preserving its current position and time format."""
|
|
742
|
+
return await run(osd_change, device, element, enabled, text)
|
|
743
|
+
|
|
744
|
+
@ctx.tool(title="Camera privacy zones", tier=Tier.SECURITY)
|
|
745
|
+
async def camera_image_privacy_zones(device: Device, enabled: Annotated[bool, Field(description="Apply the existing blacked-out regions. False shows those areas in the video")], confirm: Confirm = None) -> dict[str, Any]:
|
|
746
|
+
"""Enable or disable existing privacy zones. Level 3 with confirmation in both directions, because off shows
|
|
747
|
+
areas the owner blacked out. Read their geometry with camera_image_read."""
|
|
748
|
+
return await run(image_change, device, "privacy_zones", enabled, confirm)
|
|
749
|
+
|
|
750
|
+
@ctx.tool(title="Camera indicator light", tier=Tier.SECURITY)
|
|
751
|
+
async def camera_image_led(device: Device, enabled: Annotated[bool, Field(description="Turn the status LED on or off. Off hides that the camera is recording")], confirm: Confirm = None) -> dict[str, Any]:
|
|
752
|
+
"""Change the status indicator on cameras with the supported LED dialect. Level 3 with confirmation in both
|
|
753
|
+
directions, because a dark LED hides that the camera records."""
|
|
754
|
+
return await run(image_change, device, "led", enabled, confirm)
|
|
755
|
+
|
|
756
|
+
@ctx.tool(title="Camera privacy mode", tier=Tier.SECURITY)
|
|
757
|
+
async def camera_system_privacy(device: Device, enabled: Annotated[bool, Field(description="True closes the lens mask. False allows streaming and recording")], confirm: Confirm = None) -> dict[str, Any]:
|
|
758
|
+
"""Close or open the lens mask with level 3 confirmation. Opening it lets the camera stream and record.
|
|
759
|
+
Closing it needs the same confirmation for now. Read the current state with camera_system_read privacy."""
|
|
760
|
+
return await run(image_change, device, "privacy", enabled, confirm)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@ctx.tool(title="Camera battery settings")
|
|
764
|
+
async def camera_battery_read(device: Device, setting: Annotated[Literal["battery_status", "battery_capability", "power_mode", "working_mode", "clips", "wake_up", "pir", "charging", "charging_privacy"], Field(description="Battery setting to read")]) -> dict[str, Any]:
|
|
765
|
+
"""Read battery settings from an awake, reachable camera. Sleeping cameras may need BLE."""
|
|
766
|
+
return await run(read_setting, device, setting)
|
|
767
|
+
|
|
768
|
+
@ctx.tool(title="Camera battery mode", tier=Tier.SETTING)
|
|
769
|
+
async def camera_battery_mode(device: Device, setting: Annotated[Literal["power_mode", "working_mode", "wake_up"], Field(description="Power source, working mode or wake-up trigger")], value: Annotated[str, Field(description="Exact mode from the reported battery capability range. For wake_up: detection or doorbell", max_length=64)]) -> dict[str, Any]:
|
|
770
|
+
"""Change a supported battery mode or the event that wakes the camera. Custom working parameters are kept by
|
|
771
|
+
the camera. Choosing a wake-up trigger does not wake a sleeping camera. BLE may be needed for that."""
|
|
772
|
+
return await run(battery_change, device, setting, value)
|
|
773
|
+
|
|
774
|
+
@ctx.tool(title="Camera clip duration", tier=Tier.SETTING)
|
|
775
|
+
async def camera_battery_clips(device: Device, length_seconds: Annotated[int, Field(description="Maximum clip length. The camera may enforce a narrower range", ge=1, le=3600)], buffer_seconds: Annotated[int, Field(description="Recording buffer in seconds", ge=0, le=3600)], retrigger_seconds: Annotated[int, Field(description="Time before another clip can start", ge=0, le=3600)]) -> dict[str, Any]:
|
|
776
|
+
"""Change clip length, recording buffer and retrigger time on supported battery cameras."""
|
|
777
|
+
return await run(clips_change, device, length_seconds, buffer_seconds, retrigger_seconds)
|
|
778
|
+
|
|
779
|
+
@ctx.tool(title="Camera charging privacy", tier=Tier.SECURITY)
|
|
780
|
+
async def camera_battery_charging_privacy(device: Device, enabled: Annotated[bool, Field(description="Keep the lens masked while charging")], confirm: Confirm = None) -> dict[str, Any]:
|
|
781
|
+
"""Change supported charging privacy behavior with level 3 confirmation."""
|
|
782
|
+
return await run(battery_change, device, "charging_privacy", enabled, confirm)
|
|
783
|
+
|
|
784
|
+
@ctx.tool(title="Camera battery statistics")
|
|
785
|
+
async def camera_battery_statistics(device: Device, days: Annotated[int, Field(description="Number of recent days to read", ge=1, le=31)] = 7) -> dict[str, Any]:
|
|
786
|
+
"""Read compact daily usage, wake-up counts and solar charging statistics."""
|
|
787
|
+
return await run(battery_statistics, device, days)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@ctx.tool(title="Camera audio settings")
|
|
791
|
+
async def camera_audio_read(device: Device, setting: Annotated[Literal["microphone", "speaker", "microphone_capability", "microphone_noise", "speaker_noise", "record_audio", "voice_enhancement"], Field(description="Audio setting or noise suppression state to read")]) -> dict[str, Any]:
|
|
792
|
+
"""Read microphone, speaker, recording audio and noise processing settings."""
|
|
793
|
+
return await run(read_setting, device, setting)
|
|
794
|
+
|
|
795
|
+
@ctx.tool(title="Camera audio setting", tier=Tier.SETTING)
|
|
796
|
+
async def camera_audio_set(
|
|
797
|
+
device: Device,
|
|
798
|
+
setting: Annotated[Literal["microphone", "speaker", "record_audio", "voice_enhancement"], Field(description="microphone or speaker take volume. record_audio and voice_enhancement take enabled")],
|
|
799
|
+
volume: Annotated[int | None, Field(description="Volume from 0 (minimum) to 100, for microphone or speaker", ge=0, le=100)] = None,
|
|
800
|
+
enabled: Annotated[bool | None, Field(description="On or off, for record_audio or voice_enhancement")] = None,
|
|
801
|
+
) -> dict[str, Any]:
|
|
802
|
+
"""Set microphone or speaker volume, whether recordings include microphone audio, or voice enhancement.
|
|
803
|
+
This plays no sound."""
|
|
804
|
+
return await run(audio_set, device, setting, volume, enabled)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
@ctx.tool(title="Camera system settings")
|
|
808
|
+
async def camera_system_read(device: Device, setting: Annotated[Literal["privacy", "timezone", "clock", "wifi", "upnp", "compatibility", "account", "reboot_schedule", "firmware", "firmware_status", "firmware_capability", "auto_update"], Field(description="Safe system setting to read")]) -> dict[str, Any]:
|
|
809
|
+
"""Read privacy mode, time, signal, network exposure or firmware state. Login and network identities are hidden."""
|
|
810
|
+
return await run(read_setting, device, setting)
|
|
811
|
+
|
|
812
|
+
@ctx.tool(title="Camera time zone", tier=Tier.SETTING)
|
|
813
|
+
async def camera_system_timezone(device: Device, zone_id: Annotated[str, Field(description="IANA time zone, for example Europe/Berlin", max_length=64)]) -> dict[str, Any]:
|
|
814
|
+
"""Set the camera time zone and NTP mode. The camera synchronizes its own clock."""
|
|
815
|
+
return await run(system_change, device, "timezone", zone_id)
|
|
816
|
+
|
|
817
|
+
@ctx.tool(title="Camera UPnP", tier=Tier.SECURITY)
|
|
818
|
+
async def camera_system_upnp(device: Device, enabled: Annotated[bool, Field(description="Permit router port mapping for this camera")], confirm: Confirm = None) -> dict[str, Any]:
|
|
819
|
+
"""Enable or disable UPnP port mapping with level 3 confirmation. On lets the camera ask the router to open a
|
|
820
|
+
port, which can make the camera reachable from the internet."""
|
|
821
|
+
return await run(system_change, device, "upnp", enabled, confirm)
|
|
822
|
+
|
|
823
|
+
@ctx.tool(title="Camera third-party compatibility", tier=Tier.LIFECYCLE)
|
|
824
|
+
async def camera_system_compatibility(device: Device, enabled: Annotated[bool, Field(description="Allow local third-party integrations on this camera")], confirm: Confirm = None) -> dict[str, Any]:
|
|
825
|
+
"""Change the camera's third-party compatibility flag with level 4 confirmation. Warning: turning it off may
|
|
826
|
+
stop tolpo itself from reaching this camera, and only the Tapo app can turn it back on. Turning it on lets
|
|
827
|
+
other local clients use the camera. The cloud account flag is separate and is not changed."""
|
|
828
|
+
return await run(system_change, device, "compatibility", enabled, confirm)
|
|
829
|
+
|
|
830
|
+
@ctx.tool(title="Existing camera stream login", tier=Tier.SECURITY)
|
|
831
|
+
async def camera_system_account_enabled(device: Device, enabled: Annotated[bool, Field(description="Enable an existing RTSP and ONVIF camera login")], confirm: Confirm = None) -> dict[str, Any]:
|
|
832
|
+
"""Enable or disable an existing stream account with confirmation. Credentials are never returned."""
|
|
833
|
+
return await run(system_change, device, "account", enabled, confirm)
|
|
834
|
+
|
|
835
|
+
@ctx.tool(title="Restart camera", tier=Tier.SETTING)
|
|
836
|
+
async def camera_system_reboot(device: Device) -> dict[str, Any]:
|
|
837
|
+
"""Restart a supported camera. State refresh may report the expected temporary outage."""
|
|
838
|
+
return await run(system_change, device, "reboot")
|
|
839
|
+
|
|
840
|
+
@ctx.tool(title="Camera scheduled reboot", tier=Tier.SETTING)
|
|
841
|
+
async def camera_system_reboot_schedule(device: Device, enabled: Annotated[bool, Field(description="Enable scheduled restarts")], time: Annotated[str | None, Field(description="Optional HH:MM in camera local time. Keep the current days and random spread")] = None) -> dict[str, Any]:
|
|
842
|
+
"""Change scheduled reboot status or time while preserving the current days and random spread."""
|
|
843
|
+
return await run(reboot_schedule, device, enabled, time)
|
|
844
|
+
|
|
845
|
+
@ctx.tool(title="Check camera firmware", tier=Tier.CONTROL)
|
|
846
|
+
async def camera_system_firmware_check(device: Device) -> dict[str, Any]:
|
|
847
|
+
"""Ask the camera to check the vendor firmware catalogue. Installs nothing. Read the result with camera_system_read."""
|
|
848
|
+
return await run(system_change, device, "firmware_check")
|
|
849
|
+
|
|
850
|
+
@ctx.tool(title="Update camera firmware", tier=Tier.LIFECYCLE)
|
|
851
|
+
async def camera_system_firmware_update(device: Device, confirm: Confirm = None) -> dict[str, Any]:
|
|
852
|
+
"""Start firmware installation with level 4 confirmation. The camera downloads from the vendor."""
|
|
853
|
+
return await run(system_change, device, "firmware_update", confirm=confirm)
|
|
854
|
+
|
|
855
|
+
@ctx.tool(title="Camera automatic firmware updates", tier=Tier.LIFECYCLE)
|
|
856
|
+
async def camera_system_auto_update(device: Device, enabled: Annotated[bool, Field(description="Allow unattended firmware installation in the current update window")], confirm: Confirm = None) -> dict[str, Any]:
|
|
857
|
+
"""Change automatic firmware updates with level 4 confirmation and keep the current window."""
|
|
858
|
+
return await run(system_change, device, "auto_update", enabled, confirm)
|
|
859
|
+
|
|
860
|
+
@ctx.tool(title="Reset or restore camera", tier=Tier.LIFECYCLE)
|
|
861
|
+
async def camera_system_reset(device: Device, action: Annotated[Literal["factory_reset", "restore"], Field(description="Factory reset loses settings and may lose connectivity. Restore resets setting groups")], confirm: Confirm = None) -> dict[str, Any]:
|
|
862
|
+
"""Factory reset or restore defaults with level 4 confirmation. Newer cameras use softReset."""
|
|
863
|
+
return await run(system_change, device, action, confirm=confirm)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
METHODS = _catalog()
|