tolpo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +256 -27
  2. package/package.json +7 -1
  3. package/pyproject.toml +4 -1
  4. package/src/tolpo/__init__.py +1 -1
  5. package/src/tolpo/api.py +111 -3
  6. package/src/tolpo/areas/__init__.py +187 -0
  7. package/src/tolpo/areas/automation.py +227 -0
  8. package/src/tolpo/areas/camera_detection.py +893 -0
  9. package/src/tolpo/areas/camera_media.py +745 -0
  10. package/src/tolpo/areas/camera_system.py +866 -0
  11. package/src/tolpo/areas/common.py +25 -0
  12. package/src/tolpo/areas/device_admin.py +650 -0
  13. package/src/tolpo/areas/doorbell.py +674 -0
  14. package/src/tolpo/areas/home_plan.py +229 -0
  15. package/src/tolpo/areas/hub.py +995 -0
  16. package/src/tolpo/areas/lights.py +798 -0
  17. package/src/tolpo/areas/lock.py +367 -0
  18. package/src/tolpo/areas/network.py +114 -0
  19. package/src/tolpo/areas/overview.py +21 -0
  20. package/src/tolpo/areas/pairing.py +127 -0
  21. package/src/tolpo/areas/plugs.py +660 -0
  22. package/src/tolpo/areas/vacuum.py +649 -0
  23. package/src/tolpo/automation/__init__.py +8 -0
  24. package/src/tolpo/automation/engine.py +456 -0
  25. package/src/tolpo/automation/events.py +81 -0
  26. package/src/tolpo/automation/graph.py +26 -0
  27. package/src/tolpo/automation/models.py +194 -0
  28. package/src/tolpo/automation/store.py +194 -0
  29. package/src/tolpo/automation/sun.py +28 -0
  30. package/src/tolpo/automation/tools.py +61 -0
  31. package/src/tolpo/calls.py +92 -0
  32. package/src/tolpo/cli.py +162 -3
  33. package/src/tolpo/config.py +48 -3
  34. package/src/tolpo/home.py +147 -0
  35. package/src/tolpo/mcp_server.py +37 -6
  36. package/src/tolpo/media.py +630 -0
  37. package/src/tolpo/network/__init__.py +5 -0
  38. package/src/tolpo/network/catalog.py +80 -0
  39. package/src/tolpo/network/config.py +123 -0
  40. package/src/tolpo/network/enforcement.py +184 -0
  41. package/src/tolpo/network/models.py +223 -0
  42. package/src/tolpo/network/providers.py +452 -0
  43. package/src/tolpo/network/service.py +422 -0
  44. package/src/tolpo/network/storage.py +132 -0
  45. package/src/tolpo/policy.py +88 -6
  46. package/src/tolpo/provision.py +729 -0
  47. package/src/tolpo/registry.py +362 -20
  48. package/src/tolpo/snapshot.py +441 -0
  49. package/src/tolpo/tsvideo.py +93 -0
  50. package/src/tolpo/views.py +4 -4
  51. package/src/tolpo/web/home.css +253 -0
  52. package/src/tolpo/web/home.html +56 -0
  53. package/src/tolpo/web/home.js +1413 -0
@@ -0,0 +1,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())))