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,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."}