Core concepts
- Doors — a single device can drive up to 4 doors (
MAX_DOORS), each with its own relay GPIO and an optional open/closed sensor. The number of doors actually configured/in-use isdoorCount(1–4), set during initial provisioning only — there's currently no live "add/remove door" endpoint. - Door indexing differs between transports — HTTP addresses a door by a 1-based path segment (
/door/1/...../door/4/..., matching "Door 1".."Door 4" as shown to the user). BLE addresses a door by a 0-based byte in the command payload (doorIndex0 == "Door 1"). - Cooldown — every door shares one fixed 5-second (
cooldownMs) trigger cooldown. A trigger request while the previous trigger is still cooling down is rejected. - Provisioning — on first boot (or after a factory reset) the device isn't yet provisioned with WiFi/BLE credentials, and runs a SoftAP captive portal instead of the normal-mode HTTP/BLE APIs below. See Provisioning portal.
- Once provisioned, all normal-mode HTTP endpoints listen on port 80 of whatever IP the device gets on your WiFi network (see
GET /versionor your router's client list to find it).
HTTP API
Everything below except the provisioning portal is authenticated and lives on the normal-mode HTTP server.
Authentication
Every normal-mode endpoint requires an Authorization: Bearer <token> header, checked with a constant-time comparison. The token is either generated (random 32-byte / 64 hex char) during provisioning and shown once on the provisioning success page (and printed to Serial), or replaced at any time via POST /rotate_token.
Requests without a valid token get 401 { "ok": false, "error": "unauthorized" }.
Rate limiting / lockout
- Failed-auth attempts are tracked per source IP (via the
X-Real-IPheader if present — e.g. from a reverse proxy — otherwise the raw TCP peer address). After 5 failures from the same IP within the window, that IP is locked out for 60 seconds and gets429 { "ok": false, "error": "locked_out" }regardless of whether the token is valid. - If a flood spans more distinct IPs than the device can track at once, a global fallback lockout engages (same 429 response) to avoid an unbounded attack surface.
- Door triggers are separately rate-limited by the per-door cooldown (
429 { "ok": false, "error": "blocked" }), independent of the auth lockout above.
Door control & status
Momentarily activates door n's relay (simulates a button press), if not locked and not in cooldown.
{ "ok": true }{ "ok": false, "error": "blocked" } — locked or still in cooldown{ "ok": false, "error": "door_not_configured" } — n > doorCountcurl -X POST https://device.example/door/1/trigger \ -H "Authorization: Bearer $TOKEN"
Returns door n's current state.
{
"door": 1,
"locked": false,
"cooldownMs": 5000,
"sinceLastMs": 12345,
"sensorInstalled": true,
"doorClosed": true,
"doorStateSinceMs": 98765
}
sinceLastMs: milliseconds since the last trigger (0 if never triggered). doorClosed / doorStateSinceMs are only present when sensorInstalled is true.
{ "ok": false, "error": "door_not_configured" }Disables triggering for door n (via HTTP or BLE) until unlocked. Persisted across reboots.
{ "ok": true, "locked": true }Re-enables triggering for door n.
{ "ok": true, "locked": false }Lists every currently configured door — use this instead of hardcoding a door count.
{
"doorCount": 2,
"doors": [
{
"door": 1,
"relayPin": 23,
"locked": false,
"sensorInstalled": true,
"sensorPin": 27,
"doorClosed": true,
"doorStateSinceMs": 98765
},
{
"door": 2,
"relayPin": 22,
"locked": false,
"sensorInstalled": false
}
]
}
Door configuration
These endpoints reconfigure hardware wiring for an already-provisioned door and reboot the device to apply the change safely (a clean pinMode() re-init is required). Confirm the result afterward via GET /doors, GET /logs, or the log webhook rather than assuming success from the immediate response.
Validation: rejects GPIO 6–11 (internal flash pins) and anything already used by another door's relay or sensor pin.
{ "ok": true, "rebooting": true }{ "ok": false, "error": "missing_pin" | "invalid_pin" | "pin_in_use" }curl -X POST https://device.example/door/1/set_relay_pin \ -H "Authorization: Bearer $TOKEN" \ --data "pin=23"
Configures (or reconfigures) door n's optional open/closed sensor GPIO.
Validation: same rules as set_relay_pin, checked against all doors' relay AND sensor pins.
{ "ok": true, "rebooting": true }{ "ok": false, "error": "missing_pin" | "invalid_pin" | "pin_in_use" }curl -X POST https://device.example/door/1/set_sensor_pin \ -H "Authorization: Bearer $TOKEN" \ --data "pin=27"
Removes door n's sensor configuration. Does not reboot.
{ "ok": true, "sensorInstalled": false }Device management
Wipes all NVS state (WiFi creds, API token, BLE passkey, door config) and BLE bonds, then reboots into provisioning mode. Irreversible — the device must be reprovisioned from scratch afterward.
{ "ok": true, "resetting": true }{ "version": "1.0.7", "uptimeMs": 123456 }uptimeMs is milliseconds since boot, from millis() — wraps at ~49.7 days.
Generates a new random API token, persists it, and applies it immediately (no reboot) — the response to this very request is still authenticated against the OLD token. Rate-limited to one rotation per 60 seconds.
{ "ok": true, "token": "<new 64-char hex token>" } — shown once, save it immediately{ "ok": false, "error": "rotated_too_recently" }Switches the device to a new WiFi network without a factory reset. The ESP32 has a single WiFi radio, so this cannot validate the new network without first disconnecting from the current one — the response is sent before the switch is attempted, so it only confirms the request was accepted, not that the new network works. If the new credentials fail to connect, the device automatically reverts to the previous (still-saved) credentials. Confirm the real outcome via GET /version / GET /logs on whichever network ends up active.
{ "ok": true, "applying": true }{ "ok": false, "error": "missing_ssid" }curl -X POST https://device.example/set_wifi_credentials \ -H "Authorization: Bearer $TOKEN" \ --data "ssid=MyNetwork&password=hunter2"
OTA firmware updates
Firmware is distributed via Gitea Releases on the project's repo. Both endpoints run the download + flash synchronously and respond immediately before starting, since the device becomes briefly unresponsive during the flash write.
Force-reinstalls the latest published release, regardless of whether the version differs from what's currently running.
{ "ok": true, "updating": true }Checks the latest published release and only installs if its version differs from the currently running firmware.
{ "ok": true, "checking": true }Both endpoints validate the download over TLS (pinned CA) and verify the downloaded firmware.bin against a companion MD5 digest before activating the new firmware — a failed check leaves the currently-running firmware untouched.
Diagnostics / remote logging
Returns the in-RAM ring buffer of the last ~40 log lines (oldest first) — useful when the device is installed somewhere without practical USB access.
{
"entries": [
{ "seq": 101, "ts": 123456, "text": "[BLE] Client connected (link established, awaiting auth)" },
{ "seq": 102, "ts": 123789, "text": "[BLE] Auth complete: encrypted & bonded" }
]
}
Configures (or disables) a URL the device POSTs new log lines to in the background every ~15s (batched, best-effort, both http:// and https:// supported). Takes effect immediately, no reboot needed.
{ "ok": true }curl -X POST https://device.example/set_log_webhook_url \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "url=https://logs.example.com/opensesame"
Omit or blank out url to disable pushing again — GET /logs still works either way:
curl -X POST https://device.example/set_log_webhook_url \ -H "Authorization: Bearer $TOKEN" \ --data "url="
The webhook receives a POST body shaped like:
{
"device": "OpenSesame-A1B2",
"entries": [
{ "seq": 101, "ts": 123456, "text": "[WIFI] Reconnected after 4213 ms" }
]
}
Provisioning portal
SoftAP only, unauthenticated. Only active while the device is unprovisioned (first boot, or after /factory_reset). The device runs its own WiFi access point (SSID OpenSesame-Setup-XXXXXX, password OS-XXXXXX — both derived from the device's MAC, shown when you scan for WiFi networks) and serves a captive portal at 192.168.4.1. These routes are not bearer-protected (there's no token yet) — trust here is scoped to "you're physically near the device and connected to its temporary AP".
| Method | Path | Purpose |
|---|---|---|
| GET | / | HTML form: WiFi SSID/password, BLE passkey, door count, per-door relay/sensor pins, optional BLE device name. |
| POST | /provision | Submits the form above. Validates pins (range + no duplicates across relay/sensor), attempts the WiFi connection, and on success generates the API token, persists everything to NVS, and shows a one-time secrets page (API token, BLE passkey, BLE MAC, service/characteristic UUIDs, door pins). |
| GET | /provision_result | Re-fetches the same one-time secrets page (in case the phone got disconnected mid-request by the AP/STA channel hop that WiFi validation causes). |
| POST | /provision_confirm | Reboots into normal mode immediately, instead of waiting out the ~60s grace window. |
The device reboots into normal mode automatically ~60 seconds after a successful /provision, whether or not /provision_confirm was called.
BLE API
These UUIDs are the same for every device (not per-device-random) so a companion app can discover the service without per-device configuration. The device name shown when scanning defaults to OpenSesame-XXXX (MAC-suffixed) unless renamed during provisioning.
Pairing
- Security — bonding + MITM protection + encryption required (
setSecurityAuth(true, true, true)), IO capabilityDISPLAY_ONLY— meaning the peer must type in a 6-digit passkey (Passkey Entry, not Just Works / Numeric Comparison). - The passkey is a 6-digit number (100000–999999) chosen during provisioning and shown once on the provisioning success page (also printed to Serial).
- Both characteristics require an encrypted, bonded connection — unencrypted writes/reads are rejected (writes are silently ignored and logged; reads simply won't succeed without completing pairing first).
- After 5 failed pairing/auth attempts, BLE advertising is paused for 60 seconds (mirrors the HTTP lockout).
Command characteristic (write)
Write a 2-byte payload: [cmd, doorIndex].
| Byte | Meaning |
|---|---|
| cmd | 0x01 = trigger, 0x02 = lock, 0x03 = unlock. Any other value is ignored. |
| doorIndex | 0-based door index (0 = "Door 1", 1 = "Door 2", ...). |
Writes with fewer than 2 bytes, or a doorIndex >= doorCount, are silently ignored (logged firmware-side, no error surfaced over BLE). The status characteristic is updated immediately after a successful command.
Example: trigger Door 1 → write bytes [0x01, 0x00]. Lock Door 2 → write bytes [0x02, 0x01].
Status characteristic (read)
A compact, deliberately non-JSON string (kept terse since it's a GATT value, not an HTTP body):
N:<doorCount>,C:<cooldownMs>,U:<uptimeMs>;D0:L<0/1>,S<msSinceLastTrigger>,P<relayPin>,H<0/1>[,O<0/1>,T<msSinceStateChange>];D1:...;D2:...
| Field | Meaning |
|---|---|
| N | doorCount — number of configured doors. |
| C | shared cooldown in ms (currently always 5000). |
| U | milliseconds since boot (same value as GET /version's uptimeMs; wraps at ~49.7 days). |
| D<i> | one segment per configured door (i is 0-based). |
| L | 1 if locked, 0 if unlocked. |
| S | milliseconds since this door's last trigger (0 if never triggered). |
| P | the GPIO pin this door's relay is wired to. |
| H | 1 if an open/closed sensor is installed for this door, else 0. |
| O | (only present if H=1) 1 if the sensor currently reads "door closed", else 0. |
| T | (only present if H=1) milliseconds since O last changed. |
Example value for a 2-door device, Door 1 with a sensor (closed) and Door 2 without one:
N:2,C:5000,U:305419;D0:L0,S12345,P23,H1,O1,T98765;D1:L0,S0,P22,H0
Error codes
Common error values seen across responses (all wrapped in { "ok": false, "error": "..." } unless noted).
| Status | Error | Meaning |
|---|---|---|
| 401 | unauthorized | Missing/invalid bearer token. |
| 429 | locked_out | Caller's IP (or the global fallback) is in an auth-failure lockout window. |
| 429 | blocked | Door trigger rejected — locked or still in cooldown. |
| 429 | rotated_too_recently | /rotate_token called again within 60s of the last rotation. |
| 404 | door_not_configured | Door index/number exceeds the current doorCount. |
| 404 | nothing_to_confirm | /provision_confirm called with no pending provisioning result. |
| 400 | missing_pin / missing_ssid | Required form field not supplied. |
| 400 | invalid_pin | GPIO outside 0–33 or within the reserved 6–11 SPI-flash range. |
| 400 | pin_in_use | GPIO already assigned to another door's relay or sensor pin. |
| 400 | invalid_door_count / invalid_passkey / invalid_relay_pin / invalid_sensor_pin / duplicate_pin | Provisioning-form validation failures. |
| 400 | wifi_connect_failed | Provisioning couldn't join the given WiFi network with the given credentials. |