Visual reference¶
Every visual name AWTRIX NG understands, exactly as it appears on the wire.
There are four separate lists of names, and they do not mix:
| Name list | Where it goes | Case |
|---|---|---|
| Background effects | effect on a pushed-app / notification payload |
insensitive |
| Weather overlays | overlay on a payload, or overlay on PATCH /api/v1/display |
insensitive |
| Palettes | palette on a payload |
insensitive |
| Transitions | transitionEffect in PATCH /api/v1/settings (device-wide) |
insensitive |
All four resolve case-insensitively - "matrix", "Matrix" and "MATRIX" are the same
effect. The spelling used throughout this page is the one
GET /api/v1/capabilities lists, and the one the API returns.
Ripple and Fade each appear in two lists - once as a background effect, once as a transition -
and the two behaviours are unrelated. Random is a transition name only. Read a name in the
context of the key that carries it.
Background effects¶
A background effect fills the page behind text, icons and charts. Set it per app with the
effect key. The name is matched case-insensitively.
curl -X POST 'http://<awtrix-ip>/api/v1/apps?name=demo' \
-H 'Content-Type: application/json' \
-d '{"text":"HELLO","effect":"Pacifica"}'
AWTRIX draws 19 background effects:
effect |
What it draws | Honours palette? |
|---|---|---|
Plasma |
Full-canvas sine plasma, hue sweeping the whole wheel | yes |
TheaterChase |
Every third column lit, marching sideways (unlit pixels black) | yes |
Fade |
Whole canvas pulsing one colour; stock look is a dark-blue breath | yes |
MovingLine |
One full-height vertical line sweeping left → right | yes |
BrickBreaker |
Three rows of bricks, white ball, grey paddle on the bottom row | no |
PingPong |
A single pixel bouncing horizontally and vertically | no |
Radar |
Sweeping radius line from the centre; stock look is green | yes |
Checkerboard |
2×2-cell checkerboard, inverting every animation step | yes |
Fireworks |
An expanding ring burst at a random position and colour every 20 steps | yes |
PlasmaCloud |
Softer, slower plasma with a narrower hue band | yes |
Ripple |
A single ring expanding from the centre, repeating | yes |
Snake |
A 6-pixel snake advancing one pixel per frame, wrapping row by row | yes |
Pacifica |
Ocean waves; stock look is blue-teal | yes |
Matrix |
Per-column falling green trails, bright-headed, with random phases and trail lengths | no |
SwirlIn |
A 48-point spiral converging inward | yes |
SwirlOut |
A 48-point spiral expanding outward | yes |
LookingEyes |
Two 8x8 eyes that glance around and blink every few seconds | no |
TwinklingStars |
22 randomly placed stars twinkling on a 30-step cycle | yes |
ColorWaves |
Horizontal hue sweep across the panel | yes |
An empty effect string, or no effect key at all, draws no background.
An effect marked no above has fixed colours: sending a palette alongside it returns success
and changes nothing on screen.
A name AWTRIX does not know ("Plasm") fails validation with
422 {"code":"validationFailed","field":"effect"} and nothing is stored - array payloads are
all-or-nothing. Casing is not a typo: "pacifica" resolves to Pacifica. Check spelling against
GET /api/v1/capabilities.
Effect settings¶
An effect takes two things from the payload it rides on: a speed of its own, and the app's palette, which it shares with the text and the charts.
| Key | Type | Range | Default | Units | Meaning |
|---|---|---|---|---|---|
effectSpeed |
float | 0.1 … 10 |
1.0 |
multiplier | Multiplier on the effect's own base rate (clamped) |
palette |
string or array | built-in name, file name, or ≤ 16 stops | unset - effect keeps its own colours | - | Colour source for palette-driven effects and weather overlays |
paletteBlend |
bool | - | true |
- | Interpolate between palette entries instead of using 16 hard bands |
curl -X POST 'http://<awtrix-ip>/api/v1/apps?name=demo' -H 'Content-Type: application/json' -d '{"text":"HI","effect":"Plasma","effectSpeed":0.4,"palette":"Lava"}'
How effectSpeed works¶
Every effect and overlay has a base rate: how many animation steps it takes per second, where
one step is one visible state change - one pixel of travel, one checkerboard flip, one chase offset.
effectSpeed is a multiplier on that base rate: 2.0 runs an animation twice as fast, 0.5
half as fast.
The base rates are calibrated against each other, so 1 means the same perceived pace everywhere.
Effects still differ in character - storm falls faster than drizzle.
A value outside 0.1 … 10 is silently pulled to the nearest bound rather than rejected.
effectSpeed drives the resolved background effect and an overlay named on the same payload,
so storm at 2 falls twice as fast. An overlay set device-wide is tuned separately, through
overlaySettings on PATCH /api/v1/display.
How palette works¶
Three accepted forms:
Matched case-insensitively, against /PALETTES/<name>.txt on the filesystem first and the
built-in names second - so a file of that name wins. If neither exists the payload
is rejected with 422 {"code":"validationFailed","field":"palette"}. A typo does not become
a built-in palette.
1 to 16 colour stops; entries past the 16th are dropped. Each accepts every colour form. The stops are spread evenly across all 16 palette entries, with the colours in between blended, so two stops describe a complete ramp. An empty array is rejected.
{"palette": [{"color": "#FF0000", "pos": 0},
{"color": "#FFFF00", "pos": 70},
{"color": "#FFFFFF", "pos": 100}]}
pos is 0-100, where the stop sits across the ramp - the same thing RRGGBB@70 says in a
palette file.
Both keys are required, and the forms cannot be mixed: an array that places some stops and not others is rejected. Out-of-order stops are sorted.
One palette serves the whole app. What paints out of it is chosen per consumer, by setting a colour
field to the string "palette":
An effect that honours palettes takes it without being asked; it has no colour field to opt in with.
The palette is per app¶
The palette and effectSpeed you send belong to that app alone. If app A and app B both use
Plasma and app B sets palette: "Lava", app A keeps its own colours. Deleting an app that set
a palette leaves no residue either - the next app to use that effect starts from its stock colours.
Weather overlays¶
An overlay draws on top of the finished page - it never clears the canvas, so text and icons
stay visible underneath. Overlay names are matched case-insensitively: rain, Rain and RAIN
are the same overlay.
AWTRIX draws 6 weather overlays:
overlay |
What it draws | Base rate |
|---|---|---|
rain |
Scattered blue drops falling plumb, with short darker tails - about a third of the columns are wet at a time | ~15 steps/s |
snow |
Bright and dim grey flakes, swaying sideways as they fall | ~7 steps/s |
drizzle |
Fine mist - sparse bare flecks, lighter blue, hardly any tail | ~10 steps/s |
storm |
Dense wind-slanted streaks - long tails, drifting one column per three rows | ~25 steps/s |
thunder |
storm, plus a full-white flash at irregular intervals a few seconds apart, sometimes double-blinking |
~25 steps/s |
frost |
Static irregular icy crust along the top and bottom edges | none - it does not animate |
One step moves every drop down one pixel, so rain takes about half a second to cross the panel.
The rates differ - heavier weather falls faster - and the speed multiplier (effectSpeed on a
payload, speed in overlaySettings) scales whichever one applies. frost draws the same frame
whatever the speed is set to. Drop placement is pseudo-random, so the fall never settles into a
repeating pattern.
Overlay colours go through the palette, so a supplied palette recolours all six,
frost included. Without a palette the stock colours are unchanged.
# red rain
curl -X POST 'http://<awtrix-ip>/api/v1/apps?name=weather' \
-H 'Content-Type: application/json' \
-d '{"text":"7C","overlay":"rain","palette":"Lava"}'
Setting an overlay per app¶
curl -X POST 'http://<awtrix-ip>/api/v1/apps?name=weather' \
-H 'Content-Type: application/json' \
-d '{"text":"7C","overlay":"snow"}'
An unknown per-app overlay name fails with 422 {"code":"validationFailed","field":"overlay"}
and nothing is stored.
Setting the global overlay¶
PATCH /api/v1/display sets a device-wide overlay. null clears it.
curl -X PATCH http://<awtrix-ip>/api/v1/display \
-H 'Content-Type: application/json' \
-d '{"overlay":"rain"}'
# clear it
curl -X PATCH http://<awtrix-ip>/api/v1/display \
-H 'Content-Type: application/json' \
-d '{"overlay":null}'
| Key | Type | Range | Default | Units | Meaning |
|---|---|---|---|---|---|
overlay |
string or null | one of the 6 names, or null |
none | - | Device-wide weather overlay |
overlaySettings |
object | {speed, palette, blend} |
unset | - | Tunes the device-wide overlay |
Both keys are validated. A non-string, non-null overlay gives
422 {"overlay","must be a string or null"}; an unknown name gives
422 {"overlay","unknown overlay"}; a non-object overlaySettings gives
422 {"overlaySettings","must be an object"}. The whole PATCH applies completely or not at all.
# calm the global rain down to a third of its pace
curl -X PATCH http://<awtrix-ip>/api/v1/display \
-H 'Content-Type: application/json' \
-d '{"overlay":"rain","overlaySettings":{"speed":0.3}}'
Settings belong to the overlay they were set for: {"overlay":null} drops them as well, so they
never resurface on whatever overlay you pick next.
Both values are readable from GET /api/v1/display - overlay (null when none) and
overlaySettings, which always reports the effective speed, palette (null when unset) and
blend.
The Web UI exposes the speed as an Overlay speed slider under Display → Weather overlay, in percent: 100 % is the overlay's own pace.
Which overlay wins¶
- Pushed apps and notifications: a per-app
overlaywins over the global one. The global overlay applies only when the payload carries nooverlay. - Built-in apps (time, date, temperature, humidity, battery): only the global overlay applies. These apps have no payload, so there is no per-app overlay to consult.
- Settings follow the overlay. An overlay named on a payload is driven by that payload's
own
effectSpeedandpalette; the device-wide overlay is driven byoverlaySettings, whichever app is on screen. An app's settings never reach an overlay the app did not name.
Palettes¶
A palette is a 16-entry colour table. Every effect, overlay and text colour that draws from a palette picks its colours out of those 16 entries.
8 built-in names are accepted by palette (case-insensitive):
| Palette | Character |
|---|---|
Cloud |
Blues and dark blues, rising to sky blue and one white entry |
Lava |
Black → maroon → dark red → red → orange, with one white peak |
Ocean |
Midnight blue, navy, teal, sea green, aqua, light sky blue |
Forest |
Dark green → forest/olive → lime and lawn green |
Stripe |
Eight saturated hues alternating with black - hard stripes |
Party |
Purples, magentas, reds, oranges and yellows - no green band |
Heat |
Black → red → yellow → white ramp |
Rainbow |
Full hue wheel in 16 steps |
What blend changes¶
Every palette holds 16 colours. As an effect animates, it sweeps through them in order and wraps from the last back to the first.
paletteBlend: true(the default) - the colour slides smoothly from one entry to the next, so the sweep looks like a continuous gradient.paletteBlend: false- only the 16 colours themselves are used, giving 16 hard colour bands.
Custom palettes¶
Upload /PALETTES/<name>.txt to the AWTRIX filesystem - one stop per line, one to sixteen lines.
A leading # is tolerated and blank lines are skipped. Then reference it by file name (without
.txt) in palette. Names containing / or .. are rejected, and the file name is matched
case-insensitively like every other name in the API.
Two line forms:
| Line | Means |
|---|---|
RRGGBB |
a stop with no position - the stops are spread evenly |
RRGGBB@<0-100> |
a stop at that percentage of the ramp |
The lines are colour stops, interpolated across all 16 entries: a three-line file describes a three-colour ramp, not three colours followed by thirteen leftovers. With positions, each stop decides how much of the ramp it gets - the example above spends 70% of its length going red to yellow. Before the first stop and after the last the colour is flat, the way a gradient's ends extend, and two stops at the same position are a hard edge.
Positions are all or nothing: a file that gives some lines a position and not others is rejected.
Stops written out of order are sorted. Anything else malformed - a line that is not six hex digits, a
position above 100 - rejects the file, which comes back as 422 on the palette field of whatever
names it.
A name is looked up as a file first and only then as a built-in, so a file may carry a built-in's
name and replaces that palette while it exists - deleting the file restores the original. That is how
a built-in is edited: the palette editor writes such
a file when you change one. A name that matches neither is rejected with 422 validationFailed on
the palette field.
Transitions¶
A transition animates the change from one app's page to the next. It is a device-wide setting, not a per-app key: there is no way to give one app its own transition.
On the wire transitionEffect is a name (a string), sent to PATCH /api/v1/settings:
curl -X PATCH http://<awtrix-ip>/api/v1/settings \
-H 'Content-Type: application/json' \
-d '{"transitionEffect":"Pixelate","transitionDurationMs":600}'
| Setting | Type | Range | Default | Units | Meaning |
|---|---|---|---|---|---|
transitionEffect |
string | one of the 22 names | "Rain" |
- | How pages change |
transitionDurationMs |
int | 0 … INT_MAX |
1000 |
ms | How long one transition takes |
autoTransition |
bool | - | true |
- | Whether AWTRIX rotates through apps at all |
22 transition names:
transitionEffect |
What happens | Honours navigation direction? |
|---|---|---|
Random |
Picks one of the other twenty-one, re-picked for every transition - never itself | - |
Slide |
Horizontal slide - both pages move together | yes |
Dim |
Old page fades to black, then the new page fades up | no |
Zoom |
New page grows from the centre over the old one | no |
Rotate |
Vertical roll - the old page rolls out, the new one rolls in | yes |
Pixelate |
Per-pixel dissolve - the same pixel pattern every time, not random | no |
Curtain |
New page reveals from both edges toward the centre | no |
Ripple |
Circular reveal growing from the centre | no |
Blink |
Old page blinks out in hard steps, new page blinks in the same way | no |
Reload |
Column sweep, always left → right | no |
Fade |
Straight per-channel crossfade | no |
Cover |
New page slides over a stationary old page | yes |
Uncover |
Old page slides away and leaves the new page standing | yes |
Split |
New page opens from the centre outward - Curtain in reverse |
no |
Blinds |
Vertical bars that widen until they meet. Bar width scales with the panel | no |
Blocks |
Dissolve in 4×2 tiles instead of single pixels. Same pattern every time, like Pixelate |
no |
Flash |
Old page ramps into solid white and the new page ramps out of it | no |
Diamond |
Diamond-shaped reveal from the centre - Ripple in Manhattan distance |
no |
Wave |
Column sweep with a sine bend, so the edge crosses as a wave | yes |
Rain |
Every column rolls vertically on its own staggered clock | yes |
Melt |
Old page drips off column by column and leaves the new page behind | yes |
Interlace |
Alternating rows slide in from opposite sides | yes |
An unknown name is rejected with 422 validationFailed whose message begins must be one of: and
lists the names above - the name table is matched case-insensitively.
Every transition is derived from the configured matrix geometry, so all of them work on any panel
width from 32 to 128. Flash drives the whole panel to near-white around the midpoint, which at
high brightness on a wide panel is briefly the largest current draw AWTRIX produces.
Pacing¶
Every transition takes exactly transitionDurationMs, but they do not all map that time onto
motion the same way. They are calibrated so that they feel like the same length:
- Wipes and slides ease in and out.
- Dissolves and cross-fades (
Dim,Zoom,Pixelate,Blink,Fade,Blocks,Flash) run linearly. RainandMeltstagger each column's start, not its speed. Every column falls at the same rate and the last one still lands exactly at the end.
Colors¶
Every colour key in the whole API - payload keys, settings keys, palette entries, draw-command colours - accepts the same forms.
Accepted input forms¶
| Form | Example | Notes |
|---|---|---|
| 6-digit hex string | "#FF00AA" or "FF00AA" |
Leading # optional; digits 0-9 a-f A-F |
| 3-digit hex shorthand | "#F0A" or "F0A" |
Each nibble expanded ×17 → #FF00AA |
| RGB array | [255, 0, 170] |
Each channel clamped to 0…255; extra elements ignored |
| HSV array | ["HSV", 320, 100, 100] |
The "HSV" tag is case-sensitive |
| Packed integer | 16711850 |
A bare JSON number, masked to 0xRRGGBB |
An 8-digit #RRGGBBAA value is rejected - there is no alpha channel.
HSV ranges: h is wrapped into [0, 360), and negatives are handled correctly (-30 becomes
330). s and v are percentages clamped to 0…100, not 0…255 - ["HSV",0,100,100] is pure
red, and ["HSV",0,100,255] is the same pure red, because v clamps.
Output form¶
Colours always come back as "#RRGGBB" with uppercase hex digits, regardless of which input
form you used. Nullable colours come back as JSON null.
Nullable colours¶
| Key group | null means |
|---|---|
timeColor, dateColor, humidityColor, temperatureColor, batteryColor |
inherit - fall back to textColor |
colorCorrection, colorTint |
off - no correction applied |
Send null to restore either behaviour. Only an explicit null carries that meaning - no colour
value is reserved, so #000000 and #FFFFFF are settable and read back as themselves.
A malformed colour is rejected everywhere¶
Settings, indicators, the moodlight and app/notification payloads all run a colour through the same
parser. Every one of them rejects a value it cannot read with 422 validationFailed, the offending
key in field, and stores nothing. The message differs by route: settings, indicators and the
moodlight give
must be a color ("#RGB", "#RRGGBB", [r,g,b], ["HSV",h,s,v] or a packed integer), while a colour
key inside an app or notification payload gives the shorter "<key>" is not a valid color. Match
on the code and field, never on the wording.
Display color pipeline¶
Four settings change how the panel looks without changing what the apps draw:
saturation drains or keeps the colour, gamma shapes the brightness curve of each
channel, and colorCorrection and colorTint scale red, green and blue separately -
the first to correct the panel's own cast, the second to warm or cool the whole picture.
Types, ranges and defaults: Settings - Panel.
curl -X PATCH http://<awtrix-ip>/api/v1/settings \
-H 'Content-Type: application/json' \
-d '{"saturation":40,"gamma":2.2,"colorCorrection":"#FFB0F0","colorTint":null}'
They are applied in that order, to everything on the panel - apps, icons, notifications,
the moodlight and Art-Net frames alike. None of them change the framebuffer
GET /api/v1/display/screen returns, which is why a screenshot can look different from
the panel in front of you. All four apply live.
settings.gamma is the display output curve. The ambient-light → brightness response has its own
separate curve, set by ldrGamma - see Brightness & sensors.
The device-wide text defaults - textColor, uppercase and scroll - are in
Settings - Global text. A payload overrides each of them.
Text¶
Encoding¶
Send UTF-8. The font is addressed by Unicode code point, so nothing is converted on the way in.
This applies to plain text, to each {"text":…} fragment, to dt draw-op text and to script
output alike.
A code point the font does not cover produces a single ? placeholder - exactly one per code point,
not one per byte. "Hi 🎉" renders as "Hi ?".
What is mapped¶
| Group | Behaviour |
|---|---|
ASCII (U+0020–U+007E) |
Its own glyph |
Latin-1 supplement (ä ö ü ß é à …) |
Its own glyph |
Latin Extended-A (ą ć ę ł ń ś ż ź č ď ě ř š ž ő ű and their capitals) |
Its own glyph |
Cyrillic (U+0401–U+0491) |
Its own glyph, upper and lower case distinct |
Punctuation (– — ‘ ’ “ ”) and € |
Its own glyph |
| Everything else, including Greek | Replaced by a single ? |
An accented letter sits on the same baseline as its bare form, with the mark above it. In small
the Latin-1 accents keep to the five rows a bare letter uses, the letter body giving up a row to
make room; Latin Extended-A and Ё ё take a row of their own and reach panel row 0. In large the
mark fits into the same seven rows either way.
Font¶
Two fonts, selected per app with the font key.
small draws ASCII with the AWTRIX panel font and everything beyond it - accents, Cyrillic,
punctuation - from Matrix-Fonts. large is Matrix-Fonts
throughout. Matrix-Fonts is copyright © 2026 Trip5, MIT licensed; the full notice ships with the
firmware source in assets/fonts/MatrixFonts.LICENSE.
small (default) |
large |
|
|---|---|---|
| Capital height | 5 px | 7 px |
| Rows used | 1–5, or 0–5 for č ő ż Ё |
0–6, plus row 7 for descenders |
| Character width | 4 px | 4 px |
| Space width | 2 px | 2 px |
Coverage is identical in both - the mapped groups above - so the choice never changes which characters render, only how tall they are and, because scrolling is decided by rendered width, whether a given string scrolls.
large leaves only the bottom row free. An app drawing its own graphics along the top wants
small.
Palette text¶
Set textColor to the string "palette" and the text is painted from the app's
palette instead of a flat colour. The ramp is sampled per pixel column, so it stays
even whatever the glyph widths are.
curl -X POST http://<awtrix-ip>/api/v1/notifications \
-H 'Content-Type: application/json' \
-d '{"text":"PARTY TIME","palette":"Rainbow","textColor":"palette","paletteSpan":24,"paletteSpeed":1}'
paletteSpan is the pixels per full pass - 0, the default, stretches one pass across the whole
string. paletteSpeed moves it at that many passes per second, independently of effectSpeed.
Every key a payload can carry - text, colours, charts, effects, palettes and draw commands - is specified in App & notification payload. This page covers what each one looks like on the panel.
Scrolling¶
One scroll object describes text motion, and it means the same thing in a payload and in the
device settings. The fields, their ranges and their defaults are in
Payload - Scrolling; what the four modes look like is this:
mode |
Motion | Cycle counted | Hold |
|---|---|---|---|
static |
None; the text is drawn at its aligned position and overflow is clipped | never | - |
wrap |
Off the far edge, then back to the start anchor | per exit | at the start anchor, every cycle |
loop |
Continuous; every repetition that touches the panel is drawn, so there is no empty seam | per fold | initial only |
bounce |
Sweeps between the icon column and the far edge | per round trip | at both turning points |
In a payload each field is optional and inherits the device-wide default on its own, so
{"scroll":{"mode":"bounce"}} bounces at the configured speed. A bare string is shorthand for the
mode: "scroll": "bounce" ≡ "scroll": {"mode": "bounce"}. An unknown field, an unknown value or a
negative number is 422 validationFailed with the offending key in field - on
PATCH /api/v1/settings and inside a payload alike, and in a payload the whole app or notification
is rejected with it.
Every hold lasts holdMs, and entry: offscreen skips the initial one. Full anchor geometry: Payload → Scrolling.
Draw commands¶
The draw array carries 9 commands. Each element is an array with the command name first:
| Command | Draws |
|---|---|
["pixel", x, y, color] |
Pixel |
["pixels", color, x1, y1, …] |
Many pixels in one colour |
["line", x1, y1, x2, y2, color] |
Line |
["rect", x, y, w, h, color] |
Rect (outline) |
["rectFill", x, y, w, h, color] |
Filled rect |
["circle", cx, cy, r, color] |
Circle (outline) |
["circleFill", cx, cy, r, color] |
Filled circle |
["text", x, y, "string", color] |
Text |
["bitmap", x, y, w, h, data] |
Bitmap - row-major, base64 RGB888 or an array of colours |
curl -X POST 'http://<awtrix-ip>/api/v1/apps?name=box' \
-H 'Content-Type: application/json' \
-d '{"draw":[["rect",0,0,32,8,"#00FF00"],["pixel",16,4,[255,0,0]]]}'
Every draw colour accepts every colour form. Leave it off and the command uses the
app's resolved text colour; a colour the parser cannot read, or a command name it does not know, is
rejected with 422 validationFailed and "field":"draw[<index>]".
Panel wiring¶
How the LED strip runs through your panel is system configuration, not a display setting:
panelWidth, panels, panelStart, panelWiring and panelSerpentine, documented under
Panel and orientation. Get it wrong and the image comes out
mirrored, scrambled or split into blocks; the web UI's Panel section is where you fix it.
Discovering the names¶
GET /api/v1/capabilities returns the live name lists, so a client never has to hard-code them:
{
"effects": ["BrickBreaker", "Checkerboard", "..."],
"paletteEffects": ["Checkerboard", "ColorWaves", "..."],
"transitions": ["Random", "Slide", "Dim", "..."],
"overlays": ["drizzle", "frost", "rain", "..."],
"palettes": ["Cloud", "Lava", "Ocean", "Forest", "Stripe", "Party", "Heat", "Rainbow"]
}
| Array | Contents | Ordering |
|---|---|---|
effects |
Every background effect AWTRIX can draw | Alphabetical |
paletteEffects |
The effects from effects that use the app's palette |
Alphabetical |
overlays |
Every weather overlay | Alphabetical |
transitions |
Every transition | Random first, then as listed |
palettes |
The eight built-in palettes | As listed |
paletteEffects is what to offer a palette picker for - the fixed-colour effects appear in
effects but not here. The names in every array are spelled the way the API returns them; what you
send is matched case-insensitively.
palettes lists the built-ins only. Palettes you upload as /PALETTES/*.txt work everywhere a
built-in name does, but they do not appear in this array - list them with
GET /api/v1/files?dir=/PALETTES.
The identical JSON is published retained to MQTT <prefix>/state/capabilities on every connect,
and backs the Home Assistant select options.