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,187 @@
1
+ """Area modules: one file per part of what the Tapo app does (lights, plugs, cameras, ...).
2
+
3
+ Adding an area means adding a file here. Nothing else is edited, so many people can work at once. A module may define:
4
+
5
+ METHODS = [policy.method(name, tier, kinds, summary), ...] # every device method it sends, with its level
6
+ def tools(ctx): ... # register MCP tools with @ctx.tool
7
+ def routes(ctx): ... # register HTTP routes with @ctx.route
8
+
9
+ The module that defines MCP tools must not use `from __future__ import annotations`, because the SDK reads the
10
+ signatures at runtime. See docs/areas/README.md for the rules and a template.
11
+ """
12
+
13
+ import importlib
14
+ import json
15
+ import logging
16
+ import pkgutil
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from types import ModuleType
20
+ from typing import Any, Awaitable, Callable
21
+
22
+ from mcp.types import ToolAnnotations
23
+
24
+ from .. import policy
25
+ from ..config import Config
26
+ from ..registry import Registry
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ READ = ToolAnnotations(read_only_hint=True, open_world_hint=False)
31
+ WRITE = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False, open_world_hint=False)
32
+ RISKY = ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=False, open_world_hint=False)
33
+
34
+
35
+ def annotations_for(tier: policy.Tier) -> ToolAnnotations:
36
+ """MCP clients use these hints to decide when to ask the person first."""
37
+ if tier <= policy.Tier.READ:
38
+ return READ
39
+ return WRITE if tier <= policy.Tier.SETTING else RISKY
40
+
41
+
42
+ @dataclass
43
+ class AreaContext:
44
+ registry: Registry
45
+ caller: Any # a calls.DeviceCaller
46
+ server: Any = None # the MCPServer, when tools are being registered
47
+ api: Any = None # the FastAPI app, when routes are being registered
48
+ auth: list = field(default_factory=list) # FastAPI dependencies that every route must carry
49
+ ready_for: Callable[[list[str]], Awaitable[None]] | None = None # waits until the named devices are known
50
+ translated: Callable[[], Any] | None = None # context manager: registry errors become tool errors
51
+ catalog: dict = field(default_factory=dict) # area name -> {"enabled", "summary", "tools"}, filled by load()
52
+ current: str = "" # the area being loaded
53
+ _names: dict = field(default_factory=dict)
54
+
55
+ @property
56
+ def cfg(self) -> Config:
57
+ return self.registry.cfg
58
+
59
+ def tool(self, *, title: str, tier: policy.Tier = policy.Tier.READ, **options: Any):
60
+ """Register an MCP tool. Its docstring is what the agent reads. Does nothing when no server is being built."""
61
+
62
+ def decorate(fn):
63
+ if self.server is not None:
64
+ self.server.tool(title=title, annotations=annotations_for(tier), **options)(fn)
65
+ self._names.setdefault(self.current, []).append(options.get("name") or fn.__name__)
66
+ return fn
67
+
68
+ return decorate
69
+
70
+ def route(self, path: str, *, method: str = "get", **options: Any):
71
+ """Register an HTTP route behind the same token check as the others. Does nothing when no app is being built."""
72
+
73
+ def decorate(fn):
74
+ if self.api is not None:
75
+ getattr(self.api, method)(path, dependencies=self.auth, **options)(fn)
76
+ return fn
77
+
78
+ return decorate
79
+
80
+
81
+ def modules() -> list[ModuleType]:
82
+ """Every area module. One that fails to import is logged and skipped, so one bad area cannot take the rest down."""
83
+ found = []
84
+ for info in pkgutil.iter_modules(__path__):
85
+ if info.name.startswith("_"):
86
+ continue
87
+ try:
88
+ found.append(importlib.import_module(f"{__name__}.{info.name}"))
89
+ except Exception:
90
+ log.exception("area %s could not be imported; the rest of tolpo keeps working", info.name)
91
+ return found
92
+
93
+
94
+ def declare_methods() -> None:
95
+ """Put every area's declared device methods in the policy catalog. Safe to call again."""
96
+ for module in modules():
97
+ policy.register_methods(getattr(module, "METHODS", ()))
98
+
99
+
100
+ def area_name(module: ModuleType) -> str:
101
+ return module.__name__.rsplit(".", 1)[-1]
102
+
103
+
104
+ # An area tied to a kind of device is on by default only if the owner has such a device. Areas not listed here
105
+ # (automation, network, pairing, device admin and so on) are always on. The owner can override all of this with `areas`.
106
+ AREA_KINDS: dict[str, set[str]] = {
107
+ "lights": {"light"},
108
+ "plugs": {"plug", "switch"},
109
+ "hub": {"hub"},
110
+ "doorbell": {"hub", "doorbell", "camera"},
111
+ "camera_detection": {"camera"},
112
+ "camera_system": {"camera"},
113
+ "camera_media": {"camera"},
114
+ "vacuum": {"vacuum"},
115
+ "lock": {"lock"},
116
+ }
117
+ ALWAYS_ON = {"overview"}
118
+ # What a hub brings with it: its sensors, valves, chimes, doorbells and cameras are not hosts of their own, so they are not
119
+ # remembered. A camera paired to a hub is its child, and the camera areas must stay on for it.
120
+ HUB_BRINGS = {"sensor", "valve", "thermostat", "chime", "doorbell", "camera"}
121
+
122
+
123
+ def kind_of_model(model: str) -> str | None:
124
+ """A rough kind from a Tapo model name (L530 -> light). None when the name is not recognized."""
125
+ m = model.upper().strip()
126
+ for prefix, kind in (("RV", "vacuum"), ("DL", "lock"), ("KE", "valve"), ("TC", "camera"), ("C", "camera"), ("D", "doorbell"),
127
+ ("H", "hub"), ("L", "light"), ("P", "plug"), ("S", "switch"), ("T", "sensor")):
128
+ if m.startswith(prefix):
129
+ return kind
130
+ return None
131
+
132
+
133
+ def kinds_present(cfg: Config) -> set[str] | None:
134
+ """The kinds of devices known from earlier runs, or None when nothing is remembered (then every area is on)."""
135
+ if not cfg.cache_file:
136
+ return None
137
+ try:
138
+ hosts = json.loads(Path(cfg.cache_file).expanduser().read_text()).get("hosts", {})
139
+ except (OSError, ValueError, AttributeError):
140
+ return None
141
+ models = [str(info.get("model") or "") for info in hosts.values() if isinstance(info, dict)]
142
+ kinds = {kind_of_model(m) for m in models if m}
143
+ if not models or None in kinds: # nothing known, or a model we do not recognize: do not hide anything
144
+ return None
145
+ return kinds | (HUB_BRINGS if "hub" in kinds else set())
146
+
147
+
148
+ def enabled_areas(names: list[str], cfg: Config) -> dict[str, tuple[bool, str]]:
149
+ """For each area: is it on, and why. `areas` in the config wins; otherwise the devices the owner has decide."""
150
+ wanted = set(cfg.areas)
151
+ present = None if wanted else kinds_present(cfg)
152
+ out = {}
153
+ for name in names:
154
+ if name in ALWAYS_ON:
155
+ out[name] = (True, "always on")
156
+ elif wanted:
157
+ out[name] = (name in wanted or "all" in wanted, "chosen in the config")
158
+ elif present is None or name not in AREA_KINDS:
159
+ out[name] = (True, "on by default")
160
+ else:
161
+ needed = AREA_KINDS[name]
162
+ out[name] = (bool(needed & present), f"on because a {sorted(needed & present)[0]} is known" if needed & present else f"off: no {'/'.join(sorted(needed))} known")
163
+ return out
164
+
165
+
166
+ def load(ctx: AreaContext, *, strict: bool = False) -> None:
167
+ """Register the tools and routes of the enabled areas (all of them unless `areas` is set in the config).
168
+ `strict` re-raises, which the tests use. The result is kept in `ctx.catalog`."""
169
+ declare_methods()
170
+ found = modules()
171
+ decisions = enabled_areas([area_name(m) for m in found], ctx.cfg)
172
+ for module in found:
173
+ name = area_name(module)
174
+ enabled, why = decisions[name]
175
+ ctx.current = name
176
+ try:
177
+ if enabled and ctx.server is not None and hasattr(module, "tools"):
178
+ module.tools(ctx)
179
+ if enabled and ctx.api is not None and hasattr(module, "routes"):
180
+ module.routes(ctx)
181
+ except Exception:
182
+ if strict:
183
+ raise
184
+ log.exception("area %s could not be loaded; the rest of tolpo keeps working", module.__name__)
185
+ summary = (module.__doc__ or "").strip().splitlines()[0] if module.__doc__ else ""
186
+ ctx.catalog[name] = {"enabled": enabled, "why": why, "summary": summary, "tools": list(ctx._names.get(name, []))}
187
+ ctx.current = ""
@@ -0,0 +1,227 @@
1
+ """MCP tools for local Smart Actions. No cloud calls."""
2
+ import copy
3
+ import logging
4
+ from typing import Annotated, Any
5
+
6
+ from mcp.server.mcpserver.exceptions import ToolError
7
+ from pydantic import BeforeValidator, Field, ValidationError
8
+
9
+ from tolpo import policy
10
+ from tolpo.automation import Engine, Rule, Settings
11
+ from tolpo.calls import NeedsConfirmation
12
+ from tolpo.registry import Forbidden, RegistryError
13
+ from tolpo.views import clean
14
+ from tolpo.automation.engine import public_rule
15
+ from tolpo.automation.tools import MCPToolExecutor
16
+ from tolpo.policy import Tier
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ RESUME_PHRASE = "automation_resume"
21
+
22
+ METHODS = [
23
+ # T110, T300 and T310 sensors behind an H200 answer {"start_id": 0} with {"logs": [], "start_id": 0, "sum": 0}
24
+ # (fixtures). The H200 hub itself refuses the method, so it is not declared for hubs.
25
+ # UNVERIFIED: the event row shape (id, eventId, event, timestamp) is from python-kasa; every real log was empty.
26
+ # UNVERIFIED: button switches (S200) behind a hub.
27
+ policy.method("get_trigger_logs", Tier.READ, ["sensor", "switch"], "Read the local sensor or button trigger log."),
28
+ ]
29
+
30
+
31
+ def engine_for(ctx):
32
+ """One engine per registry; lifecycle hooks live entirely in this area."""
33
+ if not hasattr(ctx.registry, "_automation_engine"):
34
+ ctx.registry._automation_engine = Engine(
35
+ ctx.registry, ctx.caller, tool_executor=getattr(ctx.registry, "_automation_tools", None))
36
+ return ctx.registry._automation_engine
37
+
38
+
39
+ def install_lifecycle(ctx):
40
+ registry = ctx.registry
41
+ if getattr(registry, "_automation_lifecycle", False):
42
+ return
43
+ registry._automation_lifecycle = True
44
+ original_start, original_stop = registry.start, registry.stop
45
+
46
+ async def start(*args, **kwargs):
47
+ await original_start(*args, **kwargs)
48
+ try:
49
+ engine = engine_for(ctx)
50
+ except Exception as exc:
51
+ log.error("automation store could not be loaded: %s", type(exc).__name__)
52
+ return
53
+ engine.closed = False
54
+ engine.start()
55
+
56
+ async def stop():
57
+ engine = getattr(registry, "_automation_engine", None)
58
+ if engine is not None:
59
+ await engine.stop()
60
+ await original_stop()
61
+
62
+ registry.start, registry.stop = start, stop
63
+
64
+
65
+ def rule_input(value):
66
+ try:
67
+ return Rule.model_validate(value)
68
+ except Exception:
69
+ raise ToolError("invalid automation rule") from None
70
+
71
+
72
+ def settings_input(value):
73
+ try:
74
+ return Settings.model_validate(value)
75
+ except Exception:
76
+ raise ToolError("invalid automation settings") from None
77
+
78
+
79
+ def tools(ctx):
80
+ if ctx.server is not None:
81
+ adapter = MCPToolExecutor(ctx)
82
+ ctx.registry._automation_tools = adapter
83
+ original_tool = ctx.tool
84
+
85
+ def register(*, title, tier=Tier.READ, **options):
86
+ decorate = original_tool(title=title, tier=tier, **options)
87
+ def record(fn):
88
+ adapter.record(options.get("name", fn.__name__), tier)
89
+ return decorate(fn)
90
+ return record
91
+
92
+ ctx.tool = register
93
+ install_lifecycle(ctx)
94
+
95
+ def owner_allows(level, tool):
96
+ """Rules act while nobody watches, so making or changing them is a level 2 action (a schedule). The owner can
97
+ also name the single tool in `allow_methods`."""
98
+ cfg = ctx.registry.cfg
99
+ if cfg.tier < level and tool not in cfg.allow_methods:
100
+ raise Forbidden(f"{tool} is a level {level} action (it sets up actions that run without anyone asking) and the owner's policy blocks it")
101
+
102
+ async def pin_devices(rule):
103
+ """Replace every device name in the rule by the device's own short id, so that a rename in the Tapo app, or another
104
+ device taking the same name, cannot send the rule's actions to a different device."""
105
+ refs = sorted(rule.device_refs())
106
+ await ctx.ready_for(refs)
107
+ names = {}
108
+ for ref in refs:
109
+ try:
110
+ names[ref] = ctx.registry.resolve_one(ref).slug
111
+ except RegistryError:
112
+ raise ToolError(f"automation change refused: no single device matches {clean(ref)!r}") from None
113
+ return rule.with_devices(names)
114
+
115
+ def engine():
116
+ try:
117
+ return engine_for(ctx)
118
+ except Exception as exc:
119
+ raise ToolError(f"automation store could not be loaded: {type(exc).__name__}") from None
120
+
121
+ async def change(fn):
122
+ eng = engine()
123
+ async with eng.lock:
124
+ before = copy.deepcopy(eng.state)
125
+ try:
126
+ return fn(eng)
127
+ except (ValueError, ValidationError):
128
+ eng.state = before
129
+ # Validation errors may echo private input values. Do not return them.
130
+ raise ToolError("automation change refused: invalid rule, settings, id or cycle") from None
131
+ except OSError:
132
+ eng.state = before
133
+ raise ToolError("automation state could not be written") from None
134
+
135
+ @ctx.tool(title="Create local automation", tier=Tier.SETTING)
136
+ async def automation_create(rule: Annotated[Rule, BeforeValidator(rule_input), Field(description="Local rule with unique id, triggers, conditions and ordered actions. Disabled by default.")]) -> dict[str, Any]:
137
+ """Create a local rule. Actions use the owner's current policy at every execution. Device names in the rule are
138
+ stored as the devices' short ids, and the rule refuses to run on a device that is gone."""
139
+ with ctx.translated():
140
+ owner_allows(2, "automation_create")
141
+ pinned = await pin_devices(rule)
142
+ return await change(lambda eng: eng.create(pinned))
143
+
144
+ @ctx.tool(title="List local automations")
145
+ async def automation_list() -> dict[str, Any]:
146
+ """List compact local rule summaries and the kill switch state."""
147
+ eng = engine()
148
+ async with eng.lock:
149
+ return {"rules": [public_rule(r) for r in eng.state.rules], "killed": eng.state.settings.killed}
150
+
151
+ @ctx.tool(title="Show local automation")
152
+ async def automation_show(rule_id: Annotated[str, Field(description="Local rule id")]) -> dict[str, Any]:
153
+ """Show a compact rule summary. Private action parameters are omitted."""
154
+ return await change(lambda eng: public_rule(eng.rule(rule_id)))
155
+
156
+ @ctx.tool(title="Enable local automation", tier=Tier.SETTING)
157
+ async def automation_enable(rule_id: Annotated[str, Field(description="Local rule id")]) -> dict[str, Any]:
158
+ """Enable a rule and lazily start local evaluation while the server runs."""
159
+ with ctx.translated():
160
+ owner_allows(2, "automation_enable")
161
+ return await change(lambda eng: eng.enabled(rule_id, True))
162
+
163
+ @ctx.tool(title="Disable local automation", tier=Tier.SETTING)
164
+ async def automation_disable(rule_id: Annotated[str, Field(description="Local rule id")]) -> dict[str, Any]:
165
+ """Disable a rule and cancel its queued delayed actions."""
166
+ return await change(lambda eng: eng.enabled(rule_id, False))
167
+
168
+ @ctx.tool(title="Delete local automation", tier=Tier.SETTING)
169
+ async def automation_delete(rule_id: Annotated[str, Field(description="Local rule id")]) -> dict[str, Any]:
170
+ """Delete a local rule and its queued actions."""
171
+ with ctx.translated():
172
+ owner_allows(2, "automation_delete")
173
+ return await change(lambda eng: eng.delete(rule_id))
174
+
175
+ @ctx.tool(title="Preview local automations")
176
+ async def automation_dry_run() -> dict[str, Any]:
177
+ """Read current observations and say what would fire now without actions or state changes."""
178
+ return await engine().tick(dry_run=True)
179
+
180
+ @ctx.tool(title="Evaluate local automations", tier=Tier.CONTROL)
181
+ async def automation_tick(confirm: Annotated[str | None, Field(description="Optional per-call confirmation phrase for an immediate security or lifecycle action. Never saved.")] = None) -> dict[str, Any]:
182
+ """Evaluate once, run due actions through policy, and report individual failures."""
183
+ return await engine().tick(confirm=confirm)
184
+
185
+ @ctx.tool(title="Read local automation alerts", tier=Tier.CONTROL)
186
+ async def automation_alerts(clear: Annotated[bool, Field(description="Clear only the returned outbox records after reading them")] = False,
187
+ limit: Annotated[int, Field(ge=1, le=1000, description="Maximum records to return")] = 100) -> dict[str, Any]:
188
+ """Read the owner's JSONL alert outbox. Quiet records should be delivered after quiet hours."""
189
+ eng = engine()
190
+ async with eng.lock:
191
+ return {"alerts": eng.store.alerts(clear=clear, limit=limit)}
192
+
193
+ @ctx.tool(title="Configure local automation", tier=Tier.SETTING)
194
+ async def automation_settings(settings: Annotated[Settings, BeforeValidator(settings_input), Field(description="Complete local settings: IANA timezone, coordinates, poll interval, quiet hours and killed switch")],
195
+ confirm: Annotated[str | None, Field(description=f"Only to switch automation back on after it was stopped: the exact phrase {RESUME_PHRASE}")] = None) -> dict[str, Any]:
196
+ """Replace engine settings. Killing stops a running evaluation at once, cancels pending runs and blocks
197
+ ticks until cleared. Stopping is always allowed. Switching rules back on after a stop is a level 3 action
198
+ that needs the confirmation phrase, and the owner can also do it from the terminal with `tolpo automation resume`."""
199
+ eng = engine()
200
+ if settings.killed:
201
+ # Do not wait for a running tick: stop the loop first, then record the switch.
202
+ await eng.halt()
203
+ else:
204
+ with ctx.translated():
205
+ owner_allows(2, "automation_settings")
206
+ if eng.state.settings.killed or eng.store.killed_on_disk(): # also a stop the owner made from a terminal
207
+ owner_allows(3, "automation_resume")
208
+ if confirm != RESUME_PHRASE:
209
+ raise NeedsConfirmation(f"automation_resume is a level 3 action: it lets stopped rules act again. To go ahead, call again with confirm={RESUME_PHRASE!r}")
210
+
211
+ def update(eng):
212
+ eng.state.settings = settings
213
+ if settings.killed:
214
+ eng.kill()
215
+ else:
216
+ eng.halted = False
217
+ eng.save(resuming=not settings.killed)
218
+ eng.start()
219
+ return {"settings": settings.model_dump(exclude_none=True)}
220
+
221
+ try:
222
+ return await change(update)
223
+ except ToolError:
224
+ if settings.killed:
225
+ # The switch could not be saved. Keep this process stopped anyway.
226
+ eng.kill()
227
+ raise