HTTP API v1¶
Every HTTP route AWTRIX answers. Every field, every status code.
The Conventions - camelCase keys, ...Ms millisecond durations, the color
forms, the mandatory Content-Type, the shape of an error, auth-off-by-default - hold everywhere
below and are not repeated per route.
Base URL¶
All routes live under http://<awtrix-ip>:<webPort>. webPort is device configuration
(default 80; a value <= 0 falls back to 80). In access-point / provisioning mode the
server always listens on port 80 regardless of the configured value.
AWTRIX also answers on its mDNS hostname - see Finding AWTRIX.
Captive-portal redirect (AP mode only)¶
While AWTRIX is in provisioning mode, any request whose Host header is non-empty and is
neither the soft-AP IP nor <apIp>:<port> is answered with 302 text/plain and
Location: http://<apIp>/. The redirect runs before authentication, which is what pops the
setup page open on a phone or laptop that has just joined the access point. In normal
(station) mode no redirect ever happens.
Authentication¶
HTTP Basic. The check is skipped - the request is allowed - only when authEnabled is
false (the shipping default: no auth at all). Once enabled it is enforced in every mode,
including AP/provisioning, so the setup portal can be password protected. During
provisioning AWTRIX also answers far fewer routes - reads, Wi-Fi setup and
reboot; the file routes and firmware upload return
403 forbidden there.
Otherwise credentials are verified against authUser / authPass from
system configuration. On failure AWTRIX answers:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="AWTRIX NG"
Content-Type: application/json
{"error":{"code":"unauthorized","message":"authentication required"}}
The WWW-Authenticate header drives the browser login prompt; the body is the same
JSON error body every other status uses, never HTML.
Auth applies to everything AWTRIX serves: the API, the web UI at /, and the
static asset directories. The two multipart routes - POST /update and POST /api/v1/files -
re-check auth inside their upload handler, so a failure there is reported only after the whole
body has been read.
Content-Type¶
PUT and PATCH reject a non-JSON Content-Type up front with
415 unsupportedMediaType (Content-Type must be application/json). A curl -d without the
header lands here, because it defaults to application/x-www-form-urlencoded. Always send
-H "Content-Type: application/json".
An empty or {} body is not a clear. Sending one to the three routes that can be cleared returns
422 validationFailed:
| Route | Empty / {} body |
|---|---|
PUT /api/v1/apps/pushed/{name} |
422 - a JSON body is required; use DELETE /api/v1/apps/{name} to remove the app |
PUT /api/v1/display/moodlight |
422 - a JSON body is required; use DELETE to turn the mood light off |
PUT /api/v1/indicators/{id} |
422 - a JSON body is required; use DELETE to turn the indicator off |
Removing an app, turning the mood light off, or clearing an indicator is done only through the
explicit DELETE route (or the empty-payload clear idiom over MQTT). For a pushed app that route
is DELETE /api/v1/apps/{name} - the pushed path itself answers 405
to anything but PUT. See Errors.
Method override¶
Some clients can only send a fixed set of verbs - the FRITZ!Box HTTP action, a few home-automation
gateways and older shell environments have no PATCH at all. For those, AWTRIX accepts the
de-facto standard header:
The rules, and they are strict on purpose:
| Carrier method | POST only. The header on any other method is 400 invalidMethodOverride. |
| Accepted values | PUT, PATCH, DELETE, any casing, surrounding spaces ignored. Anything else - including GET and POST - is 400 invalidMethodOverride. |
| Effect | The request is handled exactly as if it had arrived with the overridden method: same routing, same 405 lists, same Content-Type gate, same body and error handling. |
| Exception | PUT /api/v1/apps/script/{name} (the raw Berry source upload) cannot be reached this way and answers 400 invalidMethodOverride. Install scripts with a real PUT. |
Nothing changes for clients that do not send the header. An absent or empty header is not an error.
Because the request is the overridden method once the header is read, X-HTTP-Method-Override:
PATCH also inherits the PATCH Content-Type gate - send
Content-Type: application/json or the request fails with 415.
Turning the display off from a client without PATCH:
curl -X POST http://<awtrix-ip>/api/v1/display \
-H "X-HTTP-Method-Override: PATCH" \
-H "Content-Type: application/json" \
-d '{"power":false}'
Two routes are not covered. POST /update and POST /api/v1/files are multipart uploads claimed by
their own handlers before the override is ever read, so the header has no effect there. The
consequence worth knowing: DELETE /api/v1/files?path=... cannot be reached through the
override - a POST to that path is always taken as a file upload. Deleting an icon or a melody
file needs a real DELETE. Every other route on this page can be driven with the override.
Errors¶
One body shape, every non-2xx status:
field is omitted entirely when no specific input key is at fault.
Eighteen codes exist, and that is the complete set - invalidJson, invalidPinConfig,
invalidPath, invalidName, invalidMethodOverride, badRequest, wrongChip, unauthorized,
forbidden, notFound,
methodNotAllowed, payloadTooLarge, unsupportedMediaType, validationFailed, internalError,
unavailable, serviceBusy, insufficientStorage. Which route raises each, the status it comes
with, and the exact messages: Errors.
Status mapping for command routes¶
Write routes answer synchronously, and the outcome maps to HTTP as:
| Outcome | Status | Body |
|---|---|---|
| Ok | 200 | {"ok":true} |
| Parse error | 400 | invalidJson, message request body is not valid JSON |
| Validation error | 422 | validationFailed, message + field from the validator |
| Not found | 404 | notFound - app not found / no MP3 of that name / no melody of that name / not found |
| Capacity | 507 | insufficientStorage, message storage capacity reached |
| Unavailable | 503 | unavailable, message not available on this device |
| Busy | 503 | serviceBusy, message device is busy, try again (with Retry-After: 2) |
| Failed / unknown | 500 | internalError, message command failed |
PATCH /api/v1/settings is the one exception: on success it returns the full resulting
settings resource instead of {"ok":true}.
Device¶
GET /api/v1/device¶
Device state and statistics. 200, or 401 when a login is enabled and the request is unauthenticated.
| Key | Type | Units / range | Meaning |
|---|---|---|---|
version |
string | - | firmware version, e.g. 1.0.12 |
uid |
string | - | device unique id |
boardType |
string | - | fixed constant "awtrixng" in the device firmware - does not vary with the wiring; the simulator reports a different value, see Device state |
soc |
string | - | chip this image was built for: esp32 or esp32s3. Pin rules live under gpio in /api/v1/capabilities |
ipAddress |
string | - | current station IP |
hostname |
string | - | name AWTRIX answers to, mDNS included; derived from the MAC when unset in /api/v1/system - see Device state |
wifiRssi |
integer | dBm | |
uptimeSeconds |
integer | s | since boot |
resetReason |
string | - | why AWTRIX last booted - see Device state |
freeHeapBytes |
integer | bytes | |
minFreeHeapBytes |
integer | bytes | low-water mark since boot; see Device state |
largestFreeBlockBytes |
integer | bytes | largest contiguous free block; see Device state |
psramTotalBytes |
integer | bytes | external PSRAM; absent on boards without it |
psramFreeBytes |
integer | bytes | free PSRAM; never add it to freeHeapBytes - see Device state |
scriptingRunning |
boolean | - | whether scripts run at all; false while scriptingEnabled is off |
scriptHeapPool |
string | - | pool the Berry VM allocates from: internal or psram |
scriptHeapBudgetBytes |
integer | bytes | ceiling on the shared Berry heap before installs are refused |
fps |
integer | frames/s | measured render-loop rate |
brightness |
integer | 0–255 | effective brightness after auto-brightness, not the setting |
matrixPower |
boolean | - | false while the display is switched off |
currentApp |
string | - | id of the app on screen |
indicators |
array | 3 entries | {on: bool, color: "#RRGGBB", blinkMs: int, fadeMs: int} - what is on the panel; see Indicators |
messageCount |
integer | - | MQTT commands received since boot; HTTP requests are not counted |
mqtt |
object | - | broker connection state; see Device state → MQTT connection |
Conditional keys - present only when the hardware provides them:
| Key | Type | Units | Present when |
|---|---|---|---|
lightLevel |
number | 0–100 % | pinLdr >= 0 - relative ambient light, rounded to 1 decimal; not lux |
ldrRaw |
integer | 0–4095 | pinLdr >= 0 - unprocessed light-sensor reading behind lightLevel |
batteryPercent |
integer | 0–100 % | pinBattery >= 0 |
batteryVoltage |
number | V, 2 decimals | pinBattery >= 0 - voltage at the cell |
batteryPinMillivolts |
integer | mV | pinBattery >= 0 - at the pin, before the divider; use it to calibrate batteryDividerRatio |
lowBattery |
boolean | - | pinBattery >= 0 - true while batteryPercent is below lowBatteryThreshold; always false when the threshold is 0 |
temperature |
number | °C | any I²C sensor was detected - every supported sensor reads temperature |
humidity |
number | % | the detected sensor measures humidity - omitted on temperature-only sensors such as the BMP280 |
pressureHpa |
number | hPa, 1 decimal | the detected sensor measures pressure (BMP280/BME280) |
Setting pinBattery to -1 removes all four battery keys on any board, and pinLdr to -1
removes both light keys. Which sensor keys appear depends on what was found on the I²C bus; ask
GET /api/v1/capabilities if you need to know before reading.
GET /api/v1/version¶
GET only. Any other method → 405, allowed method(s): GET.
GET /version¶
The same string as text/plain, with no JSON wrapper - the body is exactly the version string, e.g. 1.0.12.
Convenient for shell scripts and health checks. GET only.
POST /api/v1/device/reboot¶
No body. Reboots AWTRIX.
| Status | Condition |
|---|---|
| 200 | {"ok":true} - delivered before the restart |
| 405 | wrong method - allowed method(s): POST |
Poll GET /version until AWTRIX answers again to know it is back.
This is the only write besides PUT /api/v1/system that the provisioning
access point accepts - it is what applies the Wi-Fi credentials written there. See
First boot.
POST /api/v1/device/sleep¶
Deep-sleep for a duration, then wake and boot normally.
| Key | Type | Range | Default | Units | Required |
|---|---|---|---|---|---|
durationMs |
integer | > 0 |
- | ms | yes |
| Status | Condition |
|---|---|
| 200 | {"ok":true} - delivered before AWTRIX sleeps |
| 400 | body is not valid JSON (invalidJson) |
| 422 | durationMs missing, not an integer, or <= 0 - field: "durationMs", message must be a positive integer (milliseconds) |
| 405 | wrong method - allowed method(s): POST |
The panel is cleared before AWTRIX sleeps.
Pressing the select button ends the sleep early - but only if pinBtnSelect sits on one of the
chip's rtc pins, see gpio in capabilities. On any other pin
AWTRIX comes back when durationMs runs out and not before.
curl -X POST http://<awtrix-ip>/api/v1/device/sleep \
-H "Content-Type: application/json" \
-d '{"durationMs":60000}'
POST /api/v1/device/factory-reset¶
No body. Clears the settings and the device configuration, formats the filesystem, erases the stored Wi-Fi credentials, and reboots into provisioning mode.
HTTP only - this command is not reachable over MQTT.
| Status | Condition |
|---|---|
| 200 | {"ok":true} - delivered before the reset |
| 405 | wrong method - allowed method(s): POST |
This erases everything, and it cannot be undone
There is no confirmation step. Your icons, melodies, palettes and scripts go with the settings and the Wi-Fi credentials. Take a backup first if you want any of it back.
Settings¶
GET /api/v1/settings¶
Returns the full settings resource - all 40 keys, always present. Enum fields emit their
wire name, colors emit "#RRGGBB", nullable colors emit null when unset.
Methods other than GET/PATCH → 405, allowed method(s): GET, PATCH.
PATCH /api/v1/settings¶
Send any subset of the keys. Every key in the payload is checked before anything is written, so
the first bad one aborts the whole request and nothing is applied. On success AWTRIX answers
200 with all settings, updated - not {"ok":true}.
| Status | Condition |
|---|---|
| 200 | applied - body is every settings key |
| 400 | body is not valid JSON (invalidJson) |
| 415 | body sent with a non-JSON Content-Type (unsupportedMediaType) |
| 422 | a field failed validation - field names it |
| 405 | wrong method - allowed method(s): GET, PATCH |
Unknown keys are rejected, not ignored: field: "<key>", message unknown field.
Validator messages by field kind:
| Kind | 422 message |
|---|---|
| boolean | must be a boolean |
| integer | must be an integer (booleans are explicitly excluded) or out of range |
| millisecond long | must be a non-negative integer (milliseconds) |
| float | must be a positive number |
| enum | must be one of: <space-separated names> |
| color | must be a color ("#RGB", "#RRGGBB", [r,g,b], ["HSV",h,s,v] or a packed integer) |
| nullable color | must be a color or null |
| transition | must be one of: <comma-separated transition names> |
Fields¶
The key catalogue - type, range, default and meaning for every key - lives in Settings; this page keeps only the endpoint mechanics.
Notes:
nullon the five per-app*Colorfields means inherittextColor; oncolorCorrectionandcolorTintit means off. That is what they read back as by default.- Panel size and wiring are not here: they are system configuration.
soundEnabled: falsemutes the one-shot keys ofPOST /api/v1/audio/play- melody files, stored MP3s, DFPlayer tracks and inline RTTTL alike. A radio stream is not muted by it, andPOST /api/v1/audio/stopignores it. See Sounds.transitionEffectis matched case-insensitively against the names fromGET /api/v1/capabilities-"slide","Slide"and"SLIDE"are the same transition. The other name strings (timeSeparatorMode,dateOrder,dateSeparator,dateYearMode) accept any casing too. Responses always come back in one fixed spelling.scrollis the device-wide text motion -mode(static·wrap·loop·bounce),direction(left·right),entry(inline·offscreen),whenFits(static·scroll),speedin percent of 21 px/s,gapin pixels andholdMsin milliseconds. It merges field by field, so{"scroll":{"mode":"loop"}}keeps the configured speed, and{"scroll":"loop"}is shorthand for the same. A negative number, an unknown value or an unknown field is422 validationFailedwithfieldnaming the key, e.g.scroll.speed. A payload's ownscrolloverrides this one field by field - see Payload → Scrolling.weekdayBaris the whole weekday bar -show,startOnMonday,weekendDays(lowercase English day names, any subset,[]for no weekend) and four colors:activeColor/inactiveColorfor a workday,weekendActiveColor/weekendInactiveColorfor a weekend day, each pair split into today and not-today. The weekend colors default to the workday colors, so the bar looks unchanged until they are set. It merges field by field, so{"weekdayBar":{"weekendDays":["friday","saturday"]}}leaves the other six alone. An unknown field, a wrong type or an unknown day name is422 validationFailedwithfieldnaming the key, e.g.weekdayBar.weekendDays.startOnMondayonly rotates the display order - weekend membership follows the calendar day. Not to be confused withdateShowWeekday, which prefixes a weekday name to the date text.
curl -X PATCH http://<awtrix-ip>/api/v1/settings \
-H "Content-Type: application/json" \
-d '{"brightness":80,"autoBrightness":false,"timeColor":"#00FF00"}'
POST /api/v1/settings/reset¶
No body. Clears the settings and reboots. Device configuration - Wi-Fi, MQTT, GPIO - is not touched; only settings.
| Status | Condition |
|---|---|
| 200 | {"ok":true} - delivered before the reset |
| 405 | wrong method - allowed method(s): POST |
Display¶
GET /api/v1/display¶
| Key | Type | Meaning |
|---|---|---|
power |
boolean | false while the panel is switched off |
brightness |
integer 0–255 | effective brightness after auto-brightness |
overlay |
string | null | active global overlay name, null when none |
overlaySettings |
object | always present - {speed, palette, blend} for the global overlay; palette is null when unset. See Weather overlays |
moodlight |
object | null | {color: "#RRGGBB", brightness: 0–255}, or null when off |
PATCH /api/v1/display¶
Validate-then-apply. All fields optional.
| Key | Type | Range | Default | Meaning |
|---|---|---|---|---|
power |
boolean | - | unchanged | false blanks the panel |
overlay |
string | null | an overlay name, "", or null |
unchanged | global weather overlay over all apps |
overlaySettings |
object | {speed, palette, blend} |
unchanged | tunes the global overlay; see Weather overlays |
overlay is matched case-insensitively. null or "" clears it. Valid names - from
GET /api/v1/capabilities - are drizzle, frost, rain, snow,
storm, thunder.
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | body is not valid JSON (invalidJson) |
| 415 | body sent with a non-JSON Content-Type (unsupportedMediaType) |
| 422 | field: "power", must be a boolean |
| 422 | field: "overlay", must be a string or null |
| 422 | field: "overlay", unknown overlay |
| 422 | field: "overlaySettings", must be an object |
| 405 | wrong method - allowed method(s): GET, PATCH |
Unknown overlay names are rejected here, and the per-app overlay payload field is validated
the same way - see PUT /api/v1/apps/pushed/{name}.
curl -X PATCH http://<awtrix-ip>/api/v1/display \
-H "Content-Type: application/json" \
-d '{"power":true,"overlay":"snow"}'
PUT /api/v1/display/moodlight¶
Floods the whole panel with one color. All fields optional.
| Key | Type | Range | Default | Units |
|---|---|---|---|---|
kelvin |
integer | clamped to 1000–40000 | - | K |
color |
color | any color form | unchanged | - |
brightness |
integer | not validated - see below | unchanged | 0–255 |
Sending both is allowed, and kelvin wins: if kelvin is present, color is ignored
entirely. A color the color parser cannot read is rejected with 422 validationFailed
(field: "color"), and nothing changes.
Both fields are sticky. Omitting one keeps the value it had, so {"brightness":30} dims
without touching the color and {"color":"#FF0000"} recolors without touching the level.
Before the mood light has ever been given a color it is white, at brightness 120.
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | body is not valid JSON (invalidJson) |
| 415 | body sent with a non-JSON Content-Type (unsupportedMediaType) |
| 422 | field: "color" - the value is not a readable color |
| 422 | empty or {} body - a JSON body is required (use DELETE to turn the moodlight off) |
| 405 | wrong method - allowed method(s): PUT, DELETE |
brightness is not range-checked
brightness is cast to an 8-bit value with no validation: 300 wraps to 44, 256 wraps
to 0, and no error is returned. Keep it inside 0–255 yourself.
curl -X PUT http://<awtrix-ip>/api/v1/display/moodlight \
-H "Content-Type: application/json" \
-d '{"kelvin":2700,"brightness":90}'
DELETE /api/v1/display/moodlight¶
No body. Turns the mood light off. Always 200 {"ok":true}.
GET /api/v1/display/screen¶
The current framebuffer.
| Key | Type | Meaning |
|---|---|---|
width |
integer | canvas width, 32 by default |
height |
integer | canvas height, 8 by default |
pixels |
array of integers | width * height entries (256 by default), row-major |
Each pixel is the packed 0xRRGGBB value printed as an unsigned decimal integer, not hex.
These are the pixels the apps drew. Brightness and the panel colour settings (saturation,
gamma, colorCorrection, colorTint) change what the LEDs show, not what this endpoint
returns.
GET only; other methods → 405, allowed method(s): GET.
Apps¶
One collection. An app is a name plus an origin that says where its content comes from:
origin |
Content decided by | Written where | Survives a reboot |
|---|---|---|---|
builtin |
firmware code | - | - |
pushed |
a JSON spec sent from outside | RAM | no |
script |
Berry source stored on AWTRIX | /SCRIPTS |
yes |
module |
Berry source other scripts import, never an app in its own right | /SCRIPTS |
yes |
The two sub-collections - /api/v1/apps/pushed/{name} and /api/v1/apps/script/{name} - carry
different payloads and fail in different ways. Everything that treats an
app as an app (the inventory, the loop order, removal) is addressed at the collection and works
the same for both kinds.
Names¶
Every app name, pushed or script, must match [A-Za-z0-9_-]{1,32}. The name is checked before
anything else looks at the request; a malformed one answers 400 invalidName
(name must match [A-Za-z0-9_-]{1,32}, field: "name") and nothing is stored.
A bare sub-collection path leaves a tail that is not a valid name, so /api/v1/apps/pushed/ and
/api/v1/apps/script/ are also 400 invalidName (not 405).
active, next, previous and order are matched before any name, so they are reserved:
DELETE /api/v1/apps/next is a 405, not the deletion of an app called next.
GET /api/v1/apps¶
The full inventory: the apps you arranged first, in that order, then everything else.
Three independent properties, one key each. enabled says whether an app runs at all; inLoop says
whether it is drawn; present says whether it is on AWTRIX right now. For most apps all three agree.
Two kinds of app answer differently: a headless script
runs without ever taking a turn, and a pushed app between two pushes is
switched on and keeps its place with nothing to draw.
| Key | Type | Meaning |
|---|---|---|
name |
string | app id |
enabled |
boolean | whether it runs |
inLoop |
boolean | whether it is in the rotation |
present |
boolean | whether the app is on AWTRIX right now. false for a name it holds a place for while the app itself is away |
slot |
integer | null | 0-based place in the order you arranged; null when the app has no place of its own |
origin |
string | null | "builtin", "pushed", "script", "module"; null while present is false |
import |
string | module only - the name scripts write in their import line |
icon |
string | pushed only, and only when the spec set a non-empty icon |
skipped |
boolean | script only - the app's own should_show() last said no, so the rotation walks past it |
headless |
boolean | script only - the script carries @headless true and so never draws |
config |
boolean | scripts and modules - it declares settings, so /config has something to show. On a module these are the settings every app importing it shares |
error |
object | null | script only - null while healthy, otherwise the error the script is stuck on (see below) |
meta |
object | script only - {name, desc, author, version} from the @ headers; each entry "" when the header is absent |
icon, import, skipped, config, error and meta are omitted, not emitted empty, where
they do not apply. skipped, config, error and meta are also absent on a build with no
scripting platform.
A module is listed here because it shares the
script collection and its routes, but it is not an app: besides origin it carries only name,
import, error and meta, because it never takes a turn and so has no enabled, inLoop or
slot. Read, write and delete it under /api/v1/apps/script/{name} like any other script. An
import name import cannot take - or one already claimed by another module or a built-in one -
answers 422 validationFailed with the reason in message, and nothing is stored.
skipped is not inLoop inverted. inLoop: false is the app not being drawn at all;
skipped: true is the app in the rotation declining its turn, which it can reverse by itself at any
moment. It reports the last answer the app gave, not one taken for this request.
[
{"name":"Time","enabled":true,"inLoop":true,"slot":0,"present":true,"origin":"builtin"},
{"name":"weather","enabled":true,"inLoop":true,"slot":1,"present":true,"origin":"pushed","icon":"1"},
{"name":"doorbell","enabled":true,"inLoop":false,"slot":2,"present":true,"origin":"script",
"skipped":false,"headless":true,"config":false,"error":null,
"meta":{"name":"Doorbell","desc":"","author":"me","version":"1.0"}},
{"name":"co2","enabled":true,"inLoop":false,"slot":3,"present":false,"origin":null},
{"name":"clock","enabled":false,"inLoop":false,"slot":null,"present":true,"origin":"script",
"skipped":false,"headless":false,"config":true,"error":null,
"meta":{"name":"Wall Clock","desc":"","author":"me","version":"1.2"}}
]
doorbell runs and is never drawn; clock is switched off.
The error object¶
The same shape wherever a script error is reported - here and in the
PUT reply.
| Key | Type | Meaning |
|---|---|---|
message |
string | human-readable text, with any source position lifted out |
line |
integer | optional - 1-based line in the submitted source |
hook |
string | optional - which method raised it: setup, loop, draw, on_show, on_hide, on_button, should_show |
line and hook are omitted rather than sent as 0/"", and both are absent much of the time:
"no draw() method" and "source too large" carry neither. A compile error is the reliable source
of line; a failure that names a hook usually has no line.
{"message":"syntax_error: unexpected token ')'","line":12}
{"message":"runtime_error: operand must be number","hook":"setup"}
Built-in names are Time, Date, Temperature, Humidity, Battery. No setting turns one on or
off, but three are gated on hardware: Temperature needs a detected I²C sensor, Humidity a sensor
with a humidity element, and Battery a configured battery pin.
Methods other than GET → 405, allowed method(s): GET.
PUT /api/v1/apps/active¶
| Key | Type | Default | Meaning |
|---|---|---|---|
name |
string | - | app id to show |
fast |
boolean | false |
true jumps instantly, false plays the transition |
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 404 | notFound, app not found |
| 405 | wrong method - allowed method(s): PUT |
How the body is read:
- A body that does not start with
{is taken as a literal app name. - A malformed JSON body does not produce 400. The parse failure is swallowed and the raw
body string is used as the app name, so you get 404
app not foundinstead.
curl -X PUT http://<awtrix-ip>/api/v1/apps/active \
-H "Content-Type: application/json" \
-d '{"name":"Time","fast":true}'
POST /api/v1/apps/next¶
No body. Advances the rotation. Always 200 {"ok":true}.
Other methods → 405, allowed method(s): POST.
POST /api/v1/apps/previous¶
No body. Steps back. Always 200 {"ok":true}.
Other methods → 405, allowed method(s): POST.
PUT /api/v1/apps/order¶
Sets which apps are on, and the order of the ones that draw.
The body is an object with two lists of names:
| Key | Meaning |
|---|---|
order |
what runs, in the order it draws |
disabled |
what is switched off |
disabled is always required. order is optional, and requires disabled beside it. A body without
disabled is refused.
| Body | Effect |
|---|---|
{"order":[…],"disabled":[…]} |
sets both |
{"disabled":[…]} |
switches those off, leaves the order alone |
{"order":[…]} |
400 |
["Time","Date"] |
400 - the body must be an object |
An app named in neither list keeps what it had. Name only what you want to change.
How the lists are read:
- Duplicates are kept in
order- list an app twice and it rotates twice per cycle, each instance with its ownslot. - A switched-off script runs nothing at all - no
loop(), no HTTP or MQTT callbacks. A headless script is named inorderlike any other app to keep it running, but never draws, so the rotation steps straight over it. - A name in
orderholds its place even when no such app is there yet. An app that turns up later drops into it; anything else joins after the ordered entries, switched on. - A name in
disabledstays switched off while the app is away. - Either way the name stays in
GET /api/v1/appswithpresent: false. Deleting a script is the exception: that takes its name off both lists. - Both lists are kept across reboots.
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | not valid JSON, a body that is not an object, or one without disabled (invalidJson) |
| 405 | wrong method - allowed method(s): PUT |
curl -X PUT http://<awtrix-ip>/api/v1/apps/order \
-H "Content-Type: application/json" \
-d '{"order":["Time","weather","Time","Date"],"disabled":["Battery"]}'
Switch one app off without touching the arrangement:
curl -X PUT http://<awtrix-ip>/api/v1/apps/order \
-H "Content-Type: application/json" \
-d '{"disabled":["Battery"]}'
PUT /api/v1/apps/pushed/{name}¶
Creates or updates a pushed app. {name} is the raw path tail - everything after
/api/v1/apps/pushed/.
The body is an app payload: text, icon, color, draw, effect, charts and the rest.
The complete field table is in App & notification payload.
| Body shape | Effect |
|---|---|
| object | stores one app under {name} |
| array of objects | stores each element as {name}0, {name}1, … (non-object elements are skipped) |
empty or {} |
422 - a JSON body is required; use DELETE /api/v1/apps/{name} to remove the app |
| any other top-level type | 400 invalidJson |
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | not valid JSON, or a top-level scalar (invalidJson) |
| 415 | body sent with a non-JSON Content-Type (unsupportedMediaType) |
| 422 | empty or {} body - a JSON body is required; use DELETE /api/v1/apps/{name} to remove the app |
| 422 | field: "<key>" - an unknown key, an unreadable colour, an unrecognised mode word, a malformed scroll, a malformed draw command, or an effect/overlay name AWTRIX does not know |
| 507 | this would add a new app past the pushed-app cap (insufficientStorage) |
| 400 | invalidName - {name} is not [A-Za-z0-9_-]{1,32}, or the tail is empty (/api/v1/apps/pushed/) |
| 405 | wrong method - allowed method(s): PUT |
A pushed app is held in RAM only. It lasts until it is replaced, deleted, expired by lifetimeMs,
or AWTRIX restarts - nothing is written to flash for it. For content that must come back by
itself after a reboot, write a script.
An effect or overlay name AWTRIX does not know answers 422 validationFailed with field
naming the key, and nothing is stored - for an array payload the whole batch is rejected, not
just the offending element. Both are matched case-insensitively against
GET /api/v1/capabilities.
An empty or {} body returns 422 (use DELETE /api/v1/apps/{name} to
remove an app), and a wrong Content-Type returns 415. Neither one deletes or clears the
existing app.
A new app past the 50-app cap is rejected with 507 insufficientStorage, and an array payload
is all-or-nothing - see Limits.
curl -X PUT http://<awtrix-ip>/api/v1/apps/pushed/weather \
-H "Content-Type: application/json" \
-d '{"text":"21.5C","icon":"2422","textColor":"#00AAFF"}'
DELETE /api/v1/apps/{name}¶
No body, and it does not care what kind of app {name} is: it removes whatever is there. For a pushed app that is the exact
name and the numbered apps an array payload to {name} created ({name}0, {name}1, …). An app
you pushed to temp1 yourself is a separate app and stays. For a script it is the source and its
persisted store file, which is the only way to reset a script's store.
The two kinds leave the arrangement differently. A deleted pushed app keeps
its place: the name stays in order or disabled, listed with present: false, and the next push
drops it back where it was. A deleted script is gone for good, so its name is taken off both
lists; install it again and it joins at the end of the loop, switched on.
Always 200 {"ok":true}. Deleting an app that does not exist is not a 404, and neither is
naming a built-in - which stays in the rotation regardless.
| Status | Condition |
|---|---|
| 200 | {"ok":true} - always, for any valid name |
| 400 | invalidName - {name} is not [A-Za-z0-9_-]{1,32} |
| 403 | forbidden - the write API is closed in AP/provisioning mode |
| 405 | any other method on a valid name - allowed method(s): DELETE |
Scripts¶
Berry scripts. An installed script becomes an app in the rotation, and its source is stored on AWTRIX so it comes back by itself after a reboot. The language, the callbacks and the worked examples are in Scripting; this section is what goes over the wire.
A script is addressed like any other app: /api/v1/apps/script/{name}, where {name} is the app id
and the filename under /SCRIPTS, held to the one app name rule. Removal is not here
- it is DELETE /api/v1/apps/{name}, the route that removes any kind of app.
Methods other than GET/PUT → 405, allowed method(s): GET, PUT.
GET /api/v1/apps/script/{name}¶
That script's raw Berry source as text/plain, byte for byte as it was installed, so it can
go straight back into PUT without unwrapping anything.
| Status | Condition |
|---|---|
| 200 | text/plain - the source |
| 400 | invalidName - the name is not [A-Za-z0-9_-]{1,32} |
| 404 | notFound, no such script |
| 503 | unavailable, scripting is not available - this build has no scripting platform |
This route also answers while scriptingEnabled is off, so a script that
made the device unreachable can still be read and repaired - see
Scripts eat the memory.
The 503 above is a build without the scripting platform at all, which is a different thing.
The inventory - which scripts exist, whether each compiled, and the metadata from its @ headers -
is part of GET /api/v1/apps, under origin, error and meta. The source is
not in that listing; it can be as large as scriptMaxBytes allows.
PUT /api/v1/apps/script/{name}¶
The body is the raw Berry source, not JSON. Installs the script, or replaces one already under that name.
| Status | Body / condition |
|---|---|
| 200 | {"ok":true,"name":"X","error":null} - installed and compiled |
| 200 | {"ok":true,"name":"X","error":{...}} - installed but broken; see the error object |
| 400 | invalidName - the name is malformed, or the tail is empty (/api/v1/apps/script/) |
| 403 | forbidden - the write API is closed in AP/provisioning mode |
| 413 | payloadTooLarge - over scriptMaxBytes (16384 by default); refused, never truncated |
| 422 | validationFailed, request body must be the script source, field: "source" - empty body |
| 507 | insufficientStorage, field: "name" - see the four refusals below |
While scriptingEnabled is off the script is saved and answered with
{"ok":true,"name":"X","error":null}. Nothing runs in that state, so there is no compile result to
report; the script takes effect on the next start with scripting switched back on. That is what makes
the rescue
a way out rather than a dead end.
A 507 carries one of four reasons in message, because the remedies differ:
| Reason | Message | What helps |
|---|---|---|
| Count | script limit reached (<n> installed) |
delete a script, or raise scriptLimit |
| Berry heap | shared Berry heap <n> bytes is over the … soft limit; remove a script |
delete a script |
| System heap | not enough free memory to compile (<n> bytes free, needs <m>); remove a script or reboot |
delete a script, or reboot to defragment |
| Fragmentation | heap too fragmented to compile (largest contiguous block <n> bytes, source is <m>) |
reboot |
The last two apply to replacements as well as new names, unlike the first two. The memory requirement scales with the source size, so a short script still installs on a device that has just refused a long one. Every cap on this route is listed in Limits.
The body is Berry source, not JSON, so the application/json guard under
Content-Type does not apply here. Any content type is accepted, including none -
the body is read verbatim either way.
curl -X PUT "http://<awtrix-ip>/api/v1/apps/script/clock" \
-H "Content-Type: text/plain" --data-binary @clock.ax
Replacing an installed name starts the script fresh: subscriptions, in-flight requests and in-memory state are dropped. The persisted store is carried across, and the script keeps its position in the rotation.
scriptLimit caps how many scripts may be resident (default 16). A
new name past the cap - or past the shared-heap soft limit - is rejected with 507 and nothing
is stored; replacing a name that is already installed always works, whatever the limit says.
A non-empty error is a successful install, not a failure
A script that does not compile still installs. The source is stored and survives a reboot,
the app joins the rotation, and it renders an ERR:<name> frame. The reply is 200 with the
compiler message in error. Read error on every install: null is the only "it works".
The value also sticks. A script that failed once keeps reporting that error, and keeps
rendering ERR:<name>, until another PUT replaces it.
GET /api/v1/apps/{name}/config¶
The settings a script offers, each with the
value it currently holds. A script that declares none answers with an empty fields list, not a
404 - and GET /api/v1/apps already says which scripts have any, under
config.
{
"name": "Weather",
"fields": [
{"key": "lat", "type": "text", "label": "Latitude",
"maxlen": 16, "default": "52.52", "value": "48.14"},
{"key": "metric", "type": "bool", "label": "Celsius", "default": true, "value": true},
{"key": "every", "type": "number", "label": "Refresh", "unit": "min",
"min": 1, "max": 60, "default": 15, "value": 30},
{"key": "mode", "type": "select", "label": "Show",
"options": ["now", "today", "week"], "default": "now", "value": "today"},
{"key": "tint", "type": "color", "label": "Colour",
"default": 16746496, "value": 65280}
],
"warnings": []
}
| Field | Meaning |
|---|---|
key |
the name the script reads with store.get(key) |
type |
bool, text, number, slider, select or color |
label |
what to show, exactly as the script author wrote it |
default |
what the script declared, so a client can offer "reset" |
value |
what it holds now - the default until somebody changes it |
help, unit, min, max, step, maxlen, options |
present only when they apply |
warnings |
@config lines AWTRIX could not read, with their line numbers |
A color is a number, 0–16777215, the same form the drawing calls take. Every other type is
the JSON type its name suggests.
| Status | Condition |
|---|---|
| 200 | the object above |
| 400 | invalidName - the name is not [A-Za-z0-9_-]{1,32} |
| 404 | notFound, no such script |
| 503 | unavailable, scripting is not available |
PATCH /api/v1/apps/{name}/config¶
Changes settings. Send only the ones you want changed; everything else keeps its value, and so does anything else the script stored.
curl -X PATCH "http://<awtrix-ip>/api/v1/apps/Weather/config" \
-H "Content-Type: application/json" -d '{"lat":"48.14","tint":"#00FF00"}'
A color accepts either the number or an HTML-style "#RRGGBB" string. A number outside its
min/max is clamped, not refused - a slider cannot send an out-of-range value, and an
automation should not have to know the range to be safe.
| Status | Body / condition |
|---|---|
| 200 | {"ok":true,"name":"X","error":null} - applied, the app restarted cleanly |
| 200 | {"ok":true,"name":"X","error":{...}} - applied, but the restarted script threw; see the error object |
| 400 | invalidName |
| 403 | forbidden - the write API is closed in AP/provisioning mode |
| 404 | notFound, no such script |
| 415 | unsupportedMediaType - the body must be application/json, unlike the source route |
| 422 | validationFailed with field - unknown key, wrong type, a select value not on the list, or text over its length |
| 422 | validationFailed, a JSON body is required - empty body |
| 503 | unavailable - scriptingEnabled is off |
| 503 | serviceBusy - a script fetch is in flight; retry, Retry-After: 2 |
| 507 | insufficientStorage - the change would push the script's storage past 2 KB, or the restart could not be given memory; nothing changed either way |
Saving restarts the script, exactly as re-uploading its source would: init() and setup() run
again and see the new values, in-memory state and subscriptions are dropped, and the app keeps its
place in the rotation. Nothing is written unless the whole body is accepted - a request with one
bad field changes none of the others.
Removing a script¶
DELETE /api/v1/apps/{name} - the same call that removes a pushed app. It erases the source, the
saved store and the app from the rotation. Deleting the same name twice is safe - the second call
still answers 200. See
DELETE /api/v1/apps/{name}.
GET /api/v1/scripts/shared¶
Everything the installed scripts have published to each other through the shared module - the
volatile, owner-scoped key/value space described under
Talking to other apps.
[{"owner":"weather","key":"temp","type":"real","value":21.5,"ageMs":3200},
{"owner":"weather","key":"unit","type":"string","value":"C","ageMs":3200}]
| Field | Meaning |
|---|---|
owner |
the install name of the script that wrote it - the only script that may |
key |
the bare key inside that script's namespace; scripts address it as owner.key |
type |
int, real, bool or string - the space holds scalars only |
value |
the value, in its own JSON type (a non-finite real is null) |
ageMs |
milliseconds since it was last written |
Grouped by owner, ordered by key within each. An empty space is [].
| Status | Condition |
|---|---|
| 200 | the array above |
| 405 | methodNotAllowed, allowed method(s): GET |
| 503 | unavailable, scripting is not available - this build has no scripting platform |
Read-only. Nothing here survives a reboot, and removing or re-saving a script drops everything it had published.
Notifications¶
POST /api/v1/notifications¶
Interrupts the rotation with a one-shot message. Accepts every app payload field (App & notification payload) plus the notification-only fields below.
| Key | Type | Default | Meaning |
|---|---|---|---|
name |
string | - | an identifier for targeted dismissal - see below |
hold |
boolean | false |
keep it on screen until dismissed |
stack |
boolean | true |
queue behind other notifications instead of replacing |
wakeup |
boolean | false |
wake the display if it is off |
sound |
string | integer | - | melody file /MELODIES/<x>.txt, or a DFPlayer track number; an integer is converted to its decimal string |
soundRtttl |
string | - | inline RTTTL melody |
soundLoop |
boolean | false |
repeat the sound |
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | body is not valid JSON (invalidJson) |
| 422 | field: "<key>" - an unknown key, an unreadable colour, an unrecognised mode word, a malformed scroll, a malformed draw command, or an effect/overlay name AWTRIX does not know |
| 507 | the notification queue is full - insufficientStorage |
| 405 | wrong method - allowed method(s): POST |
The payload is applied whole or rejected whole: any unknown key, unreadable colour or malformed
draw command answers 422 with the offending key in field and queues nothing. effect and
overlay must name something AWTRIX knows
(GET /api/v1/capabilities). The full rules are in
payload → Errors.
An array payload is accepted only when it holds exactly one element, which must be an object -
that element becomes the notification. More than one element is rejected whole with
422 validationFailed (send one notification per request; an array of more than one is not
accepted). This is not the pushed-app behaviour: notifications do not expand an array into
several entries.
A push beyond the queue's capacity is rejected with 507 insufficientStorage - see
Limits.
curl -X POST http://<awtrix-ip>/api/v1/notifications \
-H "Content-Type: application/json" \
-d '{"text":"Doorbell","icon":"1234","textColor":"#FF0000","hold":true,"soundRtttl":"d:d=4,o=5,b=120:c,e,g"}'
DELETE /api/v1/notifications/active¶
No body. Dismisses the current notification. Always 200 {"ok":true} - even when there is
none. Other methods → 405, allowed method(s): DELETE.
DELETE /api/v1/notifications/{name}¶
No body. Dismisses the notification carrying name, wherever it sits in the queue - it does
not have to be the one on screen. Removing a notification that was still waiting is invisible and
leaves the current one running.
| Status | Condition |
|---|---|
| 200 | {"ok":true} - a notification with that name was removed |
| 404 | nothing in the queue carries that name (notFound) |
| 405 | wrong method - allowed method(s): DELETE |
# push one that can be retracted later
curl -X POST http://<awtrix-ip>/api/v1/notifications -H "Content-Type: application/json" -d '{"name":"backup-job","text":"Backup running","hold":true}'
# retract it, whatever else has arrived since
curl -X DELETE http://<awtrix-ip>/api/v1/notifications/backup-job
active is reserved: /api/v1/notifications/active means "whichever notification is on screen",
so a notification named literally active cannot be addressed through this route. Pick any other
name.
A name identifies, it does not protect. Anyone who can reach the API can dismiss any name they know or guess. Restricting who may call the API at all is HTTP authentication.
Indicators¶
The three indicators are real pixels on the right edge of the panel - id 1 top, id 2 middle, id 3
bottom - honouring blinkMs (50% blink) and fadeMs (breathe). These routes store the state,
echo it back in GET /api/v1/device, and publish it over MQTT/Home Assistant.
PUT /api/v1/indicators/{id}¶
{id} must be a single character, 1, 2 or 3.
| Key | Type | Range | Default when absent | Units |
|---|---|---|---|---|
color |
color | any color form | on/off state unchanged | - |
blinkMs |
integer | 0–65535, not validated | left unchanged | ms |
fadeMs |
integer | 0–65535, not validated | left unchanged | ms |
How it behaves:
- Only the presence of
colorchanges the on/off state. A payload withoutcolorleaves on/off and the stored color untouched. - A
colorof0ornullsets the indicator off but keeps the previously stored color, so Home Assistant can republish it unchanged. - Any other
colorsets the color and turns the indicator on. A value the color parser cannot read is rejected with422 validationFailed(field: "color"), leaving the indicator and its stored color untouched. blinkMsandfadeMsare left unchanged when absent - aPUTthat only setscolordoes not touch blinking or fading. OnlyDELETE /api/v1/indicators/{id}clears everything, includingblinkMsandfadeMs, back to0.- Both are read as 16-bit values with no range check. A value that does not fit reads back as
0, the same as an absent key. - An empty body or
{}does not reset the indicator - it returns422(a JSON body is required (use DELETE to turn the indicator off)). UseDELETEto clear it.
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | body is not valid JSON (invalidJson) |
| 415 | body sent with a non-JSON Content-Type (unsupportedMediaType) |
| 422 | empty or {} body - a JSON body is required (use DELETE to turn the indicator off) |
| 404 | notFound, indicator id must be 1..3 - for any other id, including 10 |
| 404 | unknown route - for the bare path /api/v1/indicators/ |
| 405 | wrong method - allowed method(s): PUT, DELETE |
curl -X PUT http://<awtrix-ip>/api/v1/indicators/1 \
-H "Content-Type: application/json" \
-d '{"color":"#FF0000","blinkMs":500}'
DELETE /api/v1/indicators/{id}¶
No body. Full reset: off, color 0, blinkMs 0, fadeMs 0. Always 200 {"ok":true}.
Same 404 rule for ids outside 1–3.
Audio¶
Everything the speaker and the buzzer do, under one address: stored MP3s, melodies, the built-in chirp and internet radio.
GET /api/v1/audio/melodies¶
Every melody on AWTRIX, with its stored contents and what falls out of parsing it. One request for the whole list.
{"melodies":[{"name":"doorbell","rtttl":"doorbell:d=4,o=5,b=100:e,c",
"bytes":26,"notes":2,"durationMs":2400,"valid":true}],
"usedBytes":41216,"totalBytes":1048576}
| Field | Meaning |
|---|---|
name |
the melody's address - the file is /MELODIES/<name>.txt |
rtttl |
the file's contents, verbatim |
bytes |
file size |
notes, durationMs |
from parsing rtttl; both 0 when it does not parse |
valid |
whether it parses |
error, index |
present only when valid is false: the reason and the offset |
usedBytes and totalBytes cover the whole filesystem, not just melodies.
A file that does not parse is listed, not hidden: it comes back with valid: false and the
reason in error, so the editor can open it and repair it.
| Status | Condition |
|---|---|
| 200 | the listing |
| 405 | wrong method - allowed method(s): GET |
PUT /api/v1/audio/melodies/{name}¶
Saves a melody. Body: {"rtttl": "d=4,o=5,b=100:e,c"}.
{name} is 1–24 characters of A-Z, a-z, 0-9, _ and -.
The title is normalised to {name}. A two-part defaults:notes string gets the name put in
front; a three-part string has its title replaced. The stored file therefore always carries the
name it is filed under.
| Status | Condition |
|---|---|
| 201 | created |
| 200 | replaced an existing melody |
| 400 | body is not valid JSON (invalidJson) |
| 415 | Content-Type is not application/json |
| 422 | field: "name" - the name is not 1–24 of [A-Za-z0-9_-] |
| 422 | field: "rtttl" - missing, not a string, or does not parse; message carries the reason and the byte offset |
| 507 | insufficientStorage - the flash is full |
curl -X PUT http://<awtrix-ip>/api/v1/audio/melodies/doorbell \
-H "Content-Type: application/json" \
-d '{"rtttl":"d=4,o=5,b=100:e,c"}'
DELETE /api/v1/audio/melodies/{name}¶
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 404 | notFound, melody not found |
| 405 | wrong method - allowed method(s): PUT, DELETE |
There is no rename route: PUT the new name, then DELETE the old one.
POST /api/v1/audio/stop¶
Stops whatever is playing. {"ok":true}, always.
Ignores soundEnabled - it is the one sound route that still works on a muted device.
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 422 | field: "scope" - must be "sounds", "stream" or "all" |
| 405 | wrong method - allowed method(s): POST |
An optional body narrows what is stopped: {"scope":"sounds"} silences melodies, MP3s and tracks
but leaves a radio stream running, {"scope":"stream"} does the reverse, and {"scope":"all"} -
the default with no body - stops both.
POST /api/v1/audio/play¶
Every source of sound the device has, one key each. The key chooses the output.
| Key | Type | Plays |
|---|---|---|
sound |
string | a name, resolved against every output - see below |
mp3 |
string | the stored MP3 /MP3/<name>.mp3 |
melody |
string | the stored melody /MELODIES/<name>.txt |
track |
integer | a DFPlayer track, 1–2999 |
rtttl |
string | an inline RTTTL melody |
station |
string | a station from the stored list, by name |
index |
number | a station from the stored list, by position |
url |
string | a stream URL, without storing it |
Send exactly one. A key counts as present whatever its type, and a body carrying more than one
is rejected whole - nothing plays. sound, mp3, melody, track and rtttl are one-shots;
station, index and url start the radio and answer like the stream routes below.
sound is the only key that consults more than one output, and it is what a notification's
sound and a script's sound.play() mean: a stored MP3 first, then a stored melody, then a
DFPlayer track if the name is a plain number in range. The DFPlayer comes last because it is the
only one that cannot be checked before playing - the module owns its SD card.
The other four never fall back: a melody that is not stored answers 404 rather than reaching an
MP3 of the same name, and a key whose output the panel does not have answers 503.
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | body is not valid JSON (invalidJson) |
| 404 | notFound, no MP3 called "x" / no melody called "x" / nothing called "x" - nothing stored under that name |
| 422 | field names the first of them in the order above, exactly one of "sound", "mp3", "melody", "track", "rtttl", "station", "index" or "url" is allowed |
| 422 | the same list is required - the body named none of them, and no field is claimed |
| 422 | field: "rtttl" - the inline melody does not parse; message carries the reason and the byte offset |
| 422 | field: "track" - must be a number between 1 and 2999 |
| 503 | unavailable - this panel has no such output: this device has no buzzer / no DFPlayer / no MP3 output. The payload is checked first, so a malformed rtttl or track still answers 422 here |
| 405 | wrong method - allowed method(s): POST |
When settings.soundEnabled is false, the one-shot keys are muted alike: each returns 200
without producing sound, and the 404 for an unknown name does not appear. A radio stream is not
muted by that switch. Which outputs a panel has is in audio in
GET /api/v1/capabilities.
curl -X POST http://<awtrix-ip>/api/v1/audio/play \
-H "Content-Type: application/json" \
-d '{"rtttl":"beep:d=4,o=5,b=120:c,e,g"}'
Radio¶
Internet radio over an I²S DAC. ESP32-S3 only. Every route here answers 503 unavailable on a
classic ESP32, on an S3 without PSRAM, and on an S3 with the I²S pins unset.
capabilities.audio.radio says which it is, and the web UI hides the Audio tab's Radio section
when it is false.
Editing the station list is the exception - that works on every build.
Wiring, limits and troubleshooting are in Internet radio.
GET /api/v1/audio¶
Playback status and the station list in one read.
{
"available": true,
"mp3": {"playing": false, "name": ""},
"radio": {
"playing": true,
"station": "SWR3",
"title": "Kraftwerk - Das Model",
"error": "",
"underruns": 0,
"decodeUs": 4180,
"starvedMs": 0,
"bufferBytes": 12288
},
"stations": [{"name": "SWR3", "url": "https://liveradio.swr.de/sw282p3/swr3/"}]
}
| Key | Type | Meaning |
|---|---|---|
available |
boolean | Whether this build and this hardware can play at all |
mp3.playing |
boolean | A stored MP3 is playing right now |
mp3.name |
string | Which one, without the .mp3; empty when none |
radio.playing |
boolean | A stream is running |
radio.station |
string | The label that was tuned to - a station name, or the URL for an ad-hoc play |
radio.title |
string | Last track title the stream reported, UTF-8, empty until one arrives |
radio.error |
string | Why playback stopped, cleared on the next successful play |
radio.underruns |
integer | Times the output fell more than one buffer behind real time - each one is a dropout you hear |
radio.decodeUs |
integer | Rolling average microseconds to decode one MP3 frame; a frame is 26100 µs of audio |
radio.starvedMs |
integer | Milliseconds the audio task waited with nothing to decode - separates a slow network from a slow decoder |
radio.bufferBytes |
integer | Undecoded bytes still buffered - how long a network stall playback can absorb |
stations |
array | The stored list |
The four counters are playback health, not settings. underruns and starvedMs accumulate for as
long as the radio service is up rather than resetting per station, so read them as the difference
between two polls. All four are 0 when available is false.
Non-GET methods → 405, allowed method(s): GET.
Starting a stream is POST /api/v1/audio/play with station, index or
url, and stopping it is POST /api/v1/audio/stop like any other sound.
A URL that points at an .m3u or .pls playlist is resolved to its first playable entry. Those
three keys add two answers of their own: 404 when no station carries that name or position, and
503 unavailable on a build without an output - serviceBusy when an HTTPS stream is asked for
with too little free heap.
GET /api/v1/audio/mp3¶
The stored MP3s, with the same shape the file listing uses.
usedBytes and totalBytes are the whole filesystem, shared with icons, melodies and scripts.
POST /api/v1/audio/mp3¶
multipart/form-data upload of one MP3. No ?dir= - the route says where it goes.
The file name is the name the MP3 is played by, so it must be 1-32 characters of A-Za-z0-9_-
plus .mp3; anything else is refused with 400 invalidName before a byte is written. Content is
sniffed from the first chunk and must look like MPEG-1 Layer III.
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | invalidName - the file name cannot be an MP3 name |
| 415 | unsupportedMediaType - the content is not an MP3 |
| 500 | internalError - the write failed, storage full |
DELETE /api/v1/audio/mp3/{name}¶
Removes one MP3, addressed by name without the .mp3.
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | invalidName - not a possible MP3 name |
| 404 | notFound, no MP3 of that name |
PUT /api/v1/audio/stations¶
Replaces the whole list. There is no per-station route.
A bare array is also accepted. Limits: 32 stations, name 1–24 characters, URL at most 255,
scheme http or https, names unique.
Validation is all-or-nothing, and the error names the offending row:
| Status | Condition |
|---|---|
| 200 | {"ok":true}, stored and persisted |
| 400 | body is not valid JSON |
| 422 | a rejected entry, or more than 32 |
| 405 | wrong method - allowed method(s): PUT |
Capabilities¶
GET /api/v1/capabilities¶
The name lists this firmware build supports. Fetch this rather than hardcoding names.
audio is one flag per sound output, so a client can tell "no buzzer" from "no speaker":
| Flag | true when |
|---|---|
buzzer |
pinBuzzer is wired - melodies and inline RTTTL have somewhere to go |
track |
dfplayer is on and both DFPlayer pins are set |
mp3 |
stored MP3s can be played: an ESP32-S3 with PSRAM and the I²S pins set |
radio |
internet radio can be streamed - the same amplifier as mp3 |
The web UI shows the matching Audio-tab sections only when their flag is true, hides the Audio
tab itself when all four are false, and shows exactly one volume slider per flag under
System › Audio.
| Key | Count | Values |
|---|---|---|
effects |
19 | BrickBreaker, Checkerboard, ColorWaves, Fade, Fireworks, LookingEyes, Matrix, MovingLine, Pacifica, PingPong, Plasma, PlasmaCloud, Radar, Ripple, Snake, SwirlIn, SwirlOut, TheaterChase, TwinklingStars |
transitions |
22 | Random, Slide, Dim, Zoom, Rotate, Pixelate, Curtain, Ripple, Blink, Reload, Fade, Cover, Uncover, Split, Blinds, Blocks, Flash, Diamond, Wave, Rain, Melt, Interlace |
overlays |
6 | drizzle, frost, rain, snow, storm, thunder |
palettes |
8 | Cloud, Lava, Ocean, Forest, Stripe, Party, Heat, Rainbow - the built-ins only; list palette files with GET /api/v1/files?dir=/PALETTES |
gpio |
- | the chip's pin rules - see below |
gpio: what the chip can do¶
The pin fields under /api/v1/system are validated against the chip the
firmware was built for, and the rules differ sharply between them: an ESP32 has GPIO 0–39 with
34–39 input-only and ADC1 on 32–39, an ESP32-S3 has 0–48 with no input-only pins at all and ADC1
on 1–10. Read them here instead of hardcoding a table per chip.
"gpio":{"soc":"esp32s3","label":"ESP32-S3","max":48,
"missing":[[22,25]],"inputOnly":[],
"reserved":[{"lo":19,"hi":20,"why":"the USB-JTAG interface"},
{"lo":26,"hi":37,"why":"the SPI flash and PSRAM"},
{"lo":43,"hi":44,"why":"the UART0 console"}],
"adc1":[[1,10]],"strapping":[[0,0],[3,3],[45,46]],"rtc":[[0,21]],
"matrix":[13,14,15,16,17,18,21,38,39,40,41,42,47],
"defaults":{"pinMatrix":21, ...}}
| Key | Meaning |
|---|---|
soc, label |
chip id and display name; same value as soc in the device state |
max |
highest GPIO number that exists |
missing |
inclusive ranges inside 0…max the package does not bond out - rejected |
inputOnly |
cannot drive an output, so they are refused for the matrix, buttons, buzzer, I²C and DFPlayer TX. Empty on the ESP32-S3 |
reserved |
taken by the flash, PSRAM, USB or the console. Each carries why, which is also what the rejection message says |
adc1 |
the only pins accepted for pinBattery and pinLdr - ADC2 stops working while WiFi is on |
strapping |
boot-mode pins. Reported as a caution, never rejected: the ESP32 defaults already use one of them (the buzzer, GPIO 15) |
rtc |
pins the RTC domain keeps powered during deep sleep. Only a pinBtnSelect inside this set can end a POST /api/v1/device/sleep early; anything else is accepted and simply cannot wake AWTRIX |
matrix |
the only values pinMatrix accepts - fixed by the firmware image, not a preference |
defaults |
the pin map a factory-fresh device of this chip starts with, and the one it falls back to if the stored map fails validation |
effects and overlays come out ASCII-sorted; transitions has its own fixed order. Effect,
overlay, palette and transition names are all matched case-insensitively - "matrix",
"Matrix" and "MATRIX" are the same effect. This response lists the spelling the API itself
returns, so it stays the list to copy from.
Non-GET methods → 405, allowed method(s): GET.
System¶
Device configuration: Wi-Fi, MQTT, NTP, auth, hardware and the GPIO map. Prose for each field: System configuration. GPIO rules in depth: GPIO & boards.
GET /api/v1/system¶
Returns 64 of the 67 configuration fields. The JSON key is the field name for every one.
wifiPass, mqttPass and authPass are omitted by default - that is the 64 of 67 above. Pass
?secrets=1 to include them, so a backup can round-trip the Wi-Fi, MQTT and auth credentials:
The parameter is ignored in provisioning (AP) mode, since that access point is open. When HTTP
auth is enabled it gates this request like any other. PUT responses never include the secrets.
PUT /api/v1/system¶
Partial merge. Every scalar is range- and type-checked and the merged GPIO map is validated as a whole before anything is stored. On success AWTRIX answers 200 with the full resulting configuration (secrets still omitted) and saves it.
| Status | Condition |
|---|---|
| 200 | the resulting configuration |
| 400 | invalidJson, request body is not valid JSON |
| 400 | invalidPinConfig + the validator's message - nothing was saved |
| 422 | validationFailed - a numeric field is out of range or the wrong type, or a load-bearing string was blanked; field names it |
| 405 | wrong method - allowed method(s): GET, PUT |
Behaviour to know:
- Numeric fields are validated before anything is stored. A value outside the documented range
answers
422 validationFailedwith the offending key infield, and nothing is saved. The ranges are in the field table below; the full list is also in Errors →PUT /api/v1/system. - Unknown keys are silently ignored - this is a partial merge, not a strict schema. Strings
(
tz,hostname,buttonCallback, …) are not range-checked either. - The deeper GPIO rules still answer 400. Duplicate pins, input-only pins and the matrix
driver whitelist are
invalidPinConfig, not 422 - see GPIO validation. - Secrets honour skip-empty: sending
"authPass": ""leaves the stored password alone rather than clearing it. Most other strings can be cleared with""-ntpServer,ip,mqttUserand so on. -
MQTT and HTTP auth are gated by a switch.
mqttEnabledruns the MQTT client andauthEnabledrequires HTTP Basic auth; each runs only while its flag istrue, and turning it off keeps the stored host/username/password.mqttHostandauthUserare ordinary strings you may blank freely. A gate is refused unless it has what it needs, with422 validationFailedand the key infield:Set Requires Field named on 422mqttEnabled: truea non-empty mqttHostmqttHostauthEnabled: truea non-empty authUserandauthPassauthUser -
wifiSsidcannot be blanked. It answers422 validationFailed(field: wifiSsid) and points atPOST /api/v1/device/factory-reset. To clear stored secrets entirely, use that same route - aPUTnever clears a secret. -
Most changes, and all pin changes, apply after a reboot. The web UI shows a "reboot required" banner in that case.
Fields¶
| Key | Type | Default | Units / notes |
|---|---|---|---|
wifiSsid |
string | "" |
|
wifiPass |
string | "" |
secret - omitted on read, "" ignored on write |
netStatic |
boolean | false |
static IP instead of DHCP |
ip |
string | "" |
accepts a CIDR suffix (192.168.1.50/24), stored as ip + subnet |
gateway |
string | "" |
|
subnet |
string | "" |
|
dns1 |
string | "" |
|
dns2 |
string | "" |
|
wifiConnectTimeout |
long | 15000 |
boot join timeout in ms (5000–120000) before falling back to the provisioning AP |
wifiRoamRssi |
int | 0 |
roam below this RSSI in dBm (−90–0); 0 = off |
mqttEnabled |
boolean | false |
master switch; true needs a non-empty mqttHost |
mqttHost |
string | "" |
|
mqttPort |
integer | 1883 |
1–65535 |
mqttUser |
string | "" |
|
mqttPass |
string | "" |
secret |
mqttPrefix |
string | "" |
empty falls back to the device uid |
haDiscovery |
boolean | false |
Home Assistant auto-discovery |
haPrefix |
string | "homeassistant" |
|
ntpServer |
string | "pool.ntp.org" |
|
tz |
string | "CET-1CEST,M3.5.0,M10.5.0/3" |
POSIX TZ string, daylight-saving rules included |
tzName |
string | "Europe/Berlin" |
IANA zone tz was picked from; display only |
hostname |
string | "" |
empty becomes awtrixng-<uid> |
webPort |
integer | 80 |
0–65535; 0 falls back to 80; AP mode always uses 80 |
authEnabled |
boolean | false |
master switch for HTTP Basic auth; true needs authUser and authPass |
authUser |
string | "" |
|
authPass |
string | "" |
secret |
tempOffset |
number | -9.0 |
°C, −20–20 |
humOffset |
number | 0.0 |
%, −50–50 |
batteryDividerRatio |
number | 1.79 |
0.1–10; V_cell / V_pin - calibrate as 4.2 / (batteryPinMillivolts / 1000) on a full cell |
minBrightness |
integer | 10 |
0–255 |
maxBrightness |
integer | 220 |
0–255 |
ldrFactor |
number | 1.0 |
0–10 |
ldrGamma |
number | 2.2 |
0.1–10; 1.0 = curve off (neutral) |
ldrOnGround |
boolean | false |
LDR wiring orientation |
brightnessSmoothing |
long | 10000 |
ms the panel takes to follow an ambient-light change (0–60000); 0 = instantly |
lowBatteryThreshold |
integer | 0 |
0–100 %; below it GET /api/v1/device reports lowBattery: true. 0 = off |
panelWidth |
integer | 32 |
1–128; panelWidth × panels must come to 32–128 |
panels |
integer | 1 |
1–128; how many panels the strip runs through |
panelStart |
string | "topLeft" |
topLeft · topRight · bottomLeft · bottomRight |
panelWiring |
string | "rows" |
rows · columns |
panelSerpentine |
boolean | true |
every second row or column runs backwards |
mirror |
boolean | false |
mirrors the displayed image, not the wiring |
rotate |
boolean | false |
rotates the displayed image 180°; also swaps left/right buttons |
swapButtons |
boolean | false |
|
dfplayer |
boolean | false |
Talk to a DFPlayer Mini on the DF pins. The buzzer keeps working alongside it; the flag does not gate DF-pin validation |
buttonCallback |
string | "" |
HTTP webhook URL fired on button press |
artnet |
boolean | false |
opt-in Art-Net DMX receiver (UDP 6454); the socket stays closed while false |
statsInterval |
integer | 10000 |
ms, 1000–600000 |
tempDecimals |
integer | 0 |
0–2 |
debugMode |
boolean | false |
gates verbose request/command tracing |
scriptingEnabled |
boolean | true |
whether the Berry stack exists at all; off frees ~40 KB RAM, script routes answer 503. Applies after reboot - see System |
scriptLimit |
integer | 16 |
0–32; how many Berry scripts may be resident. Lowering it below the number installed refuses new names without removing any |
scriptMaxBytes |
integer | 16384 |
1024–32768; largest script source accepted. Lowering it refuses larger new installs but never drops a stored script |
pinMatrix |
integer | 32 |
always treated as enabled |
pinBtnLeft |
integer | 26 |
-1 = disabled |
pinBtnSelect |
integer | 27 |
-1 = disabled |
pinBtnRight |
integer | 14 |
-1 = disabled |
pinBattery |
integer | 34 |
-1 removes all battery fields and the Battery app |
pinLdr |
integer | 35 |
-1 = disabled |
pinBuzzer |
integer | 15 |
-1 = disabled |
pinI2cSda |
integer | 21 |
-1 = disabled |
pinI2cScl |
integer | 22 |
-1 = disabled |
pinDfRx |
integer | 23 |
validated whenever set (≥ 0); dfplayer does not gate it |
pinDfTx |
integer | 18 |
validated whenever set (≥ 0); dfplayer does not gate it |
pinI2sBclk |
integer | -1 |
I²S bit clock. ESP32-S3 only; -1 on the ESP32 |
pinI2sLrclk |
integer | -1 |
I²S word-select clock. ESP32-S3 only; -1 on the ESP32 |
pinI2sDout |
integer | -1 |
I²S data to the DAC. ESP32-S3 only; -1 on the ESP32 |
The three pinI2s* fields are one bus and are validated together: give all three, or -1 for all
three. A half-configured set is a 422 validationFailed naming the missing pin.
Defaults are the stock ESP32 pin map. -1 disables a feature. Every pin* field must be -1 or a
GPIO within the compiled chip's range (0–39 on the ESP32, 0–48 except 22–25 on the ESP32-S3);
anything else is a 422 validationFailed before the pin map is even considered.
GPIO validation¶
pinMatrix is checked first; then every pin is walked in field order - pinMatrix, pinBtnLeft,
pinBtnSelect, pinBtnRight, pinBattery, pinLdr, pinBuzzer, pinI2cSda, pinI2cScl,
pinDfRx, pinDfTx, pinI2sBclk, pinI2sLrclk, pinI2sDout - each checked against the
range/reserved/input-only rules before the next
pin, so it is the pin earlier in this list that gets reported. The ADC1 and duplicate checks run
after every pin has passed the per-pin rules. The first failure wins and becomes error.message
of a 400 invalidPinConfig.
The six ordered rules and their exact per-chip messages (they name the compiled chip - ESP32 vs ESP32-S3 - and its ranges differ) are documented once in GPIO & boards - the one page to read on GPIO.
Output-needing pins are pinMatrix, the three button pins (they need INPUT_PULLUP),
pinBuzzer, pinI2cSda, pinI2cScl, pinDfTx and the three pinI2s* lines. pinMatrix is always treated as enabled;
every other pin - including pinDfRx and pinDfTx - is enabled, and validated, whenever it is
>= 0; dfplayer does not gate whether the DF pins are checked, only whether the DFPlayer is
actually driven.
curl -X PUT http://<awtrix-ip>/api/v1/system \
-H "Content-Type: application/json" \
-d '{"ntpServer":"192.168.1.1","statsInterval":30000}'
GET /api/v1/system/wifi-scan¶
Asynchronous. Poll it.
| Status | Body | Condition |
|---|---|---|
| 202 | {"scanning":true} |
no scan had been started - one is kicked off now - or a scan is still running |
| 200 | array of networks | results are ready (possibly []) |
| Key | Type | Meaning |
|---|---|---|
ssid |
string | |
rssi |
integer | dBm |
enc |
boolean | false for an open network |
Results are deleted after being served, so the next request starts a fresh scan and returns
202 again. There is no caching. GET only; other methods → 405, allowed method(s): GET.
GET /api/v1/logs¶
The incremental device log behind the web UI console.
| Query param | Type | Default | Meaning |
|---|---|---|---|
after |
integer | 0 |
return only lines with a sequence number greater than this |
| Key | Type | Meaning |
|---|---|---|
next |
integer | sequence of the newest buffered line; 0 when the buffer is empty |
lines |
array of strings | every buffered line with seq > after |
Poll incrementally by passing the previous next back as after. AWTRIX keeps the last 34
lines, each capped at 120 characters; once full, the oldest is dropped as a new one arrives.
Each line is prefixed HH:MM:SS once NTP has synced, and with the uptime in seconds -
[ 123s] - before that.
GET only; other methods → 405, allowed method(s): GET.
Files¶
The filesystem holds your icons, melodies, palettes and MP3s. How much room it has depends on the flash
size of the board - see Limits. GET /api/v1/files reports the real figures
as usedBytes and totalBytes.
GET /api/v1/files¶
| Query param | Type | Default | Meaning |
|---|---|---|---|
dir |
string | /ICONS |
directory to list |
| Key | Type | Meaning |
|---|---|---|
files |
array | {name: string, size: integer} per entry |
usedBytes |
integer | LittleFS bytes in use |
totalBytes |
integer | LittleFS partition size |
A dir that does not exist, or is not a directory, returns 200 with an empty files array
- not a 404. This read path uses dir verbatim: no leading-slash fixup and no .. traversal
guard.
POST /api/v1/files¶
multipart/form-data upload. There is no
captive-portal redirect on this route; auth is re-checked inside the upload handler instead.
| Query param | Type | Default | Meaning |
|---|---|---|---|
dir |
string | /ICONS |
target directory - ignored when the multipart filename starts with / |
Path resolution:
- filename starts with
/→ used as the absolute path,?dir=ignored - otherwise →
<dir>/<filename>, with a leading/prepended todirif missing - the resolved path must be under
/ICONS,/MELODIES,/PALETTESor/MP3and contain no..- anything else is rejected with400 invalidPathbefore a byte is written - a file for
/MP3must be named the way an MP3 is played: 1-32 characters ofA-Za-z0-9_-plus.mp3. Anything else is rejected with400 invalidName, also before a byte is written - an MP3 whose name the play route cannot form could never be played back - the parent directory is created if it does not exist
The multipart field name is irrelevant - any file part is accepted.
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | invalidPath - the target escapes the four asset folders, or contains .. |
| 400 | invalidName - a /MP3 upload whose file name is not [A-Za-z0-9_-]{1,32}.mp3 |
| 401 | auth failed - returned only after the entire body has been consumed |
| 403 | forbidden - file upload is disabled in AP/provisioning mode |
| 415 | unsupportedMediaType - the content does not match the target folder: /ICONS needs GIF or JPEG magic bytes, /MELODIES must parse as RTTTL text, /PALETTES must be plain RRGGBB-per-line text, /MP3 needs MP3 magic bytes (an ID3 tag or a frame sync) |
| 500 | internalError - the write failed (storage full); nothing is left behind |
PNG is served correctly once on AWTRIX, but it is not accepted by the /ICONS upload check -
only GIF and JPEG pass.
Verify an upload with GET /api/v1/files.
DELETE /api/v1/files¶
| Query param | Type | Required | Meaning |
|---|---|---|---|
path |
string | yes | full path of the file to remove |
Deletion is confined to /ICONS, /MELODIES, /PALETTES and /MP3: path must be under one
of those folders and contain no ...
| Status | Condition |
|---|---|
| 200 | {"ok":true} |
| 400 | invalidPath - missing path, a .. traversal, or a path outside the four asset folders |
| 404 | notFound, file not found - the path is valid but no such file exists |
| 405 | wrong method - allowed method(s): GET, POST, DELETE |
Firmware upload¶
POST /update¶
multipart/form-data upload of a firmware image - the only way firmware reaches AWTRIX over the
network, and what the web UI uses. Auth is re-checked inside the upload handler.
| Status | Condition |
|---|---|
| 200 | {"ok":true} - AWTRIX then reboots into the new image |
| 400 | wrongChip - the image was built for the other chip (esp32 vs esp32s3), for the other kind of ESP32-S3 PSRAM (quad vs octal), is a usb-*.bin for a first flash over USB, or carries no valid firmware header |
| 401 | auth failed |
| 403 | forbidden - firmware upload is disabled in AP/provisioning mode |
| 500 | internalError, firmware update failed (bad image or storage full) - the OTA slot could not be written |
The image is size-checked against the free firmware slot before any byte is written, and a refused image never replaces the running one: AWTRIX switches slots only after a whole image has arrived and verified.
Backup restore¶
POST /api/v1/restore¶
multipart/form-data upload of a backup .zip - the store-only (uncompressed) archive the web
UI produces. It is reachable during provisioning, so a blank AWTRIX can be restored, Wi-Fi
credentials included, from a backup alone. Auth is re-checked inside the upload handler, the same
rule as POST /api/v1/files - a device with no auth user configured (a blank
one in AP mode) lets the restore through, one with authEnabled still requires the credentials.
Entries are matched by name; manifest.json must be first and is validated - app awtrix-ng, a
supported backupFormat - before anything else in the archive is touched.
| Entry | Effect |
|---|---|
manifest.json |
must be first; rejects the whole archive if it does not check out |
config/wifi.json |
{wifiSsid, wifiPass} merged into the Wi-Fi config |
config/system.json |
the rest of device configuration, validated like PUT /api/v1/system |
config/settings.json |
display/behaviour settings, validated like PATCH /api/v1/settings - applied to the running device immediately |
apploop.json |
app rotation order and the switched-off list, same shape as PUT /api/v1/apps/order |
ICONS/*, MELODIES/*, PALETTES/*, MP3/*, SCRIPTS/* |
written under the matching directory |
Content is checked per folder exactly like POST /api/v1/files: a path that
escapes its folder, an entry whose CRC does not check out, or a file whose content does not match
its folder is skipped with a warning rather than trusted, and does not abort the rest of the
restore. An unrecognized entry name is skipped and warned about too.
| Status | Body | Condition |
|---|---|---|
| 200 | {"ok":true,"applied":{...},"warnings":[...]} |
the archive had a valid manifest - even if every other entry was skipped |
| 400 | {"ok":false,"error":"..."} |
the archive was rejected outright - not a zip, no manifest.json, a manifest naming a different app, or an unsupported backupFormat |
| 401 | auth failed |
A restore can partially succeed, so this route answers its own JSON shape instead of the
error body used everywhere else: ok, an applied object counting how many entries
of each kind were actually applied (wifi, system, settings, appLoop, radioStations,
icons, melodies, palettes, MP3s, scripts, plus skipped for rejected entries), and a warnings array with one
string per skipped or rejected entry.
Config changes that only take effect at boot - new Wi-Fi credentials, config/system.json - need
a reboot to apply; POST /api/v1/device/reboot is allowed in AP mode
too.
Web UI and static assets¶
GET /¶
The gzipped web UI, embedded in the firmware. Served for / and /index.html.
| Status | Condition |
|---|---|
| 304 | the request's If-None-Match matches the build's ETag; empty body |
| 200 | text/html with Content-Encoding: gzip, ETag: <build etag>, Cache-Control: no-cache |
Authentication applies. This branch is not method-gated: any method reaches it.
GET /ICONS/, /MELODIES/, /PALETTES/, /MP3/, /SCRIPTS/*, /apploop.json¶
Static files from LittleFS. GET only. /ICONS/, /MELODIES/, /PALETTES/ and /MP3/
are served in every mode; /SCRIPTS/* and /apploop.json are served over GET only outside provisioning
AP mode (they are part of the backup-readable set). Any path containing .. is rejected before
the filesystem is touched. Everything else falls through to the 404 below.
| Status | Condition |
|---|---|
| 200 | the file, with Cache-Control: max-age=3600 |
| 404 | notFound, file not found - missing file, or the path is a directory |
MIME type by extension: .jpg/.jpeg → image/jpeg, .gif → image/gif, .png →
image/png, .txt → text/plain, .mp3 → audio/mpeg, anything else →
application/octet-stream.
Authentication applies.
Unknown routes¶
Anything not matched above answers 404 notFound with message unknown route.
Route index¶
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/device |
state & statistics |
| GET | /api/v1/version |
JSON version |
| GET | /version |
plain-text version |
| POST | /api/v1/device/reboot |
200, then reboots |
| POST | /api/v1/device/sleep |
200, then sleeps |
| POST | /api/v1/device/factory-reset |
200, then resets |
| GET | /api/v1/settings |
40 keys |
| PATCH | /api/v1/settings |
returns the full resource |
| POST | /api/v1/settings/reset |
200, then resets |
| GET | /api/v1/display |
power, brightness, overlay, moodlight |
| PATCH | /api/v1/display |
power / overlay |
| PUT | /api/v1/display/moodlight |
flood color; {} → 422 |
| DELETE | /api/v1/display/moodlight |
off |
| GET | /api/v1/display/screen |
framebuffer |
| GET | /api/v1/apps |
inventory |
| PUT | /api/v1/apps/active |
switch |
| POST | /api/v1/apps/next |
next |
| POST | /api/v1/apps/previous |
previous |
| PUT | /api/v1/apps/order |
what is on, and in what order |
| PUT | /api/v1/apps/pushed/{name} |
{} → 422; 507 at cap |
| GET | /api/v1/apps/script/{name} |
raw Berry source, text/plain |
| PUT | /api/v1/apps/script/{name} |
body is Berry source; a compile error is still a 200 |
| GET | /api/v1/scripts/shared |
what the scripts publish to each other |
| DELETE | /api/v1/apps/{name} |
any kind of app; safe to repeat |
| POST | /api/v1/notifications |
400 / 507 on failure |
| DELETE | /api/v1/notifications/active |
dismiss |
| DELETE | /api/v1/notifications/{name} |
dismiss by name, anywhere in the queue |
| PUT | /api/v1/indicators/{id} |
corner pixels |
| DELETE | /api/v1/indicators/{id} |
clears the indicator |
| GET | /api/v1/audio/melodies |
every melody, parsed |
| PUT | /api/v1/audio/melodies/{name} |
save; the title is normalised to {name} |
| DELETE | /api/v1/audio/melodies/{name} |
404 if absent |
| POST | /api/v1/audio/play |
exactly one of sound/mp3/melody/track/rtttl/station/index/url |
| POST | /api/v1/audio/stop |
optional scope; ignores the mute |
| GET | /api/v1/audio |
what is sounding, and the station list |
| GET | /api/v1/audio/mp3 |
stored MP3s |
| POST | /api/v1/audio/mp3 |
upload an MP3 |
| DELETE | /api/v1/audio/mp3/{name} |
remove an MP3 |
| PUT | /api/v1/audio/stations |
replaces the whole list |
| GET | /api/v1/capabilities |
names this build supports |
| GET | /api/v1/system |
64 of 67 fields |
| PUT | /api/v1/system |
partial merge + pin validation |
| GET | /api/v1/system/wifi-scan |
async, 202 while running |
| GET | /api/v1/logs |
incremental |
| GET | /api/v1/files |
list |
| POST | /api/v1/files |
multipart upload |
| DELETE | /api/v1/files |
by ?path=, allowlisted |
| POST | /update |
firmware image |
| POST | /api/v1/restore |
backup ZIP; available in AP mode |
| GET | /, /index.html |
web UI |
| GET | /ICONS/*, /MELODIES/*, /PALETTES/*, /MP3/*, /SCRIPTS/*, /apploop.json |
static assets |