HTTP · BLE · REFERENCE

API Reference

Every client-facing interface the firmware exposes — the HTTP REST API over WiFi, and the BLE GATT API over Bluetooth. Meant for building companion apps and integrations against; if an endpoint or characteristic changes in src/opensesame.cpp, this page should change with it.

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 is doorCount (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 (doorIndex 0 == "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 /version or 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-IP header 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 gets 429 { "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

POST/door/{n}/trigger

Momentarily activates door n's relay (simulates a button press), if not locked and not in cooldown.

n = 1..4Auth: required
200{ "ok": true }
429{ "ok": false, "error": "blocked" } — locked or still in cooldown
404{ "ok": false, "error": "door_not_configured" } — n > doorCount
curl -X POST https://device.example/door/1/trigger \
  -H "Authorization: Bearer $TOKEN"
GET/door/{n}/status

Returns door n's current state.

Auth: required
{
  "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.

404{ "ok": false, "error": "door_not_configured" }
POST/door/{n}/lock

Disables triggering for door n (via HTTP or BLE) until unlocked. Persisted across reboots.

Auth: required
200{ "ok": true, "locked": true }
POST/door/{n}/unlock

Re-enables triggering for door n.

Auth: required
200{ "ok": true, "locked": false }
GET/doors

Lists every currently configured door — use this instead of hardcoding a door count.

Auth: required
{
  "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.

POST/door/{n}/set_relay_pin
Auth: requiredBody: pin (required)

Validation: rejects GPIO 6–11 (internal flash pins) and anything already used by another door's relay or sensor pin.

200{ "ok": true, "rebooting": true }
400{ "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"
POST/door/{n}/set_sensor_pin

Configures (or reconfigures) door n's optional open/closed sensor GPIO.

Auth: requiredBody: pin (required)

Validation: same rules as set_relay_pin, checked against all doors' relay AND sensor pins.

200{ "ok": true, "rebooting": true }
400{ "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"
POST/door/{n}/disable_sensor

Removes door n's sensor configuration. Does not reboot.

Auth: required
200{ "ok": true, "sensorInstalled": false }

Device management

POST/factory_reset

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.

Auth: required
200{ "ok": true, "resetting": true }
GET/version
Auth: required
200{ "version": "1.0.7", "uptimeMs": 123456 }

uptimeMs is milliseconds since boot, from millis() — wraps at ~49.7 days.

POST/rotate_token

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.

Auth: required
200{ "ok": true, "token": "<new 64-char hex token>" } — shown once, save it immediately
429{ "ok": false, "error": "rotated_too_recently" }
POST/set_wifi_credentials

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.

Auth: requiredBody: ssid (required)Body: password (optional)
200{ "ok": true, "applying": true }
400{ "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.

POST/ota_update

Force-reinstalls the latest published release, regardless of whether the version differs from what's currently running.

Auth: required
200{ "ok": true, "updating": true }
POST/ota_update_meta

Checks the latest published release and only installs if its version differs from the currently running firmware.

Auth: required
200{ "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

GET/logs

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.

Auth: required
{
  "entries": [
    { "seq": 101, "ts": 123456, "text": "[BLE] Client connected (link established, awaiting auth)" },
    { "seq": 102, "ts": 123789, "text": "[BLE] Auth complete: encrypted & bonded" }
  ]
}
POST/set_log_webhook_url

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.

Auth: requiredBody: url (optional)
200{ "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".

MethodPathPurpose
GET/HTML form: WiFi SSID/password, BLE passkey, door count, per-door relay/sensor pins, optional BLE device name.
POST/provisionSubmits 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_resultRe-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_confirmReboots 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

Service: 7d1e9c2a-4b6f-4e8a-9d3c-5f2a8b6e1c40 Command char (write): ...1c41 Status char (read): ...1c42

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 capability DISPLAY_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].

ByteMeaning
cmd0x01 = trigger, 0x02 = lock, 0x03 = unlock. Any other value is ignored.
doorIndex0-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:...
FieldMeaning
NdoorCount — number of configured doors.
Cshared cooldown in ms (currently always 5000).
Umilliseconds 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).
L1 if locked, 0 if unlocked.
Smilliseconds since this door's last trigger (0 if never triggered).
Pthe GPIO pin this door's relay is wired to.
H1 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).

StatusErrorMeaning
401unauthorizedMissing/invalid bearer token.
429locked_outCaller's IP (or the global fallback) is in an auth-failure lockout window.
429blockedDoor trigger rejected — locked or still in cooldown.
429rotated_too_recently/rotate_token called again within 60s of the last rotation.
404door_not_configuredDoor index/number exceeds the current doorCount.
404nothing_to_confirm/provision_confirm called with no pending provisioning result.
400missing_pin / missing_ssidRequired form field not supplied.
400invalid_pinGPIO outside 0–33 or within the reserved 6–11 SPI-flash range.
400pin_in_useGPIO already assigned to another door's relay or sensor pin.
400invalid_door_count / invalid_passkey / invalid_relay_pin / invalid_sensor_pin / duplicate_pinProvisioning-form validation failures.
400wifi_connect_failedProvisioning couldn't join the given WiFi network with the given credentials.