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,229 @@
|
|
|
1
|
+
"""MCP tools for the 3D home view: an agent drafts, reads and saves the floor plan that `tolpo serve` shows at /home.
|
|
2
|
+
|
|
3
|
+
The plan is the owner's own file (`server.home_layout`, by default ~/.config/tolpo/home.json). It never leaves the
|
|
4
|
+
machine and never ships with tolpo. No device is contacted: these tools read what discovery already knows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Do NOT add `from __future__ import annotations` here: the MCP SDK reads the tool signatures at runtime.
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from .. import home
|
|
15
|
+
from ..policy import Tier
|
|
16
|
+
from ..registry import Forbidden, RegistryError
|
|
17
|
+
|
|
18
|
+
METHODS = [] # no device method: the plan is tolpo's own file
|
|
19
|
+
|
|
20
|
+
PLAN_FORMAT = """\
|
|
21
|
+
A plan is {"version": 1, "rooms": [...], "devices": {...}}. Distances are metres on the floor: x grows to the right,
|
|
22
|
+
z grows toward the front of the house. A room is {"id": "office", "name": "Office", "x": -7, "z": -5, "w": 5, "d": 5},
|
|
23
|
+
where x and z are its back-left corner and w and d its size; add "outside": true for a garden, yard or street.
|
|
24
|
+
A device is placed by its short id: {"office-1": {"room": "office", "x": -5.75, "z": -3.75}}. Put a door or window
|
|
25
|
+
sensor exactly on a wall line (for example z equal to the room's z + d for its front wall): the view then draws a door
|
|
26
|
+
or a window there that opens when the sensor says open. A camera or doorbell can take "yaw" in degrees to turn it:
|
|
27
|
+
0 faces the back (-z), 180 faces the front. Rooms without any device are not drawn."""
|
|
28
|
+
|
|
29
|
+
# Words in device names that tell the room, in English, German and Spanish. The first match wins.
|
|
30
|
+
ROOM_WORDS = [
|
|
31
|
+
("office", "Office", r"office|b[uü]ro|oficina|study|desk"),
|
|
32
|
+
("kitchen", "Kitchen", r"kitchen|k[uü]che|cocina"),
|
|
33
|
+
("kids", "Kids room", r"kid|kinder|child|ni[nñ]o|nursery"),
|
|
34
|
+
("bedroom", "Bedroom", r"bed|schlaf|dormitorio|habitaci"),
|
|
35
|
+
("bathroom", "Bathroom", r"bath|\bbad\b|ba[nñ]o|wc|shower|dusche|toilet"),
|
|
36
|
+
("utility", "Utility room", r"heat|heizung|boiler|utility|laundry|wasch|caldera|technik"),
|
|
37
|
+
("garage", "Garage", r"garage|carport"),
|
|
38
|
+
("basement", "Basement", r"basement|cellar|keller|s[oó]tano"),
|
|
39
|
+
("hall", "Hall", r"hall|flur|corridor|pasillo|entr|foyer|main.?door|front.?door|haust[uü]r"),
|
|
40
|
+
("living", "Living room", r"living|lounge|wohn|sala|sal[oó]n|tv|sofa"),
|
|
41
|
+
("backyard", "Backyard", r"backyard|garden|garten|patio|terrace|terrasse|jard[ií]n|yard|outdoor|back"),
|
|
42
|
+
]
|
|
43
|
+
OUTSIDE = {"backyard", "front"}
|
|
44
|
+
ORDER = ["office", "living", "kitchen", "bedroom", "kids", "hall", "bathroom", "utility", "basement", "other", "garage"]
|
|
45
|
+
NAMES = {key: name for key, name, _ in ROOM_WORDS} | {"front": "Front yard", "other": "Other"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _guess_room(entry) -> str:
|
|
49
|
+
text = f"{entry.slug} {entry.device.alias or ''}".lower()
|
|
50
|
+
kind = entry.kind
|
|
51
|
+
if kind == "doorbell":
|
|
52
|
+
return "front"
|
|
53
|
+
for key, _, pattern in ROOM_WORDS:
|
|
54
|
+
if re.search(pattern, text):
|
|
55
|
+
if key == "backyard" and kind not in ("camera", "plug", "light", "sensor"):
|
|
56
|
+
continue
|
|
57
|
+
if key == "backyard" and kind == "sensor":
|
|
58
|
+
return "living" # a back door or window sensor sits on the house, not in the garden
|
|
59
|
+
return key
|
|
60
|
+
features = entry.device.features
|
|
61
|
+
if kind == "camera":
|
|
62
|
+
return "backyard"
|
|
63
|
+
if "water_alert" in features or "water_leak" in features:
|
|
64
|
+
return "bathroom"
|
|
65
|
+
if kind in ("hub", "plug") or "temperature" in features:
|
|
66
|
+
return "living"
|
|
67
|
+
return "other"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _opening(entry) -> str | None:
|
|
71
|
+
"""door, window or garage for a contact sensor, None for anything else."""
|
|
72
|
+
if "is_open" not in entry.device.features:
|
|
73
|
+
return None
|
|
74
|
+
text = f"{entry.slug} {entry.device.alias or ''}".lower()
|
|
75
|
+
if re.search(r"window|fenster|ventana", text):
|
|
76
|
+
return "window"
|
|
77
|
+
if re.search(r"garage", text):
|
|
78
|
+
return "garage"
|
|
79
|
+
return "door"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def draft(registry) -> dict[str, Any]:
|
|
83
|
+
"""A first plan from what discovery knows. Rooms come from the words in device names, sizes from how many devices
|
|
84
|
+
a room holds, door and window sensors go on walls, cameras and the doorbell go outside."""
|
|
85
|
+
groups: dict[str, list] = {}
|
|
86
|
+
for entry in sorted(registry.entries(), key=lambda e: e.slug):
|
|
87
|
+
groups.setdefault(_guess_room(entry), []).append(entry)
|
|
88
|
+
|
|
89
|
+
inside = [k for k in ORDER if k in groups]
|
|
90
|
+
rooms, x, z, row_depth, row_width = [], 0.0, 0.0, 0.0, 16.0
|
|
91
|
+
spots: dict[str, dict] = {}
|
|
92
|
+
for key in inside:
|
|
93
|
+
count = len(groups[key])
|
|
94
|
+
w = 4.0 if count <= 2 else 5.0
|
|
95
|
+
d = 4.0 if count <= 2 else 5.0
|
|
96
|
+
if key == "garage":
|
|
97
|
+
w, d = 5.0, 5.0
|
|
98
|
+
if x + w > row_width and x > 0:
|
|
99
|
+
x, z, row_depth = 0.0, z + row_depth, 0.0
|
|
100
|
+
rooms.append({"id": key, "name": NAMES[key], "x": x, "z": z, "w": w, "d": d})
|
|
101
|
+
x += w
|
|
102
|
+
row_depth = max(row_depth, d)
|
|
103
|
+
depth = z + row_depth
|
|
104
|
+
width = max([r["x"] + r["w"] for r in rooms] or [8.0])
|
|
105
|
+
# centre the house on the origin
|
|
106
|
+
shift_x, shift_z = -width / 2, -depth / 2
|
|
107
|
+
for r in rooms:
|
|
108
|
+
r["x"], r["z"] = round(r["x"] + shift_x, 2), round(r["z"] + shift_z, 2)
|
|
109
|
+
front, back = round(depth / 2, 2), round(-depth / 2, 2)
|
|
110
|
+
|
|
111
|
+
for room in rooms:
|
|
112
|
+
members = groups[room["id"]]
|
|
113
|
+
walls = [e for e in members if _opening(e)]
|
|
114
|
+
free = [e for e in members if not _opening(e)]
|
|
115
|
+
cols = max(1, math.ceil(math.sqrt(len(free))))
|
|
116
|
+
rows = max(1, math.ceil(len(free) / cols))
|
|
117
|
+
for i, entry in enumerate(free):
|
|
118
|
+
cx = room["x"] + room["w"] * ((i % cols) + 0.5) / cols
|
|
119
|
+
cz = room["z"] + room["d"] * ((i // cols) + 0.5) / rows
|
|
120
|
+
spots[entry.slug] = {"room": room["id"], "x": round(cx, 2), "z": round(cz, 2)}
|
|
121
|
+
for i, entry in enumerate(walls):
|
|
122
|
+
kind = _opening(entry)
|
|
123
|
+
text = f"{entry.slug} {entry.device.alias or ''}".lower()
|
|
124
|
+
along = room["x"] + room["w"] * (i + 1) / (len(walls) + 1)
|
|
125
|
+
if kind == "window":
|
|
126
|
+
spots[entry.slug] = {"room": room["id"], "x": room["x"], "z": round(room["z"] + room["d"] / 2, 2)}
|
|
127
|
+
elif kind == "garage" or re.search(r"main|front|haust|entr|principal", text) or room["id"] == "hall":
|
|
128
|
+
spots[entry.slug] = {"room": room["id"], "x": round(along, 2), "z": room["z"] + room["d"]}
|
|
129
|
+
else:
|
|
130
|
+
spots[entry.slug] = {"room": room["id"], "x": round(along, 2), "z": room["z"]}
|
|
131
|
+
|
|
132
|
+
for key, z_from, z_size, yaw in (("backyard", back - 7, 7, 0), ("front", front, 4, 180)):
|
|
133
|
+
members = groups.get(key, [])
|
|
134
|
+
if not members:
|
|
135
|
+
continue
|
|
136
|
+
rooms.append({"id": key, "name": NAMES[key], "x": round(-width / 2 - 2, 2), "z": round(z_from, 2), "w": round(width + 4, 2),
|
|
137
|
+
"d": z_size, "outside": True})
|
|
138
|
+
for i, entry in enumerate(members):
|
|
139
|
+
px = -width / 2 + width * (i + 1) / (len(members) + 1)
|
|
140
|
+
pz = back - 0.6 if key == "backyard" else front + 0.4
|
|
141
|
+
spots[entry.slug] = {"room": key, "x": round(px, 2), "z": round(pz, 2), "yaw": yaw}
|
|
142
|
+
|
|
143
|
+
plan = home.validate({"version": 1, "rooms": rooms, "devices": spots})
|
|
144
|
+
guesses = {room["name"]: sorted(spots_for(plan, room["id"])) for room in plan["rooms"]}
|
|
145
|
+
return {
|
|
146
|
+
"plan": plan,
|
|
147
|
+
"rooms_guessed": guesses,
|
|
148
|
+
"next": "This is a guess from device names. Show it to the person, ask which rooms they really have and where "
|
|
149
|
+
"each device is, change the plan, then save it with home_plan_set.",
|
|
150
|
+
"format": PLAN_FORMAT,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def spots_for(plan, room_id):
|
|
155
|
+
return [slug for slug, spot in plan["devices"].items() if spot.get("room") == room_id]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def waiting_names(registry) -> list[str]:
|
|
159
|
+
"""Names of known devices that are still connecting. Names only: addresses are not for the agent."""
|
|
160
|
+
return sorted(str(d.get("name") or "") for d in registry.not_connected())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def view_url(cfg) -> str:
|
|
164
|
+
host = cfg.host if cfg.host not in ("0.0.0.0", "::", "") else "127.0.0.1"
|
|
165
|
+
host = f"[{host}]" if ":" in host else host
|
|
166
|
+
return f"http://{host}:{cfg.port}/home"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
Plan = Annotated[dict[str, Any], Field(description="The whole plan: {\"version\": 1, \"rooms\": [...], \"devices\": {...}}. "
|
|
170
|
+
"See home_plan_get for the format.")]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def tools(ctx):
|
|
174
|
+
def known(plan):
|
|
175
|
+
slugs = {e.slug for e in ctx.registry.entries()}
|
|
176
|
+
placed = set(plan["devices"])
|
|
177
|
+
return sorted(placed - slugs), sorted(slugs - placed)
|
|
178
|
+
|
|
179
|
+
@ctx.tool(title="Home plan")
|
|
180
|
+
async def home_plan_get() -> dict[str, Any]:
|
|
181
|
+
"""Read the floor plan of the 3D home view, which devices it places and which it does not, and the plan format.
|
|
182
|
+
Nothing is contacted."""
|
|
183
|
+
await ctx.ready_for(["*"])
|
|
184
|
+
with ctx.translated():
|
|
185
|
+
try:
|
|
186
|
+
plan = home.load(ctx.registry.cfg.home_layout) or home.empty()
|
|
187
|
+
except home.LayoutError as exc:
|
|
188
|
+
raise RegistryError(f"the saved plan is not valid: {exc}") from None
|
|
189
|
+
unknown, unplaced = known(plan)
|
|
190
|
+
return {"plan": plan, "unplaced_devices": unplaced, "placed_but_not_found": unknown,
|
|
191
|
+
"still_connecting": waiting_names(ctx.registry), "view": view_url(ctx.registry.cfg), "format": PLAN_FORMAT}
|
|
192
|
+
|
|
193
|
+
@ctx.tool(title="Draft a home plan")
|
|
194
|
+
async def home_plan_draft() -> dict[str, Any]:
|
|
195
|
+
"""Draft a floor plan from the devices tolpo knows: rooms guessed from their names, door and window sensors on
|
|
196
|
+
walls, cameras and the doorbell outside. Saves nothing. Show it to the person and ask about their real rooms
|
|
197
|
+
before saving it with home_plan_set."""
|
|
198
|
+
await ctx.ready_for(["*"])
|
|
199
|
+
with ctx.translated():
|
|
200
|
+
out = draft(ctx.registry)
|
|
201
|
+
waiting = waiting_names(ctx.registry)
|
|
202
|
+
if waiting:
|
|
203
|
+
out["still_connecting"] = waiting
|
|
204
|
+
out["next"] = (f"{len(waiting)} known device(s) are still connecting and are not in this draft yet. Draft again "
|
|
205
|
+
"in a moment. ") + out["next"]
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
@ctx.tool(title="Save the home plan", tier=Tier.CONTROL)
|
|
209
|
+
async def home_plan_set(plan: Plan) -> dict[str, Any]:
|
|
210
|
+
"""Replace the floor plan of the 3D home view. Every value is checked before the owner's file is written; an
|
|
211
|
+
invalid plan is refused and the file stays as it was. Level 1. No device is changed."""
|
|
212
|
+
with ctx.translated():
|
|
213
|
+
if ctx.registry.cfg.tier < Tier.CONTROL:
|
|
214
|
+
raise Forbidden("Saving the home plan is a level 1 action and the owner's policy blocks it.")
|
|
215
|
+
try:
|
|
216
|
+
saved = home.save(ctx.registry.cfg.home_layout, plan)
|
|
217
|
+
except home.LayoutError as exc:
|
|
218
|
+
raise RegistryError(f"the plan was not saved: {exc}") from None
|
|
219
|
+
unknown, unplaced = known(saved)
|
|
220
|
+
return {"saved": True, "rooms": len(saved["rooms"]), "devices": len(saved["devices"]),
|
|
221
|
+
"unplaced_devices": unplaced, "placed_but_not_found": unknown, "view": view_url(ctx.registry.cfg)}
|
|
222
|
+
|
|
223
|
+
@ctx.tool(title="Where to see the house in 3D")
|
|
224
|
+
async def home_view_url() -> dict[str, Any]:
|
|
225
|
+
"""The address of the 3D home view. It is served by `tolpo serve` (or `npx -y tolpo serve`), not by the MCP
|
|
226
|
+
server, so the person may need to start it first. It shows the plan, the state of every device, controls, and
|
|
227
|
+
live video from the cameras the owner enabled."""
|
|
228
|
+
return {"url": view_url(ctx.registry.cfg),
|
|
229
|
+
"how": "Run `tolpo serve` (or `npx -y tolpo serve`) on the same machine, then open the address in a browser."}
|