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 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
- # Temporary Holding Version
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
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ [![CI](https://github.com/signerless/tolpo/actions/workflows/ci.yml/badge.svg)](https://github.com/signerless/tolpo/actions/workflows/ci.yml)
13
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/signerless/tolpo/blob/main/LICENSE)
14
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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
+