A MicroPython server that exposes ESP32 GPIO control through a REST API.
Runs on an ESP32, ESP32-C3 or ESP32-S3 with MicroPython 1.29, API-key authentication, and machine.Pin GPIO.
It is the ESP32 port of pi-gpio-api, with the same endpoints and wire format.
Looking for AI-agent / MCP control, Apple HomeKit or Google Home? See the Related projects below that drive one or more of these API servers (Pi, Pico, or ESP32) over their REST endpoints.
Requirements: MicroPython 1.29 on an ESP32, ESP32-C3 or ESP32-S3 (the ESP32_GENERIC, ESP32_GENERIC_C3 or ESP32_GENERIC_S3 firmware), Python 3, and a Mac or Linux computer.
Run install.sh on the computer with the ESP32 connected over USB.
One-liner, no git required - downloads the release files into ./esp32-gpio-api:
curl -fsSL https://raw.githubusercontent.com/ctrlpi/esp32-gpio-api/main/install.sh | bashinstall.sh preserves device config.json and wifi.txt, asking only for missing values. It deploys every detected USB ESP32 and removes locally staged credentials afterward.
Re-running ./install.sh uses local files. It runs build.sh only when app.mpy or upgrader.mpy is missing or older than its source.
./install.sh --config: re-ask for the API key/agent name and push it, even if the device already hasconfig.json../install.sh --wifi: re-ask for WiFi SSID/password and push it, even if the device already haswifi.txt../install.sh --upgrade: download all release files, includingapp.mpy, before deployment.
git clone https://github.com/ctrlpi/esp32-gpio-api.git
cd esp32-gpio-api
./install.shOr copy boot.py, app.mpy, upgrader.mpy, main.py, and wifi.txt to the ESP32 manually.
SSID = YourNetwork
PASSWORD = YourPassword
boot.py reads this file before loading the application. The API key defaults to "your-secret-key"; change it via POST /config/update or config.json.
MicroPython runs boot.py and main.py automatically. boot.py reserves Wi-Fi memory, then main.py loads app.mpy (or upgrader.mpy during an upgrade).
The server binds 0.0.0.0 (all interfaces) and port 8314, so it is reachable on your local network; keep it behind a trusted LAN (see the No-TLS note under Authentication).
Replace <esp32-ip> with the board address and your-secret-key with your API key.
curl http://<esp32-ip>:8314/hellocurl http://<esp32-ip>:8314/gpio/read -H "Api-Key: your-secret-key"curl -X POST http://<esp32-ip>:8314/gpio/write/26 \
-H "Api-Key: your-secret-key" -H "Content-Type: application/json" \
-d '{"value": 1, "duration": 2}'curl -X POST http://<esp32-ip>:8314/gpio/config/26 \
-H "Api-Key: your-secret-key" -H "Content-Type: application/json" \
-d '{"name": "relay", "type": "output", "reversed": true, "max": 3600}'curl http://<esp32-ip>:8314/gpio/scan -H "Api-Key: your-secret-key"curl -X POST http://<esp32-ip>:8314/gpio/config/18 \
-H "Api-Key: your-secret-key" -H "Content-Type: application/json" \
-d '{"type": "input", "watched": true}'curl -X POST http://<esp32-ip>:8314/config/update \
-H "Api-Key: your-secret-key" -H "Content-Type: application/json" \
-d '{"webhook_url": "https://example.com/webhook"}'# cpu-temp.py is the example script install.sh seeds into scripts/
curl -X POST http://<esp32-ip>:8314/sensor/config/cpu_temp \
-H "Api-Key: your-secret-key" -H "Content-Type: application/json" \
-d '{"script": "cpu-temp.py"}'curl -H "Api-Key: your-secret-key" http://<esp32-ip>:8314/sensor/read/cpu_tempAll endpoints require an Api-Key header except /hello.
Api-Key: your-secret-key
No TLS. The server speaks plain HTTP, so the key travels as plaintext on the wire. That's fine on a trusted LAN, but don't expose port 8314 directly to the internet. If you need remote access, tunnel it.
Identical wire format and behavior to pi-gpio-api. The ESP32 has no interactive /docs or /redoc.
A single unauthenticated endpoint for discovery and liveness checks; it reports the agent's name.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/hello |
none | Returns {"name": "agent-name"}, used for discovery and health checks. If name is not configured will return esp32-<serial4> (e.g. esp32-1a2b), built from the last 4 of the chip UID. |
The core of the API: read, write, configure, and watch GPIO pins. Every pin is addressed by its GPIO number or a configured name alias.
| Method | Path | Description |
|---|---|---|
GET |
/gpio/read |
Read all configured pins |
GET |
/gpio/read/{name_or_gpio} |
Read a single pin by name alias or GPIO number |
POST |
/gpio/write/{name_or_gpio} |
Write a value to a pin |
POST |
/gpio/config/{name_or_gpio} |
Configure a pin (name, type, init, pullup, max, reversed, watched) |
GET |
/gpio/watched |
List all currently watched pins, the active webhook URL, and the bridges callback URLs (URLs only, never the keys) |
GET |
/gpio/scan |
Scan the usable GPIOs of the chip it runs on: classic ESP32 0, 2, 4, 5, 12-15, 18, 19, 21-23, 25-27 and 32-39 (GPIO34-39 are input-only and have no internal pull resistors); ESP32-C3 0-10, 20 and 21; ESP32-S3 0-18, 21 and 38-48. Flash, PSRAM, USB and REPL pins are excluded. Returns physical level for every pin, logical value and reversed only for a reversed pin, and the live agent block. |
Status LED. The agent pulses the board's LED to show it is running and on WiFi: GPIO2 on a classic ESP32, GPIO8 on an ESP32-C3 (active low), and the WS2812 RGB LED on GPIO48 on an ESP32-S3 (green). Reading or scanning that pin never reconfigures it, so the pulse keeps going.
{
"value": 1,
"duration": 2.5
}| Field | Values | Description |
|---|---|---|
value |
0, 1, "on", "off", "toggle" |
Value to write. "on"/"off" are word spellings of 1/0, "toggle" flips the current state. Strings are case-insensitive. A JSON boolean (true/false) also works, coerced to 1/0. Any other integer counts as 1 if nonzero, 0 only if exactly 0 (so a negative value like -1, used by some systems for true, also means "on"). |
duration |
float (seconds) | Optional. Pulse the pin for this many seconds, then revert. If the pin has max configured, a larger duration is clamped to max, and a write without duration uses max as the duration (it pulses and reverts, it does not latch). |
Writing to an unconfigured pin auto-configures it as an output first. A pin already configured as anything else (input, vcc, gnd) is never silently flipped; the write returns 400. Reconfigure it as output via /gpio/config first.
{
"name": "relay",
"type": "output",
"init": 0,
"pullup": "up",
"reversed": true,
"max": 5.0,
"watched": true
}| Field | Values | Description |
|---|---|---|
name |
string | Name alias for the pin, usable in place of the GPIO number in all endpoints. |
type |
input, output, vcc, gnd, remove |
Pin direction. vcc/gnd are permanently driven and cannot be written or watched. remove deletes the pin config. Changing type clears fields that no longer apply (see below). |
init |
0, 1, "on", "off", "last", "restore" |
Startup behavior, also applied on every live /gpio/config call. Outputs: drive to value. Inputs: compare to reference and fire on mismatch. "on"/"off" are word spellings of 1/0, and "restore" is a synonym for "last", which uses the value from the last run. Strings are case-insensitive. A JSON boolean (true/false) also works, coerced to 1/0. Any other integer counts as 1 if nonzero, 0 only if exactly 0 (so 2 and -1 both mean "on", matching /gpio/write). All of this is stored canonically, so /config/read and /gpio/read always answer 0, 1 or "last". Unknown words are rejected, not stored and ignored. On an input the reference is compared against the pin's reported (logical) value, so pullup and reversed are already accounted for. If omitted on an output: a pin that's already configured as output keeps its current value unchanged; a pin that's newly becoming an output (was input, vcc, gnd or unconfigured) starts at 0. Cleared automatically when the pin's type changes. |
pullup |
"up", "down", "none" |
Pull resistor (inputs only). Cleared automatically when type is set to output, vcc, or gnd. |
max |
float (seconds) | Maximum pulse duration (outputs only). Writes with a larger duration are clamped to it, and writes with no duration use it as the duration (see POST /gpio/write above). Cleared automatically when type is set to input or when type is set to output if the value is 0 or unset. |
reversed |
boolean | Inverts the logical value. Output: writes the boolean flip to the physical GPIO pin (physical HIGH = logical 0). Input: flips the reported value. Cleared automatically when type is set to vcc or gnd. |
watched |
boolean | true to send a webhook on every write (outputs) or state change (inputs); false to stop. Cleared automatically when type is set to vcc or gnd. Webhooks are only sent when a webhook_url is configured. With none set, each watched event is recorded in the agent log instead (see Webhooks). |
A sensor is a named reading the agent computes on demand by running a script, a way to surface values GPIO pins can't carry (I2C sensors, 1-Wire probes, an on-chip metric, the output of any MicroPython module).
| Method | Path | Description |
|---|---|---|
GET |
/sensor/read |
Read every configured sensor, merged into one JSON object |
GET |
/sensor/read/{name} |
Read one sensor by name |
POST |
/sensor/config/{name} |
Configure a named sensor |
{
"script": "cpu-temp.py"
}Or, to delete the sensor:
{
"remove": true
}Sensors are pure config plus on-demand reads: no pins, no background polling, no extra persisted state beyond config.json.
Scripts live in the ESP32's scripts/ folder. install.sh creates it and seeds cpu-temp.py; upload another script with mpremote connect <port> fs cp weather.py :scripts/weather.py, or over the API (see Scripts).
The ESP32 has no shell, so a sensor script is a MicroPython .py file executed in-process. Its script value is "<file.py> [args...]"; the file must be a bare filename in scripts/. It should define read(*args) or set result. Arguments may contain letters and digits only, and results may be any JSON-serializable value.
# The same script with arguments (letters and digits only)
curl -X POST http://<esp32-ip>:8314/sensor/config/zone1 \
-H "Api-Key: your-secret-key" -H "Content-Type: application/json" \
-d '{"script": "weather.py zone1"}'scripts/ is the only folder a sensor can run code from. Paths, .., hidden files, and subfolders are rejected. Once a key holder can write scripts (see Scripts), they can run any code on the board: lock writes with write.lock when you don't need them.
Reading returns a clear message instead of failing when a script is missing or broken: {"<name>": "script not found"}, {"<name>": "script failed or did not produce output"} when the script errors or its read()/result yields nothing, and {"<name>": "blocked: <reason>"} when a sensor violates the name/argument rules (possible when sensors arrive via /config/load or an edited config.json; every read re-validates).
GET /sensor/read/{name} reads one sensor; GET /sensor/read reads them all, merged into one object. The output is always JSON, and a reading is always nested under the sensor's own name - one key per sensor, whatever the script returned:
- A JSON object with several fields nests whole:
{ "<name>": { ... } }. - A JSON object with a single field contributes just that field's value:
{ "<name>": <value> }(the field's own key is dropped - a script that returns{"temp": 47.8}for a sensor namedcpu_tempreads back as{"cpu_temp": 47.8}). - Anything else (a non-JSON answer such as a bare number or string) is wrapped the same way; numbers stay numbers - a script returning
47.8for a sensor namedcpu_tempreads back as{ "cpu_temp": 47.8 }(a number, not a string), and a script returningokfor a sensor namedstatusreads back as{ "status": "ok" }.
Because every sensor owns exactly one key in that merge, two sensors emitting the same field name can never overwrite each other. An unknown sensor name returns 404.
curl -H "Api-Key: your-secret-key" http://<esp32-ip>:8314/sensor/read
# → {"weather": {"pressure": 1013, "humidity": 62}, "probe": 21.4}List, read, write and run the files in scripts/, the same folder sensors run from.
| Method | Path | Description |
|---|---|---|
GET |
/script/list |
Every file in scripts/, plus whether writes are allowed |
GET |
/script/read/{name} |
One file's text |
POST |
/script/write/{name} |
Create or overwrite a file, or delete it |
POST |
/script/run/{name} |
Run a file and return its output and exit code |
{name} is a bare filename of up to 32 characters: letters, digits, ., _ and -, no leading dot or -, so no paths, no .. and no subfolders (400 otherwise). Subfolders of scripts/ are never listed, read, written or run. A file that isn't there returns 404.
{
"content": "def read(*args):\n return 42\n"
}Or, to delete the file:
{
"remove": true
}Writing an existing name replaces it. The new text is written to a temporary file and renamed into place, so a run never sees half a script. A script can be up to about 8 KB (the request limit).
The body is optional:
{
"args": ["21"]
}Arguments may contain letters and digits only.
curl -H "Api-Key: <key>" http://<esp32-ip>:8314/script/list
# → {"scripts": [{"name": "cpu-temp.py", "size": 333, "modified": "2026-10-24 09:12:03"}], "write": "enabled"}
curl -H "Api-Key: <key>" http://<esp32-ip>:8314/script/read/cpu-temp.py
# → {"name": "cpu-temp.py", "content": "..."}
curl -X POST -H "Api-Key: <key>" -H "Content-Type: application/json" \
-d '{"content": "result = 42"}' http://<esp32-ip>:8314/script/write/answer.py
# → {"name": "answer.py", "size": 11} (delete: {"name": "answer.py", "status": "removed"} or "not found")
curl -X POST -H "Api-Key: <key>" http://<esp32-ip>:8314/script/run/answer.py
# → {"name": "answer.py", "exit_code": 0, "output": "42"}A run works like a sensor read: the file is exec'd in-process as MicroPython, and output is what its read(*args) returns, or its result, as text (a string as is, anything else as JSON; first 2 KB, trailing whitespace trimmed). print() goes to the USB console, not to output. A script that raises returns exit_code 1 and "<Error>: <message>" as output; otherwise exit_code is 0. There is no timeout: a script that never returns stalls the agent, as a sensor would.
list, read and run always work. /script/write (including a delete) returns 403 when:
- the agent's
api_keyis still the shipped defaultyour-secret-key("write": "default_key"in the list), so set your own key first; - a file named
write.lockis inscripts/("write": "locked"). Its content doesn't matter. The lock coverswrite.locktoo:/script/writecan't change or delete it.
A fresh install has no lock. To lock, write the file over the API:
curl -X POST -H "Api-Key: <key>" -H "Content-Type: application/json" -d '{"content": ""}' http://<esp32-ip>:8314/script/write/write.lockTo unlock, remove it with the ESP32 on USB (+ reset restarts the agent, which mpremote stops):
mpremote connect <port> fs rm :scripts/write.lock + resetOr run ./install.sh --unlock in the esp32-gpio-api folder: it installs nothing, removes the lock from every ESP32 on USB and resets each one.
The lock blocks writes over the API, not code. /script/run still works while locked, and a script runs inside the agent with access to the whole filesystem, so one that deletes files can remove write.lock. A lock keeps the folder read-only only if you trust the scripts already in it.
write.lock shows up in /script/list like any other file, and running it returns 400.
Read and change the agent's own configuration, and drive process and device lifecycle (restart, upgrade, logs).
| Method | Path | Description |
|---|---|---|
GET |
/config/read |
Read the agent identity + full config (see response fields below) |
POST |
/config/update |
Update one or more config fields (backs up the current config first) |
POST |
/config/load |
Load a full config from a JSON dict (backs up the current config first; pass {} to reset) |
POST |
/upgrade |
Restart into upgrader.mpy, which downloads and transactionally installs app.mpy, boot.py, upgrader.mpy, and main.py, then starts the agent (see below) |
POST |
/restart |
Restart the device. Body is optional; pass {"reboot": true} to request a full hardware reset instead (see below) |
GET |
/logs |
Last 30 log lines as a JSON array (in-memory ring buffer, cleared on reboot; the boot lines say why the board last reset) |
Restart/reboot (/restart): a plain /restart reloads the agent in place (config, pins and watches are set up again, and Wi-Fi stays connected). {"reboot": true} does a full hardware reset with machine.reset(). Both reply first, with {"status": "restarting"} or {"status": "rebooting"}.
Self-upgrade (/upgrade) runs in its own boot, so the download has the memory a running agent no longer has. The agent writes upgrade.lock, replies {"status": "upgrading"} and soft-resets (Wi-Fi stays connected); main.py then loads upgrader.mpy instead of the agent. The upgrader downloads app.mpy, boot.py, upgrader.mpy, and main.py from the GitHub release branch, verifies every download before replacing anything, backs up each installed file as <name>.backup, verifies the installed copies, and restores the previous set if installation fails. Then it removes the lock and starts the agent, which logs the outcome once: [UPGRADE] Done, now <version>: ... or [UPGRADE] Failed, still <version>: .... A failed attempt resets and tries again, up to 3 times; after that the board keeps the version it had. An upgrade usually takes about 10 seconds. Configuration, Wi-Fi credentials, status, and sensor scripts are untouched.
Upgrading from an older version: /upgrade runs from upgrader.mpy, which older versions don't install, and a board without it answers /upgrade with 500. Install once over USB with install.sh; /upgrade works from then on.
Returns the agent's identity plus its full config:
| Field | Description |
|---|---|
name |
Configured name, otherwise esp32-<serial4>. Matches /hello. |
agent |
Live facts: platform, MicroPython os, host ("esp32"), chip serial, ip, Wi-Fi mac, SSID, signal strength, internal chip temperature in °C, uptime, API version, and config modification time. This block is computed on read and never stored. |
notifications |
The general-purpose webhook target: webhook and webhook_key. |
bridges |
The three bridge callbacks: homekit, homekit_key, matter, matter_key, homebridge, homebridge_key (see Bridge callbacks). |
settings |
Operational toggles: logs_enabled, log_days. |
sensors |
Named-sensor configuration map (see Sensors). |
gpios |
Per-pin configuration map (see GPIO; always the last field). |
Send fields ungrouped, at the top level - the agent routes each one into its group,
so a later /config/read shows them nested:
{
"name": "my-esp32",
"webhook_url": "https://example.com/webhook",
"webhook_key": "",
"matter": "",
"matter_key": "",
"homekit": "",
"homekit_key": "",
"homebridge": "",
"homebridge_key": "",
"logs_enabled": true,
"log_days": 7
}| Sent at the root | Lands in |
|---|---|
webhook_url, webhook_key |
notifications.webhook, notifications.webhook_key |
matter, matter_key, homekit, homekit_key, homebridge, homebridge_key |
bridges.* |
logs_enabled, log_days |
settings.* |
name, api_key |
top level, ungrouped |
The grouped spelling works too ({"settings": {"log_days": 5}}), and either way each group
merges key by key - setting log_days never resets logs_enabled, and a bridge pushing
its own URL never clears the other bridge's. /config/load accepts both forms as well, and
always writes config.json in the grouped shape.
Keys, names, URLs and scripts are always text: a number sent for one is stored as text
(4242 becomes "4242"), on /config/update, /config/load, /gpio/config and /sensor/config.
webhook_key is a dedicated key for the webhook target (see Webhooks below); it is not the agent's own api_key. An empty string clears it, same as webhook_url.
The live agent block is read-only: if it is present in an update or a whole-config load it is dropped rather than stored, so a read-modify-load round trip can post /config/read straight back.
Log retention is controlled by the log_days config field (default 7, range 0-30, clamped). An hourly background pass trims the in-memory log buffer down to that many days; setting log_days to 0 clears the buffer immediately and then keeps only the last hour on each pass.
Error responses are JSON {"error": ...}, except 404 which has an empty body:
| Status | When | Body |
|---|---|---|
400 |
Bad write/config or a request over 8 KB | {"error": "<reason>"}; oversized requests return {"error": "Bad request"} |
403 |
Missing or wrong Api-Key header |
{"error": "Invalid or missing Api-Key header"} |
403 |
/script/write refused (default key, or write.lock in place; see Scripts) |
{"error": "<reason>"} |
404 |
Unknown pin/name, or unknown route | empty |
422 |
Malformed JSON or a missing required field | {"error": "<reason>"} |
When a pin has watched: true, the server POSTs the pin's state to webhook_url on every write (outputs) or state change (inputs: every edge, the primary use case):
{ "agent": "esp32-1a2b", "gpio": 17, "value": 1, "type": "output", "name": "relay" }For reversed pins the payload also includes level (the physical GPIO state).
No webhook URL is configured by default. When a watched event occurs with no target at all configured (no webhook_url and no bridge callback), nothing is sent; the event is recorded in the agent log instead.
A TLS handshake needs roughly 35-40 KB of contiguous heap. A classic ESP32 has only about 24 KB of system memory free after boot, with no block over about 19 KB, so an https webhook on a classic ESP32 fails on every attempt with an out-of-memory error, in a way no amount of garbage collection fixes.
The ESP32-C3 has more memory, but not enough once the agent is running: its handshakes fail for lack of memory too, and a failed handshake can leave the board unresponsive until it resets.
As a result, on the classic ESP32 and the ESP32-C3, webhook_url must be http://. An https:// URL is rejected with a 400, and is not stored.
The ESP32-S3 has the memory headroom and is unaffected: https:// works there.
This applies to webhook_url only. The bridges.homekit / bridges.matter / bridges.homebridge callbacks are LAN targets that are already plain http, and plain http is unaffected on every chip.
POST /upgrade downloads over https too, but in its own boot without the agent loaded, which leaves a classic ESP32 the most memory it can have for the handshake. If the handshake still fails, the upgrader tries 3 times and the board keeps its current version, with the reason in /logs. Install over USB with install.sh instead (or copy the files with mpremote).
A failed delivery is retried twice more, 30 seconds apart (3 attempts in total, per target). A
non-2xx answer counts as a failure just like a refused connection - which is what carries an event
across a bridge restart: the bridge answers 401 until it has re-pushed its key onto this agent
(it does that off the first rejected event), so the retry 30s later is the one that lands. Each
attempt is logged, with the reason and whether another is coming.
Three extra callback URLs, one owned by each smart-home bridge, sit alongside webhook_url:
homekit for the standalone homekit-bridge, matter for matter-bridge, and homebridge
for the homebridge-ctrlpi plugin. They are additive: a watched event is POSTed to every
configured target, with the same payload, so the bridges and webhook_url never compete.
homebridge has its own slot, so the Homebridge plugin and the standalone homekit-bridge
can drive the same agent at once.
curl -X POST http://<host>:8314/config/update \
-H "Api-Key: your-secret-key" -H "Content-Type: application/json" \
-d '{"matter": "http://192.168.1.50:8317/webhook?agent=esp32"}'homekit-bridge, matter-bridge and homebridge-ctrlpi set these themselves at startup, so
you normally never touch them by hand. An empty string clears one. All three are reported by /config/read and
/gpio/watched under bridges (the latter carries the URLs only, never the keys). The group
merges key by key, so setting one bridge's fields never disturbs another's.
Each callback has its own key field, homekit_key / matter_key / homebridge_key, set in
the same call:
curl -X POST http://<host>:8314/config/update \
-H "Api-Key: your-secret-key" -H "Content-Type: application/json" \
-d '{"matter": "http://192.168.1.50:8317/webhook?agent=esp32", "matter_key": "Kp3xR9tLmQ7z"}'When set, that key is sent as the Api-Key header on that bridge's callbacks (and nowhere
else) so the bridge can tell a real event from one anybody on the LAN made up - it answers
401 and logs the attempt otherwise. The bridges generate a random 12-character key per
agent on every start and push it here themselves, so a key left behind in this file is
worthless the moment the bridge restarts. As with webhook_key, this is never the
agent's own api_key: that key can never leak to a bridge. An empty string clears one, which
goes back to unauthenticated callbacks.
On startup, the server also POSTs a one-off boot notification to every configured target:
{ "agent": "esp32-1a2b", "note": "Up and running" }It carries no pin fields; consumers of the webhook feed should treat entries with a note field as status messages, not pin events. The same webhook_key rule below applies.
If a dedicated webhook_key is configured, it is sent as the Api-Key header on every webhook POST; if not, no Api-Key header is sent at all. The agent's own api_key is never sent to the webhook target. The public https://hooks.ctrlpi.com/webhook/test test endpoint requires no key, it will display whatever key was sent for debugging.
For development and testing, ctrlPi provides a public test endpoint: point webhook_url at https://hooks.ctrlpi.com/webhook/test and monitor the incoming events live at https://ctrlpi.com/webhook/watch. The feed is public and events are retained only briefly, so use it for verification during setup, not as a production target. (On a classic ESP32 or an ESP32-C3 this target is unreachable - see https targets above.)
Change it by setting webhook_url (and optionally webhook_key) via POST /config/update:
curl -X POST http://<esp32-ip>:8314/config/update \
-H "Api-Key: your-secret-key" -H "Content-Type: application/json" \
-d '{"webhook_url": "https://example.com/webhook", "webhook_key": "target-key"}'The repository contains source, build, deployment, and device runtime files.
| File | Contents |
|---|---|
app.py |
Application source: Wi-Fi, HTTP API, GPIO, sensors, webhooks, and state |
app.mpy |
Precompiled application deployed to the ESP32 |
upgrader.py |
Over-the-air upgrade source, run in its own boot by /upgrade |
upgrader.mpy |
Precompiled upgrader deployed to the ESP32 |
boot.py |
Starts Wi-Fi before the application loads |
main.py |
Small loader that imports app.mpy, or upgrader.mpy while upgrade.lock exists |
build.sh |
Builds app.mpy and upgrader.mpy with MicroPython 1.29 mpy-cross |
install.sh |
Builds when needed, collects missing settings, and deploys over USB |
wifi.txt |
WiFi credentials (not committed; see wifi.txt.example) |
config.json |
API key, pin configs, feature flags, webhook URL. Config backup is written automatically to config-backup.json and read on boot if config.json exists but fails to parse/validate |
status.json |
Last known pin values, restored on boot when init: "last". Inputs are recorded too, so a watched input's init: "last" reference has a previous reading to compare against |
last-reset.txt |
Why the agent last reset itself, logged and deleted at the next boot |
scripts/ |
Sensor scripts; install.sh seeds ESP32 chip-temperature support |
| Feature | Pi (pi-gpio-api) |
ESP32 (esp32-gpio-api) |
|---|---|---|
| GPIO range | BCM 2-27 | Per chip: classic ESP32 0, 2, 4, 5, 12-15, 18, 19, 21-23, 25-27, 32-39 (34-39 input-only); C3 0-10, 20, 21; S3 0-18, 21, 38-48 |
| GPIO | gpiozero |
machine.Pin |
| Sensor scripts | .sh, run as sh scripts/<file> |
.py, exec'd in-process |
Interactive API docs (/docs, /redoc) |
Yes (settings.docs_enabled) |
No |
| Logs | File (agent.log), last 50 lines via /logs |
In-memory ring buffer, last 30 lines via /logs, cleared on reboot |
| Heap watchdog | No | Resets the board when free system memory stays under 6 KB for 30 s (not in the first 3 min) |
| Clock | System time | NTP at boot, retried every 2 min until set |
| https webhook targets | Always available | ESP32-S3 only (see https targets) |
Run the dependency-free test suite with:
python3 test.pyThe test suite runs app.py locally with mock MicroPython modules (test_mocks/), so no board is needed, and tests its REST API: authentication, configuration read/update/load, GPIO configuration, reading/writing/toggling pin values, duration pulses, watched pins, the status LED pin, GPIO scan, sensor scripts and their security validation, request size limits, and the Wi-Fi watchdog.
pi-gpio-api: Raspberry Pi GPIO REST server that reads inputs and drives outputs (and named sensors) over an HTTP API.pico-gpio-api: Raspberry Pi Pico W port of the GPIO REST server, with the same endpoints, wire format, and auth.mcp-bridge: MCP server that exposes GPIO control as tools for AI agents (e.g. Claude), executing them over the agents' REST API.homebridge-ctrlpi: A Homebridge plugin that brings Raspberry Pi, Pico W, and ESP32 GPIO into Apple Home, by driving one or more GPIO agents over their REST API.homekit-bridge(coming soon): Native Apple HomeKit bridge that exposes GPIO pins and sensors as HomeKit accessories, driven over the agents' REST API.matter-bridge(coming soon): Matter bridge that exposes GPIO pins and sensors to any Matter platform (Apple Home, Google Home, Alexa, Home Assistant), driven over the agents' REST API.ctrlpi-cli: Command-line interface for interacting with ctrlPi agents, with shared agent configuration.ctrlpi-lab: A local browser dashboard for your ctrlPi setup. Test GPIO and sensor endpoints, watch webhook events, and tail agent logs.pico-bay: Web app and MCP server for managing MicroPython and CircuitPython boards, such as the Raspberry Pi Pico and ESP32, over USB.pi-bay(coming soon): A local web app and MCP server for Raspberry Pi boards over the network. Edit files, open a remote terminal, and manage your Pi remotely.