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,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
|