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
package/README.md CHANGED
@@ -27,19 +27,28 @@ It ships two interfaces over the same core:
27
27
  `is any door open?`).
28
28
  - An **HTTP API** with an OpenAPI description at `/docs`, for scripts and dashboards.
29
29
 
30
+ The aim is that everything the Tapo app can do on your network can also be done by the agent: lights, plugs,
31
+ hubs and their sensors, cameras, doorbells, robot vacuums, schedules, firmware, rooms, pairing, and even what
32
+ the devices say to the internet. That is a lot of power, so every action has a level, and only the first
33
+ levels are on until you raise them. See [What an agent can do](#what-an-agent-can-do) and the
34
+ [Security model](#security-model).
35
+
30
36
  ## Features
31
37
 
32
- - **One view of the whole house.** Lights, plugs, the H100/H200 hub and its sensors, cameras and the
33
- doorbell, each with live state and a clear list of what can be changed.
34
- - **Safe by default.** Reads are broad; writes are limited to power, brightness and color unless you
35
- opt in. Cameras, pairing and hub alarms stay read-only. Wi-Fi names and Matter setup codes are
36
- never returned.
37
- - **Built for real homes.** Discovery combines broadcast with a direct per-address sweep, remembers
38
- devices between runs, retries the ones that are slow to wake, and reports honestly when something
39
- cannot be reached instead of pretending it is fine.
40
- - **Fast to answer.** State is cached briefly and device names resolve instantly, so an agent is not
41
- blocked on a slow hub.
42
- - **Tested.** A full suite runs against simulated devices, so it needs no hardware.
38
+ - Lights, plugs, the H100/H200 hub and its sensors, cameras and the doorbell, each with live state and a list of what
39
+ can be changed.
40
+ - The parts of the Tapo app are split into areas, such as lights, plugs, cameras, doorbells and the network. tolpo
41
+ remembers which kinds of device it found in earlier runs, and an area for a kind you have never had stays off.
42
+ - Every action has a level from 0 to 4. Reading and reversible control (power, brightness, color) are on. Settings,
43
+ security changes and things that are hard to undo, such as pairing or a factory reset, stay off until you raise the
44
+ level, and the last two kinds also need a confirmation phrase on every call. Wi-Fi names, setup codes, passwords
45
+ and tokens are never returned.
46
+ - A camera you name in the config gives an agent a still picture, a short run of pictures or a saved clip, through the
47
+ same video channel the Tapo app uses. No extra camera account is needed.
48
+ - Discovery combines a broadcast with a direct per-address sweep, remembers devices between runs and retries the ones
49
+ that are slow to wake. When a device cannot be reached, the answer says which one and why.
50
+ - State is cached briefly and device names resolve at once, so an agent is not blocked on a slow hub.
51
+ - A full test suite runs against simulated devices, so it needs no hardware.
43
52
 
44
53
  ## Supported devices
45
54
 
@@ -51,13 +60,15 @@ what python-kasa documents as supported but we have not tried.
51
60
  |---|---|---|---|---|
52
61
  | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/light.svg" width="36" alt="Light bulb"> | Color bulbs | L430C | L430P, L530B, L530E, L530EA, L535E | State, brightness, white temperature and color |
53
62
  | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/plug.svg" width="36" alt="Smart plug"> | Smart plugs | P115 | P110, P110M | State, plus power, voltage and current readings |
54
- | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/hub.svg" width="36" alt="Hub"> | Hubs | H200 | H100, and S200B and S200D buttons paired to a hub | The sensors paired to it show up as devices of their own. Its alarm and pairing stay blocked |
63
+ | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/hub.svg" width="36" alt="Hub"> | Hubs | H200 | H100, and S200B and S200D buttons paired to a hub | The sensors paired to it show up as devices of their own, and its event log is readable. With a higher level: siren, sensor settings, pairing |
55
64
  | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/contact.svg" width="36" alt="Door and window sensor"> | Door and window sensors | T110 | | Open or closed, low-battery flag |
56
65
  | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/climate.svg" width="36" alt="Temperature and humidity sensor"> | Temperature and humidity sensors | T310 | T315 (the one with a display) | Temperature, humidity, low-battery flag |
57
66
  | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/motion.svg" width="36" alt="Motion sensor"> | Motion sensors | | T100 | Motion, as python-kasa documents it. Not checked on real hardware |
58
67
  | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/leak.svg" width="36" alt="Water leak sensor"> | Water leak sensors | T300 | | Leak alert and battery level |
59
- | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/camera.svg" width="36" alt="Camera"> | Cameras | C225, C610, C465 | C100, C101, C110, C200, C210, C220, C325WB, C460, C520WS, C720, TC40, TC65, TC70 | State, battery where there is one, last alert and detection switches. Read-only, no video |
60
- | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/doorbell.svg" width="36" alt="Video doorbell"> | Doorbells | D230 | D100C, D130 | State and battery. Read-only |
68
+ | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/camera.svg" width="36" alt="Camera"> | Cameras | C225, C610, C465 | C100, C101, C110, C200, C210, C220, C325WB, C460, C520WS, C720, TC40, TC65, TC70 | State, battery where there is one, last alert and detection switches. With a higher level: detection tuning, image and audio settings, pan and tilt, the memory card. Pictures and clips, for the cameras you enable |
69
+ | <img src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/devices/doorbell.svg" width="36" alt="Video doorbell"> | Doorbells | D230 | D100C, D130 | State and battery. With a higher level: ring sounds, quick replies, anti-theft, wake-up |
70
+ | | Robot vacuums | | Models python-kasa has a cleaning module for | Status, start and stop, rooms and zones, suction and water, schedules. Built from the app's protocol and not tried on real hardware |
71
+ | | Door locks | | Not supported by python-kasa | Not usable yet. The DL100, DL105, DL110, DL130 and DLW10 speak a protocol python-kasa does not know. tolpo can parse their discovery record, and nothing is connected to it yet |
61
72
 
62
73
  The C610 and the C465 are not on python-kasa's list yet, but both connected fine. The C465 needs the small
63
74
  certificate fix in `compat.py`. The full upstream list is in
@@ -72,6 +83,7 @@ reports. Run `tolpo scan` to find out. If a device works or does not, tell us th
72
83
  - Python 3.11 or newer
73
84
  - One or more TP-Link Tapo devices on the same network, already set up in the Tapo app
74
85
  - Your Tapo account email and password
86
+ - [ffmpeg](https://ffmpeg.org), only if you want pictures from cameras
75
87
  - [uv](https://docs.astral.sh/uv/) (or plain `pip`) to install
76
88
 
77
89
  ## Installation
@@ -89,6 +101,35 @@ tolpo is a Python program. The npm package carries the Python code and runs it w
89
101
  downloads about forty Python packages and takes longer than later runs. If an AI client gives up waiting, run
90
102
  `npx -y tolpo --help` once and start the client again.
91
103
 
104
+ ### With Docker
105
+
106
+ For a machine that stays on, such as a Raspberry Pi or a Linux home server. The image is public, so pulling it
107
+ needs no login, and it is built for amd64 and arm64.
108
+
109
+ Keep the credentials in a file that only you can read, and pass it with `--env-file`:
110
+
111
+ ```bash
112
+ (umask 077; printf 'TAPO_EMAIL=you@example.com\nTAPO_PASSWORD=your-password\n' > ~/.tolpo.env)
113
+
114
+ docker run --rm -i --network host --env-file ~/.tolpo.env ghcr.io/signerless/tolpo
115
+ ```
116
+
117
+ That runs the MCP server over stdio. For the HTTP API, add a token, because it is no longer bound to loopback:
118
+
119
+ ```bash
120
+ echo "TOLPO_TOKEN=$(openssl rand -hex 24)" >> ~/.tolpo.env
121
+
122
+ docker run -d --name tolpo --restart unless-stopped --network host --env-file ~/.tolpo.env \
123
+ ghcr.io/signerless/tolpo serve --host 0.0.0.0
124
+ ```
125
+
126
+ `--network host` lets the container find devices by UDP broadcast, and it only works on Linux. Docker Desktop on
127
+ macOS and Windows runs containers in a virtual machine that cannot see your devices that way, so use the npx
128
+ install there.
129
+
130
+ CI builds the image for both architectures and checks the MCP handshake inside it. Finding real devices from
131
+ inside a container has not been tried yet. The image has no ffmpeg, so camera pictures do not work in it yet.
132
+
92
133
  ### From source
93
134
 
94
135
  ```bash
@@ -121,6 +162,7 @@ host = "127.0.0.1" # loopback only; a token is required to bind elsewhe
121
162
  port = 8765
122
163
  # token = "a-long-random-string"
123
164
  # cache_ttl_seconds = 10
165
+ # areas = ["lights", "plugs", "hub"] # which parts offer tools; empty means every part for the devices you own
124
166
 
125
167
  [discovery]
126
168
  # subnets = ["192.168.1.0/24"] # default: the /24 of your default-route interface
@@ -129,11 +171,34 @@ port = 8765
129
171
  interval_seconds = 600
130
172
 
131
173
  [policy]
174
+ # The highest level of action an agent may use, from 0 to 4. See the Security model.
175
+ tier = 1
176
+ # Single device methods an agent may use above `tier`, for example ["setLedStatus"].
177
+ allow_methods = []
132
178
  # Features an agent may write beyond power, brightness and color.
133
179
  # Plain ids ("pan_left") or kind-qualified ones ("camera.state").
134
180
  allow_write = []
181
+
182
+ [cameras]
183
+ # Pictures are off for every camera until you name it here, as it is called in the Tapo app.
184
+ snapshots = [] # for example ["Hallway Cam"]
185
+ # Optional. A camera account made in the Tapo app switches pictures to the standard RTSP stream.
186
+ # username = "camera-account-name"
187
+ # password = "camera-account-password"
188
+ # ffmpeg = "/opt/homebrew/bin/ffmpeg" # only if ffmpeg is not on your PATH
189
+ # clip_dir = "/Users/you/tolpo-clips" # the one folder where camera clips may be saved; no folder, no clips
135
190
  ```
136
191
 
192
+ Environment variables override the file: `TOLPO_CONFIG` (the file itself), `TOLPO_CREDENTIALS_FILE`, `TOLPO_TIER`,
193
+ `TOLPO_AREAS` (names separated by commas), `TOLPO_SNAPSHOTS`, `TOLPO_CLIP_DIR`, `TOLPO_FFMPEG`,
194
+ `TOLPO_CAMERA_USERNAME` and `TOLPO_CAMERA_PASSWORD`. A `tier` that is not a whole number from 0 to 4 stops the
195
+ server from starting.
196
+
197
+ Some parts keep their own files, so that no secret ends up in the main one. Automation keeps its rules and alert
198
+ outbox, and the local rooms and groups are kept in `~/.local/state/tolpo` (change it with `TOLPO_STATE_DIR`). The
199
+ network area reads `~/.config/tolpo/network.toml` (change it with `TOLPO_NETWORK_CONFIG`), which names the
200
+ environment variable that holds each router password and never holds the password itself.
201
+
137
202
  Verify your setup with a read-only scan: `npx -y tolpo scan`, or `.venv/bin/tolpo scan` from a source checkout.
138
203
 
139
204
  ## Usage with Claude (MCP)
@@ -163,6 +228,7 @@ temperature upstairs?".
163
228
  | `home_overview` | The whole house at a glance: what is on, open doors and windows, water alerts, offline devices, low batteries, temperatures |
164
229
  | `list_devices` | Devices with state; filter by `kind` or name |
165
230
  | `get_device` | Every feature of one device, and which ones are writable |
231
+ | `camera_snapshot` | One still picture from a camera you enabled |
166
232
  | `set_power` | Switch lights and plugs; accepts several targets and globs like `office*` |
167
233
  | `set_light` | Brightness, white temperature, or hue and saturation |
168
234
  | `set_feature` | Any other feature, only when the policy allows it |
@@ -172,6 +238,41 @@ Read tools are marked read-only and `set_feature` is marked destructive, so MCP
172
238
  confirmation where it matters. Responses are compact: all the devices of a typical home fit in under a
173
239
  thousand tokens.
174
240
 
241
+ ## What an agent can do
242
+
243
+ The Tapo app is split into areas. Each area is a group of MCP tools with its own page that lists every tool.
244
+ tolpo remembers the kinds of device it found in earlier runs, and an area for a kind you have never had is off, so a
245
+ house without a vacuum does not load vacuum tools. On a first run, or when a model is not recognized, every area is
246
+ on. Pick the areas yourself with `areas` under `[server]`, or `TOLPO_AREAS`. The tool `tolpo_areas` tells an agent
247
+ what is on.
248
+
249
+ | Area | What it covers | Tools |
250
+ |---|---|---:|
251
+ | [lights](https://github.com/signerless/tolpo/blob/main/docs/areas/lights.md) | Bulbs and strips: effects, presets, schedules, countdowns, Away Mode, power-on defaults | 19 |
252
+ | [plugs](https://github.com/signerless/tolpo/blob/main/docs/areas/plugs.md) | Plugs and switches: schedules, countdowns, energy history, electricity price, auto-off, protections | 21 |
253
+ | [hub](https://github.com/signerless/tolpo/blob/main/docs/areas/hub.md) | Hubs, sensors and radiator valves: siren, sensor settings, the door, leak, motion and button event log | 29 |
254
+ | [doorbell](https://github.com/signerless/tolpo/blob/main/docs/areas/doorbell.md) | Video doorbells and chimes: ring sounds, quick replies, anti-theft, wake-up, battery mode | 17 |
255
+ | [camera_detection](https://github.com/signerless/tolpo/blob/main/docs/areas/camera_detection.md) | Motion, person, pet and vehicle detection, zones, schedules, alarm and spotlight, what warns you | 17 |
256
+ | [camera_system](https://github.com/signerless/tolpo/blob/main/docs/areas/camera_system.md) | Pan and tilt, image, audio, battery, privacy mode, UPnP, firmware, reboot | 33 |
257
+ | [camera_media](https://github.com/signerless/tolpo/blob/main/docs/areas/camera_media.md) | Memory card, record plan, recording index, thumbnails of past events, clips saved to a folder | 11 |
258
+ | [vacuum](https://github.com/signerless/tolpo/blob/main/docs/areas/vacuum.md) | Robot vacuums: status, start and stop, rooms and zones, suction and water, schedules, wear counters | 27 |
259
+ | [lock](https://github.com/signerless/tolpo/blob/main/docs/areas/lock.md) | Door locks: read the state from the discovery record. Not usable yet | 1 |
260
+ | [device_admin](https://github.com/signerless/tolpo/blob/main/docs/areas/device_admin.md) | Names, clock, LED, firmware, and tolpo's own rooms and groups | 24 |
261
+ | [automation](https://github.com/signerless/tolpo/blob/main/docs/areas/automation.md) | A local rule engine in place of Smart Actions: triggers, conditions, actions and an alert outbox | 10 |
262
+ | [pairing](https://github.com/signerless/tolpo/blob/main/docs/areas/pairing.md) | Pair and remove hub devices, join Wi-Fi, reboot and factory reset. Most of it is level 4, a reboot is level 2 | 9 |
263
+ | [network](https://github.com/signerless/tolpo/blob/main/docs/areas/network.md) | What each device talks to, seen through your router, DNS server or firewall, and optional blocking. Blocking needs level 4, `mode = "enforce"` in `network.toml` and a Linux host that routes the traffic | 10 |
264
+
265
+ On top of these, `home_overview`, `list_devices`, `get_device`, `set_power`, `set_light`, `set_feature`,
266
+ `camera_snapshot` and `rescan` work for every device.
267
+
268
+ All the areas together are 241 tools. Their names, descriptions and parameter lists come to about 40,000 tokens. The
269
+ standard setup switches on only the areas that match the devices found at home. A client that loads every tool at once
270
+ can use `areas` to keep only what it needs.
271
+
272
+ Most of this was built from analysis of the Tapo app and tested against simulated devices. Each area page says
273
+ which parts were tried on real devices and which were not. Where the exact form of a request was a guess, the
274
+ page marks it `UNVERIFIED`, and the tool refuses when the device does not report support for it.
275
+
175
276
  ## Usage over HTTP
176
277
 
177
278
  ```bash
@@ -186,6 +287,7 @@ curl -s localhost:8765/devices/front-door # one device, every feature
186
287
  curl -s "localhost:8765/devices?kind=sensor" # doors, windows, water, temperature
187
288
 
188
289
  # Write
290
+ curl -s localhost:8765/devices/hallway-cam/snapshot -o picture.jpg # a camera you enabled
189
291
  curl -s -X POST localhost:8765/devices/desk-lamp/power \
190
292
  -H 'content-type: application/json' -d '{"on": true}'
191
293
  curl -s -X POST localhost:8765/devices/office-1/light \
@@ -198,19 +300,119 @@ A device is addressed by its short `id` (`office-1`), by its name in the Tapo ap
198
300
  or, in the batch endpoints, by a glob such as `office*`. If a name matches several devices the server
199
301
  returns `409` with the candidates instead of guessing.
200
302
 
303
+ ### The house in 3D
304
+
305
+ `tolpo serve` also serves a 3D view of the house at `http://127.0.0.1:8765/home`. It draws the rooms, puts every
306
+ device in its room and shows its state: lights glow in their real color, a door or window drawn on the wall opens when
307
+ its sensor reports open, a water leak turns red, the temperature floats over its sensor. Click a device to switch it,
308
+ dim it or change its color. Click a camera to watch it live. Rooms without a device are left out.
309
+
310
+ The floor plan is yours and stays on your machine, in `~/.config/tolpo/home.json` (`server.home_layout` or
311
+ `TOLPO_HOME_LAYOUT` to move it). The easiest way to make one is to ask your agent to "build my home plan": with the
312
+ `home_plan_draft`, `home_plan_get` and `home_plan_set` tools it drafts a plan from your devices, asks you about your
313
+ rooms and saves it. You can also press `Edit layout` in the view, drag devices where they belong, and save. The format
314
+ is in [docs/areas/home_plan.md](https://github.com/signerless/tolpo/blob/main/docs/areas/home_plan.md):
315
+
316
+ ```json
317
+ {"version": 1,
318
+ "rooms": [{"id": "office", "name": "Office", "x": -7, "z": -5, "w": 5, "d": 5},
319
+ {"id": "garden", "name": "Garden", "x": -9, "z": -12, "w": 21, "d": 7, "outside": true}],
320
+ "devices": {"office-1": {"room": "office", "x": -5.75, "z": -3.75},
321
+ "garden-cam": {"room": "garden", "x": 1.5, "z": -5.6, "yaw": 0}}}
322
+ ```
323
+
324
+ Live video comes from `GET /devices/{id}/live`, a stream of JPEG pictures (about 10 per second) from a camera you listed
325
+ in `cameras.snapshots`. One stream per camera and two cameras at once. A view ends after 15 minutes, or after 2 minutes
326
+ on a battery camera, because live video keeps it awake. The page loads three.js from one pinned jsDelivr path and may
327
+ talk to this server only. It asks battery cameras for their state every five minutes, not every few seconds, so it does
328
+ not wake them.
329
+
330
+ ## Camera pictures
331
+
332
+ An agent can ask a camera for one still picture. Every camera is off until you name it in the config, with the
333
+ name it has in the Tapo app:
334
+
335
+ ```toml
336
+ [cameras]
337
+ snapshots = ["Hallway Cam"]
338
+ ```
339
+
340
+ You also need [ffmpeg](https://ffmpeg.org) on the machine that runs tolpo. Check the setup yourself before an
341
+ agent uses it. This saves one picture to a file that only you can read:
342
+
343
+ ```bash
344
+ npx -y tolpo snapshot "Hallway Cam" -o hallway.jpg
345
+ ```
346
+
347
+ To watch a camera live on your own screen, use `npx -y tolpo watch "Hallway Cam"`. It opens ffplay, which comes with
348
+ ffmpeg, and closes when you close the window. `--seconds 30` stops it sooner. Nothing is recorded and nothing goes to
349
+ an agent.
350
+
351
+ tolpo takes one frame from the camera's live stream with ffmpeg and returns it as a JPEG, at most 1280 pixels
352
+ wide. `--quality high` asks for the main stream instead of the small one. There are two ways to reach the stream:
353
+
354
+ - The video channel of the Tapo app, on port 8800 of the camera. It uses the Tapo account tolpo already has, so
355
+ there is nothing more to set up.
356
+ - RTSP, if you create a camera account in the Tapo app and add it under `[cameras]` as `username` and `password`.
357
+ When a camera account is configured, tolpo uses RTSP.
358
+
359
+ What to know before you turn it on:
360
+
361
+ - A picture goes to the AI model your agent runs on. Enable only the cameras you are happy for it to see.
362
+ - A camera in privacy mode gives no picture. An agent cannot switch privacy mode off unless you allow
363
+ `camera.state` in `allow_write`.
364
+ - Every picture is logged with the camera and the time, never with the picture itself.
365
+ - If a camera rejects the login, tolpo leaves it alone for five minutes, because repeated failures can lock a
366
+ camera out.
367
+ - A picture is one frame from now, not a recording. Battery cameras can be asleep and may not answer.
368
+ - With RTSP, ffmpeg receives the camera account on its command line, so other users on the same machine can see
369
+ it for a moment.
370
+ - The doorbell sits behind the hub and has no stream of its own, so it gives no picture.
371
+
372
+ The `camera_media` area builds on this. It can read the memory card and its recording index, return the thumbnail
373
+ of a past event, take a short run of pictures from the live stream, and save a recording to the one folder you set
374
+ as `clip_dir`. Thumbnails, runs of pictures and clips follow the same rules as pictures: only cameras you named, never
375
+ in privacy mode, and the tool that saves a clip returns the path and never the video. Reading the card status, the
376
+ record plan and the list of recordings and events works for any camera. Formatting a card is a level 4 action.
377
+
201
378
  ## Security model
202
379
 
203
380
  This server controls physical things, so it refuses by default and widens only on request.
204
381
 
382
+ Every action on a device has a level. You choose the highest level an agent may use with `tier` under `[policy]`
383
+ (1 by default), and you can allow single device methods above it with `allow_methods`.
384
+
385
+ | Level | Name | What it is | Examples |
386
+ |---|---|---|---|
387
+ | 0 | Read | Look at anything that is not secret | State, settings, energy, event logs, a memory card index |
388
+ | 1 | Control | Reversible, as you would do by hand | Power, brightness, color, a camera sweep, vacuum start and stop |
389
+ | 2 | Setting | Changes behavior | Schedules, detection tuning, defaults, renaming, automation rules |
390
+ | 3 | Security | Matters for safety or privacy | Camera privacy mode, a siren, alarm and arm state, a camera light turned off, UPnP, audio out |
391
+ | 4 | Lifecycle | Hard to undo | Pairing and removing devices, factory reset, firmware install, Wi-Fi change, firewall changes, formatting a memory card |
392
+
393
+ Levels 3 and 4 also need a confirmation phrase on every call, such as `setLedStatus hallway-cam`. The refusal says
394
+ what the phrase is, so the phrase is a pause for the agent to ask the person, not proof that the person agreed. The
395
+ phrase names the action and the device (a few name a change or a plan instead), so one confirmation cannot be reused
396
+ for another device or another action. A refusal says what the action is, and never how to widen the permission.
397
+
205
398
  | Scope | Allowed |
206
399
  |---|---|
207
- | Read | Every feature of every device, except the Wi-Fi name and Matter setup codes, which are never returned |
208
- | Write by default | Power, brightness, color temperature, color and light presets, on lights and plugs |
209
- | Write on opt-in | Anything else, named in `policy.allow_write` (alarm test, pan/tilt, detection switches, reboot) |
210
- | Never | `pair` and `unpair` on the hub |
211
-
212
- Cameras are read-only: the `state` switch on a camera is its privacy shutter, not a power relay, so it
213
- is not treated as one. Enable it deliberately with `allow_write = ["camera.state"]`.
400
+ | Read | Everything that is not secret. The Wi-Fi name, setup codes, passwords, tokens, keys and MAC addresses are never returned |
401
+ | Control | Power, brightness, color temperature, color and light presets, on lights and plugs, at level 1 |
402
+ | More | Whatever level you allow with `tier`, or single methods named in `allow_methods` |
403
+ | Features | Anything else written through `set_feature`, named in `policy.allow_write` (alarm test, pan/tilt, detection switches) |
404
+ | Pictures | Only from the cameras named in `cameras.snapshots`. Off by default. The other camera tools (settings, card index, event lists) work for every camera at the level you allow |
405
+ | Pairing | Only through the pairing tools, at level 4. The hub `pair` and `unpair` features stay blocked in `set_feature` |
406
+
407
+ Cameras are read-only through `set_feature`: the `state` switch on a camera is its privacy shutter, not a power
408
+ relay, so it is not treated as one. Enable it deliberately with `allow_write = ["camera.state"]`, or use the
409
+ privacy tool at level 3. A feature named in `allow_write` is allowed at any level and needs no phrase, so name only
410
+ what you mean to give away.
411
+
412
+ Rules that run without anyone asking (automation) are held to the same levels at the moment they run, not only when
413
+ they are made. Setting them up needs level 2. An agent can stop all rules at any time, but starting them again
414
+ needs level 3 and a phrase. You can do it yourself with `tolpo automation resume`, which a server that is already
415
+ running picks up when it restarts.
214
416
 
215
417
  The server listens on `127.0.0.1` only. It refuses to start on any other address without a bearer
216
418
  token, and without a token it also rejects requests whose `Host` header is not local, which blocks
@@ -242,6 +444,13 @@ When something cannot be reached, the answers say so. `home_overview` and `list_
242
444
  `complete: false` and a `not_connected` list with the name, model and reason for each unreachable
243
445
  device, so an agent does not mistake an offline door sensor for a closed door.
244
446
 
447
+ ## For AI agents
448
+
449
+ Call `tolpo_areas` first. It lists the parts of tolpo that are on, what each one offers, and the level the owner
450
+ allows. If you are an AI agent asked to set tolpo up or to use it, start with
451
+ [llms.txt](https://raw.githubusercontent.com/signerless/tolpo/main/llms.txt). If you are asked to change the code,
452
+ read [AGENTS.md](https://github.com/signerless/tolpo/blob/main/AGENTS.md) first.
453
+
245
454
  ## Development
246
455
 
247
456
  ```bash
@@ -259,9 +468,18 @@ src/tolpo/
259
468
  mcp_server.py MCP server (stdio)
260
469
  config.py config file and environment loading
261
470
  compat.py small fixes for devices python-kasa cannot reach yet
262
- cli.py serve / mcp / scan entry points
471
+ snapshot.py one picture from a camera, taken with ffmpeg
472
+ media.py the video channel of the Tapo app (port 8800)
473
+ tsvideo.py finds the video track in a camera's stream
474
+ calls.py the one way an area sends a method to a device, checked against its level
475
+ provision.py Wi-Fi setup of a new device from this machine (SoftAP), behind a plan
476
+ areas/ one file per area of the Tapo app; adding an area adds a file
477
+ automation/ the local rule engine
478
+ network/ providers for routers, DNS servers and firewalls
479
+ cli.py serve / mcp / scan / snapshot / watch / automation entry points
263
480
  tests/ full suite against simulated devices
264
481
  docs/protocols.md how Tapo devices talk on the local network
482
+ docs/areas/ one page per area, with every tool and what was only tried with simulated devices
265
483
  assets/ logo, device icons and social preview
266
484
  ```
267
485
 
@@ -279,13 +497,24 @@ only ever signs in with your own Tapo account.
279
497
 
280
498
  ## Compatibility and status
281
499
 
282
- Version 0.1. Lights, plugs, hubs and sensors are fully supported over the KLAP and SSL-AES transports
283
- through `python-kasa`, plus a small patch (`compat.py`) for cameras with an ECDSA certificate, such as
500
+ This is a development version after 0.1. Lights, plugs, hubs and sensors are supported over the KLAP and SSL-AES
501
+ transports through `python-kasa`, plus a small patch (`compat.py`) for cameras with an ECDSA certificate, such as
284
502
  the C465, that `python-kasa` cannot reach on its own.
285
503
 
286
- Known limits: cameras and the doorbell are read-only, with no video; the MCP server runs over stdio,
287
- one process per client, with no shared HTTP transport yet; and devices on a second subnet have not
288
- been tested.
504
+ The areas beyond the first release were built from analysis of the Tapo app and tested with simulated devices.
505
+ Reading was tried on a real home with four bulbs, a hub, eight sensors, a doorbell, three cameras and a plug. A few
506
+ reversible writes were tried there too: the bulbs (power, brightness, color, power-on defaults, saved presets and
507
+ disabled rules), the plug (power, countdown, LED, power-on default and disabled rules), a camera light, the hub light, a
508
+ microphone volume, a detection switch and an automation rule, each set back afterwards. Most other writes have not been
509
+ tried on real hardware, and the area pages say which. Robot vacuums, door locks, radiator valves and the firewall providers were never
510
+ tried on the real thing.
511
+
512
+ Known limits: cameras give still pictures, short runs of pictures and saved clips, but no continuous video to an
513
+ agent, and the doorbell gives none; the cloud-only parts of the app (account, homes, sharing, push notifications,
514
+ Tapo Care, voice assistants) are not reachable, and the automation area replaces some of them locally; the MCP
515
+ server runs over stdio, one process per client, with no shared HTTP transport yet; the HTTP API has the original
516
+ routes plus a few for the camera memory card (`/devices/{id}/storage`, `/recordings/...`) and the door lock state, and
517
+ the other areas are MCP tools only; and devices on a second subnet have not been tested.
289
518
 
290
519
  ## License
291
520
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tolpo",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local-first MCP server and HTTP API that let AI agents read and control TP-Link Tapo smart-home devices",
5
5
  "license": "MIT",
6
6
  "author": "signerless",
@@ -29,6 +29,12 @@
29
29
  "files": [
30
30
  "bin/",
31
31
  "src/tolpo/*.py",
32
+ "src/tolpo/areas/*.py",
33
+ "src/tolpo/automation/*.py",
34
+ "src/tolpo/network/*.py",
35
+ "src/tolpo/web/home.html",
36
+ "src/tolpo/web/home.js",
37
+ "src/tolpo/web/home.css",
32
38
  "pyproject.toml",
33
39
  "README.md",
34
40
  "LICENSE"
package/pyproject.toml CHANGED
@@ -35,6 +35,9 @@ dependencies = [
35
35
  "fastapi>=0.115",
36
36
  "uvicorn>=0.30",
37
37
  "mcp>=2.3",
38
+ "pydantic>=2",
39
+ "httpx>=0.27",
40
+ "cryptography>=42",
38
41
  ]
39
42
 
40
43
  [project.urls]
@@ -44,7 +47,7 @@ Repository = "https://github.com/signerless/tolpo"
44
47
  Issues = "https://github.com/signerless/tolpo/issues"
45
48
 
46
49
  [project.optional-dependencies]
47
- dev = ["pytest>=8", "httpx>=0.27"]
50
+ dev = ["pytest>=8"]
48
51
 
49
52
  [project.scripts]
50
53
  tolpo = "tolpo.cli:main"
@@ -1 +1 @@
1
- __version__ = "0.1.0"
1
+ __version__ = "0.2.0"
package/src/tolpo/api.py CHANGED
@@ -2,12 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
6
+ import hashlib
7
+ import re
5
8
  import secrets
6
9
  from contextlib import asynccontextmanager
10
+ from pathlib import Path
11
+ from typing import Any, Literal
7
12
 
8
- from fastapi import Depends, FastAPI, Header, HTTPException
9
- from fastapi.responses import JSONResponse
13
+ from fastapi import Body, Depends, FastAPI, Header, HTTPException
14
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
10
15
  from pydantic import BaseModel, Field, JsonValue
16
+ from starlette.background import BackgroundTask
11
17
  from starlette.middleware.trustedhost import TrustedHostMiddleware
12
18
 
13
19
  from . import __version__
@@ -22,10 +28,28 @@ Refer to a device by its short `id` (`office-1`), by its name as shown in the ap
22
28
  (case-insensitive), or by a glob (`office*`) in the batch endpoints.
23
29
 
24
30
  Reads are broad; writes are limited to power, brightness and color unless the owner
25
- widens `policy.allow_write`. Responses never include Matter setup codes or the Wi-Fi name.
31
+ widens `policy.allow_write`. Responses never include Matter or HomeKit setup codes or the Wi-Fi name.
32
+ A still picture is available only from cameras the owner listed in `cameras.snapshots`.
26
33
  """
27
34
 
28
35
 
36
+ WEB = Path(__file__).parent / "web"
37
+ THREE = "https://cdn.jsdelivr.net/npm/three@0.170.0/" # the one outside source the 3D page may load code from
38
+
39
+
40
+ def page_headers(html: bytes) -> dict[str, str]:
41
+ """Headers for the 3D page. The browser may run its own script, three.js from one pinned path, and the import map
42
+ whose exact text is hashed here, and may talk to this server only."""
43
+ found = re.search(rb'<script type="importmap">(.*?)</script>', html, re.S)
44
+ allowed = f"'sha256-{base64.b64encode(hashlib.sha256(found.group(1)).digest()).decode()}'" if found else ""
45
+ policy = (
46
+ f"default-src 'none'; script-src 'self' {THREE} {allowed}; style-src 'self'; img-src 'self' blob: data:; "
47
+ "connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
48
+ )
49
+ return {"Content-Security-Policy": policy, "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer",
50
+ "Cache-Control": "no-cache"}
51
+
52
+
29
53
  class PowerBody(BaseModel):
30
54
  on: bool
31
55
 
@@ -56,6 +80,7 @@ def create_app(
56
80
  *,
57
81
  discover: bool = True,
58
82
  allowed_hosts: list[str] | None = None,
83
+ caller=None,
59
84
  ) -> FastAPI:
60
85
  reg = registry or Registry(cfg)
61
86
 
@@ -112,6 +137,52 @@ def create_app(
112
137
  await reg.refresh(entry)
113
138
  return detail(reg, entry)
114
139
 
140
+ @app.get(
141
+ "/devices/{ref}/snapshot",
142
+ dependencies=auth,
143
+ response_class=Response,
144
+ responses={200: {"content": {"image/jpeg": {}}, "description": "One JPEG picture, taken now"}},
145
+ summary="One still picture from a camera",
146
+ )
147
+ async def snapshot(ref: str, quality: Literal["low", "high"] = "low"):
148
+ """Only cameras the owner enabled. `low` is the camera's small stream, `high` its main stream
149
+ (scaled to at most 1280 pixels wide). The picture is never stored or cached."""
150
+ picture = await reg.snapshot(reg.resolve_one(ref), quality)
151
+ return Response(
152
+ picture.data,
153
+ media_type="image/jpeg",
154
+ headers={
155
+ "Cache-Control": "no-store",
156
+ "Cross-Origin-Resource-Policy": "same-origin", # another web page cannot embed it
157
+ "X-Content-Type-Options": "nosniff",
158
+ },
159
+ )
160
+
161
+ @app.get(
162
+ "/devices/{ref}/live",
163
+ dependencies=auth,
164
+ response_class=StreamingResponse,
165
+ responses={200: {"content": {"multipart/x-mixed-replace": {}}, "description": "JPEG pictures, one after the other"}},
166
+ summary="Live video from a camera",
167
+ )
168
+ async def live(ref: str, quality: Literal["low", "high"] = "low"):
169
+ """Only cameras the owner enabled. A stream of JPEG pictures (`multipart/x-mixed-replace`, about 10 per second on
170
+ `low`, 6 on `high`) that an `<img>` or a fetch reader can show. One stream per camera, two cameras at once, and at
171
+ most 15 minutes, or 2 for a battery camera. Nothing is stored. Closing the connection stops the camera."""
172
+ frames = await reg.live_pictures(reg.resolve_one(ref), quality)
173
+
174
+ async def body():
175
+ try:
176
+ async for picture in frames:
177
+ yield b"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + str(len(picture)).encode() + b"\r\n\r\n" + picture + b"\r\n"
178
+ finally:
179
+ await frames.aclose()
180
+
181
+ # The background task closes the stream even when the viewer leaves before the first picture went out.
182
+ return StreamingResponse(body(), media_type="multipart/x-mixed-replace; boundary=frame", background=BackgroundTask(frames.aclose),
183
+ headers={"Cache-Control": "no-store", "Cross-Origin-Resource-Policy": "same-origin",
184
+ "X-Content-Type-Options": "nosniff"})
185
+
115
186
  @app.post("/devices/{ref}/power", response_model=DeviceSummary, dependencies=auth, summary="Switch on or off")
116
187
  async def power(ref: str, body: PowerBody):
117
188
  entry = reg.resolve_one(ref)
@@ -150,5 +221,42 @@ def create_app(
150
221
  settings = body.model_dump(exclude={"targets"})
151
222
  return await run_batch(reg, body.targets, lambda e: reg.set_light(e, **settings))
152
223
 
224
+ from . import home as plan
225
+
226
+ @app.get("/home", include_in_schema=False)
227
+ async def home_page():
228
+ html = (WEB / "home.html").read_bytes()
229
+ return Response(html, media_type="text/html; charset=utf-8", headers=page_headers(html))
230
+
231
+ @app.get("/home/home.js", include_in_schema=False)
232
+ async def home_script():
233
+ return Response((WEB / "home.js").read_bytes(), media_type="text/javascript; charset=utf-8",
234
+ headers={"X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"})
235
+
236
+ @app.get("/home/home.css", include_in_schema=False)
237
+ async def home_style():
238
+ return Response((WEB / "home.css").read_bytes(), media_type="text/css; charset=utf-8",
239
+ headers={"X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"})
240
+
241
+ @app.get("/home/layout", dependencies=auth, summary="The floor plan of the 3D home view")
242
+ async def get_plan():
243
+ """Rooms and where each device sits. Empty until the owner saves one; the page then places devices itself."""
244
+ try:
245
+ return plan.load(cfg.home_layout) or plan.empty()
246
+ except plan.LayoutError as exc:
247
+ raise HTTPException(422, f"the saved floor plan is not valid: {exc}") from None
248
+
249
+ @app.put("/home/layout", dependencies=auth, summary="Replace the floor plan of the 3D home view")
250
+ async def put_plan(body: Any = Body(...)):
251
+ """Checked before it is written. The file is the owner's own and is never sent anywhere else."""
252
+ try:
253
+ return plan.save(cfg.home_layout, body)
254
+ except plan.LayoutError as exc:
255
+ raise HTTPException(422, str(exc)) from None
256
+
257
+ from .areas import AreaContext, load
258
+ from .calls import Caller
259
+
260
+ load(AreaContext(registry=reg, caller=caller or Caller(reg), api=app, auth=auth))
153
261
  return app
154
262