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,729 @@
|
|
|
1
|
+
"""Owner-authorized lifecycle operations through python-kasa and host Wi-Fi tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import contextvars
|
|
8
|
+
import hmac
|
|
9
|
+
import ipaddress
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
|
|
19
|
+
from kasa import Credentials, Discover, Module
|
|
20
|
+
from kasa.exceptions import AuthenticationError
|
|
21
|
+
from kasa.smart import SmartDevice
|
|
22
|
+
from kasa.smartcam import SmartCamDevice
|
|
23
|
+
|
|
24
|
+
from . import policy
|
|
25
|
+
from .calls import NeedsConfirmation
|
|
26
|
+
from .registry import DeviceUnavailable, Entry, Forbidden, NotFound, RegistryError, model_of
|
|
27
|
+
from .views import clean
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def authorize(registry, entry, method, confirm=None, subject=None):
|
|
31
|
+
"""The operation names are policy declarations, not raw protocol methods.
|
|
32
|
+
|
|
33
|
+
`subject` is the exact device the action lands on when that is not `entry`,
|
|
34
|
+
for example the child that a hub unpairs. The phrase always names it.
|
|
35
|
+
"""
|
|
36
|
+
rule = policy.rule_for(entry.kind, method)
|
|
37
|
+
if rule is None:
|
|
38
|
+
raise Forbidden(f"{method!r} is not supported on {entry.kind} devices")
|
|
39
|
+
if not policy.permitted(rule, registry.cfg.tier, registry.cfg.allow_methods):
|
|
40
|
+
raise Forbidden(f"{method!r} is a level {int(rule.tier)} action ({rule.summary}) and the owner's policy blocks it")
|
|
41
|
+
phrase = policy.confirmation(rule, subject or entry.slug)
|
|
42
|
+
if rule.needs_confirmation and confirm != phrase:
|
|
43
|
+
raise NeedsConfirmation(f"{method!r}: {rule.summary} Call again with confirm={phrase!r}")
|
|
44
|
+
# Every authorized action authenticates. Refuse it when owner-only sign-in
|
|
45
|
+
# cannot be enforced, before any device or host is touched.
|
|
46
|
+
_install_fallback_guard()
|
|
47
|
+
return rule
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def exact_entry(registry, ref):
|
|
51
|
+
"""A lifecycle target is named by its registry id or device id only.
|
|
52
|
+
|
|
53
|
+
Names and globs match device-supplied text, so they never pick a device to
|
|
54
|
+
pair, unpair, rename, move to another Wi-Fi or erase.
|
|
55
|
+
"""
|
|
56
|
+
found = [e for e in registry.resolve(ref) if ref in (e.slug, e.device.device_id)]
|
|
57
|
+
if len(found) != 1:
|
|
58
|
+
raise NotFound(f"no device has the id {clean(ref, 80)!r}; lifecycle actions need the exact device id")
|
|
59
|
+
return found[0]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_private = contextvars.ContextVar("provision_private", default=False)
|
|
63
|
+
_factory_installed = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _NoFallbackMapping(Mapping):
|
|
67
|
+
"""Stop kasa's default/blank fallback path before any default is read."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, original):
|
|
70
|
+
self.original = original
|
|
71
|
+
|
|
72
|
+
def _check(self):
|
|
73
|
+
if _private.get():
|
|
74
|
+
raise AuthenticationError("Owner credentials were rejected; fallback is disabled")
|
|
75
|
+
|
|
76
|
+
def __getitem__(self, key):
|
|
77
|
+
self._check()
|
|
78
|
+
return self.original[key]
|
|
79
|
+
|
|
80
|
+
def __iter__(self):
|
|
81
|
+
self._check()
|
|
82
|
+
return iter(self.original)
|
|
83
|
+
|
|
84
|
+
def __len__(self):
|
|
85
|
+
self._check()
|
|
86
|
+
return len(self.original)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _NoFallbackCredentials:
|
|
90
|
+
def __init__(self, original=None):
|
|
91
|
+
self.original = original
|
|
92
|
+
|
|
93
|
+
def _value(self, field):
|
|
94
|
+
if _private.get() or self.original is None:
|
|
95
|
+
raise AuthenticationError("Owner credentials were rejected; fallback is disabled")
|
|
96
|
+
return getattr(self.original, field)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def username(self):
|
|
100
|
+
return self._value("username")
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def password(self):
|
|
104
|
+
return self._value("password")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class GuardUnavailable(Forbidden):
|
|
108
|
+
"""python-kasa changed in a way the owner-only sign-in guard does not know."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _refuse_guard():
|
|
112
|
+
raise GuardUnavailable("Owner-only sign-in cannot be enforced with the installed python-kasa, so this action is refused")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Modules that can reach python-kasa's fallback sign-in values, and
|
|
116
|
+
# whether their fallback mapping is read only at login time. The SMARTCAM
|
|
117
|
+
# transport reads it while it is built, so only its decoder is wrapped.
|
|
118
|
+
_FALLBACK_MODULES = {
|
|
119
|
+
"kasa.transports.aestransport": True,
|
|
120
|
+
"kasa.transports.klaptransport": True,
|
|
121
|
+
"kasa.transports.ssltransport": True,
|
|
122
|
+
"kasa.transports.linkietransport": True,
|
|
123
|
+
"kasa.transports.sslaestransport": False,
|
|
124
|
+
# Camera admin password change tries fallback passwords. Not used here.
|
|
125
|
+
"kasa.smartcam.smartcamdevice": True,
|
|
126
|
+
}
|
|
127
|
+
_FALLBACK_NAMES = ("DEFAULT_CREDENTIALS", "get_default_credentials")
|
|
128
|
+
# Transport classes that cache a fallback identity on the instance.
|
|
129
|
+
_CACHING_TRANSPORTS = ("AesTransport", "SslTransport", "SslAesTransport")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _guard_function(original):
|
|
133
|
+
def guarded(value):
|
|
134
|
+
# Inside a protected call never even decode the fallback value.
|
|
135
|
+
return _NoFallbackCredentials(None if _private.get() else original(value))
|
|
136
|
+
|
|
137
|
+
guarded._tolpo_guarded = True
|
|
138
|
+
return guarded
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _install_fallback_guard():
|
|
142
|
+
"""Kasa 0.11 has no public owner-only authentication option.
|
|
143
|
+
|
|
144
|
+
These context-aware guards deny fallback values, not owner authentication.
|
|
145
|
+
No built-in credential values are read or copied by this area. Other tasks
|
|
146
|
+
retain normal library behavior outside the protected context.
|
|
147
|
+
|
|
148
|
+
The guards depend on python-kasa internals, so they fail closed: when a
|
|
149
|
+
known module or name is missing, or any other kasa module can reach the
|
|
150
|
+
fallback values, every lifecycle action is refused. This runs on every call.
|
|
151
|
+
"""
|
|
152
|
+
import importlib
|
|
153
|
+
import pkgutil
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
from kasa import credentials as kasa_credentials, transports
|
|
157
|
+
|
|
158
|
+
for info in pkgutil.iter_modules(transports.__path__):
|
|
159
|
+
importlib.import_module(f"{transports.__name__}.{info.name}")
|
|
160
|
+
originals = [getattr(kasa_credentials, name) for name in _FALLBACK_NAMES]
|
|
161
|
+
except Exception:
|
|
162
|
+
_refuse_guard()
|
|
163
|
+
for name, wrap_mapping in _FALLBACK_MODULES.items():
|
|
164
|
+
module = sys.modules.get(name)
|
|
165
|
+
mapping = getattr(module, "DEFAULT_CREDENTIALS", None)
|
|
166
|
+
decode = getattr(module, "get_default_credentials", None)
|
|
167
|
+
if not isinstance(mapping, Mapping) or not callable(decode):
|
|
168
|
+
_refuse_guard()
|
|
169
|
+
if wrap_mapping and not isinstance(mapping, _NoFallbackMapping):
|
|
170
|
+
module.DEFAULT_CREDENTIALS = _NoFallbackMapping(mapping)
|
|
171
|
+
if not getattr(decode, "_tolpo_guarded", False):
|
|
172
|
+
module.get_default_credentials = _guard_function(decode)
|
|
173
|
+
for name, module in list(sys.modules.items()):
|
|
174
|
+
if not name.startswith("kasa.") or name in _FALLBACK_MODULES or name == kasa_credentials.__name__:
|
|
175
|
+
continue
|
|
176
|
+
values = vars(module).values() if hasattr(module, "__dict__") else ()
|
|
177
|
+
if any(hasattr(module, n) for n in _FALLBACK_NAMES) or any(v is o for v in values for o in originals):
|
|
178
|
+
_refuse_guard() # a new path to the fallback values this guard does not cover
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _guard_cached_credentials(entry):
|
|
182
|
+
protocol = getattr(entry.top.device, "protocol", None)
|
|
183
|
+
transport = getattr(protocol, "_transport", None)
|
|
184
|
+
if transport is None:
|
|
185
|
+
from kasa.protocols import BaseProtocol
|
|
186
|
+
|
|
187
|
+
if isinstance(protocol, BaseProtocol):
|
|
188
|
+
_refuse_guard() # the transport moved; its cache cannot be checked
|
|
189
|
+
return
|
|
190
|
+
if type(transport).__name__ in _CACHING_TRANSPORTS and not hasattr(transport, "_default_credentials"):
|
|
191
|
+
_refuse_guard() # the fallback cache was renamed
|
|
192
|
+
cached = getattr(transport, "_default_credentials", None)
|
|
193
|
+
if cached is not None and not isinstance(cached, _NoFallbackCredentials):
|
|
194
|
+
transport._default_credentials = _NoFallbackCredentials(cached)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class _PrivateFilter(logging.Filter):
|
|
198
|
+
def filter(self, record):
|
|
199
|
+
return not _private.get()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@contextmanager
|
|
203
|
+
def private_logs():
|
|
204
|
+
"""Kasa debug logs can contain the Wi-Fi request. Suppress them in this task."""
|
|
205
|
+
global _factory_installed
|
|
206
|
+
_install_fallback_guard()
|
|
207
|
+
# New kasa loggers can be created by a lazy import during discovery. A
|
|
208
|
+
# context-aware record factory protects those before any handler sees them.
|
|
209
|
+
if not _factory_installed:
|
|
210
|
+
previous_factory = logging.getLogRecordFactory()
|
|
211
|
+
|
|
212
|
+
def factory(*args, **kwargs):
|
|
213
|
+
record = previous_factory(*args, **kwargs)
|
|
214
|
+
if _private.get():
|
|
215
|
+
record.msg, record.args = "Provisioning log suppressed", ()
|
|
216
|
+
record.exc_info = record.exc_text = record.stack_info = None
|
|
217
|
+
return record
|
|
218
|
+
|
|
219
|
+
logging.setLogRecordFactory(factory)
|
|
220
|
+
_factory_installed = True
|
|
221
|
+
# Logger filters apply at the origin, before propagation to any handler.
|
|
222
|
+
for name, logger in list(logging.Logger.manager.loggerDict.items()):
|
|
223
|
+
if isinstance(logger, logging.Logger) and (name.startswith("kasa") or name == "tolpo.registry"):
|
|
224
|
+
if not any(isinstance(f, _PrivateFilter) for f in logger.filters):
|
|
225
|
+
logger.addFilter(_PrivateFilter())
|
|
226
|
+
token = _private.set(True)
|
|
227
|
+
try:
|
|
228
|
+
yield
|
|
229
|
+
finally:
|
|
230
|
+
_private.reset(token)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Only variables the owner created for provisioning can be read. Any other
|
|
234
|
+
# name, such as the account the server signs in with or an unrelated secret,
|
|
235
|
+
# is refused, so an agent cannot send it to a device or a host Wi-Fi tool.
|
|
236
|
+
ENV_PREFIX = "TOLPO_PROVISION_"
|
|
237
|
+
ENV_NAME = re.compile(r"TOLPO_PROVISION_[A-Z0-9_]{1,64}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def env_name(name) -> str:
|
|
241
|
+
if not isinstance(name, str) or not ENV_NAME.fullmatch(name):
|
|
242
|
+
raise RegistryError(f"Use the name of an environment variable that starts with {ENV_PREFIX}, not a value")
|
|
243
|
+
return name
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def env_value(name: str, *, empty=False) -> str:
|
|
247
|
+
env_name(name)
|
|
248
|
+
value = os.environ.get(name)
|
|
249
|
+
if value is None or (not empty and not value) or "\x00" in value:
|
|
250
|
+
raise RegistryError("A required provisioning environment variable is missing or invalid")
|
|
251
|
+
return value
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class Lifecycle(Protocol):
|
|
255
|
+
async def invoke(self, entry: Entry, operation: str, **params: Any) -> Any: ...
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _same(a, b):
|
|
259
|
+
return isinstance(a, str) and isinstance(b, str) and hmac.compare_digest(a.encode(), b.encode())
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class KasaLifecycle:
|
|
263
|
+
"""The small fakeable interface uses public kasa device and module APIs only."""
|
|
264
|
+
|
|
265
|
+
async def invoke(self, entry, operation, **params):
|
|
266
|
+
dev = entry.device
|
|
267
|
+
if operation in {"pair_hub", "pair_remove"}:
|
|
268
|
+
setup = dev.modules.get(Module.ChildSetup)
|
|
269
|
+
if setup is None or entry.root is not None:
|
|
270
|
+
raise NotFound(f"{entry.slug} has no hub child setup support")
|
|
271
|
+
if operation == "pair_hub":
|
|
272
|
+
return await setup.pair(timeout=params["timeout"])
|
|
273
|
+
return await setup.unpair(params["device_id"])
|
|
274
|
+
if operation == "pair_name":
|
|
275
|
+
return await dev.set_alias(params["name"])
|
|
276
|
+
if operation == "wifi_scan":
|
|
277
|
+
if entry.root is not None:
|
|
278
|
+
raise NotFound(f"{entry.slug} has no own Wi-Fi radio")
|
|
279
|
+
return await dev.wifi_scan()
|
|
280
|
+
if operation == "wifi_join":
|
|
281
|
+
if entry.root is not None:
|
|
282
|
+
raise NotFound(f"{entry.slug} has no own Wi-Fi radio")
|
|
283
|
+
credentials = dev.credentials
|
|
284
|
+
if credentials is None or not (
|
|
285
|
+
_same(credentials.username, params["username"]) & _same(credentials.password, params["account_password"])
|
|
286
|
+
):
|
|
287
|
+
raise RegistryError("The device session must use the same owner's environment-supplied account")
|
|
288
|
+
return await dev.wifi_join(params["ssid"], params["password"], params["keytype"])
|
|
289
|
+
if operation in {"reset_factory", "reset_reboot"}:
|
|
290
|
+
# SmartCamDevice inherits SmartDevice but does not implement these
|
|
291
|
+
# operations in its dialect. Never send the SMART method to it.
|
|
292
|
+
if not isinstance(dev, SmartDevice) or isinstance(dev, SmartCamDevice) or entry.root is not None:
|
|
293
|
+
raise NotFound(f"{entry.slug} has no supported SMART reset or reboot")
|
|
294
|
+
if operation == "reset_factory":
|
|
295
|
+
return await dev.factory_reset()
|
|
296
|
+
return await dev.reboot(delay=params["delay"])
|
|
297
|
+
raise Forbidden("Unknown provisioning operation")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def invoke(registry, adapter, entry, operation, **params):
|
|
301
|
+
async def send():
|
|
302
|
+
with private_logs():
|
|
303
|
+
_guard_cached_credentials(entry)
|
|
304
|
+
try:
|
|
305
|
+
return await adapter.invoke(entry, operation, **params)
|
|
306
|
+
except RegistryError:
|
|
307
|
+
raise
|
|
308
|
+
except AuthenticationError:
|
|
309
|
+
raise DeviceUnavailable(f"{entry.slug} rejected the account credentials") from None
|
|
310
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
311
|
+
raise
|
|
312
|
+
except Exception:
|
|
313
|
+
# Kasa may include the SSID or the entire request in its error.
|
|
314
|
+
raise DeviceUnavailable(f"{entry.slug} failed during {operation}") from None
|
|
315
|
+
|
|
316
|
+
return await registry.guarded(entry, send)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def device_view(entry):
|
|
320
|
+
out = {"id": clean(entry.slug), "kind": entry.kind, "model": clean(str(model_of(entry)))}
|
|
321
|
+
if entry.device.alias:
|
|
322
|
+
out["name"] = clean(str(entry.device.alias))
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def refresh(registry, entry):
|
|
327
|
+
with private_logs():
|
|
328
|
+
_guard_cached_credentials(entry)
|
|
329
|
+
await registry.refresh(entry, force=True)
|
|
330
|
+
return {"state": "refreshed" if entry.online else "offline"}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def rescan_new(registry, before):
|
|
334
|
+
# Same entry point as the existing rescan tool. Do not implement discovery.
|
|
335
|
+
with private_logs():
|
|
336
|
+
for entry in registry.entries():
|
|
337
|
+
_guard_cached_credentials(entry)
|
|
338
|
+
try:
|
|
339
|
+
await registry.discover()
|
|
340
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
341
|
+
raise
|
|
342
|
+
except Exception:
|
|
343
|
+
raise DeviceUnavailable("Network rescan failed after the lifecycle action") from None
|
|
344
|
+
return [device_view(e) for e in registry.entries() if e.device.device_id not in before and e.online]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def pair_hub(registry, adapter, entry, timeout=10, confirm=None):
|
|
348
|
+
authorize(registry, entry, "pair_hub", confirm)
|
|
349
|
+
if not isinstance(timeout, int) or isinstance(timeout, bool) or not 1 <= timeout <= 10:
|
|
350
|
+
raise RegistryError("Pairing wait must be from 1 to 10 seconds")
|
|
351
|
+
before = {e.device.device_id for e in registry.entries()}
|
|
352
|
+
found = await invoke(registry, adapter, entry, "pair_hub", timeout=timeout)
|
|
353
|
+
state = await refresh(registry, entry)
|
|
354
|
+
try:
|
|
355
|
+
new = await rescan_new(registry, before)
|
|
356
|
+
except TimeoutError:
|
|
357
|
+
raise DeviceUnavailable(f"{entry.slug} pairing completed but the network rescan timed out") from None
|
|
358
|
+
# Kasa returns only successes, not a list of all candidates. Never forward
|
|
359
|
+
# its records, which can include encoded names and hardware identifiers.
|
|
360
|
+
ids = {item["device_id"] for item in found if isinstance(item, dict) and isinstance(item.get("device_id"), str)}
|
|
361
|
+
paired = [device_view(e) for e in registry.entries() if e.root is entry and e.device.device_id in ids]
|
|
362
|
+
out = {"device": clean(entry.slug), "paired_count": len(ids), **state}
|
|
363
|
+
if paired:
|
|
364
|
+
out["paired"] = paired
|
|
365
|
+
if new:
|
|
366
|
+
out["new_devices"] = new
|
|
367
|
+
return out
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def pair_remove(registry, adapter, entry, child, confirm=None):
|
|
371
|
+
# The phrase names the child that is removed, not only its hub, so one
|
|
372
|
+
# confirmation can never remove a different child of the same hub.
|
|
373
|
+
target = exact_entry(registry, child)
|
|
374
|
+
if target.root is not entry:
|
|
375
|
+
raise RegistryError("The selected device is not a child of this hub")
|
|
376
|
+
authorize(registry, entry, "pair_remove", confirm, subject=target.slug)
|
|
377
|
+
await invoke(registry, adapter, entry, "pair_remove", device_id=target.device.device_id)
|
|
378
|
+
state = await refresh(registry, entry)
|
|
379
|
+
removed = target.device.device_id not in {d.device_id for d in entry.device.children}
|
|
380
|
+
if removed:
|
|
381
|
+
registry.forget(target) # the hub confirmed it, so it need not wait for a second update to leave the list
|
|
382
|
+
return {"device": clean(entry.slug), "child": clean(target.slug), "removed": removed, **state}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
async def pair_name(registry, adapter, entry, name, confirm=None):
|
|
386
|
+
authorize(registry, entry, "pair_name", confirm)
|
|
387
|
+
if not isinstance(name, str) or not name.strip() or len(name.encode()) > 64 or clean(name, 128) != name:
|
|
388
|
+
raise RegistryError("Name must be flat text of 1 to 64 bytes with no surrounding spaces")
|
|
389
|
+
await invoke(registry, adapter, entry, "pair_name", name=name)
|
|
390
|
+
state = await refresh(registry, entry)
|
|
391
|
+
return {"device": clean(entry.slug), "name": clean(name), "changed": "name", **state}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def wifi_scan(registry, adapter, entry):
|
|
395
|
+
authorize(registry, entry, "wifi_scan")
|
|
396
|
+
networks = await invoke(registry, adapter, entry, "wifi_scan")
|
|
397
|
+
# Only radio measurements. No SSIDs, BSSIDs or opaque per-network handles.
|
|
398
|
+
rows = []
|
|
399
|
+
for net in networks[:64]:
|
|
400
|
+
row = {}
|
|
401
|
+
for key in ("channel", "signal_level", "rssi"):
|
|
402
|
+
value = getattr(net, key, None)
|
|
403
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
404
|
+
row[key] = value
|
|
405
|
+
if row:
|
|
406
|
+
rows.append(row)
|
|
407
|
+
out = {"device": clean(entry.slug), "network_count": len(networks)}
|
|
408
|
+
if rows:
|
|
409
|
+
out["radio"] = rows
|
|
410
|
+
return out
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def wifi_join(registry, adapter, entry, ssid_env, password_env, account_env, account_password_env, keytype="wpa2_psk", confirm=None):
|
|
414
|
+
authorize(registry, entry, "wifi_join", confirm)
|
|
415
|
+
# "wpa2_psk" and "none" appear in python-kasa and the app. UNVERIFIED: "wpa_psk".
|
|
416
|
+
if keytype not in {"wpa2_psk", "wpa_psk", "none"}:
|
|
417
|
+
raise RegistryError("Unsupported Wi-Fi security type")
|
|
418
|
+
ssid, password = env_value(ssid_env), env_value(password_env, empty=keytype == "none")
|
|
419
|
+
username, account_password = env_value(account_env), env_value(account_password_env)
|
|
420
|
+
if len(ssid.encode()) > 32:
|
|
421
|
+
raise RegistryError("Wi-Fi network name exceeds 32 bytes")
|
|
422
|
+
await invoke(registry, adapter, entry, "wifi_join", ssid=ssid, password=password, keytype=keytype, username=username, account_password=account_password)
|
|
423
|
+
state = await refresh(registry, entry)
|
|
424
|
+
return {"device": clean(entry.slug), "changed": "wifi_requested", **state}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def reset(registry, adapter, entry, factory=False, delay=1, confirm=None):
|
|
428
|
+
operation = "reset_factory" if factory else "reset_reboot"
|
|
429
|
+
authorize(registry, entry, operation, confirm)
|
|
430
|
+
if not isinstance(delay, int) or isinstance(delay, bool) or not 1 <= delay <= 30:
|
|
431
|
+
raise RegistryError("Reboot delay must be from 1 to 30 seconds")
|
|
432
|
+
await invoke(registry, adapter, entry, operation, delay=delay)
|
|
433
|
+
state = await refresh(registry, entry)
|
|
434
|
+
return {"device": clean(entry.slug), "changed": "factory_reset_requested" if factory else "reboot_requested", **state}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class HostWifi(Protocol):
|
|
438
|
+
async def current(self) -> str: ...
|
|
439
|
+
async def join(self, network: str) -> None: ...
|
|
440
|
+
async def restore(self, previous: str) -> None: ...
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
async def system_command(*args):
|
|
444
|
+
"""No shell, no command/output logging, and no subprocess output in errors."""
|
|
445
|
+
try:
|
|
446
|
+
proc = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
447
|
+
try:
|
|
448
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), 30)
|
|
449
|
+
except BaseException:
|
|
450
|
+
proc.kill()
|
|
451
|
+
await proc.communicate()
|
|
452
|
+
raise
|
|
453
|
+
if proc.returncode:
|
|
454
|
+
raise RegistryError("Host Wi-Fi command failed")
|
|
455
|
+
return stdout.decode(errors="replace").strip()
|
|
456
|
+
except (OSError, TimeoutError):
|
|
457
|
+
raise RegistryError("Host Wi-Fi command could not complete") from None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def interface_name(value):
|
|
461
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,31}", value):
|
|
462
|
+
raise RegistryError("Invalid Wi-Fi interface name")
|
|
463
|
+
return value
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class MacOSWifi:
|
|
467
|
+
def __init__(self, interface, run=system_command):
|
|
468
|
+
self.interface, self.run = interface_name(interface), run
|
|
469
|
+
self._joined = None
|
|
470
|
+
|
|
471
|
+
async def current(self):
|
|
472
|
+
text = await self.run("/usr/sbin/networksetup", "-getairportnetwork", self.interface)
|
|
473
|
+
prefix = "Current Wi-Fi Network: "
|
|
474
|
+
if not text.startswith(prefix) or not text[len(prefix):]:
|
|
475
|
+
raise RegistryError("The host must have a restorable Wi-Fi network before provisioning")
|
|
476
|
+
return text[len(prefix):]
|
|
477
|
+
|
|
478
|
+
async def join(self, network):
|
|
479
|
+
self._joined = network
|
|
480
|
+
await self.run("/usr/sbin/networksetup", "-setairportnetwork", self.interface, network)
|
|
481
|
+
|
|
482
|
+
async def restore(self, previous):
|
|
483
|
+
# The OS must remember credentials for the previous network.
|
|
484
|
+
restored = False
|
|
485
|
+
try:
|
|
486
|
+
await self.run("/usr/sbin/networksetup", "-setairportnetwork", self.interface, previous)
|
|
487
|
+
if await self.current() != previous:
|
|
488
|
+
raise RegistryError("The previous host Wi-Fi network was not restored")
|
|
489
|
+
restored = True
|
|
490
|
+
finally:
|
|
491
|
+
# Joining adds the open setup network to the preferred list, so the
|
|
492
|
+
# host would join any later network with that name. Remove it.
|
|
493
|
+
# UNVERIFIED on a real Mac: the exact preferred-list behavior.
|
|
494
|
+
joined, self._joined = self._joined, None
|
|
495
|
+
if joined is not None:
|
|
496
|
+
try:
|
|
497
|
+
await self.run("/usr/sbin/networksetup", "-removepreferredwirelessnetwork", self.interface, joined)
|
|
498
|
+
except Exception:
|
|
499
|
+
if restored:
|
|
500
|
+
raise RegistryError("Host Wi-Fi restored, but the setup network stayed in its saved networks") from None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class LinuxWifi:
|
|
504
|
+
# A dedicated profile that never connects on its own. `nmcli device wifi
|
|
505
|
+
# connect` would save an auto-connecting open profile for the setup network.
|
|
506
|
+
# UNVERIFIED on a real Linux host: the nmcli argument forms below.
|
|
507
|
+
PROFILE = "tolpo-softap"
|
|
508
|
+
|
|
509
|
+
def __init__(self, interface, run=system_command):
|
|
510
|
+
self.interface, self.run = interface_name(interface), run
|
|
511
|
+
|
|
512
|
+
async def current(self):
|
|
513
|
+
value = await self.run("nmcli", "-g", "GENERAL.CON-UUID", "device", "show", self.interface)
|
|
514
|
+
if not re.fullmatch(r"[0-9a-fA-F-]{36}", value):
|
|
515
|
+
raise RegistryError("The host must have a saved Wi-Fi connection before provisioning")
|
|
516
|
+
return value
|
|
517
|
+
|
|
518
|
+
async def _drop_profile(self):
|
|
519
|
+
await self.run("nmcli", "connection", "delete", "id", self.PROFILE)
|
|
520
|
+
|
|
521
|
+
async def join(self, network):
|
|
522
|
+
# A profile left by an interrupted run is replaced, never reused.
|
|
523
|
+
with contextlib.suppress(RegistryError):
|
|
524
|
+
await self._drop_profile()
|
|
525
|
+
# network is one argv value after its keyword, never an option.
|
|
526
|
+
await self.run(
|
|
527
|
+
"nmcli", "connection", "add", "type", "wifi", "ifname", self.interface,
|
|
528
|
+
"con-name", self.PROFILE, "autoconnect", "no", "ssid", network,
|
|
529
|
+
)
|
|
530
|
+
await self.run("nmcli", "connection", "up", "id", self.PROFILE, "ifname", self.interface)
|
|
531
|
+
|
|
532
|
+
async def restore(self, previous):
|
|
533
|
+
restored = False
|
|
534
|
+
try:
|
|
535
|
+
await self.run("nmcli", "connection", "up", "uuid", previous, "ifname", self.interface)
|
|
536
|
+
if await self.current() != previous:
|
|
537
|
+
raise RegistryError("The previous host Wi-Fi connection was not restored")
|
|
538
|
+
restored = True
|
|
539
|
+
finally:
|
|
540
|
+
try:
|
|
541
|
+
await self._drop_profile()
|
|
542
|
+
except Exception:
|
|
543
|
+
if restored:
|
|
544
|
+
raise RegistryError("Host Wi-Fi restored, but the setup profile could not be removed") from None
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def host_wifi(interface):
|
|
548
|
+
if sys.platform == "darwin":
|
|
549
|
+
return MacOSWifi(interface)
|
|
550
|
+
if sys.platform.startswith("linux"):
|
|
551
|
+
return LinuxWifi(interface)
|
|
552
|
+
raise RegistryError("Host Wi-Fi provisioning requires macOS or Linux")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@dataclass(repr=False)
|
|
556
|
+
class SoftAPPlan:
|
|
557
|
+
target: str
|
|
558
|
+
device_id: str
|
|
559
|
+
ap_host: str
|
|
560
|
+
ap_env: str
|
|
561
|
+
ssid_env: str
|
|
562
|
+
wifi_password_env: str
|
|
563
|
+
account_env: str
|
|
564
|
+
account_password_env: str
|
|
565
|
+
interface: str
|
|
566
|
+
# Environment names only. Values are read after authorization at execution.
|
|
567
|
+
wait_seconds: int = 90
|
|
568
|
+
|
|
569
|
+
def validate(self):
|
|
570
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", self.target):
|
|
571
|
+
raise RegistryError("Provisioning target must be a short lowercase identifier")
|
|
572
|
+
if not isinstance(self.device_id, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", self.device_id):
|
|
573
|
+
raise RegistryError("Provide the exact expected device id")
|
|
574
|
+
try:
|
|
575
|
+
address = ipaddress.ip_address(self.ap_host)
|
|
576
|
+
except ValueError:
|
|
577
|
+
raise RegistryError("Provide the device AP's local IP address") from None
|
|
578
|
+
if address.version != 4 or not address.is_private or address.is_loopback or address.is_unspecified or address.is_multicast:
|
|
579
|
+
raise RegistryError("Provide the device AP's local IPv4 address")
|
|
580
|
+
interface_name(self.interface)
|
|
581
|
+
for name in (self.ap_env, self.ssid_env, self.wifi_password_env, self.account_env, self.account_password_env):
|
|
582
|
+
env_name(name)
|
|
583
|
+
if not isinstance(self.wait_seconds, int) or isinstance(self.wait_seconds, bool) or not 1 <= self.wait_seconds <= 180:
|
|
584
|
+
raise RegistryError("LAN wait must be from 1 to 180 seconds")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def softap_entry(plan):
|
|
588
|
+
from types import SimpleNamespace
|
|
589
|
+
from kasa import DeviceType
|
|
590
|
+
|
|
591
|
+
# This is a policy target, not a registry device or a guessed connection.
|
|
592
|
+
return Entry(SimpleNamespace(device_type=DeviceType.Plug), plan.target)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def softap_plan(registry, plan, confirm=None):
|
|
596
|
+
plan.validate()
|
|
597
|
+
# The phrase names the expected device id, not only the agent's label.
|
|
598
|
+
authorize(registry, softap_entry(plan), "pair_softap", confirm, subject=plan.device_id)
|
|
599
|
+
return {"target": plan.target, "steps": [
|
|
600
|
+
"Save the host's current Wi-Fi connection.",
|
|
601
|
+
"Join the device's open setup Wi-Fi network.",
|
|
602
|
+
"Authenticate with the owner's account and verify the expected SMART device id.",
|
|
603
|
+
"Send home Wi-Fi settings and the account through python-kasa wifi_join.",
|
|
604
|
+
"Restore the host's previous network, including after a failure.",
|
|
605
|
+
"Wait on the LAN using the existing registry rescan and verify the device and cloud state.",
|
|
606
|
+
]}
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class KasaConnector:
|
|
610
|
+
async def connect(self, host, username, password):
|
|
611
|
+
dev = await Discover.discover_single(host, credentials=Credentials(username, password), discovery_timeout=5)
|
|
612
|
+
if dev is None:
|
|
613
|
+
raise DeviceUnavailable("The setup device did not answer")
|
|
614
|
+
try:
|
|
615
|
+
if not isinstance(dev, SmartDevice) or isinstance(dev, SmartCamDevice):
|
|
616
|
+
raise RegistryError("SoftAP self-binding is supported here only for SMART devices")
|
|
617
|
+
# Discovery alone has not authenticated or loaded device time,
|
|
618
|
+
# which SMART wifi_join includes in its quick-setup request.
|
|
619
|
+
await asyncio.wait_for(dev.update(), 20)
|
|
620
|
+
return dev
|
|
621
|
+
except BaseException:
|
|
622
|
+
await dev.disconnect()
|
|
623
|
+
raise
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
_host_lock = asyncio.Lock()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
async def _restore(host, previous):
|
|
630
|
+
task = asyncio.create_task(asyncio.wait_for(_host_call(host, "restore", previous), 45))
|
|
631
|
+
cancelled = False
|
|
632
|
+
while not task.done():
|
|
633
|
+
try:
|
|
634
|
+
await asyncio.shield(task)
|
|
635
|
+
except asyncio.CancelledError:
|
|
636
|
+
# Repeated caller cancellation must not cancel the restore task.
|
|
637
|
+
cancelled = True
|
|
638
|
+
except Exception:
|
|
639
|
+
break # Inspect the task below without forwarding raw output.
|
|
640
|
+
try:
|
|
641
|
+
task.result()
|
|
642
|
+
except TimeoutError:
|
|
643
|
+
raise RegistryError("Host Wi-Fi restoration timed out") from None
|
|
644
|
+
if cancelled:
|
|
645
|
+
raise asyncio.CancelledError()
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
async def _host_call(host, operation, *args):
|
|
649
|
+
try:
|
|
650
|
+
return await getattr(host, operation)(*args)
|
|
651
|
+
except asyncio.CancelledError:
|
|
652
|
+
raise
|
|
653
|
+
except RegistryError as exc:
|
|
654
|
+
# Our host adapters write these messages; they hold no command output.
|
|
655
|
+
raise RegistryError(f"Host Wi-Fi {operation} failed: {exc}") from None
|
|
656
|
+
except Exception:
|
|
657
|
+
raise RegistryError(f"Host Wi-Fi {operation} failed") from None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
async def softap_provision(registry, adapter, plan, host, connector=None, confirm=None, sleep=asyncio.sleep):
|
|
661
|
+
softap_plan(registry, plan, confirm)
|
|
662
|
+
connector = connector or KasaConnector()
|
|
663
|
+
ap = env_value(plan.ap_env)
|
|
664
|
+
ssid = env_value(plan.ssid_env)
|
|
665
|
+
password = env_value(plan.wifi_password_env)
|
|
666
|
+
username = env_value(plan.account_env)
|
|
667
|
+
account_password = env_value(plan.account_password_env)
|
|
668
|
+
if len(ap.encode()) > 32 or len(ssid.encode()) > 32 or ap.startswith("-"):
|
|
669
|
+
raise RegistryError("Invalid provisioning Wi-Fi network name")
|
|
670
|
+
before = {e.device.device_id for e in registry.entries()}
|
|
671
|
+
async with _host_lock:
|
|
672
|
+
previous = await _host_call(host, "current")
|
|
673
|
+
try:
|
|
674
|
+
await _host_call(host, "join", ap)
|
|
675
|
+
dev = None
|
|
676
|
+
try:
|
|
677
|
+
with private_logs():
|
|
678
|
+
dev = await connector.connect(plan.ap_host, username, account_password)
|
|
679
|
+
if not isinstance(dev, SmartDevice) or isinstance(dev, SmartCamDevice):
|
|
680
|
+
raise RegistryError("SoftAP self-binding is supported here only for SMART devices")
|
|
681
|
+
if dev.device_id != plan.device_id:
|
|
682
|
+
raise RegistryError("The setup device does not match the expected device id")
|
|
683
|
+
entry = Entry(dev, plan.target)
|
|
684
|
+
if entry.kind not in {"plug", "light", "switch", "hub"}:
|
|
685
|
+
raise RegistryError("This device family has no supported SMART SoftAP onboarding")
|
|
686
|
+
# One lifecycle authorization covers this self-binding plan.
|
|
687
|
+
await invoke(registry, adapter, entry, "wifi_join", ssid=ssid, password=password, keytype="wpa2_psk", username=username, account_password=account_password)
|
|
688
|
+
await refresh(registry, entry)
|
|
689
|
+
except RegistryError:
|
|
690
|
+
raise
|
|
691
|
+
except AuthenticationError:
|
|
692
|
+
raise DeviceUnavailable(f"{plan.target} rejected the account credentials") from None
|
|
693
|
+
except asyncio.CancelledError:
|
|
694
|
+
raise
|
|
695
|
+
except Exception:
|
|
696
|
+
raise DeviceUnavailable(f"{plan.target} failed during SoftAP provisioning") from None
|
|
697
|
+
finally:
|
|
698
|
+
if dev is not None:
|
|
699
|
+
with private_logs():
|
|
700
|
+
try:
|
|
701
|
+
await dev.disconnect()
|
|
702
|
+
except Exception:
|
|
703
|
+
raise DeviceUnavailable(f"{plan.target} could not close the setup connection") from None
|
|
704
|
+
finally:
|
|
705
|
+
await _restore(host, previous)
|
|
706
|
+
# Keep the host lock until verification completes. Another provisioning
|
|
707
|
+
# call must not move the host off the LAN midway through this scan.
|
|
708
|
+
deadline = asyncio.get_running_loop().time() + plan.wait_seconds
|
|
709
|
+
while True:
|
|
710
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
711
|
+
if remaining <= 0:
|
|
712
|
+
raise DeviceUnavailable(f"{plan.target} did not appear on the LAN after provisioning; host Wi-Fi restored")
|
|
713
|
+
try:
|
|
714
|
+
await asyncio.wait_for(rescan_new(registry, before), remaining)
|
|
715
|
+
except TimeoutError:
|
|
716
|
+
raise DeviceUnavailable(f"{plan.target} LAN verification timed out; host Wi-Fi restored") from None
|
|
717
|
+
match = next((e for e in registry.entries() if e.device.device_id == plan.device_id and e.root is None), None)
|
|
718
|
+
if match:
|
|
719
|
+
await refresh(registry, match)
|
|
720
|
+
if match.online:
|
|
721
|
+
out = {"target": plan.target, "verified": "lan", "host_wifi": "restored", "device": device_view(match)}
|
|
722
|
+
feat = match.device.features.get("cloud_connection")
|
|
723
|
+
if feat is not None and isinstance(feat.value, bool):
|
|
724
|
+
out["cloud_connected"] = feat.value
|
|
725
|
+
new = [device_view(e) for e in registry.entries() if e.device.device_id not in before and e.online]
|
|
726
|
+
if new:
|
|
727
|
+
out["new_devices"] = new
|
|
728
|
+
return out
|
|
729
|
+
await sleep(min(3, max(0, deadline - asyncio.get_running_loop().time())))
|