tolpo 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +297 -2
- package/bin/tolpo.js +48 -0
- package/package.json +40 -4
- package/pyproject.toml +64 -0
- package/src/tolpo/__init__.py +1 -0
- package/src/tolpo/__main__.py +3 -0
- package/src/tolpo/api.py +154 -0
- package/src/tolpo/cli.py +81 -0
- package/src/tolpo/compat.py +30 -0
- package/src/tolpo/config.py +102 -0
- package/src/tolpo/mcp_server.py +231 -0
- package/src/tolpo/policy.py +28 -0
- package/src/tolpo/registry.py +606 -0
- package/src/tolpo/views.py +220 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 signerless
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,298 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/signerless/tolpo/main/assets/logo-dark.svg">
|
|
4
|
+
<img alt="tolpo" src="https://raw.githubusercontent.com/signerless/tolpo/main/assets/logo-light.svg" width="280">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
<p align="center"><strong>Local-first control of TP-Link Tapo smart-home devices for AI agents.</strong></p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
|
|
12
|
+
[](https://github.com/signerless/tolpo/actions/workflows/ci.yml)
|
|
13
|
+
[](https://github.com/signerless/tolpo/blob/main/LICENSE)
|
|
14
|
+
[](https://www.python.org/downloads/)
|
|
15
|
+
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
`tolpo` turns the Tapo devices on your network into a single interface that an AI agent, or
|
|
19
|
+
your own code, can read and control. It talks to each device directly over the LAN through the open
|
|
20
|
+
source [python-kasa](https://github.com/python-kasa/python-kasa) library, so no request ever leaves
|
|
21
|
+
your home and no vendor cloud sits in the path. The only credential it needs is the Tapo account the
|
|
22
|
+
devices already belong to.
|
|
23
|
+
|
|
24
|
+
It ships two interfaces over the same core:
|
|
25
|
+
|
|
26
|
+
- An **MCP server** that assistants such as Claude use as tools (`turn off the office lights`,
|
|
27
|
+
`is any door open?`).
|
|
28
|
+
- An **HTTP API** with an OpenAPI description at `/docs`, for scripts and dashboards.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
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.
|
|
43
|
+
|
|
44
|
+
## Supported devices
|
|
45
|
+
|
|
46
|
+
tolpo works with whatever [python-kasa](https://github.com/python-kasa/python-kasa) can talk to, so the list is
|
|
47
|
+
longer than the hardware we own. The table keeps two things apart: what we have run against real devices, and
|
|
48
|
+
what python-kasa documents as supported but we have not tried.
|
|
49
|
+
|
|
50
|
+
| | Device | Tested here | Documented by python-kasa, not tried here | What an agent gets |
|
|
51
|
+
|---|---|---|---|---|
|
|
52
|
+
| <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
|
+
| <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 |
|
|
55
|
+
| <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
|
+
| <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
|
+
| <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
|
+
| <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 |
|
|
61
|
+
|
|
62
|
+
The C610 and the C465 are not on python-kasa's list yet, but both connected fine. The C465 needs the small
|
|
63
|
+
certificate fix in `compat.py`. The full upstream list is in
|
|
64
|
+
[python-kasa's SUPPORTED.md](https://github.com/python-kasa/python-kasa/blob/master/SUPPORTED.md).
|
|
65
|
+
|
|
66
|
+
A model that is not listed may still work, because tolpo hands the device to python-kasa and shows whatever it
|
|
67
|
+
reports. Run `tolpo scan` to find out. If a device works or does not, tell us through a
|
|
68
|
+
[device support issue](https://github.com/signerless/tolpo/issues/new?template=device_support.yml) so the table can improve.
|
|
69
|
+
|
|
70
|
+
## Requirements
|
|
71
|
+
|
|
72
|
+
- Python 3.11 or newer
|
|
73
|
+
- One or more TP-Link Tapo devices on the same network, already set up in the Tapo app
|
|
74
|
+
- Your Tapo account email and password
|
|
75
|
+
- [uv](https://docs.astral.sh/uv/) (or plain `pip`) to install
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
### With npx
|
|
80
|
+
|
|
81
|
+
You need [Node](https://nodejs.org) and [uv](https://docs.astral.sh/uv/). Nothing is installed globally:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx -y tolpo scan # find your devices
|
|
85
|
+
npx -y tolpo mcp # run as an MCP server
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
tolpo is a Python program. The npm package carries the Python code and runs it with uv, so the first run
|
|
89
|
+
downloads about forty Python packages and takes longer than later runs. If an AI client gives up waiting, run
|
|
90
|
+
`npx -y tolpo --help` once and start the client again.
|
|
91
|
+
|
|
92
|
+
### From source
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git clone https://github.com/signerless/tolpo && cd tolpo
|
|
96
|
+
uv venv --python 3.13 .venv
|
|
97
|
+
uv pip install --python .venv/bin/python -e ".[dev]"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
Credentials can come from environment variables or a config file. The server reads the file first,
|
|
103
|
+
then lets the environment override it.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
export TAPO_EMAIL='you@example.com'
|
|
107
|
+
export TAPO_PASSWORD='your-password'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or `~/.config/tolpo/config.toml` (run `chmod 600` on it, since it holds a password):
|
|
111
|
+
|
|
112
|
+
```toml
|
|
113
|
+
[tapo]
|
|
114
|
+
email = "you@example.com"
|
|
115
|
+
password = "your-password"
|
|
116
|
+
# or point at an existing JSON file instead of copying the secret:
|
|
117
|
+
# credentials_file = "~/.tapo_config.json"
|
|
118
|
+
|
|
119
|
+
[server]
|
|
120
|
+
host = "127.0.0.1" # loopback only; a token is required to bind elsewhere
|
|
121
|
+
port = 8765
|
|
122
|
+
# token = "a-long-random-string"
|
|
123
|
+
# cache_ttl_seconds = 10
|
|
124
|
+
|
|
125
|
+
[discovery]
|
|
126
|
+
# subnets = ["192.168.1.0/24"] # default: the /24 of your default-route interface
|
|
127
|
+
# hosts = ["192.168.10.20"] # devices that ignore broadcast or live on another VLAN
|
|
128
|
+
# sweep = true # also probe every address in the subnet (networks up to /22)
|
|
129
|
+
interval_seconds = 600
|
|
130
|
+
|
|
131
|
+
[policy]
|
|
132
|
+
# Features an agent may write beyond power, brightness and color.
|
|
133
|
+
# Plain ids ("pan_left") or kind-qualified ones ("camera.state").
|
|
134
|
+
allow_write = []
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Verify your setup with a read-only scan: `npx -y tolpo scan`, or `.venv/bin/tolpo scan` from a source checkout.
|
|
138
|
+
|
|
139
|
+
## Usage with Claude (MCP)
|
|
140
|
+
|
|
141
|
+
Put your credentials in `~/.config/tolpo/config.toml` (see Configuration, and `chmod 600` it). The server
|
|
142
|
+
reads that file by itself, so nothing secret ends up in your AI client's settings. Then register the server
|
|
143
|
+
once:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
claude mcp add tolpo -- npx -y tolpo mcp
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
From a source checkout, point at your own copy instead:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
claude mcp add tolpo -- $PWD/.venv/bin/tolpo mcp
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
If you would rather keep the credentials out of a file, pass them with `-e TAPO_EMAIL=... -e TAPO_PASSWORD=...`,
|
|
156
|
+
but your client will then store them in its own settings in plain text.
|
|
157
|
+
|
|
158
|
+
After the next restart you can ask things like "dim office-1 to 30%", "is any door open?" or "what is the
|
|
159
|
+
temperature upstairs?".
|
|
160
|
+
|
|
161
|
+
| Tool | What it does |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `home_overview` | The whole house at a glance: what is on, open doors and windows, water alerts, offline devices, low batteries, temperatures |
|
|
164
|
+
| `list_devices` | Devices with state; filter by `kind` or name |
|
|
165
|
+
| `get_device` | Every feature of one device, and which ones are writable |
|
|
166
|
+
| `set_power` | Switch lights and plugs; accepts several targets and globs like `office*` |
|
|
167
|
+
| `set_light` | Brightness, white temperature, or hue and saturation |
|
|
168
|
+
| `set_feature` | Any other feature, only when the policy allows it |
|
|
169
|
+
| `rescan` | Look for new or moved devices |
|
|
170
|
+
|
|
171
|
+
Read tools are marked read-only and `set_feature` is marked destructive, so MCP clients can ask for
|
|
172
|
+
confirmation where it matters. Responses are compact: all the devices of a typical home fit in under a
|
|
173
|
+
thousand tokens.
|
|
174
|
+
|
|
175
|
+
## Usage over HTTP
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npx -y tolpo serve # or .venv/bin/tolpo serve from a source checkout
|
|
179
|
+
# http://127.0.0.1:8765, interactive docs at /docs
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# Read
|
|
184
|
+
curl -s localhost:8765/devices # everything, with state and capabilities
|
|
185
|
+
curl -s localhost:8765/devices/front-door # one device, every feature
|
|
186
|
+
curl -s "localhost:8765/devices?kind=sensor" # doors, windows, water, temperature
|
|
187
|
+
|
|
188
|
+
# Write
|
|
189
|
+
curl -s -X POST localhost:8765/devices/desk-lamp/power \
|
|
190
|
+
-H 'content-type: application/json' -d '{"on": true}'
|
|
191
|
+
curl -s -X POST localhost:8765/devices/office-1/light \
|
|
192
|
+
-H 'content-type: application/json' -d '{"brightness": 40, "color_temp": 3000}'
|
|
193
|
+
curl -s -X POST localhost:8765/batch/light \
|
|
194
|
+
-H 'content-type: application/json' -d '{"targets": ["office*"], "brightness": 60}'
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A device is addressed by its short `id` (`office-1`), by its name in the Tapo app (case-insensitive),
|
|
198
|
+
or, in the batch endpoints, by a glob such as `office*`. If a name matches several devices the server
|
|
199
|
+
returns `409` with the candidates instead of guessing.
|
|
200
|
+
|
|
201
|
+
## Security model
|
|
202
|
+
|
|
203
|
+
This server controls physical things, so it refuses by default and widens only on request.
|
|
204
|
+
|
|
205
|
+
| Scope | Allowed |
|
|
206
|
+
|---|---|
|
|
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"]`.
|
|
214
|
+
|
|
215
|
+
The server listens on `127.0.0.1` only. It refuses to start on any other address without a bearer
|
|
216
|
+
token, and without a token it also rejects requests whose `Host` header is not local, which blocks
|
|
217
|
+
DNS-rebinding attacks from a web page.
|
|
218
|
+
|
|
219
|
+
Device names are free text typed by whoever set the device up. The server flattens and shortens them
|
|
220
|
+
before an agent sees them, and the instructions it sends tell the agent to treat names and readings as
|
|
221
|
+
data, not commands. A shared Tapo account still means anyone in it can rename a device, so keep the
|
|
222
|
+
account to people you trust.
|
|
223
|
+
|
|
224
|
+
## How discovery handles a busy network
|
|
225
|
+
|
|
226
|
+
Homes often have many devices, some battery-powered and slow to answer. Discovery is designed for that:
|
|
227
|
+
|
|
228
|
+
- A UDP broadcast, followed by a direct query to each address in the subnet (`discovery.sweep`),
|
|
229
|
+
because some battery cameras ignore broadcasts yet always answer a direct question.
|
|
230
|
+
- Devices seen before are remembered in `~/.cache/tolpo/hosts.json` (address, name and model,
|
|
231
|
+
no secrets) and queried directly at startup. A device missing from one scan is never forgotten;
|
|
232
|
+
entries unseen for 30 days are dropped.
|
|
233
|
+
- Devices that do not answer are retried after 5, 15 and 45 seconds, and again on every rescan.
|
|
234
|
+
|
|
235
|
+
Startup is staged so the server is useful immediately: the first scan runs in the background, and each
|
|
236
|
+
device is connected the moment it answers. A device you name exactly is usable as soon as it is up,
|
|
237
|
+
without waiting for the hub, which can take about ten seconds from cold. Operations that span the whole
|
|
238
|
+
house wait for the scan, but never longer than fifteen seconds; anything still connecting finishes in
|
|
239
|
+
the background.
|
|
240
|
+
|
|
241
|
+
When something cannot be reached, the answers say so. `home_overview` and `list_devices` carry
|
|
242
|
+
`complete: false` and a `not_connected` list with the name, model and reason for each unreachable
|
|
243
|
+
device, so an agent does not mistake an offline door sensor for a closed door.
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
.venv/bin/python -m pytest # runs against simulated devices, no hardware required
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Project layout:
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
src/tolpo/
|
|
255
|
+
registry.py device discovery, state cache, control operations
|
|
256
|
+
policy.py what may be read and written (deny-by-default)
|
|
257
|
+
views.py shared presentation for the HTTP and MCP layers
|
|
258
|
+
api.py FastAPI HTTP server
|
|
259
|
+
mcp_server.py MCP server (stdio)
|
|
260
|
+
config.py config file and environment loading
|
|
261
|
+
compat.py small fixes for devices python-kasa cannot reach yet
|
|
262
|
+
cli.py serve / mcp / scan entry points
|
|
263
|
+
tests/ full suite against simulated devices
|
|
264
|
+
docs/protocols.md how Tapo devices talk on the local network
|
|
265
|
+
assets/ logo, device icons and social preview
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
See [CONTRIBUTING.md](https://github.com/signerless/tolpo/blob/main/CONTRIBUTING.md) for the ground rules, [docs/protocols.md](https://github.com/signerless/tolpo/blob/main/docs/protocols.md) for the protocol notes and
|
|
269
|
+
[SECURITY.md](https://github.com/signerless/tolpo/blob/main/SECURITY.md) to report a vulnerability.
|
|
270
|
+
|
|
271
|
+
## Background
|
|
272
|
+
|
|
273
|
+
The protocols behind Tapo devices are not documented by the vendor. Part of this project is independent
|
|
274
|
+
analysis of the official Tapo app and of real devices, done so that software can interoperate with hardware
|
|
275
|
+
its owners already have. The result is written down in [docs/protocols.md](https://github.com/signerless/tolpo/blob/main/docs/protocols.md): which
|
|
276
|
+
transport each model uses, how discovery works, what the newer TPAP protocol looks like, and what we could not
|
|
277
|
+
establish. The notes are in our own words. They contain no vendor code, keys or credentials, and tolpo itself
|
|
278
|
+
only ever signs in with your own Tapo account.
|
|
279
|
+
|
|
280
|
+
## Compatibility and status
|
|
281
|
+
|
|
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
|
|
284
|
+
the C465, that `python-kasa` cannot reach on its own.
|
|
285
|
+
|
|
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.
|
|
289
|
+
|
|
290
|
+
## License
|
|
291
|
+
|
|
292
|
+
MIT, see [LICENSE](https://github.com/signerless/tolpo/blob/main/LICENSE).
|
|
293
|
+
|
|
294
|
+
This project is not affiliated with or endorsed by TP-Link. Tapo is a trademark of TP-Link. All device
|
|
295
|
+
communication goes through the open source
|
|
296
|
+
[python-kasa](https://github.com/python-kasa/python-kasa) library.
|
|
297
|
+
|
|
298
|
+
<!-- mcp-name: io.github.signerless/tolpo -->
|
package/bin/tolpo.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// tolpo is a Python program. This launcher exists so that `npx tolpo mcp` works: it runs the Python code
|
|
5
|
+
// shipped inside this package with uvx, which builds an isolated environment the first time.
|
|
6
|
+
//
|
|
7
|
+
// The launcher must never write to stdout. With `tolpo mcp`, stdout carries the MCP protocol, and
|
|
8
|
+
// stdin, stdout and stderr are handed to the child process untouched.
|
|
9
|
+
|
|
10
|
+
const { spawn } = require("node:child_process");
|
|
11
|
+
const { constants } = require("node:os");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
|
|
14
|
+
const root = path.resolve(__dirname, "..");
|
|
15
|
+
|
|
16
|
+
const MISSING_UV = `tolpo runs its Python code through uv, and uvx was not found on your PATH.
|
|
17
|
+
|
|
18
|
+
Install uv, then run this again:
|
|
19
|
+
macOS and Linux: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
20
|
+
Homebrew: brew install uv
|
|
21
|
+
Windows: powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
22
|
+
|
|
23
|
+
More options: https://docs.astral.sh/uv/getting-started/installation/
|
|
24
|
+
If an editor or an AI client starts tolpo, make sure it can see uv: use the full path to uvx or add its
|
|
25
|
+
folder to the PATH that the client uses.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const child = spawn("uvx", ["--from", root, "tolpo", ...process.argv.slice(2)], { stdio: "inherit" });
|
|
29
|
+
|
|
30
|
+
child.on("error", (error) => {
|
|
31
|
+
if (error.code === "ENOENT") {
|
|
32
|
+
process.stderr.write(MISSING_UV);
|
|
33
|
+
process.exit(127);
|
|
34
|
+
}
|
|
35
|
+
process.stderr.write(`tolpo could not start uvx: ${error.message}\n`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
child.on("exit", (code, signal) => {
|
|
40
|
+
if (signal) {
|
|
41
|
+
process.exit(128 + (constants.signals[signal] || 0));
|
|
42
|
+
}
|
|
43
|
+
process.exit(code === null ? 1 : code);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
47
|
+
process.on(signal, () => child.kill(signal));
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tolpo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-first MCP server and HTTP API that let AI agents read and control TP-Link Tapo smart-home devices",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "signerless",
|
|
7
|
+
"homepage": "https://github.com/signerless/tolpo#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/signerless/tolpo.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/signerless/tolpo/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"tapo",
|
|
19
|
+
"tp-link",
|
|
20
|
+
"smart-home",
|
|
21
|
+
"home-automation",
|
|
22
|
+
"ai-agents",
|
|
23
|
+
"local-first"
|
|
24
|
+
],
|
|
25
|
+
"mcpName": "io.github.signerless/tolpo",
|
|
26
|
+
"bin": {
|
|
27
|
+
"tolpo": "bin/tolpo.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"src/tolpo/*.py",
|
|
32
|
+
"pyproject.toml",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node --test"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tolpo"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Local-first MCP server and HTTP API that let AI agents read and control TP-Link Tapo smart-home devices"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "signerless" }]
|
|
9
|
+
keywords = [
|
|
10
|
+
"mcp",
|
|
11
|
+
"model-context-protocol",
|
|
12
|
+
"tapo",
|
|
13
|
+
"tp-link",
|
|
14
|
+
"smart-home",
|
|
15
|
+
"home-automation",
|
|
16
|
+
"ai-agents",
|
|
17
|
+
"local-first",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Intended Audience :: End Users/Desktop",
|
|
23
|
+
"Topic :: Home Automation",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Framework :: AsyncIO",
|
|
30
|
+
"Framework :: FastAPI",
|
|
31
|
+
]
|
|
32
|
+
requires-python = ">=3.11"
|
|
33
|
+
dependencies = [
|
|
34
|
+
"python-kasa>=0.11",
|
|
35
|
+
"fastapi>=0.115",
|
|
36
|
+
"uvicorn>=0.30",
|
|
37
|
+
"mcp>=2.3",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/signerless/tolpo"
|
|
42
|
+
Documentation = "https://github.com/signerless/tolpo#readme"
|
|
43
|
+
Repository = "https://github.com/signerless/tolpo"
|
|
44
|
+
Issues = "https://github.com/signerless/tolpo/issues"
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
dev = ["pytest>=8", "httpx>=0.27"]
|
|
48
|
+
|
|
49
|
+
[project.scripts]
|
|
50
|
+
tolpo = "tolpo.cli:main"
|
|
51
|
+
|
|
52
|
+
[build-system]
|
|
53
|
+
requires = ["hatchling"]
|
|
54
|
+
build-backend = "hatchling.build"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.version]
|
|
57
|
+
path = "src/tolpo/__init__.py"
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.wheel]
|
|
60
|
+
packages = ["src/tolpo"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
package/src/tolpo/api.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""HTTP surface. Small on purpose: list, inspect, switch, dim, and a guarded generic setter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI, Header, HTTPException
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
11
|
+
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .registry import Registry, RegistryError
|
|
16
|
+
from .views import BatchItem, DeviceDetail, DeviceSummary, detail, run_batch, summary
|
|
17
|
+
|
|
18
|
+
DESCRIPTION = """
|
|
19
|
+
Local control of smart-home devices for AI agents.
|
|
20
|
+
|
|
21
|
+
Refer to a device by its short `id` (`office-1`), by its name as shown in the app
|
|
22
|
+
(case-insensitive), or by a glob (`office*`) in the batch endpoints.
|
|
23
|
+
|
|
24
|
+
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.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PowerBody(BaseModel):
|
|
30
|
+
on: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LightBody(BaseModel):
|
|
34
|
+
on: bool | None = Field(None, description="Also switch the light on (true) or off (false)")
|
|
35
|
+
brightness: int | None = Field(None, ge=1, le=100)
|
|
36
|
+
color_temp: int | None = Field(None, ge=1000, le=12000, description="Kelvin; white light")
|
|
37
|
+
hue: int | None = Field(None, ge=0, le=360)
|
|
38
|
+
saturation: int | None = Field(None, ge=0, le=100)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FeatureBody(BaseModel):
|
|
42
|
+
value: JsonValue = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BatchPower(PowerBody):
|
|
46
|
+
targets: list[str] = Field(min_length=1, examples=[["office*", "Desk Lamp"]])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BatchLight(LightBody):
|
|
50
|
+
targets: list[str] = Field(min_length=1, examples=[["office*"]])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_app(
|
|
54
|
+
cfg: Config,
|
|
55
|
+
registry: Registry | None = None,
|
|
56
|
+
*,
|
|
57
|
+
discover: bool = True,
|
|
58
|
+
allowed_hosts: list[str] | None = None,
|
|
59
|
+
) -> FastAPI:
|
|
60
|
+
reg = registry or Registry(cfg)
|
|
61
|
+
|
|
62
|
+
@asynccontextmanager
|
|
63
|
+
async def lifespan(_: FastAPI):
|
|
64
|
+
await reg.start(discover=discover)
|
|
65
|
+
yield
|
|
66
|
+
await reg.stop()
|
|
67
|
+
|
|
68
|
+
app = FastAPI(title="tolpo", version=__version__, description=DESCRIPTION, lifespan=lifespan)
|
|
69
|
+
|
|
70
|
+
if not cfg.token:
|
|
71
|
+
# Without a token the only protection is the loopback bind, so also refuse requests
|
|
72
|
+
# whose Host header is not local (blocks DNS-rebinding from a web page).
|
|
73
|
+
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts or ["localhost", "127.0.0.1"])
|
|
74
|
+
|
|
75
|
+
async def require_token(authorization: str | None = Header(default=None)) -> None:
|
|
76
|
+
if not cfg.token:
|
|
77
|
+
return
|
|
78
|
+
scheme, _, supplied = (authorization or "").partition(" ")
|
|
79
|
+
if scheme.lower() != "bearer" or not secrets.compare_digest(supplied.encode(), cfg.token.encode()):
|
|
80
|
+
raise HTTPException(401, "missing or invalid bearer token", headers={"WWW-Authenticate": "Bearer"})
|
|
81
|
+
|
|
82
|
+
auth = [Depends(require_token)]
|
|
83
|
+
|
|
84
|
+
@app.exception_handler(RegistryError)
|
|
85
|
+
async def on_registry_error(_, exc: RegistryError):
|
|
86
|
+
return JSONResponse(status_code=exc.status_code, content={"error": str(exc), "candidates": exc.candidates})
|
|
87
|
+
|
|
88
|
+
@app.get("/health")
|
|
89
|
+
async def health():
|
|
90
|
+
return {"status": "ok", "version": __version__}
|
|
91
|
+
|
|
92
|
+
@app.get("/status", dependencies=auth, summary="Discovery status and devices that are offline")
|
|
93
|
+
async def status():
|
|
94
|
+
return reg.health()
|
|
95
|
+
|
|
96
|
+
@app.post("/scan", dependencies=auth, summary="Search the network for new or moved devices")
|
|
97
|
+
async def scan():
|
|
98
|
+
await reg.discover()
|
|
99
|
+
return reg.health()
|
|
100
|
+
|
|
101
|
+
@app.get("/devices", response_model=list[DeviceSummary], dependencies=auth, summary="List devices")
|
|
102
|
+
async def list_devices(kind: str | None = None, q: str | None = None, fresh: bool = False):
|
|
103
|
+
"""Filter by `kind` (light, plug, sensor, camera...) or by text in the name with `q`.
|
|
104
|
+
State is cached for a few seconds; pass `fresh=true` to ask the devices again."""
|
|
105
|
+
items = reg.entries(kind=kind, q=q)
|
|
106
|
+
await reg.refresh_all(items, force=fresh)
|
|
107
|
+
return [summary(reg, e) for e in items]
|
|
108
|
+
|
|
109
|
+
@app.get("/devices/{ref}", response_model=DeviceDetail, dependencies=auth, summary="Full detail of one device")
|
|
110
|
+
async def get_device(ref: str):
|
|
111
|
+
entry = reg.resolve_one(ref)
|
|
112
|
+
await reg.refresh(entry)
|
|
113
|
+
return detail(reg, entry)
|
|
114
|
+
|
|
115
|
+
@app.post("/devices/{ref}/power", response_model=DeviceSummary, dependencies=auth, summary="Switch on or off")
|
|
116
|
+
async def power(ref: str, body: PowerBody):
|
|
117
|
+
entry = reg.resolve_one(ref)
|
|
118
|
+
await reg.set_power(entry, body.on)
|
|
119
|
+
return summary(reg, entry)
|
|
120
|
+
|
|
121
|
+
@app.post("/devices/{ref}/toggle", response_model=DeviceSummary, dependencies=auth, summary="Flip the power state")
|
|
122
|
+
async def toggle(ref: str):
|
|
123
|
+
entry = reg.resolve_one(ref)
|
|
124
|
+
await reg.toggle(entry)
|
|
125
|
+
return summary(reg, entry)
|
|
126
|
+
|
|
127
|
+
@app.post("/devices/{ref}/light", response_model=DeviceSummary, dependencies=auth, summary="Brightness and color")
|
|
128
|
+
async def light(ref: str, body: LightBody):
|
|
129
|
+
entry = reg.resolve_one(ref)
|
|
130
|
+
await reg.set_light(entry, **body.model_dump())
|
|
131
|
+
return summary(reg, entry)
|
|
132
|
+
|
|
133
|
+
@app.put(
|
|
134
|
+
"/devices/{ref}/features/{feature_id}",
|
|
135
|
+
response_model=DeviceSummary,
|
|
136
|
+
dependencies=auth,
|
|
137
|
+
summary="Set any feature the policy allows",
|
|
138
|
+
)
|
|
139
|
+
async def put_feature(ref: str, feature_id: str, body: FeatureBody):
|
|
140
|
+
entry = reg.resolve_one(ref)
|
|
141
|
+
await reg.set_feature(entry, feature_id, body.value)
|
|
142
|
+
return summary(reg, entry)
|
|
143
|
+
|
|
144
|
+
@app.post("/batch/power", response_model=list[BatchItem], dependencies=auth, summary="Switch several devices")
|
|
145
|
+
async def batch_power(body: BatchPower):
|
|
146
|
+
return await run_batch(reg, body.targets, lambda e: reg.set_power(e, body.on))
|
|
147
|
+
|
|
148
|
+
@app.post("/batch/light", response_model=list[BatchItem], dependencies=auth, summary="Set several lights")
|
|
149
|
+
async def batch_light(body: BatchLight):
|
|
150
|
+
settings = body.model_dump(exclude={"targets"})
|
|
151
|
+
return await run_batch(reg, body.targets, lambda e: reg.set_light(e, **settings))
|
|
152
|
+
|
|
153
|
+
return app
|
|
154
|
+
|