openapi: 3.1.0
info:
  title: AWTRIX NG HTTP API
  version: '1.0'
  description: "The AWTRIX NG device API. JSON operations use camelCase keys. Multipart\nuploads, raw\
    \ script source and the plain-text version route state their formats\non the operation.\n\n**Conventions**\n\
    - Colors are `\"#RRGGBB\"` strings on settings and display output. App config\n  descriptors return\
    \ colors as integers `0`-`16777215`. Input additionally accepts\n  `\"RRGGBB\"`, `[r,g,b]` and `[\"\
    HSV\",h,s,v]` (h 0-360, s/v 0-100).\n  Fields documented as *nullable* use `null` for \"inherit\"\
    /\"off\". Every\n  concrete color including pure black (`#000000`) and pure white\n  (`#FFFFFF`) is\
    \ a settable value distinct from `null`.\n- Durations are integer **milliseconds** with an `...Ms`\
    \ key suffix.\n- Names are matched **case-insensitively**: `effect`, `overlay`, `palette`,\n  `transitionEffect`\
    \ and the string enums (`transitionDirection`, `timeSeparatorMode`,\n  `dateOrder`, `dateSeparator`,\
    \ `dateYearMode`) accept any casing, so\n  `\"matrix\"`, `\"Matrix\"` and `\"MATRIX\"` are the same\
    \ name.\n  `GET /api/v1/capabilities` lists the spelling the API returns.\n- Send JSON request bodies\
    \ with `Content-Type: application/json`. The server\n  rejects a supplied non-JSON type on `PUT` and\
    \ `PATCH` with\n  `415 unsupportedMediaType`. Omitting the header bypasses this check.\n  `POST` is\
    \ not gated by this check. Script source and script-update\n  writes also bypass it, though script-update\
    \ still requires a JSON body.\n  Multipart routes use the format documented on the operation.\n- Clients\
    \ that cannot send `PATCH`, `PUT` or `DELETE` may send a `POST`\n  carrying `X-HTTP-Method-Override:\
    \ PATCH|PUT|DELETE`. The request is then\n  handled exactly as that method. The header is rejected\
    \ with\n  `400 invalidMethodOverride` on any other carrier method, for any other\n  value, and for\
    \ `PUT /api/v1/apps/script/{name}`.\n- On the writing `PUT` routes (`.../apps/pushed/{name}`,\n  `.../display/moodlight`,\
    \ `.../indicators/{id}`) an empty or `{}` body is\n  rejected with `422`: a JSON body is required.\
    \ Removing/turning off is\n  done only via the explicit `DELETE` route (or an empty MQTT payload).\n\
    - Errors always come back in one shape (see `Error`) with the proper status code:\n  400 malformed\
    \ JSON, 401 auth, 403 disabled in setup mode,\n  404 unknown resource/name, 405 wrong method, 415\
    \ wrong content type,\n  413 body too large, 422 validation failure (with `field`),\n  507 storage\
    \ full, too little free memory for the body, or an applied change not saved.\n  Any route answers\
    \ `400 invalidMethodOverride` to a bad override header and\n  `405 methodNotAllowed` to a method it\
    \ does not take.\n- A path that is no route answers `404 notFound` with `unknown route`.\n- A CORS\
    \ preflight `OPTIONS` on any path answers `204` with no body.\n- Optional HTTP Basic Auth is off by\
    \ default. Setting `authEnabled` enables\n  it for every route, including setup mode. Configure\n\
    \  `authUser` and `authPass` before enabling it.\n\nThe MQTT surface mirrors this API with identical\
    \ bodies. See\nhttps://blueforcer.github.io/awtrix-ng/reference/mqtt/.\n"
servers:
- url: http://{device}
  variables:
    device:
      default: awtrixng-a1b2c3.local
security:
- {}
- basicAuth: []
tags:
- name: device
- name: settings
- name: display
- name: apps
- name: notifications
- name: indicators
- name: sounds
- name: radio
- name: scripts
- name: system
- name: files
paths:
  /api/v1/device:
    get:
      tags:
      - device
      summary: Device state & statistics
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The device state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceState'
  /api/v1/version:
    get:
      tags:
      - device
      summary: Firmware version
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The firmware version.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:
                    type: string
  /api/v1/device/reboot:
    post:
      tags:
      - device
      summary: Reboot the device
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
  /api/v1/device/sleep:
    post:
      tags:
      - device
      summary: Timed deep sleep
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - durationMs
              properties:
                durationMs:
                  type: integer
                  minimum: 1
      responses:
        '400':
          $ref: '#/components/responses/BadJson'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '422':
          $ref: '#/components/responses/Invalid'
      description: Sleeps for durationMs and wakes on its timer. The select button ends the sleep early
        when pinBtnSelect is one of capabilities.gpio.rtc.
  /api/v1/device/factory-reset:
    post:
      tags:
      - device
      summary: Wipe filesystem, WiFi credentials and settings, then reboot
      description: Not available over MQTT. Only from the device's own web page or a client that is not
        a browser.
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenOwnPage'
        '200':
          $ref: '#/components/responses/Ok'
  /update:
    post:
      tags:
      - system
      summary: Install the update file for this device
      description: 'Multipart upload of the file named by device.updateImage, in the field `firmware`.
        The file is checked before anything is installed, so a refused or interrupted upload leaves the
        device as it was. After the answer the device installs the file and restarts.


        Only from the device''s own web page or a client that is not a browser. This route uses multipart/form-data
        rather than the JSON body format.

        '
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                firmware:
                  type: string
                  format: binary
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: Accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '403':
          description: forbidden in setup mode, or forbiddenOrigin for a rejected browser origin.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Update write or installation failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400':
          description: badRequest (no file, more than one file, or an incomplete upload), or a file this
            device does not take.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/restore:
    post:
      tags:
      - system
      summary: Restore a backup archive (multipart ZIP)
      description: 'Multipart upload of a backup `.zip` produced by the web UI. The archive

        is a store-only (uncompressed) ZIP. The device streams it, so its size

        is not bounded by the JSON body cap. Entry names decide what is applied:


        | entry | effect |

        |---|---|

        | `manifest.json` | validated first. Must name app `awtrix-ng` |

        | `config/wifi.json` | `{wifiSsid, wifiPass}` merged into the config |

        | `config/system.json` | rest of the device config (validated like PUT /system) |

        | `config/settings.json` | display/behavior settings (applied live + persisted) |

        | `ICONS/*`, `MELODIES/*`, `PALETTES/*`, `MP3/*`, `SCRIPTS/*` | stored on the device |

        | `SCRIPTS/<name>/<sound>.mp3` | a script''s own sound, checked like any MP3. Nothing else under
        `SCRIPTS/` may sit in a folder |

        | `apploop.json` | app rotation order |


        Content is checked per folder exactly like `POST /api/v1/files`. A

        traversal path, a foreign app or a bad-CRC entry is skipped with a

        warning rather than trusted. Restore also works in setup mode, so a new

        or reset clock gets its Wi-Fi and settings back from the backup alone.

        Reboot afterwards to apply restored system settings.

        '
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '507':
          description: insufficientStorage, restored, not saved yet. Free storage and repeat before rebooting.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/ForbiddenOwnPage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: Applied. Per-category counts and any skip warnings.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  applied:
                    type: object
                    properties:
                      wifi:
                        type: integer
                      system:
                        type: integer
                      settings:
                        type: integer
                      appLoop:
                        type: integer
                      radioStations:
                        type: integer
                      icons:
                        type: integer
                      iconOrigins:
                        type: integer
                      melodies:
                        type: integer
                      palettes:
                        type: integer
                      mp3:
                        type: integer
                        description: MP3 files, the scripts' own sounds included
                      scripts:
                        type: integer
                      skipped:
                        type: integer
                  warnings:
                    type: array
                    items:
                      type: string
        '400':
          description: 'The archive was rejected outright (not a zip, no manifest, or a manifest for a
            different app): `ok` is false and `error` explains. An upload without a file gets the normal
            error body instead: `badRequest`, `no file received`.

            '
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  required:
                  - ok
                  - error
                  properties:
                    ok:
                      type: boolean
                      enum:
                      - false
                    error:
                      type: string
                    applied:
                      type: object
                    warnings:
                      type: array
                      items:
                        type: string
                - $ref: '#/components/schemas/Error'
  /api/v1/settings:
    get:
      tags:
      - settings
      summary: Read all settings
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: All settings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Settings'
    patch:
      tags:
      - settings
      summary: Update settings (any subset, all or nothing)
      description: 'Validates every field first. On error nothing is applied and a 422

        names the offending field. Returns the full resulting settings.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Settings'
      responses:
        '415':
          $ref: '#/components/responses/WrongContentType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The complete resulting settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Settings'
        '400':
          $ref: '#/components/responses/BadJson'
        '422':
          $ref: '#/components/responses/Invalid'
  /api/v1/settings/reset:
    post:
      tags:
      - settings
      summary: Reset all settings to defaults and reboot
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
  /api/v1/display:
    get:
      tags:
      - display
      summary: Display runtime state
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The display state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Display'
    patch:
      tags:
      - display
      summary: Set display power and/or the global overlay
      description: 'Applies completely or not at all: a rejected field leaves the other

        fields unchanged.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                power:
                  type: boolean
                overlay:
                  type:
                  - string
                  - 'null'
                  description: 'Weather overlay name, matched case-insensitively against

                    `capabilities.overlays`. Unknown names are rejected with 422.

                    `null` (or `""`) clears it, and clearing also resets

                    `overlaySettings`.

                    '
                overlaySettings:
                  $ref: '#/components/schemas/EffectSettings'
      responses:
        '400':
          $ref: '#/components/responses/BadJson'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '422':
          $ref: '#/components/responses/Invalid'
  /api/v1/display/moodlight:
    put:
      tags:
      - display
      summary: Enable the mood light (a JSON body is required)
      description: '**A JSON body is required.** An empty body or exactly `{}` is rejected

        with `422`. Use `DELETE` (or an empty MQTT payload) to turn the mood

        light off. A request sent with a non-JSON `Content-Type` is rejected

        with `415`.


        `kelvin` **wins** over `color`: when `kelvin` is present,

        `color` is ignored entirely. An unparseable `color` is rejected with

        `422` (`field: "color"`) and nothing is stored.


        **Both `color` and `brightness` are sticky.** A payload that omits

        either one keeps the value it had, so `{"brightness":30}` dims without

        touching the color and `{"color":"#FF0000"}` recolors without touching

        the level. Until a color has ever been set the mood light is white, and

        the starting brightness is `120`.


        `brightness` is 0-255. A larger value is not rejected: it wraps around,

        so 300 becomes 44.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                color:
                  $ref: '#/components/schemas/Color'
                  description: Ignored when `kelvin` is present.
                kelvin:
                  type: integer
                  minimum: 1000
                  maximum: 40000
                  description: Limited to 1000-40000. Wins over `color` when both are sent.
                brightness:
                  type: integer
                  minimum: 0
                  maximum: 255
                  description: '0-255. A larger value wraps around (300 becomes 44). Kept when absent.
                    Starts at 120.

                    '
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '422':
          $ref: '#/components/responses/Invalid'
    delete:
      tags:
      - display
      summary: Disable the mood light
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
  /api/v1/display/screen:
    get:
      tags:
      - display
      summary: Current screen content
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The picture on the panel.
          content:
            application/json:
              schema:
                type: object
                properties:
                  width:
                    type: integer
                  height:
                    type: integer
                  pixels:
                    type: array
                    items:
                      type: integer
                      description: packed 0xRRGGBB
  /api/v1/apps:
    get:
      tags:
      - apps
      summary: Full app inventory (the arranged apps in order, then the rest)
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: All apps.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    name:
                      type: string
                    enabled:
                      type: boolean
                      description: 'Whether the app runs at all. Independent of `inLoop`, which says whether
                        it is drawn, and of `present`, which says whether it is there right now. All three
                        agree for every app except a headless script, which runs without ever taking a
                        turn, and a pushed app between two pushes, which is on and keeps its place with
                        nothing to draw.

                        '
                    inLoop:
                      type: boolean
                    present:
                      type: boolean
                      description: 'Whether the app is on the device right now. False for a name the device
                        holds a place for while the app itself is away.

                        '
                    slot:
                      type:
                      - integer
                      - 'null'
                      description: '0-based place in the order the user arranged. Kept while the app is
                        away. Null when the app has no place of its own.

                        '
                    origin:
                      type:
                      - string
                      - 'null'
                      enum:
                      - builtin
                      - pushed
                      - script
                      - module
                      - null
                      description: 'Where the app''s content comes from. `builtin` is firmware code. `pushed`
                        is a JSON spec sent from outside and held in RAM only. `script` is Berry source
                        stored on the device. `module` is a stored Berry file that other scripts import:
                        it is listed here because it shares the file collection, but it never draws, so
                        it carries no `enabled`, `inLoop` or `slot`.

                        '
                    import:
                      type: string
                      description: 'Modules only. The name scripts write in their `import` line: the file
                        name unless the header''s `@module` gives another.

                        '
                    icon:
                      type: string
                      description: 'Pushed apps only, and only when the spec set a non-empty icon. Omitted
                        otherwise.

                        '
                    skipped:
                      type: boolean
                      description: 'Script apps only. True when the app''s own `should_show()` last answered
                        false, so the rotation walks past it. Independent of `inLoop`: the app is still
                        in the rotation and can still be switched to by name. Reports the last answer
                        given, not a fresh one.

                        '
                    headless:
                      type: boolean
                      description: 'Script apps only. True when the script carries `@headless true` and
                        so never draws: it has no place in the rotation, and `draw()`, `should_show()`
                        and `duration()` are never called.

                        '
                    ondemand:
                      type: boolean
                      description: 'Script apps only. True when the script carries `@ondemand`: it is
                        not in the rotation and runs only after it was started from the device menu or
                        with `PUT /api/v1/apps/active`. `inLoop` is true while it runs.

                        '
                    config:
                      type: boolean
                      description: 'True when the app has settings a client can change: a built-in under
                        `/api/v1/apps/builtin/{name}/config`, a script or module, whose file declares
                        `@config` lines, under `/api/v1/apps/{name}/config`. A module''s values are shared
                        by every app that imports it. Omitted for pushed apps.

                        '
                    error:
                      description: 'Scripts and modules only. `null` while the script works, otherwise
                        the error it stopped on. A script app shows it as `ERR:<name>`. Omitted for other
                        origins and on a build with no scripting platform.

                        '
                      oneOf:
                      - type: 'null'
                      - $ref: '#/components/schemas/ScriptError'
                    meta:
                      $ref: '#/components/schemas/ScriptMeta'
  /api/v1/apps/active:
    put:
      tags:
      - apps
      summary: Switch to an app
      description: 'Naming an `@ondemand` script starts it: it is loaded fresh and has the

        display to itself until another app is switched to, `next` or

        `previous` is called, or select is held on the device. It fails with

        507 or 503 when there is not enough memory to start it.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
                fast:
                  type: boolean
                  description: true = jump instantly, false = animated transition
      responses:
        '415':
          $ref: '#/components/responses/WrongContentType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          $ref: '#/components/responses/Unavailable'
        '507':
          $ref: '#/components/responses/InsufficientStorage'
  /api/v1/apps/next:
    post:
      tags:
      - apps
      summary: Next app
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
  /api/v1/apps/previous:
    post:
      tags:
      - apps
      summary: Previous app
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
  /api/v1/apps/order:
    put:
      tags:
      - apps
      summary: Set which apps are on, and the order of the ones that draw
      description: '`order` is what runs, in the order it draws. `disabled` is the complete

        list of switched-off apps: every app it does not name is switched on,

        also one that was off before. To switch one app and leave the others as

        they are, use `PUT /api/v1/apps/{name}/enabled`.


        `disabled` is always required. `order` is optional and requires `disabled`

        beside it. `{"disabled":[...]}` alone sets the switched-off apps and

        keeps the order. A body without `disabled`, or one that is not an

        object, is 400.


        A switched-off script runs nothing at all. A headless script never

        draws, so naming it in `order` keeps it running without giving it a

        place in the rotation. A name keeps its place even when no such app

        exists yet, and a name in `disabled` stays off while the app is absent.

        The same app may appear several times in `order` to show it more than

        once per cycle (each entry gets its own place).

        A 507 means the change is active but not saved. Free storage and repeat

        the request before rebooting.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - disabled
              properties:
                order:
                  type: array
                  items:
                    type: string
                disabled:
                  type: array
                  items:
                    type: string
      responses:
        '415':
          $ref: '#/components/responses/WrongContentType'
        '507':
          description: insufficientStorage, applied, not saved yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadJson'
  /api/v1/apps/{name}/enabled:
    put:
      tags:
      - apps
      summary: Switch one app on or off
      description: '`true` switches the app on, `false` switches it off. Every other app

        stays as it is. A switched-off app keeps its place in the order and

        returns to it when switched on again. The name may belong to an app

        that is not there yet: when it arrives, it is on or off as set. The

        switch is kept after a restart. An app that already is on or off

        answers 200 too.

        A 507 means the change is active but not saved. Free storage and repeat

        the request before rebooting.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: boolean
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: '`name` is not `[A-Za-z0-9_-]{1,32}` (`invalidName`)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '422':
          description: The body is not `true` or `false` (`validationFailed`, `must be true or false`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '507':
          description: insufficientStorage, applied, not saved yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/apps/pushed/{name}:
    put:
      tags:
      - apps
      summary: Create or update a pushed app (a JSON body is required)
      description: 'Payload fields follow the AWTRIX pushed-app schema (text, icon, icons,

        textColor, draw, effect, ...). An array payload creates indexed apps

        `{name}0..n`. Non-object elements are skipped. Time fields are

        milliseconds: `durationMs`, `lifetimeMs`, `textBlinkMs`, `textFadeMs`.


        **A JSON body is required.** An empty body or exactly `{}` is rejected

        with `422`. Use the `DELETE` route (or an empty MQTT payload) to remove

        an app and its indexed children. A request sent with a non-JSON

        `Content-Type` is rejected with `415`.


        `effect` and `overlay` names are checked against the lists

        returned by `GET /api/v1/capabilities` (case-insensitively). An

        unknown name is rejected with `422 validationFailed` and

        `field: "effect"` / `"overlay"`, and NOTHING is stored: an array

        payload is all-or-nothing here too.


        50 pushed apps may be resident at once. Creating a new app beyond that

        cap is rejected with `507 insufficientStorage`. An array payload is

        all-or-nothing: if the whole set will not fit, the entire request is

        rejected and nothing is stored.


        A pushed app is not saved on the device: it lasts until it is

        replaced, deleted, expired by `lifetimeMs`, or the device restarts.

        Content that must come back by itself after a reboot belongs in a

        script (`PUT /api/v1/apps/script/{name}`).


        Removal is not here: it is `DELETE /api/v1/apps/{name}`, which

        removes any kind of app.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
        description: 'Checked after the method and before the body. A name outside the pattern is rejected
          with `400 invalidName` and `field: "name"`.

          '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
              - type: object
                minProperties: 1
                description: One pushed app stored under {name}.
                allOf: []
                properties:
                  icons:
                    $ref: '#/components/schemas/PlacedIcons'
                  iconGap:
                    $ref: '#/components/schemas/IconGap'
                  scroll:
                    $ref: '#/components/schemas/Scroll'
                    description: 'Overrides the device''s `scroll` setting field by field. An omitted
                      field keeps the configured default.

                      '
                  textOffsetX:
                    type: integer
                    description: 'X shift applied after positioning, and part of every scroll anchor,
                      so it lengthens each cycle by the same amount in every mode. X axis only.

                      '
              - type: array
                items:
                  type: object
                  properties:
                    icons:
                      $ref: '#/components/schemas/PlacedIcons'
                    iconGap:
                      $ref: '#/components/schemas/IconGap'
                description: Indexed apps {name}0, {name}1, ...
      responses:
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: 'Body is not valid JSON (`invalidJson`), or `{name}` is not `[A-Za-z0-9_-]{1,32}`
            (`invalidName`, `field: "name"`).

            '
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '422':
          $ref: '#/components/responses/Invalid'
        '507':
          $ref: '#/components/responses/InsufficientStorage'
  /api/v1/apps/{name}:
    delete:
      tags:
      - apps
      summary: Delete an app, whatever kind it is
      description: 'Works for any kind of app, and calling it twice is safe. For a pushed app this erases
        the exact

        name plus the indexed children an array payload sent to `{name}`

        created (`{name}0`, `{name}1`, ...). It is the array push that ties

        them together, not the spelling: an app pushed to `{name}1` directly is

        its own app and is left alone. For a script it erases the source and its

        persisted store: the only way to reset that store. Its own sounds in

        `/SCRIPTS/<name>/` stay until `DELETE /api/v1/apps/script/{name}/sounds`.

        A script installed again under the name uses them. A

        built-in or an unknown name is also `200`: nothing is removed and

        nothing is an error.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: '`name` is not `[A-Za-z0-9_-]{1,32}` (`invalidName`)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/notifications:
    post:
      tags:
      - notifications
      summary: Show a notification
      description: 'Accepts all pushed-app fields plus `name`, `hold`, `stack`, `wakeup` and

        `sound`. `sound` is the sound of POST /api/v1/audio/play: a stored

        name, an object with one source key, or a list of 1 to 4 of them. It plays as an alert when the
        notification appears. With

        `loop: true` it repeats while the notification is shown and stops when it

        leaves. `""` and `null` mean no sound. An invalid `sound` refuses the

        whole notification with 422 and `field` under `sound` (for example

        `sound.rtttl`, `sound[1].file`). A name that is not stored, or a sound

        the clock cannot play, is not an error: the notification shows without

        sound. `durationMs` defaults to the global app time.


        `effect` and `overlay` names are checked against the lists

        returned by `GET /api/v1/capabilities` (case-insensitively). An

        unknown name is rejected with `422 validationFailed` and

        `field: "effect"` / `"overlay"`, and the notification is not queued.


        Up to 32 notifications can be stacked. A `stack` request beyond that cap

        is rejected with `507 insufficientStorage`.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                icons:
                  $ref: '#/components/schemas/PlacedIcons'
                iconGap:
                  $ref: '#/components/schemas/IconGap'
                sound:
                  description: A sound as on POST /api/v1/audio/play. `""` or null means no sound.
                  oneOf:
                  - $ref: '#/components/schemas/AudioPlay'
                  - type: 'null'
      responses:
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadJson'
        '422':
          $ref: '#/components/responses/Invalid'
        '507':
          $ref: '#/components/responses/InsufficientStorage'
  /api/v1/notifications/active:
    delete:
      tags:
      - notifications
      summary: Dismiss the current notification
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
  /api/v1/notifications/{name}:
    delete:
      tags:
      - notifications
      summary: Dismiss the notification pushed under this name
      description: 'Removes the notification whose payload carried this `name`, wherever it

        sits in the queue: it need not be the one on screen, and removing a

        waiting one leaves the current one running.


        Lets several senders share a device without dismissing each other''s

        messages. The unnamed dismiss always removes the one on screen. The name

        identifies, it does not protect: anyone who can reach the API can

        dismiss a name they know. Use HTTP auth to restrict callers.


        `active` is reserved for the route above, so a notification cannot be

        addressed under that name.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '404':
          description: No queued notification carries that name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/indicators/{id}:
    put:
      tags:
      - indicators
      summary: 'Set a corner indicator (1..3): a JSON body is required'
      description: 'Corner indicators are shown on the right edge of the panel: id 1

        top, id 2 middle, id 3 bottom. `blinkMs` gives a 50% on/off blink and

        `fadeMs` a breathing fade. The state is also echoed in

        `GET /api/v1/device` and published over MQTT/Home Assistant (three HA

        lights).


        **A JSON body is required.** An empty body or exactly `{}` is rejected

        with `422`. Use the `DELETE` route (or an empty MQTT payload) to turn it

        off. A request sent with a non-JSON `Content-Type` is rejected with

        `415`.


        Only `color` changes the on/off state: a `color` of 0 (black) or `null`

        turns the indicator off and keeps the stored color. Any other color

        turns it on. A payload without `color` leaves both untouched.


        `blinkMs` and `fadeMs` are 0 when absent, so a request without them

        gives a steady light.

        '
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
          maximum: 3
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                color:
                  $ref: '#/components/schemas/Color'
                  description: A color of 0 or null turns the indicator off and keeps the stored color.
                blinkMs:
                  type: integer
                  minimum: 0
                  maximum: 65535
                  description: 0 when absent. 0-65535. A value outside that range is stored as 0.
                fadeMs:
                  type: integer
                  minimum: 0
                  maximum: 65535
                  description: 0 when absent. 0-65535. A value outside that range is stored as 0.
      responses:
        '400':
          $ref: '#/components/responses/BadJson'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '404':
          $ref: '#/components/responses/NotFound'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '422':
          $ref: '#/components/responses/Invalid'
    delete:
      tags:
      - indicators
      summary: Turn a corner indicator off
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
          maximum: 3
      responses:
        '404':
          description: '`notFound`, `id must be 1..3`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
  /api/v1/audio/melodies:
    get:
      tags:
      - sounds
      summary: Every melody on the device
      description: 'One request for the whole editor. `notes` and `durationMs` come from

        parsing `rtttl`. A file that does not parse is listed with

        `valid: false` plus `error` and `index` rather than hidden, so you can

        repair it in the editor.

        '
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The melody list
          content:
            application/json:
              schema:
                type: object
                properties:
                  melodies:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        rtttl:
                          type: string
                          description: The stored melody, verbatim
                        bytes:
                          type: integer
                        notes:
                          type: integer
                        durationMs:
                          type: integer
                        valid:
                          type: boolean
                        error:
                          type: string
                        index:
                          type: integer
                  usedBytes:
                    type: integer
                  totalBytes:
                    type: integer
  /api/v1/audio/melodies/{name}:
    parameters:
    - name: name
      in: path
      required: true
      schema:
        type: string
        pattern: ^[A-Za-z0-9_-]{1,24}$
    put:
      tags:
      - sounds
      summary: Save a melody
      description: 'Stores `/MELODIES/{name}.txt`. The RTTTL title is normalised to `{name}`

        - a two-part `defaults:notes` string gets the name put in front, a

        three-part one has its title replaced: so a file cannot come to claim a

        name other than the one it is filed under.


        An unparseable melody is rejected with `422`. The message carries the

        reason and the byte offset. An MP3 and a melody never share a name: when

        `/MP3/{name}.mp3` exists the answer is `409 nameTaken`, `name taken`.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - rtttl
              properties:
                rtttl:
                  type: string
                  maxLength: 512
      responses:
        '400':
          description: '`invalidJson`, `invalid JSON` with `field: "rtttl"`: the body is not valid JSON.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '201':
          $ref: '#/components/responses/Ok'
        '200':
          $ref: '#/components/responses/Ok'
        '409':
          description: '`nameTaken`, `name taken`: an MP3 of that name exists'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/Invalid'
        '507':
          $ref: '#/components/responses/InsufficientStorage'
    delete:
      tags:
      - sounds
      summary: Delete a melody
      description: 'There is no rename route: PUT the new name, then DELETE the old one.

        '
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/audio:
    get:
      tags:
      - radio
      summary: What each group plays, and the station list
      description: What the radio, the app and the alert group play, and the station list. The same document
        is published, retained, on `<P>/state/audio` on every change of any group.
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: Status and stations
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AudioState'
  /api/v1/audio/play:
    post:
      tags:
      - sounds
      - radio
      summary: Play a sound
      description: 'The body is a sound: a stored name (`"ding"`, short for

        `{"file":"ding"}`), an object with exactly one source key plus `loop`,

        or a list of 1 to 4 strings or objects. A list plays its first entry

        the clock can play: it has the hardware, and for `file` the file is

        stored. The source keys are in `SoundObject`.


        Everything sent here plays as an alert, at `volume` × `alertVolume`:

        it replaces an alert that is playing. `loop: true` repeats it until

        POST /api/v1/audio/stop stops the `alert` group or everything, or a

        new alert replaces it.


        The body is validated before stored files and audio hardware are checked.

        See [Audio playback errors](https://blueforcer.github.io/awtrix-ng/reference/errors/#audio-playback)

        for all statuses, fields and messages. Inside a list, `field` starts with

        the entry position, for example `[1].rtttl`.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AudioPlay'
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadJson'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Invalid'
        '503':
          $ref: '#/components/responses/Unavailable'
  /api/v1/audio/stop:
    post:
      tags:
      - sounds
      - radio
      summary: Stop whatever is playing
      description: 'With no body or `{}` this stops everything. `group` stops one group:

        `alert` the alert that is playing, `app` every sound of the scripts,

        `radio` the radio. Another value answers 422 `must be alert, app or radio`.

        '
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AudioStop'
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadJson'
        '422':
          $ref: '#/components/responses/Invalid'
  /api/v1/apps/script-update/{name}:
    put:
      tags:
      - scripts
      summary: Update a script only if its source has not changed
      description: 'Requires `scriptUpdates` in capabilities. The check and the install

        happen in one step, so no other change can come in between. Use

        `expected_source: null` to create a separate copy only if the name does

        not exist. An existing script keeps settings that still fit. If the new

        source fails to compile or to set up, the old script is restored. Errors

        that happen later while it runs are not reported here. The whole JSON

        body must fit in the memory the device has free for script sources.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - expected_source
              - source
              properties:
                expected_source:
                  type:
                  - string
                  - 'null'
                  description: Exact current source, or null to create only.
                source:
                  type: string
                  minLength: 1
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadJson'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Source changed or the copy name exists (`scriptChanged`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/Invalid'
        '503':
          description: Scripting unavailable or temporarily busy.
        '507':
          description: Insufficient script capacity.
  /api/v1/apps/script/{name}:
    get:
      tags:
      - scripts
      summary: One script's raw Berry source
      description: 'Answers the script''s source as `text/plain`, byte for byte as it was

        installed, so an editor can round-trip it straight back into `PUT`.


        There is no separate script inventory: `GET /api/v1/apps` lists every

        app with an `origin`, and a script entry carries its `error` state and

        its `@name`/`@desc`/`@author`/`@version`/`@icons` header in `meta`. `error` is

        `null` while the script works. Otherwise it is the error the matrix is

        showing as `ERR:<name>`. The script is tried again when you send a new

        version, save its settings or data, or restart the device. If it fails

        again, the new error is shown. The listing does not include the source.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The raw source
          content:
            text/plain:
              schema:
                type: string
        '400':
          description: '`name` is not `[A-Za-z0-9_-]{1,32}` (`invalidName`)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          description: This build has no scripting platform (`unavailable`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    put:
      tags:
      - scripts
      summary: Install or replace a script
      description: 'The body is the raw Berry source, NOT JSON. `{name}` becomes the app id

        in the rotation as well as the filename under `/SCRIPTS`, so it is

        limited to `[A-Za-z0-9_-]{1,32}`.


        Any `Content-Type` is accepted, or none. For example, send the source

        with `Content-Type: text/plain` and `curl --data-binary`.


        **A script that does not compile still installs.** The source is stored,

        the app joins the rotation, and it shows `ERR:<name>`. The reply is

        `200` with `error` carrying the compiler message. `error` is `null` when

        the script works.


        Replacing an installed script restarts it: its subscriptions, running

        requests and values held in memory are dropped. Its saved store is

        kept. The script keeps its position in the rotation.


        A new name is rejected with `507` when the script memory limit is

        reached, and nothing is stored. Replacing an existing one always works. Modules

        share the same limit. The name of a built-in app, such as `Time`, is

        rejected with `422` and nothing is stored.


        A source whose header carries `@module` installs as a module instead of

        an app: it is stored and listed the same way, but it never draws and is

        reached by other scripts with `import`. Its import name is the file name

        unless `@module <name>` gives another. A name that is not an identifier,

        is already taken, or belongs to a built-in module is rejected with `422`

        and nothing is stored.


        Removal is `DELETE /api/v1/apps/{name}`, the route for any kind of app.

        It erases the source and the saved store, but not the script''s sounds.

        Replacing the source keeps the sounds too.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      requestBody:
        required: true
        content:
          text/plain:
            schema:
              type: string
              description: Berry source.
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: Installed (check `error` for the compile result)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  name:
                    type: string
                  error:
                    description: '`null` when the script works, otherwise the error it stopped on.'
                    oneOf:
                    - type: 'null'
                    - $ref: '#/components/schemas/ScriptError'
        '400':
          description: '`name` is not `[A-Za-z0-9_-]{1,32}` (`invalidName`)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/Forbidden'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '422':
          $ref: '#/components/responses/Invalid'
        '500':
          description: This build has no scripting platform (`internalError`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '507':
          $ref: '#/components/responses/InsufficientStorage'
  /api/v1/apps/script/{name}/sounds:
    parameters:
    - name: name
      in: path
      required: true
      description: The script's install name.
      schema:
        type: string
        pattern: ^[A-Za-z0-9_-]{1,32}$
    get:
      tags:
      - scripts
      - sounds
      summary: A script's own sounds, with the SHA-256 of each
      description: 'A script''s MP3s live in `/SCRIPTS/<name>/<sound>.mp3`, next to its

        source. The running script plays them by name: `sound.play("boost")`

        plays `boost.mp3` from its own folder, and `/MP3/boost.mp3` when the

        folder has none. From outside, `{"file":"<name>/boost"}` on POST

        /api/v1/audio/play plays it. A script''s sounds may share a name with a

        melody or an MP3 in /MP3. Replacing the script''s source keeps them, and so does

        `DELETE /api/v1/apps/{name}`: sounds left from a deleted script stay

        listable here until they are deleted.


        `sha256` is worked out from the stored bytes, so an installer can tell

        which sounds it still has to send. A script without sounds answers

        `files: []`.

        '
      responses:
        '200':
          description: The script's sounds and whole-filesystem usage.
          content:
            application/json:
              schema:
                type: object
                properties:
                  files:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          description: File name with the .mp3
                        size:
                          type: integer
                        sha256:
                          type: string
                          pattern: ^[0-9a-f]{64}$
                  usedBytes:
                    type: integer
                  totalBytes:
                    type: integer
        '400':
          description: '`name` is not `[A-Za-z0-9_-]{1,32}` (`invalidName`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
      - scripts
      - sounds
      summary: Delete all of a script's sounds
      description: Deletes every sound and the folder, and stops any that is playing. The script itself
        stays. `200` also when there was nothing to delete.
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: '`name` is not `[A-Za-z0-9_-]{1,32}` (`invalidName`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
      - scripts
      - sounds
      summary: Upload one sound into a script's folder
      description: The script must be installed first. The filename is 1–32 characters A–Z, a–z, 0–9,
        underscore or hyphen followed by .mp3, and a sound of the same name is replaced. The first chunk
        must identify MP3 audio. The multipart field name is not significant. Send one file. A failed
        write keeps a sound already stored under that name.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: invalidName (the script's or the file's name), or badRequest (no file received).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '415':
          description: 'unsupportedMediaType: not an MP3.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '507':
          description: 'insufficientStorage, write failed: the file could not be saved. The previous version
            is kept.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/apps/script/{name}/sounds/{sound}:
    delete:
      tags:
      - scripts
      - sounds
      summary: Delete one of a script's sounds
      description: The folder goes with the script's last sound. The script need not be installed, so
        sounds kept from a deleted script are deleted the same way.
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      - name: sound
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
        description: Name without the .mp3 extension.
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: invalidName.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/apps/{name}/config:
    get:
      tags:
      - scripts
      summary: The settings a script offers, with their current values
      description: 'A script declares its user-changeable values as `@config` lines in its

        header, and this route hands them back as field descriptors: what to

        show, what it holds now, and what it would fall back to. `GET

        /api/v1/apps` already flags which scripts have any, under `config`, so a

        client only asks for the ones that do.


        A script that declares none answers `200` with an empty `fields` list,

        not a `404`. `warnings` carries the `@config` lines the device could not

        read, each with its line number: a malformed one is skipped rather than

        failing the install, so this is where the author finds out.


        The values live in the script''s saved store, and the script can change

        them itself. So `value` can differ from `default` without anyone using

        this API. When a new source does not declare a setting, its stored

        value is removed.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The declared settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScriptConfig'
        '400':
          description: '`name` is not `[A-Za-z0-9_-]{1,32}` (`invalidName`)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          description: This build has no scripting platform (`unavailable`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '507':
          $ref: '#/components/responses/InsufficientStorage'
    patch:
      tags:
      - scripts
      summary: Change a script's settings
      description: 'Send only the keys to change. Every other setting keeps its value, and

        so does anything else the script has stored.


        **All or nothing.** One bad field rejects the whole body with `422` and

        `field` naming it: nothing is written and the script is not disturbed.

        Rejected are: a key the script never declared (`unknown setting`), a

        value of the wrong type, a `select` value that is not on the offered

        list, and text longer than the field allows. A `number` outside its

        `min`/`max` is clamped to the range instead of rejected. A script that

        declares no settings answers `422` with `no settings`.


        A `color` takes either the number (`0`-`16777215`) or an HTML-style

        `"#RRGGBB"` string, and is always stored as the number.


        **Saving restarts the script**, exactly as re-uploading its source

        would: `init()` and `setup()` run again and see the new values, values

        held in memory and subscriptions are dropped, the app keeps its place in the

        rotation. The reply therefore carries the same `error` field as

        `PUT /api/v1/apps/script/{name}`: a `200` with an `error` object

        means the settings were applied and the restarted script then threw.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: 'Setting key to new value. Keys must be ones the script declared. An unknown
                key is a `422`, never silently ignored.

                '
              additionalProperties:
                oneOf:
                - type: string
                - type: number
                - type: boolean
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: Applied (check `error` for what the restarted script did)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppConfigWriteResult'
        '400':
          description: Malformed JSON (`invalidJson`) or a name outside `[A-Za-z0-9_-]{1,32}` (`invalidName`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '422':
          $ref: '#/components/responses/Invalid'
        '503':
          description: Scripting is switched off (`unavailable`), or busy (`serviceBusy`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '507':
          $ref: '#/components/responses/InsufficientStorage'
  /api/v1/apps/builtin/{name}/config:
    parameters:
    - name: name
      in: path
      required: true
      schema:
        type: string
        pattern: ^[A-Za-z0-9_-]{1,32}$
    get:
      tags:
      - apps
      summary: The settings of a built-in app, with their current values
      description: 'The built-in apps offer their settings here as field descriptors.

        `GET /api/v1/apps` flags them with `config`. A switched-off app can

        still be configured. A built-in without settings answers `200` with an

        empty `fields` list. `404` (`no such app`) when the device has no such

        built-in, it cannot run here, or a pushed app has its name.

        '
      responses:
        '200':
          description: The built-in app's fields, defaults and current values
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BuiltinAppConfig'
        '400':
          description: '`name` is not `[A-Za-z0-9_-]{1,32}` (`invalidName`)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
      - apps
      summary: Change a built-in app's settings
      description: 'Send only the settings to change, each at its descriptor''s `path`.

        `path: ["weekdayBar", "show"]` is sent as `{"weekdayBar":{"show":false}}`.

        Everything else keeps its value. The change shows at once and is saved

        with the other settings.


        **All or nothing.** One bad field rejects the whole body with `422` and

        `field` naming it: a setting the app does not offer on this device

        (`unknown setting`), a value of the wrong type or outside its range. An

        app without settings answers `422` with `no settings`.


        A `color` takes the number (`0`-`16777215`), `"#RRGGBB"` or the other

        color forms. `null` only where the descriptor is `nullable`. A `days`

        value is an array of lowercase weekday names.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BuiltinAppConfigPatch'
      responses:
        '200':
          description: Applied, with error null
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AppConfigWriteResult'
        '400':
          description: Malformed JSON (`invalidJson`) or a name outside `[A-Za-z0-9_-]{1,32}` (`invalidName`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '422':
          $ref: '#/components/responses/Invalid'
  /api/v1/apps/{name}/data:
    get:
      tags:
      - scripts
      summary: What a script saved with store.set()
      description: 'The script''s saved values, key by key. The values of its `@config`

        settings are left out: they are under `/api/v1/apps/{name}/config`:

        and so is a key the script cleared with `store.set(key, nil)`. A script

        that saved nothing answers `{}`.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The saved values
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          description: '`name` is not `[A-Za-z0-9_-]{1,32}` (`invalidName`)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          description: This build has no scripting platform (`unavailable`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '507':
          $ref: '#/components/responses/InsufficientStorage'
    patch:
      tags:
      - scripts
      summary: Change what a script saved
      description: 'Send only the keys to change. A value replaces the stored one or adds

        the key, `null` removes it, and every key left out keeps its value.

        A key that is one of the script''s `@config` settings is refused with

        `422`. Change it through `/config`.


        **Saving restarts the script**, as saving its settings does: `init()`

        and `setup()` run again and read the new values. A `200` with an

        `error` object means the values were saved and the restarted script

        then threw.

        '
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Stored key to its new value, or `null` to remove it.
              additionalProperties: true
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: Applied (check `error` for what the restarted script did)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  name:
                    type: string
                  error:
                    description: The restarted script's error. `null` when it works.
                    oneOf:
                    - type: 'null'
                    - $ref: '#/components/schemas/ScriptError'
        '400':
          description: Malformed JSON (`invalidJson`) or a name outside `[A-Za-z0-9_-]{1,32}` (`invalidName`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '415':
          $ref: '#/components/responses/WrongContentType'
        '422':
          $ref: '#/components/responses/Invalid'
        '503':
          description: Scripting is switched off (`unavailable`), or busy (`serviceBusy`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '507':
          $ref: '#/components/responses/InsufficientStorage'
  /api/v1/scripts/shared:
    get:
      tags:
      - scripts
      summary: What the scripts have published to each other
      description: 'The `shared` module''s whole key/value space. Scripts write BARE keys,

        which are filed under the writing app, and read QUALIFIED ones

        (`owner.key`): so a value''s origin is always exactly who wrote it and

        no app can overwrite another''s. Values are scalars only. A script

        publishes `json.dump(...)` when it needs structure.


        Nothing here survives a reboot, and nothing survives its author:

        removing a script (or re-saving it, which restarts it) drops

        everything it had published.


        Read-only: only scripts can write here.

        '
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: Grouped by owner, ordered by key within each. `[]` when empty.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required:
                  - owner
                  - key
                  - type
                  - value
                  - ageMs
                  properties:
                    owner:
                      type: string
                      description: 'Install name of the script that wrote it: the only one that may.'
                    key:
                      type: string
                      description: 'The bare key inside that namespace, `[A-Za-z0-9_-]{1,24}`. Scripts
                        address it as `owner.key`. Keys cannot contain a dot.

                        '
                    type:
                      type: string
                      enum:
                      - int
                      - real
                      - bool
                      - string
                    value:
                      description: The value in its own JSON type. A non-finite real is `null`.
                      type:
                      - integer
                      - number
                      - boolean
                      - string
                      - 'null'
                    ageMs:
                      type: integer
                      description: 'Milliseconds since it was last written. Nothing expires on its own:
                        this is how a reader tells a live value from one whose provider quietly stopped
                        updating.

                        '
        '503':
          description: This build has no scripting platform (`unavailable`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/capabilities:
    get:
      tags:
      - device
      summary: What this device supports
      description: 'Effect, transition, overlay and palette names, sound outputs and sensors. Lists every
        name in the spelling the API returns. Requests may use any casing.

        '
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: What this device supports.
          content:
            application/json:
              schema:
                type: object
                properties:
                  effects:
                    type: array
                    items:
                      type: string
                  paletteEffects:
                    type: array
                    items:
                      type: string
                    description: 'The subset of `effects` that honors the app''s `palette`. Scene-style
                      effects that draw fixed colors (PingPong, Matrix, LookingEyes) are absent from it.
                      `blend` applies when `palette` is supplied too. It interpolates between palette
                      entries.

                      '
                  transitions:
                    type: array
                    items:
                      type: string
                  overlays:
                    type: array
                    items:
                      type: string
                  audio:
                    type: object
                    description: What this device can play. Every flag is always present.
                    required:
                    - mp3
                    - rtttl
                    - song
                    - speech
                    - track
                    - radio
                    - url
                    - effect
                    - clip
                    properties:
                      mp3:
                        type: boolean
                        allOf:
                        - description: Always false
                      rtttl:
                        type: boolean
                        allOf:
                        - description: Melodies play. A buzzer pin is set
                      song:
                        type: boolean
                        allOf:
                        - description: Always false
                      speech:
                        type: boolean
                        allOf:
                        - description: Always false
                      track:
                        type: boolean
                        allOf:
                        - description: A DFPlayer is wired and switched on
                      radio:
                        type: boolean
                        allOf:
                        - description: Always false
                      url:
                        type: boolean
                        allOf:
                        - description: Always false
                      effect:
                        type: boolean
                        allOf:
                        - description: Always false
                      clip:
                        type: boolean
                        allOf:
                        - description: Always false
                  microphone:
                    type: boolean
                    allOf:
                    - description: Always false
                  sensors:
                    type: object
                    description: 'Which sensors this device has. Without a light sensor `autoBrightness`
                      has no effect and the panel shows `brightness`.

                      '
                    properties:
                      light:
                        type: boolean
                        allOf:
                        - description: The device has a light sensor. `pinLdr` is set to a pin, read at
                            boot.
                  palettes:
                    type: array
                    items:
                      type: string
                    description: 'The built-in palette names, which always resolve. Uploaded palettes
                      are not listed here. Read them from GET /api/v1/files?dir=/PALETTES. A file may
                      carry a built-in''s name and replaces it while it exists.

                      '
                  gpio:
                    oneOf:
                    - $ref: '#/components/schemas/GpioRules'
                  platform:
                    type: object
                    properties:
                      id:
                        type: string
                        description: Platform identity.
                  scriptUpdates:
                    type: boolean
                    description: The compare-and-update script route is available.
                  display:
                    type: object
                    properties:
                      width:
                        type: integer
                      height:
                        type: integer
                      requestedWidth:
                        type: integer
                      requestedHeight:
                        type: integer
                      minWidth:
                        type: integer
                      maxWidth:
                        type: integer
                      minHeight:
                        type: integer
                      maxHeight:
                        type: integer
                      maxPixels:
                        type: integer
                      estimatedWireTimeUs:
                        type: integer
                      configurable:
                        type: boolean
                      restartRequired:
                        type: boolean
                      ready:
                        type: boolean
                      wireTimeIsEstimate:
                        type: boolean
                    allOf:
                    - description: Active dimensions and accepted geometry. The panel is 8 high and 32
                        to 128 wide.
                  fonts:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        ascent:
                          type: integer
                        descent:
                          type: integer
                        lineHeight:
                          type: integer
  /api/v1/system:
    get:
      tags:
      - system
      summary: Device configuration (network, MQTT, NTP, auth, hardware)
      description: 'Secrets (`wifiPass`, `mqttPass`, `authPass`) are omitted from the response (every
        other configuration field is returned) unless `?secrets=1` is passed, which includes them so a
        backup can round-trip the wifi/MQTT/auth credentials. In setup mode, any `secrets` query parameter
        makes the whole request fail with `403 forbidden`. A request for secrets from a web page of another
        site answers `403 forbiddenOrigin` and carries no CORS headers.

        '
      parameters:
      - name: secrets
        in: query
        required: false
        schema:
          type: boolean
        description: 'Include the secret fields in the response (backup export). Any presence of this
          parameter is refused in setup mode, even when false. Behind HTTP auth when configured.

          '
      responses:
        '403':
          $ref: '#/components/responses/ForbiddenOwnPage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The device configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SystemConfigRead'
    put:
      tags:
      - system
      summary: Update device configuration (partial. Most changes apply after reboot)
      description: 'In setup mode only string fields `wifiSsid`, `wifiPass` and

        `hostname` are accepted. Other fields, duplicate fields and malformed

        setup bodies are refused with `403 forbidden`.


        Partial merge. Every numeric field is range/type checked BEFORE

        anything is stored, with the ranges given in `SystemConfig`. The first

        offender is rejected with `422 validationFailed` and `field` naming it,

        and nothing is saved.


        Unknown keys are ignored and strings are not range-checked.


        The three secret fields honor skip-empty: sending `""` for `wifiPass`,

        `mqttPass` or `authPass` leaves the stored secret alone rather than

        clearing it. Non-secret strings can be cleared with `""`.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SystemConfig'
      responses:
        '415':
          $ref: '#/components/responses/WrongContentType'
        '403':
          $ref: '#/components/responses/ForbiddenOwnPage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The resulting configuration (secrets omitted)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SystemConfigRead'
        '400':
          description: Malformed JSON (`invalidJson`), or a pin map the chip does not allow (`invalidPinConfig`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/Invalid'
  /api/v1/system/wifi-scan:
    get:
      tags:
      - system
      summary: Scan for WiFi networks (async)
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '202':
          description: 'Scan running: poll again'
          content:
            application/json:
              schema:
                type: object
                properties:
                  scanning:
                    type: boolean
        '200':
          description: The networks found.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    ssid:
                      type: string
                    rssi:
                      type: integer
                    enc:
                      type: boolean
  /api/v1/logs:
    get:
      tags:
      - system
      summary: Incremental device log
      parameters:
      - name: after
        in: query
        schema:
          type: integer
      responses:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The log lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  next:
                    type: integer
                  lines:
                    type: array
                    items:
                      type: string
  /api/v1/icons/origins:
    description: 'Records which published icon a local icon came from. Kept on the device and available
      offline.

      Overwriting an icon keeps the recorded SHA256, so you can detect local edits. Deleting an icon

      removes its record. These are user-supplied links, not signatures or proof of authorship.

      All methods are blocked in setup mode, including GET.

      At most 64 records and 16 KiB in total.

      '
    get:
      tags:
      - files
      summary: List installed icon origins
      responses:
        '403':
          $ref: '#/components/responses/Forbidden'
        '200':
          description: Existing icon files with saved origins, no pagination within the hard limit
          headers:
            Cache-Control:
              schema:
                type: string
                const: no-store
          content:
            application/json:
              schema:
                type: object
                required:
                - icons
                properties:
                  icons:
                    type: array
                    maxItems: 64
                    items:
                      $ref: '#/components/schemas/IconOrigin'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          description: 'storageError: unreadable or corrupt origin metadata'
    put:
      tags:
      - files
      summary: Associate an existing local icon with a published original
      description: Creates or replaces the whole record for this filename. Sending it twice is safe. Maximum
        body 1024 bytes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IconOrigin'
      responses:
        '415':
          $ref: '#/components/responses/WrongContentType'
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: invalidOrigin
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          description: payloadTooLarge
        '507':
          description: 'insufficientStorage: record or byte limit reached'
        '500':
          description: 'storageError: the origin records could not be read or saved'
    delete:
      tags:
      - files
      summary: Remove a local origin association without deleting the image
      parameters:
      - name: name
        in: query
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}\.(gif|jpg)$
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: invalidName
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          description: 'storageError: the origin records could not be read or saved'
  /api/v1/icons/rename:
    post:
      tags:
      - files
      summary: Rename an icon
      description: 'The icon''s origin record moves to the new name. Apps, notifications and scripts that
        use the

        old name show no icon until they are changed. Blocked in setup mode.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - from
              - to
              properties:
                from:
                  type: string
                  pattern: ^[A-Za-z0-9_-]{1,32}\.(gif|jpg)$
                  example: mail.gif
                to:
                  type: string
                  pattern: ^[A-Za-z0-9_-]{1,32}\.(gif|jpg)$
                  description: Same extension as from
                  example: letter.gif
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: 'invalidJson, or invalidName: a name is not valid or the extension differs'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: 'nameTaken: an icon with the new name exists as .gif or .jpg'
        '500':
          description: 'internalError or storageError: the icon keeps its name'
  /api/v1/files:
    get:
      tags:
      - files
      summary: List files (icons, melodies, palettes, sounds)
      parameters:
      - name: dir
        in: query
        schema:
          type: string
          default: /ICONS
      responses:
        '400':
          $ref: '#/components/responses/InvalidPath'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          description: The files in the folder.
          content:
            application/json:
              schema:
                type: object
                properties:
                  files:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        size:
                          type: integer
                  usedBytes:
                    type: integer
                  totalBytes:
                    type: integer
    post:
      tags:
      - files
      summary: Upload a file (multipart) into ?dir=
      parameters:
      - name: dir
        in: query
        schema:
          type: string
          default: /ICONS
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '400':
          description: '`invalidPath` for a folder outside the asset folders, `invalidName` for an MP3
            name that is not valid, or `badRequest`, `no file received`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          description: '`unsupportedMediaType`: the file does not suit its folder, for example `expected
            GIF or JPEG` in /ICONS.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '409':
          description: '`nameTaken`, `name taken`: an MP3 for /MP3 whose name a melody has, or a melody
            for /MELODIES whose name an MP3 has'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '507':
          description: 'insufficientStorage, write failed: the file could not be saved. The previous version
            is kept.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
      - files
      summary: Delete a file
      parameters:
      - name: path
        in: query
        required: true
        schema:
          type: string
      responses:
        '400':
          $ref: '#/components/responses/InvalidPath'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '200':
          $ref: '#/components/responses/Ok'
        '404':
          $ref: '#/components/responses/NotFound'
  /version:
    get:
      tags:
      - device
      summary: Read the version as plain text
      responses:
        '200':
          description: Version string.
          content:
            text/plain:
              schema:
                type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/v1/audio/mp3:
    get:
      tags:
      - sounds
      summary: List stored MP3 files, and each script's own sounds
      responses:
        '200':
          description: The MP3s in /MP3, the sounds of each installed script that has any, and whole-filesystem
            usage.
          content:
            application/json:
              schema:
                type: object
                properties:
                  files:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        size:
                          type: integer
                  scripts:
                    type: array
                    description: One entry for each script folder with at least one sound.
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          description: The script's install name
                        title:
                          type: string
                          description: Its @name header, or the install name without one
                        orphan:
                          type: boolean
                          description: True for sounds whose script was deleted without them
                        files:
                          type: array
                          items:
                            type: object
                            properties:
                              name:
                                type: string
                              size:
                                type: integer
                  usedBytes:
                    type: integer
                  totalBytes:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags:
      - sounds
      summary: Upload one stored MP3
      description: The filename is 1–32 characters A–Z, a–z, 0–9, underscore or hyphen followed by .mp3.
        The first chunk must identify MP3 audio. The multipart field name is not significant. Send one
        file. An MP3 and a melody never share a name, so a name a melody has answers 409.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '400':
          description: invalidName.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: 'nameTaken, "name taken": a melody of that name exists.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          description: 'unsupportedMediaType: not an MP3.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '507':
          description: 'insufficientStorage, write failed: the file could not be saved. The previous version
            is kept.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/audio/mp3/{name}:
    delete:
      tags:
      - sounds
      summary: Delete a stored MP3
      parameters:
      - name: name
        in: path
        required: true
        schema:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}$
        description: Name without the .mp3 extension.
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '400':
          description: invalidName.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
  /api/v1/audio/mp3/rename:
    post:
      tags:
      - sounds
      summary: Rename a stored MP3
      description: 'Renames one MP3 in /MP3. Sounds in a script''s own folder cannot be renamed. Alarms,

        notifications and scripts that play the old name stay silent until they are changed.

        Blocked in setup mode.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - from
              - to
              properties:
                from:
                  type: string
                  pattern: ^[A-Za-z0-9_-]{1,32}$
                  example: ding
                to:
                  type: string
                  pattern: ^[A-Za-z0-9_-]{1,32}$
                  example: bell
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: 'invalidJson, or invalidName: a name is not valid'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: 'nameTaken: an MP3 or a melody with the new name exists'
        '500':
          description: 'internalError: the MP3 keeps its name'
components:
  parameters: {}
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
      description: 'HTTP Basic, realm "AWTRIX NG". Enabled only when the `authEnabled` device

        configuration flag is true. The shipping default is false, so the whole

        API is open. Once on, it applies in every mode, including

        setup mode. A failure answers 401 with the standard JSON

        error body (code `unauthorized`) plus a `WWW-Authenticate` header.

        Applies to every route below, and to `GET /` and the static asset

        directories.

        '
  responses:
    NotOnThisDevice:
      description: '`notFound`, `unknown route`: this device does not have the feature.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PayloadTooLarge:
      description: '`payloadTooLarge`: the body is larger than this device takes. Nothing was applied.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InvalidPath:
      description: '`invalidPath`: the path is outside the asset folders or contains `..`.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Basic auth is enabled and credentials are missing or wrong
      headers:
        WWW-Authenticate:
          schema:
            type: string
          description: Basic realm="AWTRIX NG"
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Ok:
      description: Success
      content:
        application/json:
          schema:
            type: object
            properties:
              ok:
                type: boolean
    BadJson:
      description: Malformed JSON body
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Invalid:
      description: Validation failed (error.field names the offender)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Unknown app/sound/file
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: Request is unavailable in setup mode (`Wi-Fi setup only`)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ForbiddenOwnPage:
      description: '`forbidden` in setup mode, or `forbiddenOrigin` for a request from a web page of another
        site: only the device''s own web page and clients that are not browsers may use this route'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    WrongContentType:
      description: A supplied Content-Type is not application/json. An omitted header is accepted.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InsufficientStorage:
      description: A store or queue is full, or a change could not be saved. See the error reference for
        applied-state details.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unavailable:
      description: 'The feature is not present on this build or this hardware. Unlike a 500 nothing went
        wrong, and unlike serviceBusy retrying will not help.

        '
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  schemas:
    GpioRules:
      type: object
      description: The chip's pin rules, so a client can check pin changes before sending them.
      properties:
        soc:
          type: string
          allOf:
          - const: esp32
          description: Chip id, the same as `soc` in the device state.
        label:
          type: string
          description: Display name of the chip
        max:
          type: integer
          description: highest GPIO number that exists
        missing:
          type: array
          items:
            type: array
            items:
              type: integer
          description: ranges inside 0..max the package does not bond out
        inputOnly:
          type: array
          items:
            type: array
            items:
              type: integer
          description: 'Cannot drive an output, so they are refused for the matrix, buttons, buzzer, I2C
            and DFPlayer TX.

            '
        reserved:
          type: array
          description: 'Taken by the flash, PSRAM, USB or the console. `why` is the same wording the rejection
            message uses.

            '
          items:
            type: object
            properties:
              lo:
                type: integer
              hi:
                type: integer
              why:
                type: string
        adc1:
          type: array
          items:
            type: array
            items:
              type: integer
          description: 'The only pins accepted for `pinBattery` and `pinLdr`: ADC2 stops working while
            WiFi is on.

            '
        strapping:
          type: array
          items:
            type: array
            items:
              type: integer
          description: 'Boot-mode pins, reported as a caution and never rejected.

            '
        rtc:
          type: array
          items:
            type: array
            items:
              type: integer
          description: 'Pins the RTC domain keeps powered during deep sleep. Only a `pinBtnSelect` inside
            this set can end a `POST /api/v1/device/sleep` early. Any other pin is accepted and simply
            cannot wake the device.

            '
        matrix:
          type: array
          items:
            type: integer
          description: 'The only values `pinMatrix` accepts on this chip.

            '
        defaults:
          type: object
          additionalProperties:
            type: integer
          description: 'The pin map a factory-fresh device of this chip starts with, and the one it falls
            back to when the stored map fails validation.

            '
    IconOrigin:
      type: object
      required:
      - name
      - hub
      - slug
      - sha256
      properties:
        name:
          type: string
          pattern: ^[A-Za-z0-9_-]{1,32}\.(gif|jpg)$
          description: Existing local filename including extension
        hub:
          type: string
          maxLength: 240
          pattern: ^https://[^@?#%\\ ]+/icons/$
          description: HTTPS icon base. No credentials, query, fragment, escapes or dot-segments
        slug:
          type: string
          pattern: ^[a-z0-9_-]{1,32}$
        sha256:
          type: string
          pattern: ^[a-f0-9]{64}$
          description: SHA256 of local bytes when linked. Not updated by normal file overwrite
    ScriptMeta:
      type: object
      description: 'Scripts and modules only, in `GET /api/v1/apps`. The `@name`/`@desc`/`@author`/`@version`/`@icons`/`@requires`/`@needs`/`@display`
        header comments the source declared. Each string is `""` when absent. Omitted for other origins
        and on a build with no scripting platform.

        '
      properties:
        name:
          type: string
          description: 'The `@name` header: presentation only, not the install name.'
        desc:
          type: string
        author:
          type: string
        version:
          type: string
        icons:
          type: array
          items:
            type: string
            pattern: ^[A-Za-z0-9_-]{1,32}$
          maxItems: 32
          description: 'The icon database IDs from the `@icons` header lines: what the script draws with
            `icon()` and what a client offers to install into `/ICONS`. `[]` when the header is absent.

            '
        requires:
          type: array
          maxItems: 8
          description: 'The `@requires` header lines: the scripts and modules this script needs on the
            same display. `[]` when the header names none.

            '
          items:
            type: object
            required:
            - name
            - missing
            properties:
              name:
                type: string
                pattern: ^[A-Za-z0-9_-]{1,32}$
                description: A script's install name or a module's import name.
              hub:
                type: string
                pattern: ^[A-Za-z0-9]{12}$
                description: Its AWTRIX Hub ID, when the line gives one.
              missing:
                type: boolean
                description: 'True while no installed script has this name and no installed module is
                  imported under it.

                  '
        needs:
          type: array
          maxItems: 8
          description: 'The capabilities the `@needs` header lines ask for. `[]` when the header names
            none. The display only reports. It installs and runs the script either way.

            '
          items:
            type: object
            required:
            - name
            - missing
            properties:
              name:
                type: string
                pattern: ^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$
                description: 'A boolean of `GET /api/v1/capabilities` at this dotted path, for example
                  `audio.rtttl`.

                  '
              missing:
                type: boolean
                description: True while this display does not have it.
        display:
          description: 'The smallest panel from the `@display` header line, or null when the script names
            none and runs on any panel.

            '
          oneOf:
          - type: 'null'
          - type: object
            required:
            - width
            - height
            - fits
            properties:
              width:
                type: integer
                minimum: 1
              height:
                type: integer
                minimum: 1
              fits:
                type: boolean
                description: True when this display's panel is at least that large.
    BuiltinAppConfig:
      type: object
      description: 'What `GET /api/v1/apps/builtin/{name}/config` answers: the settings a built-in app
        offers on this device, each with its factory value and the value it holds now.

        '
      required:
      - name
      - fields
      - warnings
      properties:
        name:
          type: string
          description: The built-in app's name.
        fields:
          type: array
          items:
            $ref: '#/components/schemas/BuiltinAppConfigField'
        warnings:
          type: array
          maxItems: 0
          items:
            type: string
    BuiltinAppConfigField:
      type: object
      required:
      - key
      - type
      - path
      - default
      - value
      properties:
        key:
          type: string
          description: The setting's name. A weekday bar member is dotted, such as `weekdayBar.show`.
        type:
          type: string
          enum:
          - bool
          - select
          - color
          - days
        group:
          type: string
          enum:
          - time
          - calendar
          - date
          - weekday
          description: The part of the form the setting belongs to. Omitted for apps with one part.
        path:
          type: array
          minItems: 1
          items:
            type: string
          description: 'Where the value goes in a `PATCH`. [weekdayBar, show] means send {"weekdayBar":{"show":false}}.

            '
          example:
          - weekdayBar
          - show
        nullable:
          type: boolean
          description: 'Present and true on colors that may be null, which means "use the global text
            color". 0 is black.

            '
        options:
          type: array
          description: '`select`: the accepted values, numbers or strings. Send a value with its JSON
            type.

            '
          items:
            type:
            - string
            - number
        default:
          $ref: '#/components/schemas/BuiltinAppConfigValue'
          description: The factory value.
        value:
          $ref: '#/components/schemas/BuiltinAppConfigValue'
          description: The value it holds now.
    BuiltinAppConfigValue:
      description: 'A `bool` is a boolean, a `select` value has the JSON type of its options, a `color`
        is a number 0-16777215 or null where `nullable`, and `days` is an array of lowercase weekday names,
        Sunday first.

        '
      oneOf:
      - type: string
      - type: number
      - type: boolean
      - type: 'null'
      - type: array
        items:
          type: string
          enum:
          - sunday
          - monday
          - tuesday
          - wednesday
          - thursday
          - friday
          - saturday
    BuiltinAppConfigPatch:
      type: object
      description: 'The settings to change, each at its descriptor''s path. Only settings the app offers
        on this device are accepted.

        '
      additionalProperties:
        oneOf:
        - type: string
        - type: number
        - type: boolean
        - type: 'null'
        - $ref: '#/components/schemas/WeekdayBar'
      examples:
      - time24h: false
        weekdayBar:
          show: false
          weekendDays:
          - friday
          - saturday
      - timeColor: null
    AppConfigWriteResult:
      type: object
      required:
      - ok
      - name
      - error
      properties:
        ok:
          type: boolean
          const: true
        name:
          type: string
        error:
          description: 'Always null for a built-in app. For a script, null while it runs, or the error
            the restarted script stopped on: the settings were saved either way.

            '
          oneOf:
          - type: 'null'
          - $ref: '#/components/schemas/ScriptError'
    ScriptError:
      type: object
      description: The error a script or module stopped on.
      required:
      - message
      properties:
        message:
          type: string
          description: The error text, without the line number.
        line:
          type: integer
          minimum: 1
          description: Line in the script source, counting from 1. Omitted when unknown.
        hook:
          type: string
          description: 'The function that failed, such as `setup`, `draw` or `on_button`. Omitted when
            unknown. Never sent for a module.

            '
    ScriptConfig:
      type: object
      description: 'What `GET /api/v1/apps/{name}/config` answers: the settings a script declared in its
        header, each with the value it currently holds.

        '
      required:
      - name
      - fields
      - warnings
      properties:
        name:
          type: string
          description: The script's install name.
        fields:
          type: array
          maxItems: 12
          items:
            $ref: '#/components/schemas/ScriptConfigField'
        warnings:
          type: array
          description: '`@config` lines the device could not read, each prefixed with its line number.
            A malformed line is skipped, never fatal.

            '
          items:
            type: string
    ScriptConfigField:
      type: object
      description: 'One setting. `key` is what the script reads and what a `PATCH` sends. The optional
        members are present only when the declaration gave them.

        '
      required:
      - key
      - type
      - label
      - default
      - value
      properties:
        key:
          type: string
          pattern: ^[A-Za-z_][A-Za-z0-9_]{0,23}$
          description: The name the script reads its value under.
        type:
          type: string
          enum:
          - bool
          - text
          - number
          - slider
          - select
          - color
        label:
          type: string
          description: 'What to show, exactly as the script author wrote it. AWTRIX ships no translation
            for it: it is one string, in whatever language the author chose.

            '
        help:
          type: string
          description: One line of explanation under the label.
        unit:
          type: string
          description: '`number`/`slider`: shown after the control.'
        group:
          type: string
          description: 'Optional category heading from `group=` in the declaration, clipped to 48 bytes.
            Shown as written without translation. Fields with the same nonempty group share a collapsible
            section. Omitted or empty groups leave the field outside sections. Presentation only: stored
            values and PATCH keys are unchanged.

            '
        min:
          type: number
        max:
          type: number
        step:
          type: number
        maxlen:
          type: integer
          description: '`text`: longest value accepted, at most 256.'
        options:
          type: array
          maxItems: 12
          description: '`select`: the accepted values. A `PATCH` outside this list is a 422.'
          items:
            type: string
        default:
          description: 'What the header declared, so a client can offer a reset. A `color` is a number
            0-16777215. Every other type is the JSON type its name suggests.

            '
          oneOf:
          - type: string
          - type: number
          - type: boolean
        value:
          description: 'What the setting holds now: the default until somebody changes it. Same type as
            `default`.

            '
          oneOf:
          - type: string
          - type: number
          - type: boolean
    Error:
      type: object
      properties:
        error:
          type: object
          required:
          - code
          - message
          properties:
            code:
              type: string
              description: 'The error code. The list covers every clock: a code that belongs to a feature
                this clock does not have never comes back.

                '
              enum:
              - badRequest
              - invalidOrigin
              - storageError
              - invalidJson
              - invalidPath
              - invalidPinConfig
              - invalidName
              - invalidMethodOverride
              - invalidPlayer
              - wrongChip
              - invalidPackage
              - wrongTarget
              - unauthorized
              - forbidden
              - forbiddenOrigin
              - notFound
              - methodNotAllowed
              - unsupportedMediaType
              - payloadTooLarge
              - validationFailed
              - scriptChanged
              - gamepadsFull
              - scanUnavailable
              - notNewer
              - updateBusy
              - nameTaken
              - insufficientMemory
              - internalError
              - serviceBusy
              - unavailable
              - notSupported
              - insufficientStorage
            message:
              type: string
            field:
              type: string
    Color:
      description: '"#RRGGBB" (output). Input also accepts "RRGGBB", [r,g,b], ["HSV",h,s,v]'
      type:
      - string
      - array
      - integer
    NullableColor:
      description: 'Color or null (inherit/off). The null state is tracked separately from the color value,
        so every concrete color is representable: `#000000` stores and reads back as `#000000`, and `#FFFFFF`
        is a real value: only an explicit `null` means inherit/off.

        '
      type:
      - string
      - array
      - integer
      - 'null'
    IconGap:
      type: integer
      minimum: 0
      maximum: 128
      default: 1
      description: 'Empty columns between the right edge of the `icon` and the text, bars and line chart,
        counted from the icon''s own width. `0` puts the text right against the icon. Scrolling text is
        cut off at the far side of the gap. Anything but an integer from 0 to 128 rejects the request
        with 422 validationFailed and `field: "iconGap"`.

        '
    PlacedIcons:
      type: array
      maxItems: 4
      description: 'Additional independently animated icons at absolute pixel positions.

        Each GIF uses its own colors and frame timings. Available on pushed apps

        and notifications. Drawn in array order after the icon field, text and

        decorations, before overlays. Later icons cover earlier ones.

        They reserve no text columns and do not follow

        iconMode. Negative positions clip at the panel edge. Empty or omitted

        means no additional icons. Invalid entries or more than four icons

        reject the entire request with 422 validationFailed and an icons field

        path. Each icon uses its own image dimensions, bounded by the panel.

        '
      example:
      - icon: weather
        x: 0
        y: 0
      - icon: mail
        x: 16
        y: 0
      items:
        type: object
        additionalProperties: false
        required:
        - icon
        properties:
          icon:
            type: string
            minLength: 1
            allOf:
            - description: 'Icon ID up to 64 characters, or the image as a data URL (data:image/gif.Base64,…
                or data:image/jpeg.Base64,…).

                '
          x:
            type: integer
            minimum: -65535
            maximum: 65535
            default: 0
          y:
            type: integer
            minimum: -65535
            maximum: 65535
            default: 0
    Scroll:
      description: 'Text motion. The same object is accepted in a pushed-app payload, a notification payload
        and the device settings. In the settings every field is concrete, in a payload every field is
        optional and each omitted one falls back to the device default **on its own**, so `{"mode":"bounce"}`
        keeps the configured speed. The string shorthand `"bounce"` is exactly `{"mode":"bounce"}`. Values
        are resolved when the page is drawn, so changing a device default also moves apps that are already
        pushed. An unknown field, an unknown enum value or a negative number is rejected with `422 validationFailed`
        and the offending key in `field` (for example `"scroll.speed"`), on every route that takes a `scroll`.
        Nothing is stored when it trips.

        '
      oneOf:
      - type: string
        enum:
        - static
        - wrap
        - loop
        - bounce
        description: 'Shorthand for `{"mode": ...}`.'
      - type: object
        additionalProperties: false
        properties:
          mode:
            type: string
            enum:
            - static
            - wrap
            - loop
            - bounce
            default: wrap
            description: 'static = no motion, overflow clipped. Wrap = run off the far edge, then restart
              at the start anchor. Loop = continuous marquee with no empty seam. Bounce = sweep back and
              forth, holding at both turning points.

              '
          direction:
            type: string
            enum:
            - left
            - right
            default: left
            description: 'Travel direction. `right` mirrors the whole geometry (the rest, entry and exit
              anchors swap ends) rather than only negating the velocity.

              '
          entry:
            type: string
            enum:
            - inline
            - offscreen
            default: inline
            description: '`offscreen` starts the text outside the panel and skips the initial hold. Ignored
              when `mode` is `static`.

              '
          whenFits:
            type: string
            enum:
            - static
            - scroll
            default: static
            description: Whether text that already fits the panel still animates.
          speed:
            type: integer
            minimum: 0
            default: 100
            description: 'Percent of the 21 px/s base rate. `0` freezes the text. There is no upper clamp.
              `200` is the sharpest a scroll gets on an 8 px panel.

              '
          gap:
            type: integer
            minimum: 0
            default: 8
            description: '`loop` only: pixels between repetitions.'
          holdMs:
            type: integer
            minimum: 0
            default: 1000
            description: 'How long the text rests before it starts moving, and at each `bounce` turning
              point. `0` removes the pause.

              '
    WeekdayBar:
      description: 'The seven-segment weekday bar the clock draws. A column is colored on two independent
        axes (weekend or workday, today or not) so `activeColor` is today on a workday, `inactiveColor`
        another workday, `weekendActiveColor` today when today is a weekend day and `weekendInactiveColor`
        another weekend day. The two weekend colors **default to the workday colors**, so the bar looks
        the same until you set them. In the settings every field is concrete. A `PATCH` merges field by
        field, so `{"weekdayBar":{"weekendDays":["friday","saturday"]}}` leaves the other six untouched.
        An unknown field, a wrong type or an unknown weekday name is rejected with `422 validationFailed`
        and the offending key in `field` (for example `"weekdayBar.weekendDays"`). Nothing is applied
        when it trips.

        '
      type: object
      additionalProperties: false
      properties:
        show:
          type: boolean
          default: true
          description: 'Draw the bar. Where it sits depends on the clock layout.

            '
        startOnMonday:
          type: boolean
          default: true
          description: 'Monday is the first column. `false` starts the week on Sunday. This only rotates
            the display order: weekend membership is decided on the calendar day, so a Sunday-first week
            puts a Saturday/Sunday weekend on the first and the last column instead of the last two.

            '
        weekendDays:
          type: array
          default:
          - sunday
          - saturday
          description: 'Which calendar days count as weekend. Any subset in any order. An empty array
            means no weekend at all and every column uses the workday colors. In some countries, for example,
            the weekend is Friday and Saturday. Read back in calendar order, Sunday first.

            '
          items:
            type: string
            enum:
            - sunday
            - monday
            - tuesday
            - wednesday
            - thursday
            - friday
            - saturday
        activeColor:
          $ref: '#/components/schemas/Color'
          default: '#FFFFFF'
          description: Today, when today is a workday. Not nullable.
        inactiveColor:
          $ref: '#/components/schemas/Color'
          default: '#666666'
          description: Any other workday. Not nullable.
        weekendActiveColor:
          $ref: '#/components/schemas/Color'
          default: '#FFFFFF'
          description: Today, when today is a weekend day. Not nullable.
        weekendInactiveColor:
          $ref: '#/components/schemas/Color'
          default: '#666666'
          description: Any other weekend day. Not nullable.
    RadioStation:
      type: object
      required:
      - name
      - url
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 24
        url:
          type: string
          maxLength: 255
          description: http:// or https://
    RadioStations:
      type: object
      properties:
        stations:
          type: array
          maxItems: 32
          items:
            $ref: '#/components/schemas/RadioStation'
    AudioPlay:
      description: 'A sound: a stored name (short for {"file": name}), one SoundObject, or a list of 1
        to 4 of them. A list plays its first entry the clock can play.

        '
      oneOf:
      - type: string
        description: 'A stored name, the same as {"file": name}'
      - $ref: '#/components/schemas/SoundObject'
      - type: array
        minItems: 1
        maxItems: 4
        items:
          oneOf:
          - type: string
          - $ref: '#/components/schemas/SoundObject'
    SoundObject:
      type: object
      description: Exactly one source key, plus `loop`.
      additionalProperties: false
      properties:
        file:
          type: string
          allOf:
          - description: A stored melody, /MELODIES/<name>.txt. Names are 1 to 32 of [A-Za-z0-9_-].
        rtttl:
          type: string
          maxLength: 512
          description: An RTTTL melody
        track:
          type: integer
          minimum: 1
          maximum: 2999
          description: A DFPlayer track
        loop:
          type: boolean
          default: false
          description: Repeat until stopped or replaced by a new alert. In a notification, while it is
            shown
      oneOf:
      - required:
        - file
      - required:
        - rtttl
      - required:
        - track
    AudioStop:
      type: object
      additionalProperties: false
      properties:
        group:
          type: string
          enum:
          - alert
          - app
          - radio
          description: The group to stop. Without it, everything stops.
    AudioGroupState:
      type: object
      properties:
        playing:
          type: boolean
        name:
          type: string
          description: 'What the group played last: the `file` value as sent, else the source key'
        error:
          type: string
          description: Why the last sound did not play. Cleared by the next sound of the group
    AudioState:
      type: object
      properties:
        radio:
          type: object
          properties:
            playing:
              type: boolean
            station:
              type: string
              description: The station name
              or the address of a stream played by address: null
            title:
              type: string
              description: Last track title from the stream, UTF-8
            error:
              type: string
              description: Why playback stopped. Cleared on the next successful play
            underruns:
              type: integer
              description: Audible dropouts since boot. Read it as a difference across two polls
            decodeUs:
              type: integer
              description: Average time in microseconds to decode one piece of audio
            starvedMs:
              type: integer
              description: Milliseconds the player spent waiting for stream data. A high value points
                to a slow network rather than slow decoding. Counts from boot like underruns. It does
                not reset per station.
            bufferBytes:
              type: integer
              description: Bytes received from the stream and not played yet
        app:
          allOf:
          - $ref: '#/components/schemas/AudioGroupState'
          description: What scripts play.
        alert:
          allOf:
          - $ref: '#/components/schemas/AudioGroupState'
          description: What notifications, POST /api/v1/audio/play and the other alerts play.
        stations:
          type: array
          items:
            $ref: '#/components/schemas/RadioStation'
    Settings:
      type: object
      description: 'The saved preferences of this device. A key it does not have fails validation on a
        direct write. A backup restore skips such keys and restores the remaining preferences.

        '
      properties:
        autoBrightness:
          type: boolean
          description: Follow the light sensor. No effect without one (capabilities.sensors.light)
        brightness:
          type: integer
          minimum: 0
          maximum: 255
        autoTransition:
          type: boolean
        textColor:
          $ref: '#/components/schemas/Color'
        transitionEffect:
          type: string
          description: One of capabilities.transitions (for example "Slide", "Random")
        transitionDirection:
          type: string
          enum:
          - normal
          - reverse
          default: normal
          description: 'Reverses the movement of direction-aware transitions without changing app order.
            Symmetric transitions are unaffected.

            '
        transitionDurationMs:
          type: integer
          minimum: 0
        appDurationMs:
          type: integer
          minimum: 0
        timeMode:
          type: integer
          minimum: 0
          maximum: 6
          description: Clock layout 0-6. A higher value is rejected with 422.
        calendarHeaderColor:
          $ref: '#/components/schemas/Color'
        calendarTextColor:
          $ref: '#/components/schemas/Color'
        calendarBodyColor:
          $ref: '#/components/schemas/Color'
        time24h:
          type: boolean
          description: false = 12-hour clock
        timeLeadingZero:
          type: boolean
          description: 07:05 instead of 7:05
        timeShowSeconds:
          type: boolean
          description: only clock layouts with room for the seconds show them
        timeShowAmPm:
          type: boolean
          description: 12-hour clock only. Dropped while seconds are shown
        timeSeparatorMode:
          type: string
          enum:
          - steady
          - blink
          - pulse
          description: How the clock's colon behaves (blink = hard 1 s toggle, pulse = soft fade)
        dateOrder:
          type: string
          enum:
          - dayMonthYear
          - monthDayYear
          - yearMonthDay
        dateSeparator:
          type: string
          enum:
          - dot
          - slash
          - dash
          description: Ignored when dateMonthNames is set
        dateYearMode:
          type: string
          enum:
          - none
          - twoDigit
          - fourDigit
        dateShowWeekday:
          type: boolean
          description: short weekday name before the date
        dateMonthNames:
          type: boolean
          description: '"31 Dec" instead of "31.12"'
        useCelsius:
          type: boolean
        blockNavigation:
          type: boolean
        uppercase:
          type: boolean
        weekdayBar:
          $ref: '#/components/schemas/WeekdayBar'
          description: 'The clock''s weekday bar. A `PATCH` with it sets the Date app''s bar too. Every
            field is concrete here. A `PATCH` may carry any subset of them.

            '
        dateWeekdayBar:
          $ref: '#/components/schemas/WeekdayBar'
          description: 'The Date app''s weekday bar. A `PATCH` with it sets only this bar, also when `weekdayBar`
            is in the same request.

            '
        timeColor:
          $ref: '#/components/schemas/NullableColor'
        dateColor:
          $ref: '#/components/schemas/NullableColor'
        humidityColor:
          $ref: '#/components/schemas/NullableColor'
        temperatureColor:
          $ref: '#/components/schemas/NullableColor'
        batteryColor:
          $ref: '#/components/schemas/NullableColor'
        scroll:
          $ref: '#/components/schemas/Scroll'
          description: 'Device-wide text motion. Every field is concrete here. A payload''s own `scroll`
            overrides it field by field.

            '
        volume:
          type: integer
          minimum: 0
          maximum: 100
          allOf:
          - description: Master volume, default 60. Every group plays at volume × group volume / 100
        appVolume:
          type: integer
          minimum: 0
          maximum: 100
          description: The app group (everything a script plays), share of the master volume, default
            100
        alertVolume:
          type: integer
          minimum: 0
          maximum: 100
          description: The alert group (notification sounds, POST /api/v1/audio/play and the other alerts),
            share of the master volume, default 100
        saturation:
          type: integer
          minimum: 0
          maximum: 100
          description: Color saturation of the whole panel in percent. 100 leaves colors untouched, 0
            shows grays
        gamma:
          type: number
          exclusiveMinimum: 0
        colorCorrection:
          $ref: '#/components/schemas/NullableColor'
          description: 'RGB output correction. `null` = correction off. `#FFFFFF` is a settable value.

            '
        colorTint:
          $ref: '#/components/schemas/NullableColor'
          description: 'RGB output tint. `null` = off. `#FFFFFF` is a settable value.

            '
    SystemConfig:
      type: object
      description: 'Device configuration. The JSON key is the field name for

        every one. Unknown keys are silently ignored on PUT.

        '
      properties:
        wifiSsid:
          type: string
          default: ''
        wifiPass:
          type: string
          default: ''
          writeOnly: true
          description: 'Secret: omitted from GET. An empty string is ignored on PUT.'
        netStatic:
          type: boolean
          default: false
          description: static IP instead of DHCP
        ip:
          type: string
          default: ''
          description: Dotted quad, optionally with a CIDR suffix (192.168.1.50/24) that is split and
            stored as ip + subnet.
        gateway:
          type: string
          default: ''
        subnet:
          type: string
          default: ''
        dns1:
          type: string
          default: ''
        dns2:
          type: string
          default: ''
        wifiConnectTimeout:
          type: integer
          minimum: 5000
          maximum: 120000
          default: 15000
          description: 'How long the boot join may take before falling back to the setup hotspot. The
            AP retries the stored credentials every 30 s and restarts on success, so this rarely needs
            raising.

            '
        wifiRoamRssi:
          type: integer
          minimum: -90
          maximum: 0
          default: 0
          description: 'Roam away from an access point weaker than this (dBm). 0 disables it. Roaming
            is a reconnect rather than a handover, so the link drops briefly: it is opt-in for that reason.

            '
        mqttEnabled:
          type: boolean
          default: false
          description: 'Master switch for the MQTT client. Runs only while true. False keeps the settings
            below but never connects. Setting it true requires a non-empty mqttHost, else 422 validationFailed.

            '
        mqttHost:
          type: string
          default: ''
        mqttPort:
          type: integer
          default: 1883
          minimum: 1
          maximum: 65535
        mqttUser:
          type: string
          default: ''
        mqttPass:
          type: string
          default: ''
          writeOnly: true
          description: 'Secret: omitted from GET. An empty string is ignored on PUT.'
        mqttPrefix:
          type: string
          default: ''
          description: empty falls back to the device uid
        haDiscovery:
          type: boolean
          default: false
          description: Home Assistant auto-discovery
        haPrefix:
          type: string
          default: homeassistant
        ntpServer:
          type: string
          default: pool.ntp.org
          description: Applied immediately. The clock syncs again with the new server.
        tz:
          type: string
          default: CET-1CEST,M3.5.0,M10.5.0/3
          description: POSIX TZ string, daylight-saving rules included. Applied immediately, no reboot.
        tzName:
          type: string
          default: Europe/Berlin
          description: IANA zone tz was picked from. Only the web UI uses it, to show the chosen city.
        hostname:
          type: string
          default: ''
          description: empty becomes awtrixng-<uid>
        webPort:
          type: integer
          default: 80
          minimum: 0
          maximum: 65535
          description: 0 falls back to 80. setup mode always serves on 80.
        authEnabled:
          type: boolean
          default: false
          description: 'Master switch for HTTP Basic auth over the whole API. Setting it true requires
            a non-empty authUser AND authPass, else 422 validationFailed. False keeps the stored credentials
            but serves the API without a login.

            '
        authUser:
          type: string
          default: ''
          description: HTTP Basic username. Gated by authEnabled, not by emptiness.
        authPass:
          type: string
          default: ''
          writeOnly: true
          description: 'Secret: omitted from GET. An empty string is ignored on PUT.'
        tempOffset:
          type: number
          default: -9.0
          minimum: -20
          maximum: 20
          description: degrees C
        humOffset:
          type: number
          default: 0.0
          minimum: -50
          maximum: 50
          description: percent
        batteryDividerRatio:
          type: number
          default: 1.79
          minimum: 0.1
          maximum: 10
          description: 'V_cell / V_pin. Calibrate on a full cell as 4.2 / (batteryPinMillivolts / 1000)
            using GET /api/v1/device.

            '
        lowBatteryThreshold:
          type: integer
          default: 0
          minimum: 0
          maximum: 100
          description: 'Percent. When batteryPercent drops below it, GET /api/v1/device reports lowBattery=true
            (and a Home Assistant "Low battery" binary_sensor trips). 0 = off.

            '
        minBrightness:
          type: integer
          default: 10
          minimum: 0
          maximum: 255
        maxBrightness:
          type: integer
          default: 220
          minimum: 0
          maximum: 255
        ldrFactor:
          type: number
          default: 1.0
          minimum: 0
          maximum: 10
        ldrGamma:
          type: number
          default: 2.2
          minimum: 0.1
          maximum: 10
          description: 1.0 = curve off (neutral)
        ldrOnGround:
          type: boolean
          default: false
          description: LDR wiring orientation
        brightnessSmoothing:
          type: integer
          minimum: 0
          maximum: 60000
          default: 10000
          description: 'Time constant in ms for following a change in ambient light. 0 follows instantly.
            Auto-brightness only: a manual brightness applies at once, and lightLevel is reported unsmoothed.

            '
        panelWidth:
          type: integer
          default: 32
          minimum: 1
          maximum: 128
          description: 'Width of one panel in pixels. Every panel is 8 pixels high. panelWidth x panels
            must come to between 32 and 128, or the write is rejected with 422 on panelWidth. A change
            of the total width applies after a reboot.

            '
        panels:
          type: integer
          default: 1
          minimum: 1
          maximum: 128
          description: 'How many identical panels the LED strip runs through, left to right. A change
            of the total width applies after a reboot.

            '
        panelStart:
          type: string
          default: topLeft
          enum:
          - topLeft
          - topRight
          - bottomLeft
          - bottomRight
          description: 'The corner the first LED sits in. Matched case-insensitively. Any other value
            is rejected with 422. Applies on the next frame.

            '
        panelWiring:
          type: string
          default: rows
          enum:
          - rows
          - columns
          description: 'Whether the strip runs along the rows or down the columns inside a panel. Applies
            on the next frame.

            '
        panelColorOrder:
          type: string
          default: grb
          enum:
          - rgb
          - rbg
          - grb
          - gbr
          - brg
          - bgr
          description: 'Physical color-byte order expected by the LED panel. Matched case-insensitively.
            Any other value is rejected with 422. Applies on the next frame.

            '
        panelSerpentine:
          type: boolean
          default: true
          description: 'Every second row or column runs backwards: the zigzag most panels are wired in.
            false = every run starts on the same side. Applies on the next frame.

            '
        panelChainReverse:
          type: boolean
          default: false
          description: 'The data cable enters the chain of panels at the other end, without changing how
            a single panel is wired inside. Applies on the next frame.

            '
        panelChainSerpentine:
          type: boolean
          default: false
          description: 'Every second panel along the cable is mounted rotated 180 degrees, so one panel''s
            output sits next to the next panel''s input. Applies on the next frame.

            '
        mirror:
          type: boolean
          default: false
          description: 'Mirror the displayed image horizontally. Describes the picture, not the wiring.
            Applies on the next frame.

            '
        rotate:
          type: boolean
          default: false
          description: 'Rotate the displayed image 180 degrees, and swap the left and right button with
            it. Applies on the next frame.

            '
        swapButtons:
          type: boolean
          default: false
        dfplayer:
          type: boolean
          default: false
          description: Use a DFPlayer Mini on the DF pins
        buttonCallback:
          type: string
          default: ''
          allOf:
          - description: HTTP webhook URL called on every button press and release
        artnet:
          type: boolean
          default: false
          description: 'Opt-in Art-Net DMX receiver (UDP 6454). Off by default. The device listens on
            the port only when enabled.

            '
        mirrorShare:
          type: boolean
          default: false
          description: 'Let other clocks with the same panel size show this display (UDP 4212). Any device
            on the network can watch. Applies at once.

            '
        mirrorShareApps:
          type: string
          default: '*'
          description: 'Apps to share, comma separated and matched without regard to case. "*" shares
            every app, an empty string none.

            '
        mirrorShareNotifications:
          type: boolean
          default: true
          description: Share notifications too.
        mirrorFrom:
          type: string
          default: ''
          description: 'IP address or host name of the clock whose display this clock shows. Empty mirrors
            nothing. Applies at once.

            '
        mirrorFromApps:
          type: string
          default: '*'
          description: 'Apps of that clock to show, comma separated and matched without regard to case.
            "*" shows every app, an empty string none.

            '
        mirrorFromNotifications:
          type: boolean
          default: true
          description: Show that clock's notifications too.
        statsInterval:
          type: integer
          default: 10000
          minimum: 1000
          maximum: 600000
          description: milliseconds
        tempDecimals:
          type: integer
          default: 0
          minimum: 0
          maximum: 2
        debugMode:
          type: boolean
          default: false
        scriptingEnabled:
          type: boolean
          default: true
          description: 'Whether Berry scripts can run at all. Off: no script runs, and script updates
            and script settings answer 503. Installed scripts stay stored. Applies after a reboot.

            '
        pinMatrix:
          type: integer
          description: Must be in capabilities.gpio.matrix. Always treated as enabled.
        pinBtnLeft:
          type: integer
          description: -1 = disabled. Not an input-only pin
        pinBtnSelect:
          type: integer
          description: -1 = disabled. Not an input-only pin
        pinBtnRight:
          type: integer
          description: -1 = disabled. Not an input-only pin
        pinBattery:
          type: integer
          description: '-1 disables battery monitoring entirely (no batteryPercent/ batteryVoltage/batteryPinMillivolts
            in GET /api/v1/device, no Battery app). When enabled it must be one of capabilities.gpio.adc1.

            '
        pinLdr:
          type: integer
          description: -1 = disabled. When enabled it must be one of capabilities.gpio.adc1
        pinBuzzer:
          type: integer
          description: -1 = disabled. Output-capable pin required
        pinI2cSda:
          type: integer
          description: -1 = disabled. Output-capable pin required
        pinI2cScl:
          type: integer
          description: -1 = disabled. Output-capable pin required
        pinDfRx:
          type: integer
          description: DFPlayer Mini serial RX. -1 = disabled
        pinDfTx:
          type: integer
          description: DFPlayer Mini serial TX. -1 = disabled, output-capable pin required
    SystemConfigRead:
      description: 'The configuration as returned by GET/PUT: identical to SystemConfig minus the three
        secrets, which are never read back.

        '
      allOf:
      - $ref: '#/components/schemas/SystemConfig'
      not:
        anyOf:
        - required:
          - wifiPass
        - required:
          - mqttPass
        - required:
          - authPass
    EffectSettings:
      type: object
      description: 'Tuning shared by background effects and weather overlays. Every field is

        optional. An omitted field leaves the effect''s own choice in place.

        '
      properties:
        speed:
          type: number
          minimum: 0.1
          maximum: 10
          default: 1
          description: 'Multiplier on the effect''s own base speed. A value out of range is

            clamped, not rejected.

            '
        palette:
          oneOf:
          - type: string
          - type: array
            maxItems: 16
          - type: 'null'
          description: 'Palette name (a /PALETTES file first, then a built-in) or up to 16 color stops.
            A stop may be a bare color, spread evenly, or {"color": <color>, "pos": 0-100} to place it
            at a percentage of the ramp. The two forms cannot be mixed in one array.

            '
        blend:
          type: boolean
          default: true
          description: 'Interpolate between palette entries instead of 16 hard bands. Inert

            unless `palette` is supplied too.

            '
    Display:
      type: object
      properties:
        power:
          type: boolean
        brightness:
          type: integer
          description: effective brightness after auto-brightness
        overlay:
          type:
          - string
          - 'null'
        overlaySettings:
          allOf:
          - $ref: '#/components/schemas/EffectSettings'
          description: 'Always reported. `speed` falls back to 1 and `palette` to null when

            they were never set.

            '
        moodlight:
          type:
          - object
          - 'null'
          properties:
            color:
              type: string
            brightness:
              type: integer
    DeviceState:
      type: object
      properties:
        version:
          type: string
        uid:
          type: string
        boardType:
          type: string
          description: Board identity.
          allOf:
          - const: awtrixng
        soc:
          type: string
          allOf:
          - const: esp32
            description: The chip.
        updateImage:
          type: string
          allOf:
          - const: firmware-awtrix-ng.bin
          description: 'The release file this device updates from: the one `POST /update` accepts. The
            web UI uses it to offer the right download.

            '
        ipAddress:
          type: string
        macAddress:
          type: string
          example: A4:CF:12:0B:3C:7D
          description: The Wi-Fi MAC address the clock joins networks with, in upper case with colons.
        hostname:
          type: string
          description: 'The name AWTRIX actually answers to on the network and over mDNS. When `hostname`
            in `/api/v1/system` is empty this is the name derived from the MAC address, so the two differ
            by design.

            '
        wifiRssi:
          type: integer
        uptimeSeconds:
          type: integer
        resetReason:
          type: string
          description: Boot or runtime-start reason. Values depend on the platform.
        freeHeapBytes:
          type: integer
          allOf:
          - description: Free internal RAM.
        minFreeHeapBytes:
          type: integer
          description: 'The lowest free RAM seen since boot. It only goes down, so it also

            catches a short dip between two polls. Watch it to spot a slow

            leak. Resets on reboot.

            '
        largestFreeBlockBytes:
          type: integer
          description: The largest piece of free internal RAM in one block.
        scriptingRunning:
          type: boolean
          description: 'Whether scripts are running at all. False when scriptingEnabled is off: installed
            scripts stay listed and editable, none of them runs.

            '
        scriptHeapPool:
          type: string
          description: Which memory scripts run in.
          allOf:
          - const: internal
        scriptHeapBudgetBytes:
          type: integer
          allOf:
          - description: How much memory all scripts together may use before further installs are refused.
              98304.
        fps:
          type: integer
          description: measured display frames per second
        brightness:
          type: integer
          description: effective brightness after auto-brightness
        lightLevel:
          type: number
          minimum: 0
          maximum: 100
          description: 'Relative ambient light in percent. Not lux: the light sensor has no absolute unit.
            Linear in ldrRaw. The ldrGamma curve applies only to brightness, not to this value. Present
            only when pinLdr >= 0.

            '
        ldrRaw:
          type: integer
          description: 'Unprocessed light-sensor reading behind lightLevel: 0 in the dark, 4095 in bright
            light. Present only when pinLdr >= 0.

            '
        batteryPercent:
          type: integer
          description: Battery charge in percent. Present only with a battery.
        batteryVoltage:
          type: number
          description: Battery voltage in volts, rounded to two decimals. Present only with battery support.
        batteryPinMillivolts:
          type: integer
          description: Millivolts at the configured ADC input, before applying the divider ratio.
        lowBattery:
          type: boolean
          description: 'True when batteryPercent has dropped below the configured lowBatteryThreshold.
            Always false when the threshold is 0 (off).

            '
        temperature:
          type: number
          description: only with a sensor
        humidity:
          type: number
          description: only with a sensor
        pressureHpa:
          type: number
          description: 'Barometric pressure in hPa, rounded to 1 decimal. Present only with a pressure-capable
            sensor (BME280/BMP280).

            '
        matrixPower:
          type: boolean
        currentApp:
          type: string
        indicators:
          type: array
          items:
            type: object
            properties:
              'on':
                type: boolean
              color:
                type: string
              blinkMs:
                type: integer
              fadeMs:
                type: integer
        messageCount:
          type: integer
          description: MQTT commands received since boot (HTTP does not count)
        wifi:
          type: object
          description: 'State of the WiFi station connection, in the same shape as `mqtt`. `host` is the
            network name (SSID) and `endpoint` the address AWTRIX holds on it. `state` is "disabled" when
            no network is stored.

            '
          properties:
            enabled:
              type: boolean
              description: A network name is stored.
            state:
              type: string
              enum:
              - disabled
              - offline
              - connecting
              - connected
            host:
              type: string
              description: The network name (SSID) AWTRIX joined.
            endpoint:
              type: string
              description: 'The address AWTRIX holds on that network. Empty string while not joined.

                '
            attempts:
              type: integer
              description: Consecutive failed attempts. Zero while connected.
            retryInMs:
              type: integer
              description: Milliseconds until the next attempt. Zero while connected.
            connects:
              type: integer
              description: 'Successful associations since boot. A number that keeps climbing is an access
                point that keeps dropping the device.

                '
            error:
              type:
              - string
              - 'null'
              description: Why the connection is not up right now. Null when it is.
              enum:
              - hostNotFound
              - badCredentials
              - timeout
              - lost
              - null
            lastError:
              type:
              - string
              - 'null'
              description: 'The last reason this link went down, kept after it recovers. While WiFi is
                down this API is unreachable, so this is what you read afterwards. Null until something
                goes wrong.

                '
              enum:
              - hostNotFound
              - badCredentials
              - timeout
              - lost
              - null
        mqtt:
          type: object
          description: 'State of the broker connection. Present whether or not MQTT is enabled. `state`
            is "disabled" when it is off.

            '
          properties:
            enabled:
              type: boolean
              description: Mirrors mqttEnabled in the system configuration.
            state:
              type: string
              enum:
              - disabled
              - offline
              - connecting
              - connected
            host:
              type: string
              description: The configured broker host, as entered.
            endpoint:
              type: string
              description: 'The address and port actually in use, once the host has been resolved. Empty
                string before that.

                '
            attempts:
              type: integer
              description: Consecutive failed attempts. Zero while connected.
            retryInMs:
              type: integer
              description: 'Milliseconds until the next attempt. Zero while connected, and while an attempt
                is in progress.

                '
            connects:
              type: integer
              description: Successful connections since boot.
            error:
              type:
              - string
              - 'null'
              description: Why the connection is not up right now. Null when it is.
              enum:
              - noWifi
              - hostNotFound
              - refused
              - badCredentials
              - rejected
              - timeout
              - lost
              - null
            lastError:
              type:
              - string
              - 'null'
              description: 'The last reason this link went down, kept after it recovers. Null until something
                goes wrong.

                '
              enum:
              - noWifi
              - hostNotFound
              - refused
              - badCredentials
              - rejected
              - timeout
              - lost
              - null
        mirror:
          type: object
          description: What display mirroring is doing.
          properties:
            sharing:
              type: boolean
              description: This clock shares its display and is on the network.
            viewers:
              type: integer
              description: Clocks watching this display right now.
            source:
              type: string
              description: The clock this clock mirrors
              as entered in mirrorFrom.: null
            state:
              type: string
              description: What mirroring the source clock does right now.
              enum:
              - 'off'
              - offline
              - resolving
              - notFound
              - waiting
              - idle
              - filtered
              - sizeMismatch
              - noMemory
              - showing
            sourceWidth:
              type: integer
              description: Panel width the source clock reported. Absent until it answered.
            sourceHeight:
              type: integer
              description: Panel height the source clock reported. Absent until it answered.
