{
  "openapi": "3.1.0",
  "info": {
    "title": "Holo Player HTTP API",
    "version": "1.0.0",
    "summary": "Everything a Holo Player board does, over Wi-Fi: its files, screen, lights, holoprojector, scenes, settings, network and firmware.",
    "description": "The board serves this API on port 80 of every interface it has: its address on your network, `<hostname>.local` (mDNS, by default `holo-xxxx.local`), and `192.168.4.1` on its own access point (`wifi ap on`). The same file is served by the board at `/api/v1/openapi.json`, and always describes the firmware serving it.\n\nThis document's `version` is the API's, not the firmware's: `/api/v1/info` reports the firmware version, and the path's `v1` changes only when an existing client would break.\n\n**Protection.** Reading is always open. A request that changes something (`PUT`, `POST`, `PATCH`, `DELETE`) must carry the board's address or name in its `Host` header, and a `POST` or `PATCH` must be `Content-Type: application/json`; together these stop a web page elsewhere from driving the board. Pages from a short list of trusted sites -- Swagger Editor, `astromech.co` and `davidiansmith.ca` with their subdomains, changed with `web cors` on the console -- may call the API from a browser: the board answers their CORS preflights. When a password is set on the board (`web password <password>` on its console), those requests -- and downloading a file, and reading the web settings -- also need it, as `Authorization: Bearer <password>` or HTTP Basic with any user name.\n\n**Updates** are a session: an image is received (uploaded or pulled) into the slot that is not running, checked by the board, and *staged*. Activating makes it the boot image; it runs from the next restart, on trial until it is up, when it confirms itself. Until an image is activated, nothing changes what the board boots.\n\n**Now, and from now on.** Some resources are what the board is doing *now* -- `/screen`, `/leds`, `/holo`, the servos' positions -- and change nothing that survives a restart. Others are *saved*: `/settings`, `/scenes`, a servo's calibration and drive policy, `/network`'s known networks and access point, and `/web`. What the board does when it starts is `settings.boot_scene`: a scene, applied after the saved backlight and LED brightness.\n\n**Shapes.** What the board is doing now is read with `GET` and changed with `PATCH`: send only the fields that change; the reply is the whole new state. Actions are `POST` with a JSON body. Saved, named things -- a scene, a servo's calibration, a known network -- are `PUT` and `DELETE`, named in the query (`?name=`, `?id=`, `?ssid=`).\n\n**Files** are named by a `path` query parameter, from the root of the board's storage: `/clips/intro.mov`. `/` is the root; `..`, `.` and empty segments are refused, as are names over 63 bytes and paths over 159. Moving, replacing or deleting the file on the screen stops it first.\n\n**Long operations** -- an upload or pull of firmware, a file upload, download or copy, a SHA-256, a Wi-Fi scan -- run one at a time; another gets `409 busy`. The server answers other requests meanwhile.\n\n**Events.** Rather than polling, a client can follow `GET /api/v1/events`, a stream of Server-Sent Events: each resource (`screen`, `leds`, `holo`, the running `scene`, ...) as it changes, and what happens (`clip_ended`, `scene_ended`, `touch`). A browser's `EventSource` reconnects by itself and replays what it missed.\n\n**The host link.** Most of this API is also served over a wire, to a controller on the board's spare pins: one request a line, with the same paths, bodies, replies and errors (`/link` has its settings; the manual's *Host link protocol* page has the line format). There, no password or `Host` rule applies.",
    "license": {
      "name": "See the repository",
      "url": "https://github.com/daveismith/holo-player-fw"
    }
  },
  "externalDocs": {
    "description": "The HTTP API guide, with curl recipes",
    "url": "https://davidiansmith.ca/holo-player-fw/latest/reference/http-api/"
  },
  "servers": [
    {
      "url": "http://{host}",
      "description": "A board. `holo-xxxx.local`, its address on your network, or 192.168.4.1 on its access point.",
      "variables": {
        "host": {
          "default": "holo-2db0.local"
        }
      }
    }
  ],
  "tags": [
    {
      "name": "device",
      "description": "What the board is, and restarting it"
    },
    {
      "name": "events",
      "description": "What changes on the board, as it happens: a stream of Server-Sent Events, instead of polling"
    },
    {
      "name": "ota",
      "description": "Firmware updates: slots, uploads, pulls, switching"
    },
    {
      "name": "files",
      "description": "The storage volume: browse, upload, download, move, copy, delete"
    },
    {
      "name": "screen",
      "description": "What the round screen shows now: a clip, an image, a colour, or nothing; its backlight"
    },
    {
      "name": "leds",
      "description": "The NeoPixel strip, now"
    },
    {
      "name": "holo",
      "description": "The holoprojector's motion, now"
    },
    {
      "name": "servos",
      "description": "The servos behind the holo: positions now; calibration and drive policy, saved"
    },
    {
      "name": "scenes",
      "description": "Saved combinations of screen, LEDs and holo motion, applied in one call"
    },
    {
      "name": "settings",
      "description": "What the board starts with: defaults, and the boot scene"
    },
    {
      "name": "network",
      "description": "Wi-Fi: the network the board joins, the ones it knows, and its own access point"
    },
    {
      "name": "link",
      "description": "The host link: a wired controller on the spare pins of P2 (UART or RS485), its settings and its events. See the manual's Host link pages."
    }
  ],
  "paths": {
    "/api/v1/info": {
      "get": {
        "tags": [
          "device"
        ],
        "operationId": "getInfo",
        "summary": "The board: firmware, network, and the features its web app has",
        "responses": {
          "200": {
            "description": "The board",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Info"
                },
                "example": {
                  "api": 1,
                  "firmware": {
                    "project": "holo-player-fw",
                    "version": "v1.1.0",
                    "date": "Sep 26 2026",
                    "time": "21:04:11",
                    "idf": "v6.1",
                    "elf_sha256": "77e7c39c8d1f0b6a1e2d3c4b5a69788766554433221100ffeeddccbbaa998877"
                  },
                  "chip": "esp32s3",
                  "hostname": "holo-2db0",
                  "uptime_s": 3712,
                  "heap_free": 2197504,
                  "features": [
                    "ota"
                  ],
                  "auth": false,
                  "via": "sta",
                  "sta": {
                    "enabled": true,
                    "connected": true,
                    "ssid": "home",
                    "ip": "10.0.19.118",
                    "rssi": -45,
                    "channel": 11,
                    "ipv6": [
                      "fe80::3e0f:2ff:fe6f:2db0"
                    ]
                  },
                  "ap": {
                    "on": false
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/restart": {
      "post": {
        "tags": [
          "device"
        ],
        "operationId": "restart",
        "summary": "Restart the board",
        "description": "Restarts after `delay_ms` (default 500, at least 200, at most 60000), so the reply can leave first.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RestartRequest"
              },
              "example": {}
            }
          }
        },
        "responses": {
          "202": {
            "description": "The board will restart",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "restart_in_ms"
                  ],
                  "properties": {
                    "restart_in_ms": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "restart_in_ms": 500
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/openapi.json": {
      "get": {
        "tags": [
          "device"
        ],
        "operationId": "getOpenApi",
        "summary": "This document",
        "responses": {
          "200": {
            "description": "The OpenAPI description of the firmware serving it",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/ota": {
      "get": {
        "tags": [
          "ota"
        ],
        "operationId": "getOta",
        "summary": "Everything about updates: the slots, the session, whether the board can pull",
        "description": "What the web app polls. `slots` lists each application slot and the image in it; `session` is the update in progress or last finished, whoever started it (upload, pull, or the console).",
        "responses": {
          "200": {
            "description": "Update state",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ota"
                },
                "example": {
                  "rollback": true,
                  "slots": [
                    {
                      "label": "ota_0",
                      "address": 65536,
                      "size": 2359296,
                      "running": true,
                      "boots": true,
                      "next": false,
                      "state": "confirmed",
                      "app": {
                        "project": "holo-player-fw",
                        "version": "v1.0.0",
                        "date": "Sep 24 2026",
                        "time": "03:51:02",
                        "idf": "v6.1",
                        "elf_sha256": "045f8a7a737ab97ee6ed4bacd8e29502682b87bd06879997f8b0c7175f8bf157"
                      }
                    },
                    {
                      "label": "ota_1",
                      "address": 2424832,
                      "size": 2359296,
                      "running": false,
                      "boots": false,
                      "next": true,
                      "state": null,
                      "app": null
                    }
                  ],
                  "session": {
                    "state": "idle",
                    "source": "none",
                    "slot": null,
                    "url": null,
                    "bytes": 0,
                    "total": 0,
                    "sha256": null,
                    "app": null,
                    "boots_next": false,
                    "dry_run": false,
                    "error": null,
                    "elapsed_ms": 0
                  },
                  "pull": {
                    "available": true,
                    "channels": true,
                    "online": true
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/ota/image": {
      "get": {
        "tags": [
          "ota"
        ],
        "operationId": "getImage",
        "summary": "The state of the uploaded (or pulled) image: the session",
        "responses": {
          "200": {
            "description": "The session",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Session"
                },
                "examples": {
                  "receiving": {
                    "value": {
                      "state": "receiving",
                      "source": "upload",
                      "slot": "ota_1",
                      "url": null,
                      "bytes": 524288,
                      "total": 1355328,
                      "sha256": null,
                      "app": {
                        "project": "holo-player-fw",
                        "version": "v1.1.0",
                        "date": "Sep 26 2026",
                        "time": "21:04:11",
                        "idf": "v6.1",
                        "elf_sha256": "77e7c39c8d1f0b6a1e2d3c4b5a69788766554433221100ffeeddccbbaa998877"
                      },
                      "boots_next": false,
                      "dry_run": false,
                      "error": null,
                      "elapsed_ms": 4100
                    }
                  },
                  "staged": {
                    "value": {
                      "state": "staged",
                      "source": "upload",
                      "slot": "ota_1",
                      "url": null,
                      "bytes": 1355328,
                      "total": 1355328,
                      "sha256": "0b1c9e3f5a7d2e4c6b8a0f1e3d5c7b9a2e4f6a8c0d2e4f6a8b0c2d4e6f8a0b2c",
                      "app": {
                        "project": "holo-player-fw",
                        "version": "v1.1.0",
                        "date": "Sep 26 2026",
                        "time": "21:04:11",
                        "idf": "v6.1",
                        "elf_sha256": "77e7c39c8d1f0b6a1e2d3c4b5a69788766554433221100ffeeddccbbaa998877"
                      },
                      "boots_next": false,
                      "dry_run": false,
                      "error": null,
                      "elapsed_ms": 14230
                    }
                  },
                  "failed": {
                    "value": {
                      "state": "failed",
                      "source": "upload",
                      "slot": "ota_1",
                      "url": null,
                      "bytes": 4096,
                      "total": 20288,
                      "sha256": null,
                      "app": null,
                      "boots_next": false,
                      "dry_run": false,
                      "error": "no application description in the image",
                      "elapsed_ms": 120
                    }
                  }
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": [
          "ota"
        ],
        "operationId": "uploadImage",
        "summary": "Upload an application image",
        "description": "The body is the application image as built (`build/holo-player-fw.bin`, or a release's `holo-player-fw-vX.Y.Z.bin` -- not the bootloader, partition table or OTA data files), with a `Content-Length`. It is written into the slot that is not running as it arrives; the first 288 bytes are checked at once (an application, for this chip and this project), and the whole image once it is in. The reply comes when the image is staged -- or activated, or the board restarting, as the query asks -- or has failed.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Sha256"
          },
          {
            "$ref": "#/components/parameters/Activate"
          },
          {
            "$ref": "#/components/parameters/Reboot"
          },
          {
            "$ref": "#/components/parameters/Force"
          }
        ],
        "requestBody": {
          "$ref": "#/components/requestBodies/Image"
        },
        "responses": {
          "200": {
            "description": "Staged (or activated, or restarting, as asked)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Session"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "409": {
            "$ref": "#/components/responses/Busy"
          },
          "411": {
            "description": "No Content-Length",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "length_required",
                  "message": "send the image as the body, with a Content-Length"
                }
              }
            }
          },
          "413": {
            "description": "Too large for the slot",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "too_large",
                  "message": "2500000 bytes will not fit the 2304 KB slot"
                }
              }
            }
          },
          "422": {
            "description": "Not a usable image: not an application, for another chip or project, a SHA-256 that does not match, or one that failed verification. The running image still boots.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid": {
                    "value": {
                      "error": "invalid_image",
                      "message": "not an application image (first byte 0x00, not 0xe9)"
                    }
                  },
                  "sha": {
                    "value": {
                      "error": "sha256_mismatch",
                      "message": "SHA-256 mismatch: received 0b1c9e3f5a7d..., expected 9f86d081884c..."
                    }
                  }
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "ota"
        ],
        "operationId": "discardImage",
        "summary": "Cancel an update in progress, or forget a staged or failed one",
        "description": "An upload or pull in progress stops at its next block, and fails as `cancelled`. A staged image is forgotten: it stays in its slot, harmless, until the next update overwrites it. An image already activated cannot be discarded -- activate the running slot instead.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "responses": {
          "200": {
            "description": "The session now",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Session"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "409": {
            "description": "Already the boot image, or being verified",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "cannot_discard",
                  "message": "it is already the boot image; activate the running slot to go back"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/ota/activate": {
      "post": {
        "tags": [
          "ota"
        ],
        "operationId": "activate",
        "summary": "Make an image the boot image, and restart into it",
        "description": "With no `slot`, the staged image. With a `slot`, the image already in it: naming the other slot goes back to the previous firmware, and naming the running slot undoes an activation that has not restarted yet. The board checks the image before selecting it. `reboot` defaults to true: the board restarts a second after replying.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ActivateRequest"
              },
              "examples": {
                "staged": {
                  "summary": "The staged image, and restart",
                  "value": {}
                },
                "back": {
                  "summary": "Go back to the other slot's image",
                  "value": {
                    "slot": "ota_0",
                    "reboot": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "It boots next; the board restarts if asked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActivateResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "No such slot",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_such_slot",
                  "message": "no application slot called ota_7"
                }
              }
            }
          },
          "409": {
            "description": "Nothing staged, or the slot is being written",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "nothing_staged",
                  "message": "nothing is staged; name a slot"
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          },
          "422": {
            "description": "The slot holds no good image",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "invalid_image",
                  "message": "ota_1 holds no good image (ESP_ERR_OTA_VALIDATE_FAILED)"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/ota/pull": {
      "post": {
        "tags": [
          "ota"
        ],
        "operationId": "pull",
        "summary": "Have the board download an image itself",
        "description": "From `url` -- an application image, or a release manifest (`.../firmware/manifest.json` on the documentation site), whose SHA-256 is then enforced -- or from a release `channel` such as `latest`, looked up on the documentation site. The download runs in the background: the reply comes once it has started, and `GET /api/v1/ota/image` follows it. Redirects from https to plain http are refused. Needs the board's station link to the internet.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PullRequest"
              },
              "examples": {
                "latest": {
                  "summary": "The latest release",
                  "value": {
                    "channel": "latest"
                  }
                },
                "manifest": {
                  "summary": "A release, by its manifest",
                  "value": {
                    "url": "https://davidiansmith.ca/holo-player-fw/1.0/firmware/manifest.json"
                  }
                },
                "image": {
                  "summary": "An image on a local server, installed and restarted into",
                  "value": {
                    "url": "http://10.0.19.20:8000/holo-player-fw.bin",
                    "activate": true,
                    "reboot": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "The download has started",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Session"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "409": {
            "$ref": "#/components/responses/Busy"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          },
          "503": {
            "$ref": "#/components/responses/Offline"
          }
        }
      }
    },
    "/api/v1/ota/check": {
      "get": {
        "tags": [
          "ota"
        ],
        "operationId": "checkUpdate",
        "summary": "Look for an update without downloading it",
        "description": "Resolves a channel (default `latest`) or a `url` as a pull would, and compares its version with the running one. Answers are kept for five minutes; `refresh` looks again. `newer` is null when either version is not a release tag (a development build).",
        "parameters": [
          {
            "name": "channel",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "latest"
            },
            "description": "A release channel"
          },
          {
            "name": "url",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uri"
            },
            "description": "A manifest or image URL, instead of a channel"
          },
          {
            "name": "refresh",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Look again rather than use a recent answer"
          }
        ],
        "responses": {
          "200": {
            "description": "What is available",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CheckResult"
                },
                "example": {
                  "channel": "latest",
                  "current": "v1.0.0",
                  "version": "v1.1.0",
                  "newer": true,
                  "image_url": "https://davidiansmith.ca/holo-player-fw/1.1/firmware/holo-player-fw-v1.1.0.bin",
                  "release_url": "https://github.com/daveismith/holo-player-fw/releases/tag/v1.1.0",
                  "size": 1402112,
                  "sha256": "0b1c9e3f5a7d2e4c6b8a0f1e3d5c7b9a2e4f6a8c0d2e4f6a8b0c2d4e6f8a0b2c",
                  "checked_s_ago": 0
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "description": "The channel or URL publishes no firmware (the `dev` documentation, say: only releases carry firmware)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_firmware",
                  "message": "the `dev` channel publishes no firmware: only releases do"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/Offline"
          }
        }
      }
    },
    "/api/v1/fs": {
      "get": {
        "tags": [
          "files"
        ],
        "operationId": "getVolume",
        "summary": "The storage volume: its size and free space",
        "responses": {
          "200": {
            "description": "The volume",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Volume"
                },
                "example": {
                  "root": "/data",
                  "total": 11534336,
                  "used": 4194304,
                  "free": 7340032
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/fs/list": {
      "get": {
        "tags": [
          "files"
        ],
        "operationId": "listDirectory",
        "summary": "What is in a directory",
        "parameters": [
          {
            "$ref": "#/components/parameters/DirPath"
          }
        ],
        "responses": {
          "200": {
            "description": "The directory",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Listing"
                },
                "example": {
                  "path": "/clips",
                  "entries": [
                    {
                      "name": "old",
                      "path": "/clips/old",
                      "type": "dir",
                      "size": 0,
                      "mtime": "2026-09-20T10:00:00Z"
                    },
                    {
                      "name": "intro.mov",
                      "path": "/clips/intro.mov",
                      "type": "file",
                      "size": 3145728,
                      "mtime": "2026-09-27T14:02:11Z"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Not a directory, or a bad path",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "file": {
                    "value": {
                      "error": "not_a_directory",
                      "message": "/clips/intro.mov is a file"
                    }
                  },
                  "path": {
                    "value": {
                      "error": "bad_path",
                      "message": "'..' is not allowed in a path: /clips/.."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          }
        }
      }
    },
    "/api/v1/fs/entry": {
      "get": {
        "tags": [
          "files"
        ],
        "operationId": "getEntry",
        "summary": "One file or directory",
        "description": "With `sha256=true`, the file is hashed: a long operation, so another one running gives `409 busy`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Path"
          },
          {
            "$ref": "#/components/parameters/WithSha256"
          }
        ],
        "responses": {
          "200": {
            "description": "It",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Entry"
                },
                "example": {
                  "name": "intro.mov",
                  "path": "/clips/intro.mov",
                  "type": "file",
                  "size": 3145728,
                  "mtime": "2026-09-27T14:02:11Z",
                  "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadPath"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "409": {
            "$ref": "#/components/responses/JobBusy"
          }
        }
      },
      "delete": {
        "tags": [
          "files"
        ],
        "operationId": "deleteEntry",
        "summary": "Delete a file or directory",
        "description": "A file on the screen stops showing first.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Path"
          },
          {
            "$ref": "#/components/parameters/Recursive"
          }
        ],
        "responses": {
          "204": {
            "description": "Gone"
          },
          "400": {
            "description": "A bad path, or the root",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "root": {
                    "value": {
                      "error": "bad_path",
                      "message": "the root of the volume cannot be deleted"
                    }
                  },
                  "path": {
                    "value": {
                      "error": "bad_path",
                      "message": "'..' is not allowed in a path: /.."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "409": {
            "description": "A directory with something in it, and no `recursive`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_empty",
                  "message": "/clips is not empty: 12 entries (recursive=true deletes them too)"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/fs/file": {
      "get": {
        "tags": [
          "files"
        ],
        "operationId": "downloadFile",
        "summary": "Download a file",
        "description": "Needs the password when one is set: the volume holds the console's history, which can hold what was typed. A long operation.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Path"
          }
        ],
        "responses": {
          "200": {
            "description": "The file, as it is stored. `Content-Type` is by its extension (`application/octet-stream` when unknown), with `Content-Disposition: attachment`.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "A directory, or a bad path",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "dir": {
                    "value": {
                      "error": "is_a_directory",
                      "message": "/clips is a directory"
                    }
                  },
                  "path": {
                    "value": {
                      "error": "bad_path",
                      "message": "'..' is not allowed in a path: /../x"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "409": {
            "$ref": "#/components/responses/JobBusy"
          }
        }
      },
      "put": {
        "tags": [
          "files"
        ],
        "operationId": "uploadFile",
        "summary": "Upload a file",
        "description": "The body is the file. It is written beside the destination as `<name>.part`, and takes its place only once it has all arrived (and matched `sha256`, if given): an upload that fails or is cut off changes nothing. A long operation. A clip playing from the volume carries on, but may stutter while the flash is written; replacing the file on the screen stops it first.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Path"
          },
          {
            "$ref": "#/components/parameters/Overwrite"
          },
          {
            "$ref": "#/components/parameters/Parents"
          },
          {
            "$ref": "#/components/parameters/FileSha256"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/octet-stream": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Replaced a file that was there",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Entry"
                },
                "example": {
                  "name": "intro.mov",
                  "path": "/clips/intro.mov",
                  "type": "file",
                  "size": 3145728,
                  "mtime": "2026-09-27T14:02:11Z"
                }
              }
            }
          },
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Entry"
                },
                "example": {
                  "name": "intro.mov",
                  "path": "/clips/intro.mov",
                  "type": "file",
                  "size": 3145728,
                  "mtime": "2026-09-27T14:02:11Z"
                }
              }
            }
          },
          "400": {
            "description": "A bad path, or a directory's",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "path": {
                    "value": {
                      "error": "bad_path",
                      "message": "a name may be at most 63 bytes"
                    }
                  },
                  "dir": {
                    "value": {
                      "error": "is_a_directory",
                      "message": "/clips is a directory"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "A directory on the way is missing, and no `parents`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_found",
                  "message": "no directory /clips/new (parents=true makes it)"
                }
              }
            }
          },
          "409": {
            "description": "Something is there, or another long operation is running",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "exists": {
                    "value": {
                      "error": "exists",
                      "message": "/clips/intro.mov exists (overwrite=true replaces it)"
                    }
                  },
                  "busy": {
                    "value": {
                      "error": "busy",
                      "message": "a file upload is in progress; try again when it is done"
                    }
                  }
                }
              }
            }
          },
          "411": {
            "description": "No Content-Length",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "length_required",
                  "message": "send the file as the body, with a Content-Length"
                }
              }
            }
          },
          "422": {
            "description": "The SHA-256 did not match; nothing was changed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "sha256_mismatch",
                  "message": "SHA-256 mismatch: received 0b1c9e3f5a7d..., expected 9f86d081884c..."
                }
              }
            }
          },
          "507": {
            "$ref": "#/components/responses/NoSpace"
          }
        }
      }
    },
    "/api/v1/fs/mkdir": {
      "post": {
        "tags": [
          "files"
        ],
        "operationId": "makeDirectory",
        "summary": "Make a directory",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MkdirRequest"
              },
              "example": {
                "path": "/clips/new",
                "parents": true
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Made",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Entry"
                },
                "example": {
                  "name": "new",
                  "path": "/clips/new",
                  "type": "dir",
                  "size": 0,
                  "mtime": "2026-09-27T14:05:00Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadPath"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "A directory on the way is missing, and no `parents`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_found",
                  "message": "no directory /clips (parents=true makes it)"
                }
              }
            }
          },
          "409": {
            "description": "Something is there",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "exists",
                  "message": "/clips/new exists"
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/fs/move": {
      "post": {
        "tags": [
          "files"
        ],
        "operationId": "moveEntry",
        "summary": "Move or rename a file or directory",
        "description": "Within the volume; a directory moves with everything in it. `overwrite` replaces a file, never a directory. Moving the file on the screen stops it first.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MoveRequest"
              },
              "example": {
                "from": "/clips/intro.mov",
                "to": "/clips/opening.mov"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Moved: where it is now",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Entry"
                },
                "example": {
                  "name": "opening.mov",
                  "path": "/clips/opening.mov",
                  "type": "file",
                  "size": 3145728,
                  "mtime": "2026-09-27T14:02:11Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadPath"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "409": {
            "description": "Something is at `to`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "exists",
                  "message": "/clips/opening.mov exists (overwrite=true replaces it)"
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/fs/copy": {
      "post": {
        "tags": [
          "files"
        ],
        "operationId": "copyFile",
        "summary": "Copy a file",
        "description": "Files only. A long operation.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MoveRequest"
              },
              "example": {
                "from": "/clips/intro.mov",
                "to": "/clips/intro-copy.mov"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The copy",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Entry"
                },
                "example": {
                  "name": "intro-copy.mov",
                  "path": "/clips/intro-copy.mov",
                  "type": "file",
                  "size": 3145728,
                  "mtime": "2026-09-27T14:02:11Z"
                }
              }
            }
          },
          "400": {
            "description": "A bad path, or a directory",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "dir": {
                    "value": {
                      "error": "is_a_directory",
                      "message": "/clips is a directory: only files are copied"
                    }
                  },
                  "path": {
                    "value": {
                      "error": "bad_path",
                      "message": "'..' is not allowed in a path: /../x"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "409": {
            "description": "Something is at `to`, or another long operation is running",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "exists": {
                    "value": {
                      "error": "exists",
                      "message": "/clips/intro-copy.mov exists (overwrite=true replaces it)"
                    }
                  },
                  "busy": {
                    "value": {
                      "error": "busy",
                      "message": "a file upload is in progress; try again when it is done"
                    }
                  }
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          },
          "507": {
            "$ref": "#/components/responses/NoSpace"
          }
        }
      }
    },
    "/api/v1/screen": {
      "get": {
        "tags": [
          "screen"
        ],
        "operationId": "getScreen",
        "summary": "What the screen shows now",
        "responses": {
          "200": {
            "description": "The screen",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScreenState"
                },
                "example": {
                  "powered": true,
                  "backlight": 80,
                  "showing": "clip",
                  "path": "/clips/intro.mov",
                  "loop": true,
                  "clip": {
                    "width": 240,
                    "height": 240,
                    "fps": 24.0,
                    "frames": 288,
                    "shown": 1043,
                    "loops": 3,
                    "late": 0,
                    "elapsed_ms": 43458
                  }
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "screen"
        ],
        "operationId": "patchScreen",
        "summary": "Change the backlight now",
        "description": "Until the next start; the level it starts at is `settings.screen.backlight`.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScreenPatch"
              },
              "example": {
                "backlight": 40
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The screen",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScreenState"
                },
                "example": {
                  "powered": true,
                  "backlight": 40,
                  "showing": "clip",
                  "path": "/clips/intro.mov",
                  "loop": true,
                  "clip": {
                    "width": 240,
                    "height": 240,
                    "fps": 24.0,
                    "frames": 288,
                    "shown": 1043,
                    "loops": 3,
                    "late": 0,
                    "elapsed_ms": 43458
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      },
      "delete": {
        "tags": [
          "screen"
        ],
        "operationId": "clearScreen",
        "summary": "Show nothing",
        "description": "Stops a clip; the panel sleeps with its backlight off.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "responses": {
          "200": {
            "description": "The screen, asleep",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScreenState"
                },
                "example": {
                  "powered": false,
                  "backlight": 80,
                  "showing": "nothing"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          }
        }
      }
    },
    "/api/v1/screen/show": {
      "post": {
        "tags": [
          "screen"
        ],
        "operationId": "show",
        "summary": "Show a clip, an image, a colour, or the crosshair",
        "description": "Replaces whatever is showing. The file is checked before the reply -- that it is there, and is a clip or an image the board can show -- so an error here means the screen did not change. A clip plays in the background: `GET /api/v1/screen` follows it.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ShowRequest"
              },
              "examples": {
                "clip": {
                  "value": {
                    "path": "/clips/intro.mov",
                    "loop": true
                  }
                },
                "image": {
                  "value": {
                    "path": "/stills/logo.png"
                  }
                },
                "colour": {
                  "value": {
                    "colour": "#102030"
                  }
                },
                "calibration": {
                  "value": {
                    "calibration": true
                  }
                },
                "three times": {
                  "value": {
                    "path": "/clips/intro.mov",
                    "loops": 3
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Showing it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScreenState"
                },
                "example": {
                  "powered": true,
                  "backlight": 80,
                  "showing": "clip",
                  "path": "/clips/intro.mov",
                  "loop": true,
                  "clip": {
                    "width": 240,
                    "height": 240,
                    "fps": 24.0,
                    "frames": 288,
                    "shown": 1043,
                    "loops": 3,
                    "late": 0,
                    "elapsed_ms": 43458
                  }
                }
              }
            }
          },
          "400": {
            "description": "A bad path or colour",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "colour": {
                    "value": {
                      "error": "bad_request",
                      "message": "not a colour: 'mauve'"
                    }
                  },
                  "path": {
                    "value": {
                      "error": "bad_path",
                      "message": "'..' is not allowed in a path: /../x"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          },
          "422": {
            "description": "The file cannot be shown; the screen is unchanged",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "codec": {
                    "value": {
                      "error": "not_playable",
                      "message": "/clips/a.mov: codec 'avc1', not Motion-JPEG ('jpeg' or 'mjpa')"
                    }
                  },
                  "size": {
                    "value": {
                      "error": "not_playable",
                      "message": "/clips/b.mov: 320x240 is larger than the 240x240 screen"
                    }
                  },
                  "format": {
                    "value": {
                      "error": "not_playable",
                      "message": "/notes.txt: not a clip or an image (.mov, .png, .jpg, .gif)"
                    }
                  },
                  "jpeg": {
                    "value": {
                      "error": "not_playable",
                      "message": "/stills/c.jpg: progressive JPEG; only baseline is supported"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/media": {
      "get": {
        "tags": [
          "screen"
        ],
        "operationId": "listMedia",
        "summary": "Every clip and image under a directory",
        "description": "Recursive, by extension only: quick, but a file listed here may still not be playable (`GET /api/v1/media/info` says).",
        "parameters": [
          {
            "$ref": "#/components/parameters/DirPath"
          }
        ],
        "responses": {
          "200": {
            "description": "What could be shown",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MediaList"
                },
                "example": {
                  "items": [
                    {
                      "path": "/clips/cantina.mov",
                      "kind": "clip",
                      "size": 4718592
                    },
                    {
                      "path": "/clips/intro.mov",
                      "kind": "clip",
                      "size": 3145728
                    },
                    {
                      "path": "/stills/logo.png",
                      "kind": "image",
                      "size": 18234
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadPath"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          }
        }
      }
    },
    "/api/v1/media/info": {
      "get": {
        "tags": [
          "screen"
        ],
        "operationId": "getMediaInfo",
        "summary": "Describe a clip or image without showing it",
        "description": "Reads the file's header. A file of a known kind the board cannot play is described with `playable: false` and `why`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Path"
          }
        ],
        "responses": {
          "200": {
            "description": "What it is",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MediaInfo"
                },
                "example": {
                  "path": "/clips/intro.mov",
                  "kind": "clip",
                  "format": "mov",
                  "width": 240,
                  "height": 240,
                  "frames": 288,
                  "fps": 24.0,
                  "duration_s": 12.0,
                  "codec": "jpeg",
                  "playable": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadPath"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "422": {
            "description": "Not a clip or image at all",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_playable",
                  "message": "/notes.txt: not a clip or an image (.mov, .png, .jpg, .gif)"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/leds": {
      "get": {
        "tags": [
          "leds"
        ],
        "operationId": "getLeds",
        "summary": "What the LED strip is doing now",
        "responses": {
          "200": {
            "description": "The strip",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LedState"
                },
                "example": {
                  "mode": "solid",
                  "colour": "#ff8000",
                  "loop": false,
                  "brightness": 30,
                  "count": 16,
                  "gpio": 16
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "leds"
        ],
        "operationId": "patchLeds",
        "summary": "Change the LED strip now",
        "description": "Send what changes. A new `mode` starts it (a pattern from the beginning); a new `colour` or `loop` alone restarts the current one with it. `brightness` applies at once, until the next start (`settings.leds.brightness` is what it starts at).",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LedPatch"
              },
              "examples": {
                "solid": {
                  "value": {
                    "mode": "solid",
                    "colour": "orange"
                  }
                },
                "rainbow": {
                  "value": {
                    "mode": "rainbow",
                    "loop": true
                  }
                },
                "dim": {
                  "value": {
                    "brightness": 10
                  }
                },
                "off": {
                  "value": {
                    "mode": "off"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The strip",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LedState"
                },
                "example": {
                  "mode": "solid",
                  "colour": "#ff8000",
                  "loop": false,
                  "brightness": 30,
                  "count": 16,
                  "gpio": 16
                }
              }
            }
          },
          "400": {
            "description": "A bad colour or mode",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "bad_request",
                  "message": "not a colour: 'mauve'"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/holo": {
      "get": {
        "tags": [
          "holo"
        ],
        "operationId": "getHolo",
        "summary": "The holo's motion and position",
        "responses": {
          "200": {
            "description": "The holo",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HoloState"
                },
                "example": {
                  "ready": true,
                  "motion": "twitch",
                  "position": {
                    "x": 0.21,
                    "y": -0.08
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/holo/motion": {
      "post": {
        "tags": [
          "holo"
        ],
        "operationId": "moveHolo",
        "summary": "Move the holo, or start or stop a behaviour",
        "description": "Replaces the motion running now. `GET /api/v1/holo` follows it.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MotionRequest"
              },
              "examples": {
                "twitch": {
                  "value": {
                    "motion": "twitch",
                    "range": 50,
                    "interval_s": [
                      2,
                      6
                    ]
                  }
                },
                "move": {
                  "value": {
                    "motion": "move",
                    "x": 40,
                    "y": -20,
                    "duration_ms": 600
                  }
                },
                "wag": {
                  "value": {
                    "motion": "wag",
                    "count": 3
                  }
                },
                "stop": {
                  "value": {
                    "motion": "stop"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Started: the holo as it is now",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HoloState"
                },
                "example": {
                  "ready": true,
                  "motion": "twitch",
                  "position": {
                    "x": 0.21,
                    "y": -0.08
                  }
                }
              }
            }
          },
          "400": {
            "description": "A bad motion or parameter",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "bad_request",
                  "message": "`x` for move is -100..100, not 150"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "409": {
            "description": "The holo cannot move now",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "uncalibrated": {
                    "value": {
                      "error": "not_ready",
                      "message": "holo: refused, uncalibrated (tilt)"
                    }
                  },
                  "fitted": {
                    "value": {
                      "error": "not_ready",
                      "message": "holo: refused, not fitted (pan)"
                    }
                  }
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/servos": {
      "get": {
        "tags": [
          "servos"
        ],
        "operationId": "listServos",
        "summary": "Each servo: where it is, its limits and its policy",
        "responses": {
          "200": {
            "description": "The servos",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ServoList"
                },
                "example": {
                  "servos": [
                    {
                      "id": "pan",
                      "gpio": 17,
                      "us": 1525,
                      "driven": true,
                      "released": false,
                      "limits": {
                        "abs_min_us": 1000,
                        "abs_max_us": 2000,
                        "op_min_us": 1150,
                        "op_max_us": 1900,
                        "invert": false,
                        "calibrated": true
                      },
                      "policy": {
                        "closed": "hold",
                        "open": "hold",
                        "mid": "hold",
                        "settle_ms": 0
                      }
                    },
                    {
                      "id": "tilt",
                      "gpio": 18,
                      "us": 1625,
                      "driven": true,
                      "released": false,
                      "limits": {
                        "abs_min_us": 1000,
                        "abs_max_us": 2000,
                        "op_min_us": 1300,
                        "op_max_us": 1950,
                        "invert": false,
                        "calibrated": false
                      },
                      "policy": {
                        "closed": "hold",
                        "open": "hold",
                        "mid": "hold",
                        "settle_ms": 0
                      }
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/servos/move": {
      "post": {
        "tags": [
          "servos"
        ],
        "operationId": "moveServo",
        "summary": "Drive a servo to a position",
        "description": "For finding endpoints while calibrating: this bypasses the holo, whose motion (if any) is stopped first.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ServoMove"
              },
              "examples": {
                "us": {
                  "value": {
                    "id": "pan",
                    "us": 1500
                  }
                },
                "percent": {
                  "value": {
                    "id": "tilt",
                    "percent": 50
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The servo",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Servo"
                },
                "example": {
                  "id": "pan",
                  "gpio": 17,
                  "us": 1525,
                  "driven": true,
                  "released": false,
                  "limits": {
                    "abs_min_us": 1000,
                    "abs_max_us": 2000,
                    "op_min_us": 1150,
                    "op_max_us": 1900,
                    "invert": false,
                    "calibrated": true
                  },
                  "policy": {
                    "closed": "hold",
                    "open": "hold",
                    "mid": "hold",
                    "settle_ms": 0
                  }
                }
              }
            }
          },
          "400": {
            "description": "Out of range",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "bad_request",
                  "message": "`us` must be within 1000..2000"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "No servo by that id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_found",
                  "message": "no servo 'yaw' (there is: pan, tilt)"
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/servos/release": {
      "post": {
        "tags": [
          "servos"
        ],
        "operationId": "releaseServo",
        "summary": "Stop driving a servo, or all of them",
        "description": "They go limp until their next move.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ServoRelease"
              },
              "example": {
                "id": "all"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The servos",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ServoList"
                },
                "example": {
                  "servos": [
                    {
                      "id": "pan",
                      "gpio": 17,
                      "us": 1525,
                      "driven": false,
                      "released": false,
                      "limits": {
                        "abs_min_us": 1000,
                        "abs_max_us": 2000,
                        "op_min_us": 1150,
                        "op_max_us": 1900,
                        "invert": false,
                        "calibrated": true
                      },
                      "policy": {
                        "closed": "hold",
                        "open": "hold",
                        "mid": "hold",
                        "settle_ms": 0
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "No servo by that id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_found",
                  "message": "no servo 'yaw' (there is: pan, tilt)"
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/servos/calibration": {
      "put": {
        "tags": [
          "servos"
        ],
        "operationId": "calibrateServo",
        "summary": "Save a servo's working range",
        "description": "The holo's travel: `min_us` is its closed end (full left or down), `max_us` its open end; `invert` swaps which is which.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ServoId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Calibration"
              },
              "example": {
                "min_us": 1150,
                "max_us": 1900
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The servo",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Servo"
                },
                "example": {
                  "id": "pan",
                  "gpio": 17,
                  "us": 1525,
                  "driven": true,
                  "released": false,
                  "limits": {
                    "abs_min_us": 1000,
                    "abs_max_us": 2000,
                    "op_min_us": 1150,
                    "op_max_us": 1900,
                    "invert": false,
                    "calibrated": true
                  },
                  "policy": {
                    "closed": "hold",
                    "open": "hold",
                    "mid": "hold",
                    "settle_ms": 0
                  }
                }
              }
            }
          },
          "400": {
            "description": "Outside the absolute limits, or min not below max",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "bad_request",
                  "message": "the working range must be within 1000..2000, and min below max"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "No servo by that id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_found",
                  "message": "no servo 'yaw' (there is: pan, tilt)"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "servos"
        ],
        "operationId": "clearServoCalibration",
        "summary": "Forget a servo's working range",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ServoId"
          }
        ],
        "responses": {
          "200": {
            "description": "The servo, with the firmware's default range",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Servo"
                },
                "example": {
                  "id": "pan",
                  "gpio": 17,
                  "us": 1525,
                  "driven": true,
                  "released": false,
                  "limits": {
                    "abs_min_us": 1000,
                    "abs_max_us": 2000,
                    "op_min_us": 1150,
                    "op_max_us": 1900,
                    "invert": false,
                    "calibrated": false
                  },
                  "policy": {
                    "closed": "hold",
                    "open": "hold",
                    "mid": "hold",
                    "settle_ms": 0
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "No servo by that id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_found",
                  "message": "no servo 'yaw' (there is: pan, tilt)"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/servos/policy": {
      "put": {
        "tags": [
          "servos"
        ],
        "operationId": "setServoPolicy",
        "summary": "Save a servo's drive policy",
        "description": "Whether the servo keeps being driven once it has settled, by where it is: releasing at an end stops the buzz of a servo held against a stop.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ServoId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DrivePolicy"
              },
              "example": {
                "closed": "release",
                "open": "hold",
                "mid": "hold",
                "settle_ms": 400
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The servo",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Servo"
                },
                "example": {
                  "id": "pan",
                  "gpio": 17,
                  "us": 1525,
                  "driven": true,
                  "released": false,
                  "limits": {
                    "abs_min_us": 1000,
                    "abs_max_us": 2000,
                    "op_min_us": 1150,
                    "op_max_us": 1900,
                    "invert": false,
                    "calibrated": true
                  },
                  "policy": {
                    "closed": "hold",
                    "open": "hold",
                    "mid": "hold",
                    "settle_ms": 0
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "No servo by that id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_found",
                  "message": "no servo 'yaw' (there is: pan, tilt)"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/scenes": {
      "get": {
        "tags": [
          "scenes"
        ],
        "operationId": "listScenes",
        "summary": "The saved scenes",
        "responses": {
          "200": {
            "description": "The scenes",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SceneList"
                },
                "example": {
                  "scenes": [
                    {
                      "name": "cantina",
                      "description": "Band clip, warm LEDs, a look around",
                      "screen": {
                        "path": "/clips/cantina.mov",
                        "loop": true
                      },
                      "leds": {
                        "mode": "solid",
                        "colour": "#ff8000",
                        "brightness": 30
                      },
                      "holo": {
                        "motion": "twitch",
                        "range": 50
                      }
                    },
                    {
                      "name": "amber",
                      "leds": {
                        "mode": "solid",
                        "colour": "#ffb000"
                      }
                    },
                    {
                      "name": "message",
                      "description": "Leia's message, then back to what it was doing",
                      "screen": {
                        "path": "/clips/leia.mov",
                        "loops": 1
                      },
                      "leds": {
                        "mode": "flicker",
                        "colour": "#4da6ff"
                      },
                      "holo": {
                        "motion": "center"
                      },
                      "then": "restore"
                    }
                  ],
                  "active": {
                    "name": "message",
                    "then": "restore",
                    "until_clip_ends": true,
                    "remaining_s": null
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/scenes/scene": {
      "put": {
        "tags": [
          "scenes"
        ],
        "operationId": "saveScene",
        "summary": "Save a scene",
        "description": "The body's `name`, if any, must be the query's. Files it names are not checked until it is applied.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/SceneName"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Scene"
              },
              "examples": {
                "cantina": {
                  "value": {
                    "name": "cantina",
                    "description": "Band clip, warm LEDs, a look around",
                    "screen": {
                      "path": "/clips/cantina.mov",
                      "loop": true
                    },
                    "leds": {
                      "mode": "solid",
                      "colour": "#ff8000",
                      "brightness": 30
                    },
                    "holo": {
                      "motion": "twitch",
                      "range": 50
                    },
                    "slot": 1
                  }
                },
                "message": {
                  "summary": "A clip, then back to what the board was doing",
                  "value": {
                    "name": "message",
                    "description": "Leia's message, then back to what it was doing",
                    "screen": {
                      "path": "/clips/leia.mov",
                      "loops": 1
                    },
                    "leds": {
                      "mode": "flicker",
                      "colour": "#4da6ff"
                    },
                    "holo": {
                      "motion": "center"
                    },
                    "then": "restore"
                  }
                },
                "bow": {
                  "summary": "Thirty seconds of flicker, then everything off",
                  "value": {
                    "name": "bow",
                    "leds": {
                      "mode": "flicker",
                      "colour": "#ffffff"
                    },
                    "holo": {
                      "motion": "nod",
                      "count": 2
                    },
                    "duration_s": 30,
                    "then": "off"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Replaced",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Scene"
                },
                "example": {
                  "name": "cantina",
                  "description": "Band clip, warm LEDs, a look around",
                  "screen": {
                    "path": "/clips/cantina.mov",
                    "loop": true
                  },
                  "leds": {
                    "mode": "solid",
                    "colour": "#ff8000",
                    "brightness": 30
                  },
                  "holo": {
                    "motion": "twitch",
                    "range": 50
                  }
                }
              }
            }
          },
          "201": {
            "description": "Saved",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Scene"
                },
                "example": {
                  "name": "cantina",
                  "description": "Band clip, warm LEDs, a look around",
                  "screen": {
                    "path": "/clips/cantina.mov",
                    "loop": true
                  },
                  "leds": {
                    "mode": "solid",
                    "colour": "#ff8000",
                    "brightness": 30
                  },
                  "holo": {
                    "motion": "twitch",
                    "range": 50
                  }
                }
              }
            }
          },
          "400": {
            "description": "Not a scene",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "led": {
                    "value": {
                      "error": "bad_request",
                      "message": "leds: `mode` is off, solid, wipe, rainbow or flicker"
                    }
                  },
                  "then": {
                    "value": {
                      "error": "bad_request",
                      "message": "`then` needs the scene to end: give `duration_s`, or a clip on the screen that does not loop forever"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "409": {
            "description": "Sixteen scenes are saved already, or another scene has its slot",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "full": {
                    "value": {
                      "error": "full",
                      "message": "16 scenes are saved; delete one first"
                    }
                  },
                  "slot": {
                    "value": {
                      "error": "slot_taken",
                      "message": "slot 3 is taken by 'cantina'"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "scenes"
        ],
        "operationId": "deleteScene",
        "summary": "Delete a scene",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/SceneName"
          }
        ],
        "responses": {
          "204": {
            "description": "Gone; if it was the boot scene, the board now starts with nothing"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "No scene by that name",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "unknown_scene",
                  "message": "no scene 'party'"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/scenes/apply": {
      "post": {
        "tags": [
          "scenes"
        ],
        "operationId": "applyScene",
        "summary": "Apply a scene, saved or given",
        "description": "The screen, then the LEDs, then the holo: a part that fails stops the rest, and the error names it.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApplyRequest"
              },
              "examples": {
                "saved": {
                  "value": {
                    "name": "cantina"
                  }
                },
                "given": {
                  "value": {
                    "scene": {
                      "name": "now",
                      "leds": {
                        "mode": "rainbow",
                        "loop": true
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Applied: the screen, LEDs and holo as they are now",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApplyResult"
                },
                "example": {
                  "screen": {
                    "powered": true,
                    "backlight": 80,
                    "showing": "clip",
                    "path": "/clips/intro.mov",
                    "loop": true,
                    "clip": {
                      "width": 240,
                      "height": 240,
                      "fps": 24.0,
                      "frames": 288,
                      "shown": 1043,
                      "loops": 3,
                      "late": 0,
                      "elapsed_ms": 43458
                    }
                  },
                  "leds": {
                    "mode": "solid",
                    "colour": "#ff8000",
                    "loop": false,
                    "brightness": 30,
                    "count": 16,
                    "gpio": 16
                  },
                  "holo": {
                    "ready": true,
                    "motion": "twitch",
                    "position": {
                      "x": 0.21,
                      "y": -0.08
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "No scene by that name",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "unknown_scene",
                  "message": "no scene 'party'"
                }
              }
            }
          },
          "409": {
            "description": "Its holo motion cannot run",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_ready",
                  "message": "holo: refused, uncalibrated (tilt)"
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          },
          "422": {
            "description": "Its screen part cannot be shown",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_playable",
                  "message": "screen: /clips/cantina.mov: no such file"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/settings": {
      "get": {
        "tags": [
          "settings"
        ],
        "operationId": "getSettings",
        "summary": "What the board starts with",
        "responses": {
          "200": {
            "description": "The settings",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Settings"
                },
                "example": {
                  "boot_scene": "cantina",
                  "screen": {
                    "backlight": 80
                  },
                  "leds": {
                    "brightness": 30,
                    "count": 16
                  }
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "settings"
        ],
        "operationId": "patchSettings",
        "summary": "Change what the board starts with",
        "description": "Saved. The backlight and LED brightness also change now; `leds.count` waits for a restart.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SettingsPatch"
              },
              "examples": {
                "boot": {
                  "value": {
                    "boot_scene": "cantina"
                  }
                },
                "count": {
                  "value": {
                    "leds": {
                      "count": 24
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsResult"
                },
                "example": {
                  "settings": {
                    "boot_scene": "cantina",
                    "screen": {
                      "backlight": 80
                    },
                    "leds": {
                      "brightness": 30,
                      "count": 24
                    }
                  },
                  "restart_required": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "No scene by that name",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "unknown_scene",
                  "message": "no scene 'party'"
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      },
      "delete": {
        "tags": [
          "settings"
        ],
        "operationId": "resetSettings",
        "summary": "Back to the firmware's defaults",
        "description": "Scenes, servo calibration, networks and web settings are kept.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "responses": {
          "200": {
            "description": "The defaults",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsResult"
                },
                "example": {
                  "settings": {
                    "boot_scene": null,
                    "screen": {
                      "backlight": 100
                    },
                    "leds": {
                      "brightness": 30,
                      "count": 16
                    }
                  },
                  "restart_required": false
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          }
        }
      }
    },
    "/api/v1/network": {
      "get": {
        "tags": [
          "network"
        ],
        "operationId": "getNetwork",
        "summary": "The network, the known ones, and the access point",
        "description": "Passphrases are never read back.",
        "responses": {
          "200": {
            "description": "The network",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Network"
                },
                "example": {
                  "hostname": "holo-2db0",
                  "sta": {
                    "enabled": true,
                    "connected": true,
                    "ssid": "home",
                    "ip": "10.0.19.118",
                    "rssi": -45,
                    "channel": 11,
                    "ipv6": [
                      "fe80::3e0f:2ff:fe6f:2db0"
                    ]
                  },
                  "known": [
                    {
                      "ssid": "home",
                      "last": true
                    },
                    {
                      "ssid": "workshop",
                      "last": false
                    }
                  ],
                  "ap": {
                    "on": false,
                    "ssid": "holo-2db0"
                  }
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "network"
        ],
        "operationId": "patchNetwork",
        "summary": "Turn joining networks on or off",
        "description": "The reply comes first: turning it off drops a client reaching the board over the network.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NetworkPatch"
              },
              "example": {
                "sta_enabled": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The network",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Network"
                },
                "example": {
                  "hostname": "holo-2db0",
                  "sta": {
                    "enabled": true,
                    "connected": true,
                    "ssid": "home",
                    "ip": "10.0.19.118",
                    "rssi": -45,
                    "channel": 11,
                    "ipv6": [
                      "fe80::3e0f:2ff:fe6f:2db0"
                    ]
                  },
                  "known": [
                    {
                      "ssid": "home",
                      "last": true
                    },
                    {
                      "ssid": "workshop",
                      "last": false
                    }
                  ],
                  "ap": {
                    "on": false,
                    "ssid": "holo-2db0"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/network/scan": {
      "get": {
        "tags": [
          "network"
        ],
        "operationId": "scanNetworks",
        "summary": "The networks in range",
        "description": "A long operation: about 3 s. The board stays on its channel for the access point's sake, so a client on the access point may pause.",
        "responses": {
          "200": {
            "description": "What the board hears",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanResult"
                },
                "example": {
                  "networks": [
                    {
                      "ssid": "home",
                      "rssi": -45,
                      "channel": 11,
                      "auth": "wpa2",
                      "known": true
                    },
                    {
                      "ssid": "neighbour",
                      "rssi": -80,
                      "channel": 6,
                      "auth": "wpa3",
                      "known": false
                    }
                  ]
                }
              }
            }
          },
          "409": {
            "description": "Another long operation is running, or the board is joining a network",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "busy": {
                    "value": {
                      "error": "busy",
                      "message": "a file upload is in progress; try again when it is done"
                    }
                  },
                  "joining": {
                    "value": {
                      "error": "busy",
                      "message": "the board is joining a network; try again in a moment"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/network/known": {
      "put": {
        "tags": [
          "network"
        ],
        "operationId": "saveNetwork",
        "summary": "Save a network, without joining it",
        "description": "It is joined when it is in range at a start, or by `POST /api/v1/network/join`. The board keeps 16: saving another forgets the one saved longest ago. Saving one already known replaces its passphrase.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Ssid"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/KnownNetwork"
              },
              "example": {
                "passphrase": "correct horse battery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The network",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Network"
                },
                "example": {
                  "hostname": "holo-2db0",
                  "sta": {
                    "enabled": true,
                    "connected": true,
                    "ssid": "home",
                    "ip": "10.0.19.118",
                    "rssi": -45,
                    "channel": 11,
                    "ipv6": [
                      "fe80::3e0f:2ff:fe6f:2db0"
                    ]
                  },
                  "known": [
                    {
                      "ssid": "home",
                      "last": true
                    },
                    {
                      "ssid": "workshop",
                      "last": false
                    }
                  ],
                  "ap": {
                    "on": false,
                    "ssid": "holo-2db0"
                  }
                }
              }
            }
          },
          "400": {
            "description": "A bad passphrase",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "bad_request",
                  "message": "a passphrase is 8..63 characters"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          }
        }
      },
      "delete": {
        "tags": [
          "network"
        ],
        "operationId": "forgetNetwork",
        "summary": "Forget a network",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Ssid"
          }
        ],
        "responses": {
          "204": {
            "description": "Forgotten. If it is the one the board is on, the board leaves it -- the reply comes first."
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "404": {
            "description": "Not a known network",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_found",
                  "message": "'cafe' is not a known network"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/network/join": {
      "post": {
        "tags": [
          "network"
        ],
        "operationId": "joinNetwork",
        "summary": "Join a network",
        "description": "The reply comes first. The board leaves the network it is on, so a client on that network may lose it; the access point, if on, stays. Without a passphrase, the saved one is used, or the network is taken to be open. A network joined is saved.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JoinRequest"
              },
              "example": {
                "ssid": "workshop"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Joining; the network as it was",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Network"
                },
                "example": {
                  "hostname": "holo-2db0",
                  "sta": {
                    "enabled": true,
                    "connected": true,
                    "ssid": "home",
                    "ip": "10.0.19.118",
                    "rssi": -45,
                    "channel": 11,
                    "ipv6": [
                      "fe80::3e0f:2ff:fe6f:2db0"
                    ]
                  },
                  "known": [
                    {
                      "ssid": "home",
                      "last": true
                    },
                    {
                      "ssid": "workshop",
                      "last": false
                    }
                  ],
                  "ap": {
                    "on": false,
                    "ssid": "holo-2db0"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/network/ap": {
      "patch": {
        "tags": [
          "network"
        ],
        "operationId": "patchAccessPoint",
        "summary": "The board's own access point",
        "description": "`ssid` and `passphrase` are saved (a running access point restarts with them, dropping its clients). `on` is for now only: the access point is off at every start, unless the board can't join a network then, when it comes on for 5 minutes (`off_in_s`), as it does when BOOT is pressed off the network. `on: true` keeps it on.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApPatch"
              },
              "examples": {
                "on": {
                  "value": {
                    "on": true
                  }
                },
                "credentials": {
                  "value": {
                    "ssid": "holo-2db0",
                    "passphrase": "r2d2-c3po-bb8x"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The network",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Network"
                },
                "example": {
                  "hostname": "holo-2db0",
                  "sta": {
                    "enabled": true,
                    "connected": true,
                    "ssid": "home",
                    "ip": "10.0.19.118",
                    "rssi": -45,
                    "channel": 11,
                    "ipv6": [
                      "fe80::3e0f:2ff:fe6f:2db0"
                    ]
                  },
                  "known": [
                    {
                      "ssid": "home",
                      "last": true
                    },
                    {
                      "ssid": "workshop",
                      "last": false
                    }
                  ],
                  "ap": {
                    "on": true,
                    "ssid": "holo-2db0",
                    "ip": "192.168.4.1",
                    "channel": 11,
                    "clients": 0
                  }
                }
              }
            }
          },
          "400": {
            "description": "A bad SSID or passphrase",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "bad_request",
                  "message": "a passphrase is 8..63 characters"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/web": {
      "get": {
        "tags": [
          "device"
        ],
        "operationId": "getWebSettings",
        "summary": "The web server's settings",
        "description": "Needs the password when one is set. The password itself is never read back.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "responses": {
          "200": {
            "description": "The settings",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebSettings"
                },
                "example": {
                  "hostname": "holo-2db0",
                  "auth": true,
                  "cors": [
                    "https://editor.swagger.io",
                    "https://astromech.co",
                    "https://*.astromech.co"
                  ],
                  "cors_default": [
                    "https://editor.swagger.io",
                    "https://astromech.co",
                    "https://*.astromech.co",
                    "https://davidiansmith.ca",
                    "https://*.davidiansmith.ca"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          }
        }
      },
      "patch": {
        "tags": [
          "device"
        ],
        "operationId": "patchWebSettings",
        "summary": "Change the web server's settings",
        "description": "Saved. A new hostname is announced at once; the next request must use it, or the address. A new password is needed from the next request on.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebPatch"
              },
              "examples": {
                "password": {
                  "value": {
                    "password": "hunter22"
                  }
                },
                "clear": {
                  "value": {
                    "password": null
                  }
                },
                "cors": {
                  "value": {
                    "cors": [
                      "https://editor.swagger.io"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebSettings"
                },
                "example": {
                  "hostname": "holo-2db0",
                  "auth": true,
                  "cors": [
                    "https://editor.swagger.io",
                    "https://astromech.co",
                    "https://*.astromech.co"
                  ],
                  "cors_default": [
                    "https://editor.swagger.io",
                    "https://astromech.co",
                    "https://*.astromech.co",
                    "https://davidiansmith.ca",
                    "https://*.davidiansmith.ca"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/scenes/end": {
      "post": {
        "tags": [
          "scenes"
        ],
        "operationId": "endScene",
        "summary": "End the running scene now, doing its `then`",
        "description": "For a scene applied with `then` (`restore` or `off`) that has not reached its end: does now what it would do then. A Stop button, say.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "maxProperties": 0
              },
              "example": {}
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ended: the screen, LEDs and holo as they are now",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApplyResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "409": {
            "description": "No scene is running to its end",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_running",
                  "message": "no scene is running to its end"
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/events": {
      "get": {
        "tags": [
          "events"
        ],
        "operationId": "getEvents",
        "summary": "Follow what changes: a stream of Server-Sent Events",
        "description": "With `Accept: text/event-stream` (what a browser's `EventSource` sends), the reply is a stream that stays open: each event is an SSE `event:` line naming it, and a `data:` line holding it as JSON, a StateEvent or a Happening. A happening's `seq` is also its `id:`, so a reconnecting `EventSource` sends `Last-Event-ID` and gets what it missed, if the board still has it. States are not replayed: read them again with their GETs after a reconnect. Every 15 s a stream gets `system` or, if it didn't ask for that, a comment line, so a client that went away is noticed.\n\nWithout `Accept: text/event-stream`, or with `format=json`, the reply is the kept happenings as JSON, for a client that polls.\n\nA few streams can be open at once (3 unless the firmware is built otherwise); one more gets `503` `busy`.",
        "parameters": [
          {
            "name": "kinds",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "example": "scene_started,scene_ended,clip_started",
            "description": "Comma-separated: only these kinds. Every kind without it. An unknown kind is `400`"
          },
          {
            "name": "after",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 65535
            },
            "example": 40,
            "description": "Start with the kept happenings after this `seq` (0: every kept one). The `Last-Event-ID` header does the same"
          },
          {
            "name": "Last-Event-ID",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Sent by a reconnecting `EventSource`: as `after`"
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "json"
              ]
            },
            "description": "The kept happenings as JSON, whatever `Accept` says"
          }
        ],
        "responses": {
          "200": {
            "description": "The stream, or the kept happenings",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                },
                "example": "retry: 3000\n\nevent: leds\ndata: {\"event\":\"leds\",\"state\":{\"mode\":\"flicker\",\"colour\":\"#4da6ff\",\"loop\":false,\"brightness\":30,\"count\":16,\"gpio\":16}}\n\nevent: scene\ndata: {\"event\":\"scene\",\"state\":{\"name\":\"message\",\"then\":\"restore\",\"until_clip_ends\":true,\"remaining_s\":null}}\n\nevent: clip_started\nid: 40\ndata: {\"event\":\"clip_started\",\"seq\":40,\"t_ms\":801200,\"path\":\"/clips/leia.mov\",\"loop\":false,\"plays\":1,\"frames\":330,\"fps\":30}\n\nevent: clip_ended\nid: 41\ndata: {\"event\":\"clip_ended\",\"seq\":41,\"t_ms\":812340,\"path\":\"/clips/leia.mov\",\"finished\":true}\n\nevent: scene_ended\nid: 42\ndata: {\"event\":\"scene_ended\",\"seq\":42,\"t_ms\":812345,\"name\":\"message\",\"then\":\"restore\",\"by\":\"clip\"}\n\n"
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventHistory"
                },
                "example": {
                  "seq": 42,
                  "lost": 0,
                  "events": [
                    {
                      "event": "clip_started",
                      "seq": 40,
                      "t_ms": 801200,
                      "path": "/clips/leia.mov",
                      "loop": false,
                      "plays": 1,
                      "frames": 330,
                      "fps": 30
                    },
                    {
                      "event": "clip_ended",
                      "seq": 41,
                      "t_ms": 812340,
                      "path": "/clips/leia.mov",
                      "finished": true
                    },
                    {
                      "event": "scene_ended",
                      "seq": 42,
                      "t_ms": 812345,
                      "name": "message",
                      "then": "restore",
                      "by": "clip"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "503": {
            "description": "Every stream is taken",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "busy",
                  "message": "3 event streams are open already"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/link": {
      "get": {
        "tags": [
          "link"
        ],
        "operationId": "getLink",
        "summary": "The host link: its settings, what is running, its counters",
        "responses": {
          "200": {
            "description": "The link",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Link"
                },
                "example": {
                  "protocol_version": 1,
                  "settings": {
                    "mode": "uart",
                    "protocol": "auto",
                    "pins": {
                      "a": 21,
                      "b": 33,
                      "c": 15
                    },
                    "baud": 115200,
                    "address": 1,
                    "groups": [],
                    "reply_delay_ms": 1,
                    "attn": false,
                    "i2c_address": 66,
                    "general_call": false,
                    "events": {
                      "kinds": [],
                      "push": true
                    }
                  },
                  "running": {
                    "mode": "uart",
                    "protocol": "auto",
                    "pins": {
                      "a": 21,
                      "b": 33,
                      "c": 15
                    }
                  },
                  "restart_required": false,
                  "counters": {
                    "requests": 112,
                    "events": 9,
                    "bad_lines": 0,
                    "crc_errors": 0,
                    "framing_errors": 0,
                    "overruns": 0,
                    "ignored": 0
                  },
                  "last_request_s": 1.8,
                  "events": {
                    "kinds": [
                      "scene_ended"
                    ],
                    "push": true,
                    "seq": 9,
                    "pending": 0
                  }
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "link"
        ],
        "operationId": "patchLink",
        "summary": "Change the host link's settings (saved)",
        "description": "Saved. A new `mode`, `pins`, `attn`, `i2c_address` or `general_call` waits for a restart, and the reply says so with `restart_required`; the rest applies at once. Over the link itself, a new `baud` takes effect after the reply, which is sent at the old one.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LinkPatch"
              },
              "examples": {
                "uart": {
                  "value": {
                    "mode": "uart",
                    "baud": 115200
                  }
                },
                "rs485": {
                  "value": {
                    "mode": "rs485",
                    "address": 3,
                    "groups": [
                      2
                    ]
                  }
                },
                "i2c": {
                  "value": {
                    "mode": "i2c",
                    "i2c_address": 66
                  }
                },
                "events": {
                  "value": {
                    "events": {
                      "kinds": [
                        "scene_ended",
                        "clip_ended"
                      ],
                      "push": true
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved: the link",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Link"
                },
                "example": {
                  "protocol_version": 1,
                  "settings": {
                    "mode": "rs485",
                    "protocol": "auto",
                    "pins": {
                      "a": 21,
                      "b": 33,
                      "c": 15
                    },
                    "baud": 115200,
                    "address": 3,
                    "groups": [],
                    "reply_delay_ms": 1,
                    "attn": false,
                    "i2c_address": 66,
                    "general_call": false,
                    "events": {
                      "kinds": [],
                      "push": true
                    }
                  },
                  "running": {
                    "mode": "uart",
                    "protocol": "auto",
                    "pins": {
                      "a": 21,
                      "b": 33,
                      "c": 15
                    }
                  },
                  "restart_required": true,
                  "counters": {
                    "requests": 112,
                    "events": 9,
                    "bad_lines": 0,
                    "crc_errors": 0,
                    "framing_errors": 0,
                    "overruns": 0,
                    "ignored": 0
                  },
                  "last_request_s": 1.8,
                  "events": {
                    "kinds": [
                      "scene_ended"
                    ],
                    "push": true,
                    "seq": 9,
                    "pending": 0
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "409": {
            "description": "A pin is one the board uses for something else",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "pin_in_use",
                  "message": "GPIO16 is the LED strip's"
                }
              }
            }
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    },
    "/api/v1/link/events": {
      "get": {
        "tags": [
          "link"
        ],
        "operationId": "getLinkEvents",
        "summary": "The happenings the link hasn't given its host yet",
        "description": "The happenings of the link's kinds not yet given to its host, oldest first; over the link, they then count as given. Over HTTP, this only looks. To catch up after a gap in `seq`, use `GET /events?after=`.",
        "responses": {
          "200": {
            "description": "Events, oldest first",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventHistory"
                },
                "example": {
                  "seq": 43,
                  "lost": 0,
                  "events": [
                    {
                      "event": "clip_ended",
                      "seq": 41,
                      "t_ms": 811900,
                      "path": "/clips/leia.mov",
                      "finished": true
                    },
                    {
                      "event": "scene_ended",
                      "seq": 42,
                      "t_ms": 812345,
                      "name": "message",
                      "then": "restore",
                      "by": "clip"
                    }
                  ]
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "link"
        ],
        "operationId": "patchLinkEvents",
        "summary": "The events the link reports, until the next restart",
        "description": "Not saved: `PATCH /link` with `events` sets what it reports from the start.",
        "security": [
          {},
          {
            "bearer": []
          },
          {
            "basic": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LinkEventsPatch"
              },
              "example": {
                "kinds": [
                  "scene_ended",
                  "clip_ended",
                  "leds"
                ],
                "push": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What it reports now",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LinkEventState"
                },
                "example": {
                  "kinds": [
                    "clip_ended",
                    "leds",
                    "scene_ended"
                  ],
                  "push": true,
                  "seq": 0,
                  "pending": 0
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/AuthRequired"
          },
          "403": {
            "$ref": "#/components/responses/ForbiddenHost"
          },
          "415": {
            "$ref": "#/components/responses/ContentType"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "The board's web password as the token. Only needed once a password is set (`web password` on the console)."
      },
      "basic": {
        "type": "http",
        "scheme": "basic",
        "description": "Any user name, and the board's web password. Only needed once a password is set."
      }
    },
    "parameters": {
      "Sha256": {
        "name": "sha256",
        "in": "query",
        "schema": {
          "type": "string",
          "pattern": "^[0-9a-fA-F]{64}$"
        },
        "description": "The image's SHA-256. The board refuses the image if what arrived differs."
      },
      "Activate": {
        "name": "activate",
        "in": "query",
        "schema": {
          "type": "boolean",
          "default": false
        },
        "description": "Make it the boot image once staged"
      },
      "Reboot": {
        "name": "reboot",
        "in": "query",
        "schema": {
          "type": "boolean",
          "default": false
        },
        "description": "With `activate`: restart into it, a moment after replying"
      },
      "Force": {
        "name": "force",
        "in": "query",
        "schema": {
          "type": "boolean",
          "default": false
        },
        "description": "Accept an image built for another project"
      },
      "Path": {
        "name": "path",
        "in": "query",
        "required": true,
        "schema": {
          "type": "string",
          "maxLength": 159,
          "examples": [
            "/clips/intro.mov"
          ]
        },
        "description": "From the root of the storage volume. `/data/...` is accepted too."
      },
      "DirPath": {
        "name": "path",
        "in": "query",
        "schema": {
          "type": "string",
          "default": "/",
          "maxLength": 159
        },
        "description": "A directory, from the root of the storage volume"
      },
      "Overwrite": {
        "name": "overwrite",
        "in": "query",
        "schema": {
          "type": "boolean",
          "default": false
        },
        "description": "Replace a file that is already there"
      },
      "Parents": {
        "name": "parents",
        "in": "query",
        "schema": {
          "type": "boolean",
          "default": false
        },
        "description": "Make any missing directories on the way"
      },
      "Recursive": {
        "name": "recursive",
        "in": "query",
        "schema": {
          "type": "boolean",
          "default": false
        },
        "description": "Delete a directory with everything in it"
      },
      "WithSha256": {
        "name": "sha256",
        "in": "query",
        "schema": {
          "type": "boolean",
          "default": false
        },
        "description": "Also hash the file (a long operation: about 1 s a megabyte)"
      },
      "FileSha256": {
        "name": "sha256",
        "in": "query",
        "schema": {
          "type": "string",
          "pattern": "^[0-9a-fA-F]{64}$"
        },
        "description": "The file's SHA-256. The board refuses the file if what arrived differs."
      },
      "SceneName": {
        "name": "name",
        "in": "query",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9 _.-]{1,32}$"
        },
        "example": "cantina"
      },
      "ServoId": {
        "name": "id",
        "in": "query",
        "required": true,
        "schema": {
          "type": "string"
        },
        "example": "pan",
        "description": "The servo's ident, as `GET /api/v1/servos` lists them"
      },
      "Ssid": {
        "name": "ssid",
        "in": "query",
        "required": true,
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 32
        },
        "example": "home"
      }
    },
    "requestBodies": {
      "Image": {
        "required": true,
        "description": "The application image",
        "content": {
          "application/octet-stream": {
            "schema": {
              "type": "string",
              "format": "binary"
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request is malformed",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "bad_request",
              "message": "give either `url` or `channel`"
            }
          }
        }
      },
      "AuthRequired": {
        "description": "A password is set, and this request did not carry it",
        "headers": {
          "WWW-Authenticate": {
            "schema": {
              "type": "string"
            },
            "example": "Bearer realm=\"holo-2db0\""
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "auth_required",
              "message": "this needs the board's web password"
            }
          }
        }
      },
      "ForbiddenHost": {
        "description": "The Host header does not name the board",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "forbidden_host",
              "message": "the Host header must be the board's address or holo-2db0.local"
            }
          }
        }
      },
      "ContentType": {
        "description": "A POST or PATCH without Content-Type: application/json",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "content_type",
              "message": "a POST or PATCH must be Content-Type: application/json"
            }
          }
        }
      },
      "Busy": {
        "description": "Another update is in progress",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "busy",
              "message": "another update is in progress"
            }
          }
        }
      },
      "Offline": {
        "description": "The board has no network connection to download from, or the release site did not answer",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "offline",
              "message": "the board has no network connection to download from"
            }
          }
        }
      },
      "NotFound": {
        "description": "Not here",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "not_found",
              "message": "no such endpoint: /api/v1/nope"
            }
          }
        }
      },
      "BadPath": {
        "description": "The path is not one the board accepts",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "bad_path",
              "message": "'..' is not allowed in a path: /clips/../history.txt"
            }
          }
        }
      },
      "PathNotFound": {
        "description": "Nothing is at that path",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "not_found",
              "message": "no such file or directory: /clips/intro.mov"
            }
          }
        }
      },
      "NoSpace": {
        "description": "The storage volume is too full",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "no_space",
              "message": "5242880 bytes will not fit: 3145728 free, less a 32768-byte margin"
            }
          }
        }
      },
      "JobBusy": {
        "description": "Another long operation is running",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "busy",
              "message": "a file upload is in progress; try again when it is done"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error",
          "message"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "A stable code to branch on",
            "examples": [
              "busy",
              "invalid_image",
              "auth_required"
            ]
          },
          "message": {
            "type": "string",
            "description": "What went wrong, in words"
          }
        }
      },
      "AppDesc": {
        "type": "object",
        "description": "An image's description, as built into it",
        "required": [
          "project",
          "version",
          "date",
          "time",
          "idf",
          "elf_sha256"
        ],
        "properties": {
          "project": {
            "type": "string",
            "examples": [
              "holo-player-fw"
            ]
          },
          "version": {
            "type": "string",
            "description": "The release tag, or `git describe` for a development build",
            "examples": [
              "v1.0.0",
              "v1.0.0-6-g4b8387d"
            ]
          },
          "date": {
            "type": "string",
            "examples": [
              "Sep 24 2026"
            ]
          },
          "time": {
            "type": "string",
            "examples": [
              "03:51:02"
            ]
          },
          "idf": {
            "type": "string",
            "examples": [
              "v6.1"
            ]
          },
          "elf_sha256": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$"
          }
        }
      },
      "Info": {
        "type": "object",
        "required": [
          "api",
          "firmware",
          "chip",
          "hostname",
          "uptime_s",
          "heap_free",
          "features",
          "auth",
          "via",
          "sta",
          "ap"
        ],
        "properties": {
          "api": {
            "type": "integer",
            "const": 1
          },
          "firmware": {
            "$ref": "#/components/schemas/AppDesc"
          },
          "chip": {
            "type": "string"
          },
          "hostname": {
            "type": "string",
            "description": "The board answers to `<hostname>.local`"
          },
          "uptime_s": {
            "type": "integer"
          },
          "heap_free": {
            "type": "integer",
            "description": "Bytes"
          },
          "features": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "ota",
                "files",
                "screen",
                "leds",
                "holo",
                "scenes",
                "settings",
                "network",
                "events",
                "link"
              ]
            },
            "description": "What the web app offers on this firmware"
          },
          "auth": {
            "type": "boolean",
            "description": "Whether a password is set: changes need it"
          },
          "via": {
            "type": "string",
            "enum": [
              "sta",
              "ap"
            ],
            "description": "Whether this request came over the board's own access point"
          },
          "sta": {
            "type": "object",
            "required": [
              "enabled",
              "connected"
            ],
            "properties": {
              "enabled": {
                "type": "boolean"
              },
              "connected": {
                "type": "boolean"
              },
              "ssid": {
                "type": "string"
              },
              "ip": {
                "type": "string",
                "format": "ipv4"
              },
              "rssi": {
                "type": "integer",
                "description": "dBm"
              },
              "channel": {
                "type": "integer"
              },
              "ipv6": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "ap": {
            "type": "object",
            "required": [
              "on"
            ],
            "properties": {
              "on": {
                "type": "boolean"
              },
              "ssid": {
                "type": "string"
              },
              "ip": {
                "type": "string",
                "format": "ipv4"
              },
              "clients": {
                "type": "integer"
              },
              "off_in_s": {
                "type": "integer",
                "minimum": 1,
                "description": "On for a while (it came on because the board couldn't join a network at start, or from the BOOT button): seconds until it turns itself off. Absent when it stays on"
              }
            }
          }
        }
      },
      "Slot": {
        "type": "object",
        "required": [
          "label",
          "address",
          "size",
          "running",
          "boots",
          "next",
          "state",
          "app"
        ],
        "properties": {
          "label": {
            "type": "string",
            "examples": [
              "ota_0"
            ]
          },
          "address": {
            "type": "integer",
            "description": "Flash offset"
          },
          "size": {
            "type": "integer",
            "description": "Bytes"
          },
          "running": {
            "type": "boolean"
          },
          "boots": {
            "type": "boolean",
            "description": "The boot image: runs after a restart"
          },
          "next": {
            "type": "boolean",
            "description": "The slot an update writes"
          },
          "state": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "new",
              "trial",
              "confirmed",
              "invalid",
              "rolled_back",
              "none",
              null
            ],
            "description": "The bootloader's record: `trial` runs until it confirms itself; null when there is no record (an image installed over USB)"
          },
          "app": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/AppDesc"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "Session": {
        "type": "object",
        "description": "The update in progress, or the last one this boot",
        "required": [
          "state",
          "source",
          "slot",
          "url",
          "bytes",
          "total",
          "sha256",
          "app",
          "boots_next",
          "dry_run",
          "error",
          "elapsed_ms"
        ],
        "properties": {
          "state": {
            "type": "string",
            "enum": [
              "idle",
              "receiving",
              "downloading",
              "verifying",
              "staged",
              "active",
              "failed"
            ],
            "description": "`receiving` (an upload or the console) or `downloading` (a pull); `verifying` while the board checks the whole image; `staged` checked and waiting; `active` the boot image, running from the next restart; `failed` with `error`. The running image boots in every state but `active`."
          },
          "source": {
            "type": "string",
            "enum": [
              "none",
              "upload",
              "pull",
              "console"
            ]
          },
          "slot": {
            "type": [
              "string",
              "null"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "For a pull: the image's URL, once resolved"
          },
          "bytes": {
            "type": "integer"
          },
          "total": {
            "type": "integer",
            "description": "0 while not known"
          },
          "sha256": {
            "type": [
              "string",
              "null"
            ],
            "description": "Of the image as received, once complete"
          },
          "app": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/AppDesc"
              },
              {
                "type": "null"
              }
            ],
            "description": "Known from the image's first 288 bytes"
          },
          "boots_next": {
            "type": "boolean"
          },
          "dry_run": {
            "type": "boolean"
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          },
          "elapsed_ms": {
            "type": "integer"
          }
        }
      },
      "Ota": {
        "type": "object",
        "required": [
          "rollback",
          "slots",
          "session",
          "pull"
        ],
        "properties": {
          "rollback": {
            "type": "boolean",
            "description": "Updated images run on trial, and a reset before they confirm goes back"
          },
          "slots": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Slot"
            }
          },
          "session": {
            "$ref": "#/components/schemas/Session"
          },
          "pull": {
            "type": "object",
            "required": [
              "available",
              "channels",
              "online"
            ],
            "properties": {
              "available": {
                "type": "boolean",
                "description": "The firmware can pull images"
              },
              "channels": {
                "type": "boolean",
                "description": "It knows release channels (`latest`)"
              },
              "online": {
                "type": "boolean",
                "description": "Its station is connected"
              }
            }
          }
        }
      },
      "RestartRequest": {
        "type": "object",
        "properties": {
          "delay_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 60000,
            "default": 500
          }
        }
      },
      "ActivateRequest": {
        "type": "object",
        "properties": {
          "slot": {
            "type": "string",
            "description": "A slot's label; the staged image when absent"
          },
          "reboot": {
            "type": "boolean",
            "default": true
          }
        }
      },
      "ActivateResult": {
        "type": "object",
        "required": [
          "session",
          "restart_in_ms"
        ],
        "properties": {
          "session": {
            "$ref": "#/components/schemas/Session"
          },
          "restart_in_ms": {
            "type": [
              "integer",
              "null"
            ]
          }
        }
      },
      "PullRequest": {
        "type": "object",
        "oneOf": [
          {
            "required": [
              "url"
            ],
            "not": {
              "required": [
                "channel"
              ]
            }
          },
          {
            "required": [
              "channel"
            ],
            "not": {
              "required": [
                "url"
              ]
            }
          }
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 255,
            "description": "An image, or a release manifest (ending .json)"
          },
          "channel": {
            "type": "string",
            "examples": [
              "latest"
            ],
            "description": "A release channel: a documentation version or alias"
          },
          "sha256": {
            "type": "string",
            "pattern": "^[0-9a-fA-F]{64}$",
            "description": "Refuse the image unless it has this SHA-256 (a manifest's own is used otherwise)"
          },
          "activate": {
            "type": "boolean",
            "default": false
          },
          "reboot": {
            "type": "boolean",
            "default": false
          },
          "force": {
            "type": "boolean",
            "default": false,
            "description": "Accept an image built for another project"
          }
        }
      },
      "CheckResult": {
        "type": "object",
        "required": [
          "channel",
          "current",
          "version",
          "newer",
          "image_url",
          "release_url",
          "size",
          "sha256",
          "checked_s_ago"
        ],
        "properties": {
          "channel": {
            "type": [
              "string",
              "null"
            ]
          },
          "current": {
            "type": "string",
            "description": "The running firmware's version"
          },
          "version": {
            "type": [
              "string",
              "null"
            ],
            "description": "The release's version; null for a bare image URL"
          },
          "newer": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Whether `version` is a later release than `current`; null when either is not a release tag"
          },
          "image_url": {
            "type": "string"
          },
          "release_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "size": {
            "type": "integer",
            "description": "0 if unknown"
          },
          "sha256": {
            "type": [
              "string",
              "null"
            ]
          },
          "checked_s_ago": {
            "type": "integer"
          }
        }
      },
      "Volume": {
        "type": "object",
        "required": [
          "root",
          "total",
          "used",
          "free"
        ],
        "properties": {
          "root": {
            "type": "string",
            "description": "Where the volume is mounted on the board; paths here are relative to it"
          },
          "total": {
            "type": "integer",
            "description": "Bytes"
          },
          "used": {
            "type": "integer"
          },
          "free": {
            "type": "integer"
          }
        }
      },
      "Entry": {
        "type": "object",
        "required": [
          "name",
          "path",
          "type",
          "size",
          "mtime"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "path": {
            "type": "string",
            "maxLength": 159
          },
          "type": {
            "type": "string",
            "enum": [
              "file",
              "dir"
            ]
          },
          "size": {
            "type": "integer",
            "description": "Bytes; 0 for a directory"
          },
          "mtime": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When it last changed, in UTC; null when the board's clock was not set then"
          },
          "sha256": {
            "type": "string",
            "description": "Only when asked for"
          }
        }
      },
      "Listing": {
        "type": "object",
        "required": [
          "path",
          "entries"
        ],
        "properties": {
          "path": {
            "type": "string",
            "maxLength": 159
          },
          "entries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Entry"
            },
            "description": "Directories first, then files, each by name"
          }
        }
      },
      "MkdirRequest": {
        "type": "object",
        "required": [
          "path"
        ],
        "properties": {
          "path": {
            "type": "string",
            "maxLength": 159
          },
          "parents": {
            "type": "boolean",
            "default": false
          }
        }
      },
      "MoveRequest": {
        "type": "object",
        "required": [
          "from",
          "to"
        ],
        "properties": {
          "from": {
            "type": "string",
            "maxLength": 159
          },
          "to": {
            "type": "string",
            "maxLength": 159,
            "description": "The new path. An existing directory is not a destination: name the file inside it."
          },
          "overwrite": {
            "type": "boolean",
            "default": false
          }
        }
      },
      "ClipStats": {
        "type": "object",
        "properties": {
          "width": {
            "type": "integer"
          },
          "height": {
            "type": "integer"
          },
          "fps": {
            "type": "number"
          },
          "frames": {
            "type": "integer",
            "description": "In one pass of the clip"
          },
          "shown": {
            "type": "integer",
            "description": "Frames shown since it started, every loop"
          },
          "loops": {
            "type": "integer",
            "description": "Times through so far"
          },
          "late": {
            "type": "integer",
            "description": "Frames that missed their time"
          },
          "elapsed_ms": {
            "type": "integer"
          },
          "plays": {
            "type": "integer",
            "description": "Times through in all; 0 forever"
          }
        }
      },
      "ScreenState": {
        "type": "object",
        "required": [
          "powered",
          "backlight",
          "showing"
        ],
        "properties": {
          "powered": {
            "type": "boolean",
            "description": "Whether the panel is awake; it sleeps whenever it shows nothing"
          },
          "backlight": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "showing": {
            "type": "string",
            "enum": [
              "nothing",
              "colour",
              "calibration",
              "image",
              "clip"
            ],
            "description": "An animated GIF is a `clip`"
          },
          "colour": {
            "type": "string",
            "pattern": "^#[0-9a-f]{6}$",
            "description": "`#rrggbb`"
          },
          "path": {
            "type": "string",
            "maxLength": 159
          },
          "loop": {
            "type": "boolean"
          },
          "clip": {
            "$ref": "#/components/schemas/ClipStats"
          }
        }
      },
      "ScreenPatch": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "backlight": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          }
        }
      },
      "ShowFile": {
        "type": "object",
        "required": [
          "path"
        ],
        "additionalProperties": false,
        "properties": {
          "path": {
            "type": "string",
            "maxLength": 159,
            "description": "A QuickTime Motion-JPEG clip (`.mov`), or a PNG, baseline JPEG or GIF"
          },
          "loop": {
            "type": "boolean",
            "default": false,
            "description": "A clip or animation: play it forever"
          },
          "whole_frame": {
            "type": "boolean",
            "default": false,
            "description": "Decode each frame whole, rather than streamed in blocks"
          },
          "loops": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "description": "A clip or animation: play it this many times. Without `loop` or `loops`, a clip plays once and an animated GIF as the file says."
          }
        },
        "not": {
          "required": [
            "loop",
            "loops"
          ],
          "properties": {
            "loop": {
              "const": true
            }
          }
        }
      },
      "ShowColour": {
        "type": "object",
        "required": [
          "colour"
        ],
        "additionalProperties": false,
        "properties": {
          "colour": {
            "type": "string",
            "examples": [
              "#ff8000",
              "orange",
              "255,128,0"
            ],
            "description": "`#rrggbb`, `rrggbb`, `R,G,B`, or a name: black, white, red, green, blue, yellow, cyan, magenta, orange, purple, pink, grey"
          }
        }
      },
      "ShowCalibration": {
        "type": "object",
        "required": [
          "calibration"
        ],
        "additionalProperties": false,
        "properties": {
          "calibration": {
            "const": true,
            "description": "The alignment crosshair"
          }
        }
      },
      "ShowRequest": {
        "oneOf": [
          {
            "$ref": "#/components/schemas/ShowFile"
          },
          {
            "$ref": "#/components/schemas/ShowColour"
          },
          {
            "$ref": "#/components/schemas/ShowCalibration"
          }
        ]
      },
      "MediaItem": {
        "type": "object",
        "required": [
          "path",
          "kind",
          "size"
        ],
        "properties": {
          "path": {
            "type": "string",
            "maxLength": 159
          },
          "kind": {
            "type": "string",
            "enum": [
              "clip",
              "image",
              "animation"
            ],
            "description": "By the name's extension: `.mov` a clip, `.gif` an animation, `.png` and `.jpg` an image. An animation may turn out to have one frame."
          },
          "size": {
            "type": "integer"
          }
        }
      },
      "MediaList": {
        "type": "object",
        "required": [
          "items"
        ],
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MediaItem"
            }
          }
        }
      },
      "MediaInfo": {
        "type": "object",
        "required": [
          "path",
          "kind",
          "format",
          "playable"
        ],
        "properties": {
          "path": {
            "type": "string",
            "maxLength": 159
          },
          "kind": {
            "type": "string",
            "enum": [
              "clip",
              "image",
              "animation"
            ]
          },
          "format": {
            "type": "string",
            "enum": [
              "mov",
              "png",
              "jpeg",
              "gif"
            ]
          },
          "width": {
            "type": "integer"
          },
          "height": {
            "type": "integer"
          },
          "frames": {
            "type": "integer"
          },
          "fps": {
            "type": "number"
          },
          "duration_s": {
            "type": "number"
          },
          "codec": {
            "type": "string",
            "description": "A clip's: `jpeg` or `mjpa`"
          },
          "playable": {
            "type": "boolean"
          },
          "why": {
            "type": "string",
            "description": "When not playable"
          }
        }
      },
      "LedState": {
        "type": "object",
        "required": [
          "mode",
          "colour",
          "loop",
          "brightness",
          "count",
          "gpio"
        ],
        "properties": {
          "mode": {
            "type": "string",
            "enum": [
              "off",
              "solid",
              "wipe",
              "rainbow",
              "flicker"
            ],
            "description": "`wipe` lights each LED in turn to the colour; `rainbow` turns the colour wheel round the ring -- played once, either ends `off`. `flicker` flickers the colour like a failing hologram, until something else."
          },
          "colour": {
            "type": "string",
            "pattern": "^#[0-9a-f]{6}$",
            "description": "`#rrggbb`"
          },
          "loop": {
            "type": "boolean"
          },
          "brightness": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100
          },
          "count": {
            "type": "integer"
          },
          "gpio": {
            "type": "integer"
          }
        }
      },
      "LedPatch": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "mode": {
            "type": "string",
            "enum": [
              "off",
              "solid",
              "wipe",
              "rainbow",
              "flicker"
            ]
          },
          "colour": {
            "type": "string",
            "examples": [
              "#ff8000",
              "orange",
              "255,128,0"
            ],
            "description": "For `solid`, `wipe` and `flicker` (white if never given). `#rrggbb`, `rrggbb`, `R,G,B`, or a name: black, white, red, green, blue, yellow, cyan, magenta, orange, purple, pink, grey"
          },
          "loop": {
            "type": "boolean",
            "description": "For `wipe` and `rainbow`"
          },
          "brightness": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100
          }
        }
      },
      "HoloState": {
        "type": "object",
        "required": [
          "ready",
          "motion",
          "position"
        ],
        "properties": {
          "ready": {
            "type": "boolean",
            "description": "Whether it can move now"
          },
          "why": {
            "type": "string",
            "enum": [
              "not fitted",
              "not armed",
              "uncalibrated",
              "unknown axis"
            ],
            "description": "Why it cannot move, when it cannot"
          },
          "motion": {
            "type": "string",
            "enum": [
              "hold",
              "move",
              "twitch",
              "wag",
              "nod",
              "scan",
              "circle"
            ]
          },
          "stopped": {
            "type": "string",
            "description": "Why the last motion ended early, if it did"
          },
          "position": {
            "type": "object",
            "required": [
              "x",
              "y"
            ],
            "properties": {
              "x": {
                "type": "number",
                "minimum": -1,
                "maximum": 1,
                "description": "+ is right"
              },
              "y": {
                "type": "number",
                "minimum": -1,
                "maximum": 1,
                "description": "+ is up"
              }
            }
          }
        }
      },
      "MotionRequest": {
        "type": "object",
        "required": [
          "motion"
        ],
        "additionalProperties": false,
        "properties": {
          "motion": {
            "type": "string",
            "enum": [
              "center",
              "move",
              "nudge",
              "twitch",
              "wag",
              "nod",
              "scan",
              "circle",
              "stop",
              "off"
            ],
            "description": "`center`, `move` and `nudge` go to a place; `twitch` (random glances) and `scan` (a slow sweep) run until stopped or for `time_s`; `wag`, `nod` and `circle` run `count` times and return. `stop` holds where it is; `off` also lets the axes go limp."
          },
          "x": {
            "type": "integer",
            "minimum": -200,
            "maximum": 200,
            "description": "`move`: -100..100, percent of the way from the centre to an endpoint, + right. `nudge`: -200..200, relative."
          },
          "y": {
            "type": "integer",
            "minimum": -200,
            "maximum": 200,
            "description": "As `x`; + is up"
          },
          "duration_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000,
            "description": "`center`, `move`, `nudge`: how long to take; by default, by the distance"
          },
          "range": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "description": "Percent of the travel each side of the centre. Defaults: twitch 60, scan 70, wag 50, nod 40, circle 50."
          },
          "count": {
            "type": "integer",
            "minimum": 1,
            "maximum": 20,
            "description": "`wag`, `nod`: cycles (3, 2); `circle`: turns (1)"
          },
          "period_ms": {
            "type": "integer",
            "minimum": 200,
            "maximum": 60000,
            "description": "`wag` 700, `nod` 800, `circle` 2400 a turn, `scan` 6000 a sweep there and back"
          },
          "interval_s": {
            "type": "array",
            "items": {
              "type": "number",
              "minimum": 0.1,
              "maximum": 600
            },
            "minItems": 2,
            "maxItems": 2,
            "description": "`twitch`: the pause between glances, [min, max]; default [1, 5]"
          },
          "time_s": {
            "type": "number",
            "minimum": 0.1,
            "maximum": 3600,
            "description": "`twitch`, `scan`: stop after this long"
          }
        }
      },
      "DrivePolicy": {
        "type": "object",
        "required": [
          "closed",
          "open",
          "mid",
          "settle_ms"
        ],
        "properties": {
          "closed": {
            "type": "string",
            "enum": [
              "hold",
              "release"
            ],
            "description": "At the closed end of the working range (the first 2% of travel): keep driving once settled, or let go"
          },
          "open": {
            "type": "string",
            "enum": [
              "hold",
              "release"
            ],
            "description": "At the open end"
          },
          "mid": {
            "type": "string",
            "enum": [
              "hold",
              "release"
            ],
            "description": "Anywhere between"
          },
          "settle_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 65535,
            "description": "How long a command is driven before a `release` takes effect (at least 100)"
          }
        }
      },
      "ServoLimits": {
        "type": "object",
        "required": [
          "abs_min_us",
          "abs_max_us",
          "op_min_us",
          "op_max_us",
          "invert",
          "calibrated"
        ],
        "properties": {
          "abs_min_us": {
            "type": "integer",
            "description": "The most any command may send"
          },
          "abs_max_us": {
            "type": "integer"
          },
          "op_min_us": {
            "type": "integer",
            "description": "The working range: 0% of travel"
          },
          "op_max_us": {
            "type": "integer",
            "description": "100% of travel"
          },
          "invert": {
            "type": "boolean",
            "description": "0% is `op_max_us`"
          },
          "calibrated": {
            "type": "boolean",
            "description": "Whether the working range was saved, rather than the firmware's default"
          }
        }
      },
      "Servo": {
        "type": "object",
        "required": [
          "id",
          "us",
          "driven",
          "released",
          "limits",
          "policy"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "gpio": {
            "type": "integer",
            "description": "For a servo driven from a pin"
          },
          "us": {
            "type": "integer",
            "description": "The last pulse commanded; 0 before any"
          },
          "driven": {
            "type": "boolean",
            "description": "Whether pulses are going out now"
          },
          "released": {
            "type": "boolean",
            "description": "Whether the drive policy is what stopped them"
          },
          "limits": {
            "$ref": "#/components/schemas/ServoLimits"
          },
          "policy": {
            "$ref": "#/components/schemas/DrivePolicy"
          }
        }
      },
      "ServoList": {
        "type": "object",
        "required": [
          "servos"
        ],
        "properties": {
          "servos": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Servo"
            }
          }
        }
      },
      "ServoMove": {
        "type": "object",
        "required": [
          "id"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string"
          },
          "us": {
            "type": "integer",
            "description": "A pulse width, within the absolute limits"
          },
          "percent": {
            "type": "number",
            "minimum": 0,
            "maximum": 100,
            "description": "Of the working range"
          }
        },
        "oneOf": [
          {
            "required": [
              "us"
            ]
          },
          {
            "required": [
              "percent"
            ]
          }
        ]
      },
      "ServoRelease": {
        "type": "object",
        "required": [
          "id"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "A servo, or `all`"
          }
        }
      },
      "Calibration": {
        "type": "object",
        "required": [
          "min_us",
          "max_us"
        ],
        "additionalProperties": false,
        "properties": {
          "min_us": {
            "type": "integer"
          },
          "max_us": {
            "type": "integer"
          },
          "invert": {
            "type": "boolean",
            "default": false
          }
        }
      },
      "SceneScreen": {
        "oneOf": [
          {
            "$ref": "#/components/schemas/ShowFile"
          },
          {
            "$ref": "#/components/schemas/ShowColour"
          },
          {
            "$ref": "#/components/schemas/ShowCalibration"
          },
          {
            "type": "object",
            "required": [
              "clear"
            ],
            "additionalProperties": false,
            "properties": {
              "clear": {
                "const": true,
                "description": "Nothing on the screen"
              }
            }
          }
        ]
      },
      "Scene": {
        "type": "object",
        "required": [
          "name"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "pattern": "^[A-Za-z0-9 _.-]{1,32}$"
          },
          "description": {
            "type": "string",
            "maxLength": 120
          },
          "screen": {
            "$ref": "#/components/schemas/SceneScreen"
          },
          "leds": {
            "$ref": "#/components/schemas/LedPatch"
          },
          "holo": {
            "$ref": "#/components/schemas/MotionRequest"
          },
          "then": {
            "type": "string",
            "enum": [
              "stay",
              "restore",
              "off"
            ],
            "default": "stay",
            "description": "When the scene ends: `stay` leaves everything as it is; `restore` puts the screen, LEDs and holo back to what they were doing before the scene -- a behaviour such as a twitch starts again; `off` turns the screen and LEDs off and lets the holo's servos go limp. Anything but `stay` needs the scene to end: `duration_s`, or a clip or animation on the screen that does not `loop` (in such a scene, one with neither `loop` nor `loops` plays once)."
          },
          "duration_s": {
            "type": "number",
            "minimum": 0.1,
            "maximum": 86400,
            "description": "The scene ends after this long, or when its clip has played if that is sooner"
          },
          "slot": {
            "type": "integer",
            "minimum": 1,
            "maximum": 255,
            "description": "A number for the scene, 1..255, that small controllers on the host link apply it by instead of its name. Unique: saving a slot another scene has is `409 slot_taken`."
          }
        },
        "description": "Each part is optional: a part left out leaves that alone. A scene with only `leds` is an LED preset. A scene ends when its clip has played (once, or its `loops`), or after `duration_s`; `then` is what happens next. Applying another scene, or showing something else in place of its clip, ends it without `then`."
      },
      "SceneList": {
        "type": "object",
        "required": [
          "scenes",
          "active"
        ],
        "properties": {
          "scenes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Scene"
            },
            "maxItems": 16
          },
          "active": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ActiveScene"
              },
              {
                "type": "null"
              }
            ],
            "description": "The scene running to its end, with a `then` to do when it gets there"
          }
        }
      },
      "ApplyRequest": {
        "oneOf": [
          {
            "type": "object",
            "required": [
              "name"
            ],
            "additionalProperties": false,
            "properties": {
              "name": {
                "type": "string",
                "pattern": "^[A-Za-z0-9 _.-]{1,32}$"
              }
            }
          },
          {
            "type": "object",
            "required": [
              "scene"
            ],
            "additionalProperties": false,
            "properties": {
              "scene": {
                "$ref": "#/components/schemas/Scene",
                "description": "Applied without saving it; its name is not checked against the saved ones"
              }
            }
          }
        ]
      },
      "ApplyResult": {
        "type": "object",
        "required": [
          "screen",
          "leds",
          "holo"
        ],
        "properties": {
          "screen": {
            "$ref": "#/components/schemas/ScreenState"
          },
          "leds": {
            "$ref": "#/components/schemas/LedState"
          },
          "holo": {
            "$ref": "#/components/schemas/HoloState"
          }
        }
      },
      "Settings": {
        "type": "object",
        "required": [
          "boot_scene",
          "screen",
          "leds"
        ],
        "properties": {
          "boot_scene": {
            "oneOf": [
              {
                "type": "string",
                "pattern": "^[A-Za-z0-9 _.-]{1,32}$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The scene applied at every start; null: the screen and LEDs start off"
          },
          "screen": {
            "type": "object",
            "required": [
              "backlight"
            ],
            "properties": {
              "backlight": {
                "type": "integer",
                "minimum": 1,
                "maximum": 100,
                "default": 100
              }
            }
          },
          "leds": {
            "type": "object",
            "required": [
              "brightness",
              "count"
            ],
            "properties": {
              "brightness": {
                "type": "integer",
                "minimum": 1,
                "maximum": 100,
                "description": "Keep it at 30 or below when the strip is powered from the board"
              },
              "count": {
                "type": "integer",
                "minimum": 1,
                "maximum": 256,
                "description": "LEDs on the strip. Takes effect at the next start."
              }
            }
          }
        }
      },
      "SettingsPatch": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "boot_scene": {
            "oneOf": [
              {
                "type": "string",
                "pattern": "^[A-Za-z0-9 _.-]{1,32}$"
              },
              {
                "type": "null"
              }
            ]
          },
          "screen": {
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "backlight": {
                "type": "integer",
                "minimum": 1,
                "maximum": 100
              }
            }
          },
          "leds": {
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "brightness": {
                "type": "integer",
                "minimum": 1,
                "maximum": 100
              },
              "count": {
                "type": "integer",
                "minimum": 1,
                "maximum": 256
              }
            }
          }
        }
      },
      "SettingsResult": {
        "type": "object",
        "required": [
          "settings",
          "restart_required"
        ],
        "properties": {
          "settings": {
            "$ref": "#/components/schemas/Settings"
          },
          "restart_required": {
            "type": "boolean",
            "description": "A change that takes effect only at the next start is waiting for one"
          }
        }
      },
      "Network": {
        "type": "object",
        "required": [
          "hostname",
          "sta",
          "known",
          "ap"
        ],
        "properties": {
          "hostname": {
            "type": "string"
          },
          "sta": {
            "type": "object",
            "required": [
              "enabled",
              "connected"
            ],
            "properties": {
              "enabled": {
                "type": "boolean"
              },
              "connected": {
                "type": "boolean"
              },
              "ssid": {
                "type": "string"
              },
              "ip": {
                "type": "string",
                "format": "ipv4"
              },
              "rssi": {
                "type": "integer",
                "description": "dBm"
              },
              "channel": {
                "type": "integer"
              },
              "ipv6": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "known": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "ssid",
                "last"
              ],
              "properties": {
                "ssid": {
                  "type": "string"
                },
                "last": {
                  "type": "boolean",
                  "description": "The one joined last, which the board rejoins at start"
                }
              }
            }
          },
          "ap": {
            "type": "object",
            "required": [
              "on",
              "ssid"
            ],
            "properties": {
              "on": {
                "type": "boolean"
              },
              "ssid": {
                "type": "string",
                "description": "The name it has when on"
              },
              "ip": {
                "type": "string",
                "format": "ipv4"
              },
              "channel": {
                "type": "integer"
              },
              "clients": {
                "type": "integer"
              },
              "off_in_s": {
                "type": "integer",
                "minimum": 1,
                "description": "On for a while (it came on because the board couldn't join a network at start, or from the BOOT button): seconds until it turns itself off. Absent when it stays on"
              }
            }
          }
        }
      },
      "NetworkPatch": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "sta_enabled": {
            "type": "boolean",
            "description": "Whether the board joins networks at all. Off, a board reached over the network is lost until it is turned back on from the console or the access point."
          }
        }
      },
      "ScanResult": {
        "type": "object",
        "required": [
          "networks"
        ],
        "properties": {
          "networks": {
            "type": "array",
            "description": "Strongest first; one entry an SSID",
            "items": {
              "type": "object",
              "required": [
                "ssid",
                "rssi",
                "channel",
                "auth",
                "known"
              ],
              "properties": {
                "ssid": {
                  "type": "string"
                },
                "rssi": {
                  "type": "integer",
                  "description": "dBm"
                },
                "channel": {
                  "type": "integer"
                },
                "auth": {
                  "type": "string",
                  "enum": [
                    "open",
                    "wep",
                    "wpa",
                    "wpa2",
                    "wpa3",
                    "enterprise",
                    "other"
                  ]
                },
                "known": {
                  "type": "boolean"
                }
              }
            }
          }
        }
      },
      "KnownNetwork": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "passphrase": {
            "type": "string",
            "minLength": 8,
            "maxLength": 63,
            "description": "Leave it out for an open network"
          }
        }
      },
      "JoinRequest": {
        "type": "object",
        "required": [
          "ssid"
        ],
        "additionalProperties": false,
        "properties": {
          "ssid": {
            "type": "string",
            "minLength": 1,
            "maxLength": 32
          },
          "passphrase": {
            "type": "string",
            "maxLength": 63,
            "description": "Leave it out to use the saved one, or for an open network"
          }
        }
      },
      "ApPatch": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "on": {
            "type": "boolean",
            "description": "Now only: the access point is off at every start, unless the board can't join a network then. `true` also keeps one that is on for a while on"
          },
          "ssid": {
            "type": "string",
            "minLength": 1,
            "maxLength": 32,
            "description": "Saved"
          },
          "passphrase": {
            "type": "string",
            "minLength": 8,
            "maxLength": 63,
            "description": "Saved; never read back"
          }
        }
      },
      "WebSettings": {
        "type": "object",
        "required": [
          "hostname",
          "auth",
          "cors",
          "cors_default"
        ],
        "properties": {
          "hostname": {
            "type": "string"
          },
          "auth": {
            "type": "boolean",
            "description": "Whether a password is set"
          },
          "cors": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The sites whose pages may call the API: `https://host`, or `https://*.host` for any subdomain"
          },
          "cors_default": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "WebPatch": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "hostname": {
            "oneOf": [
              {
                "type": "string",
                "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,30}[A-Za-z0-9])?$"
              },
              {
                "type": "null"
              }
            ],
            "description": "The `.local` name; null for the default"
          },
          "password": {
            "oneOf": [
              {
                "type": "string",
                "minLength": 4,
                "maxLength": 64
              },
              {
                "type": "null"
              }
            ],
            "description": "Null to clear it. Never read back."
          },
          "cors": {
            "oneOf": [
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              {
                "type": "null"
              }
            ],
            "description": "The whole list; `[]` for none, null for the default"
          }
        }
      },
      "ActiveScene": {
        "type": "object",
        "required": [
          "name",
          "then",
          "until_clip_ends",
          "remaining_s"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "then": {
            "type": "string",
            "enum": [
              "restore",
              "off"
            ]
          },
          "until_clip_ends": {
            "type": "boolean",
            "description": "It ends when its clip has played"
          },
          "remaining_s": {
            "type": [
              "number",
              "null"
            ],
            "description": "Until its `duration_s` is up; null without one"
          },
          "slot": {
            "type": "integer",
            "minimum": 1,
            "maximum": 255,
            "description": "Its slot, when it has one"
          }
        }
      },
      "Happening": {
        "type": "object",
        "required": [
          "event",
          "seq",
          "t_ms"
        ],
        "description": "Something that happened. `seq` numbers happenings from 1, going back to 1 after 65535, so a gap is a missed one (among the kinds a client follows, a gap means one of another kind, or a missed one: `?after=` with its `kinds` says which); the board keeps the last 32. Its other fields depend on `event` (ClipStarted, ClipEnded, SceneStarted, SceneEnded, Touch). A client ignores events and fields it doesn't know.",
        "properties": {
          "event": {
            "type": "string",
            "examples": [
              "clip_started",
              "clip_ended",
              "scene_started",
              "scene_ended",
              "touch"
            ]
          },
          "seq": {
            "type": "integer",
            "minimum": 1,
            "maximum": 65535
          },
          "t_ms": {
            "type": "integer",
            "description": "The board's uptime when it happened, in milliseconds"
          }
        }
      },
      "ClipEnded": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Happening"
          },
          {
            "type": "object",
            "required": [
              "path",
              "finished"
            ],
            "properties": {
              "event": {
                "const": "clip_ended"
              },
              "path": {
                "type": "string",
                "description": "From the root of the volume"
              },
              "finished": {
                "type": "boolean",
                "description": "It played to its end; false when something replaced or stopped it"
              }
            }
          }
        ],
        "description": "A clip or animation stopped"
      },
      "SceneEnded": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Happening"
          },
          {
            "type": "object",
            "required": [
              "name",
              "then",
              "by"
            ],
            "properties": {
              "event": {
                "const": "scene_ended"
              },
              "name": {
                "type": "string"
              },
              "then": {
                "type": "string",
                "enum": [
                  "restore",
                  "off"
                ],
                "description": "What it does at its end"
              },
              "by": {
                "type": "string",
                "enum": [
                  "clip",
                  "time",
                  "end",
                  "replaced"
                ],
                "description": "`clip`: its clip played; `time`: its `duration_s` was up; `end`: `POST /scenes/end`; `replaced`: another scene or clip took over, and its `then` was not done"
              },
              "slot": {
                "type": "integer",
                "minimum": 1,
                "maximum": 255,
                "description": "The scene's slot, when it has one"
              }
            }
          }
        ],
        "description": "A scene running to its end (one with a `then`) ended. It comes before what its `then` does"
      },
      "Touch": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Happening"
          },
          {
            "type": "object",
            "required": [
              "action",
              "x",
              "y"
            ],
            "properties": {
              "event": {
                "const": "touch"
              },
              "action": {
                "type": "string",
                "enum": [
                  "down",
                  "up"
                ]
              },
              "x": {
                "type": "integer",
                "minimum": 0,
                "maximum": 239
              },
              "y": {
                "type": "integer",
                "minimum": 0,
                "maximum": 239
              }
            }
          }
        ],
        "description": "The screen was touched, or let go; only while touch reporting is on (`touch on`)"
      },
      "StateEvent": {
        "type": "object",
        "required": [
          "event",
          "state"
        ],
        "description": "A resource changed: `state` is what its GET returns now. `screen`: ScreenState; `leds`: LedState; `holo`: HoloState, four times a second at most while it moves; `scene`: the running scene (SceneList.active), or null; `scenes`: the saved scenes (SceneList.scenes); `settings`: Settings; `network`: Network; `ota`: the update session (Ota.session), each second at most while an image arrives; `system`: {uptime_s, heap_free, rssi}, every 15 s. While a clip plays or a scene runs to its end, `screen` and `scene` are also sent every 5 s (CONFIG_WEBUI_EVENTS_SYNC_S), for clients counting frames or time on by themselves to sync to. Changes close together are sent as one, the latest; states are not kept, so a client that reconnects reads them again.",
        "properties": {
          "event": {
            "type": "string",
            "enum": [
              "screen",
              "leds",
              "holo",
              "scene",
              "scenes",
              "settings",
              "network",
              "ota",
              "system"
            ]
          },
          "state": {
            "description": "What the resource's GET returns"
          }
        }
      },
      "EventHistory": {
        "type": "object",
        "required": [
          "seq",
          "lost",
          "events"
        ],
        "properties": {
          "seq": {
            "type": "integer",
            "minimum": 0,
            "maximum": 65535,
            "description": "The latest happening's number; 0 for none yet"
          },
          "lost": {
            "type": "integer",
            "minimum": 0,
            "description": "Happenings after `after` no longer kept"
          },
          "events": {
            "type": "array",
            "maxItems": 32,
            "items": {
              "oneOf": [
                {
                  "$ref": "#/components/schemas/ClipStarted"
                },
                {
                  "$ref": "#/components/schemas/ClipEnded"
                },
                {
                  "$ref": "#/components/schemas/SceneStarted"
                },
                {
                  "$ref": "#/components/schemas/SceneEnded"
                },
                {
                  "$ref": "#/components/schemas/Touch"
                }
              ]
            }
          }
        }
      },
      "LinkEventsSetting": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "kinds": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "clip_ended",
                "scene_ended",
                "touch",
                "clip_started",
                "scene_started",
                "screen",
                "leds",
                "holo",
                "scene",
                "scenes",
                "settings",
                "network",
                "ota",
                "system"
              ]
            },
            "uniqueItems": true,
            "description": "The events the link reports: happenings, and resources whose changes are pushed (UART, with `push`). `[]` for none"
          },
          "push": {
            "type": "boolean",
            "description": "Send them as they happen (UART only), or keep them for the host to fetch"
          }
        }
      },
      "LinkSettings": {
        "type": "object",
        "required": [
          "mode",
          "protocol",
          "pins",
          "baud",
          "address",
          "groups",
          "reply_delay_ms",
          "attn",
          "i2c_address",
          "general_call",
          "events"
        ],
        "description": "The link's saved settings. `mode`, `pins`, `attn`, `i2c_address` and `general_call` take effect at the next restart; the rest at once.",
        "properties": {
          "mode": {
            "type": "string",
            "enum": [
              "off",
              "uart",
              "rs485",
              "i2c"
            ],
            "description": "The transport. `i2c` is described ahead of the firmware, which refuses it for now"
          },
          "protocol": {
            "type": "string",
            "enum": [
              "auto",
              "json",
              "native"
            ],
            "description": "What UART and RS485 accept: JSON lines, native frames, or either (`auto`, told apart by the first byte). I2C is always `native`"
          },
          "pins": {
            "type": "object",
            "required": [
              "a",
              "b",
              "c"
            ],
            "additionalProperties": false,
            "description": "The GPIOs for the three roles: A is TX or SDA, B is RX or SCL, C is ATTN, DE or IRQ",
            "properties": {
              "a": {
                "type": "integer",
                "minimum": 0,
                "maximum": 48
              },
              "b": {
                "type": "integer",
                "minimum": 0,
                "maximum": 48
              },
              "c": {
                "type": [
                  "integer",
                  "null"
                ],
                "minimum": 0,
                "maximum": 48,
                "description": "null leaves role C unused"
              }
            }
          },
          "baud": {
            "type": "integer",
            "minimum": 1200,
            "maximum": 921600,
            "description": "UART and RS485; 8N1, no flow control"
          },
          "address": {
            "type": "integer",
            "minimum": 1,
            "maximum": 223,
            "description": "The board's address on UART and RS485"
          },
          "groups": {
            "type": "array",
            "items": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31
            },
            "maxItems": 8,
            "uniqueItems": true,
            "description": "The groups it acts for, on UART and RS485"
          },
          "reply_delay_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 50,
            "description": "RS485: how long it waits before answering, for the host's transceiver to stop sending"
          },
          "attn": {
            "type": "boolean",
            "description": "UART: role C is an ATTN output, low while events are waiting"
          },
          "i2c_address": {
            "type": "integer",
            "minimum": 8,
            "maximum": 119,
            "description": "I2C: the board's 7-bit address"
          },
          "general_call": {
            "type": "boolean",
            "description": "I2C: act on commands written to address 0"
          },
          "events": {
            "$ref": "#/components/schemas/LinkEventsSetting",
            "description": "What the link reports from the start, until the host asks for something else"
          }
        }
      },
      "LinkPatch": {
        "type": "object",
        "description": "Only what changes. The link's saved settings. `mode`, `pins`, `attn`, `i2c_address` and `general_call` take effect at the next restart; the rest at once.",
        "properties": {
          "mode": {
            "type": "string",
            "enum": [
              "off",
              "uart",
              "rs485",
              "i2c"
            ],
            "description": "The transport. `i2c` is described ahead of the firmware, which refuses it for now"
          },
          "protocol": {
            "type": "string",
            "enum": [
              "auto",
              "json",
              "native"
            ],
            "description": "What UART and RS485 accept: JSON lines, native frames, or either (`auto`, told apart by the first byte). I2C is always `native`"
          },
          "pins": {
            "type": "object",
            "additionalProperties": false,
            "description": "The GPIOs for the three roles: A is TX or SDA, B is RX or SCL, C is ATTN, DE or IRQ",
            "properties": {
              "a": {
                "type": "integer",
                "minimum": 0,
                "maximum": 48
              },
              "b": {
                "type": "integer",
                "minimum": 0,
                "maximum": 48
              },
              "c": {
                "type": [
                  "integer",
                  "null"
                ],
                "minimum": 0,
                "maximum": 48,
                "description": "null leaves role C unused"
              }
            }
          },
          "baud": {
            "type": "integer",
            "minimum": 1200,
            "maximum": 921600,
            "description": "UART and RS485; 8N1, no flow control"
          },
          "address": {
            "type": "integer",
            "minimum": 1,
            "maximum": 223,
            "description": "The board's address on UART and RS485"
          },
          "groups": {
            "type": "array",
            "items": {
              "type": "integer",
              "minimum": 1,
              "maximum": 31
            },
            "maxItems": 8,
            "uniqueItems": true,
            "description": "The groups it acts for, on UART and RS485"
          },
          "reply_delay_ms": {
            "type": "integer",
            "minimum": 0,
            "maximum": 50,
            "description": "RS485: how long it waits before answering, for the host's transceiver to stop sending"
          },
          "attn": {
            "type": "boolean",
            "description": "UART: role C is an ATTN output, low while events are waiting"
          },
          "i2c_address": {
            "type": "integer",
            "minimum": 8,
            "maximum": 119,
            "description": "I2C: the board's 7-bit address"
          },
          "general_call": {
            "type": "boolean",
            "description": "I2C: act on commands written to address 0"
          },
          "events": {
            "$ref": "#/components/schemas/LinkEventsSetting",
            "description": "What the link reports from the start, until the host asks for something else"
          }
        },
        "minProperties": 1,
        "additionalProperties": false
      },
      "LinkCounters": {
        "type": "object",
        "required": [
          "requests",
          "events",
          "bad_lines",
          "crc_errors",
          "framing_errors",
          "overruns",
          "ignored"
        ],
        "properties": {
          "requests": {
            "type": "integer",
            "description": "Requests answered"
          },
          "events": {
            "type": "integer",
            "description": "Events given to the host"
          },
          "bad_lines": {
            "type": "integer",
            "description": "JSON lines that didn't parse, or were too long"
          },
          "crc_errors": {
            "type": "integer",
            "description": "Native frames dropped for a bad CRC"
          },
          "framing_errors": {
            "type": "integer",
            "description": "Bytes received damaged: a wrong baud rate, or noise"
          },
          "overruns": {
            "type": "integer",
            "description": "Bytes lost because the host sent faster than the board read"
          },
          "ignored": {
            "type": "integer",
            "description": "RS485 lines with no address"
          }
        }
      },
      "LinkEventState": {
        "type": "object",
        "required": [
          "kinds",
          "push",
          "seq",
          "pending"
        ],
        "properties": {
          "kinds": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "clip_ended",
                "scene_ended",
                "touch",
                "clip_started",
                "scene_started",
                "screen",
                "leds",
                "holo",
                "scene",
                "scenes",
                "settings",
                "network",
                "ota",
                "system"
              ]
            }
          },
          "push": {
            "type": "boolean"
          },
          "seq": {
            "type": "integer",
            "minimum": 0,
            "maximum": 65535,
            "description": "The latest happening's number on the board (shared with GET /events); 0 for none yet"
          },
          "pending": {
            "type": "integer",
            "minimum": 0,
            "description": "Happenings of these kinds not yet given to the host"
          }
        }
      },
      "Link": {
        "type": "object",
        "required": [
          "protocol_version",
          "settings",
          "running",
          "restart_required",
          "counters",
          "last_request_s",
          "events"
        ],
        "properties": {
          "protocol_version": {
            "type": "integer",
            "description": "The host link protocol's version"
          },
          "settings": {
            "$ref": "#/components/schemas/LinkSettings"
          },
          "running": {
            "type": "object",
            "required": [
              "mode",
              "protocol"
            ],
            "description": "What is running now, which differs from `settings` until a restart",
            "properties": {
              "mode": {
                "type": "string",
                "enum": [
                  "off",
                  "uart",
                  "rs485",
                  "i2c"
                ]
              },
              "protocol": {
                "type": "string",
                "enum": [
                  "auto",
                  "json",
                  "native"
                ]
              },
              "pins": {
                "type": "object",
                "properties": {
                  "a": {
                    "type": "integer"
                  },
                  "b": {
                    "type": "integer"
                  },
                  "c": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  }
                }
              }
            }
          },
          "restart_required": {
            "type": "boolean",
            "description": "A saved change waits for a restart"
          },
          "counters": {
            "$ref": "#/components/schemas/LinkCounters"
          },
          "last_request_s": {
            "type": [
              "number",
              "null"
            ],
            "description": "Seconds since the last request over the link; null for none since the start"
          },
          "events": {
            "$ref": "#/components/schemas/LinkEventState"
          }
        }
      },
      "LinkEventsPatch": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "kinds": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "clip_ended",
                "scene_ended",
                "touch",
                "clip_started",
                "scene_started",
                "screen",
                "leds",
                "holo",
                "scene",
                "scenes",
                "settings",
                "network",
                "ota",
                "system"
              ]
            },
            "uniqueItems": true,
            "description": "The events the link reports: happenings, and resources whose changes are pushed (UART, with `push`). `[]` for none"
          },
          "push": {
            "type": "boolean",
            "description": "Send them as they happen (UART only), or keep them for the host to fetch"
          }
        },
        "minProperties": 1
      },
      "ClipStarted": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Happening"
          },
          {
            "type": "object",
            "required": [
              "path",
              "loop",
              "plays",
              "frames",
              "fps"
            ],
            "properties": {
              "event": {
                "const": "clip_started"
              },
              "path": {
                "type": "string",
                "description": "From the root of the volume"
              },
              "loop": {
                "type": "boolean",
                "description": "It plays forever"
              },
              "plays": {
                "type": "integer",
                "minimum": 0,
                "description": "Times through in all; 0 forever"
              },
              "frames": {
                "type": "integer",
                "description": "In one pass, from its header"
              },
              "fps": {
                "type": "number"
              }
            }
          }
        ],
        "description": "A clip or animation started, whoever started it: the API, the console, the host link, or a scene (whose clip starts just before its `scene_started`)"
      },
      "SceneStarted": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Happening"
          },
          {
            "type": "object",
            "required": [
              "name",
              "then",
              "until_clip_ends"
            ],
            "properties": {
              "event": {
                "const": "scene_started"
              },
              "name": {
                "type": "string"
              },
              "slot": {
                "type": "integer",
                "minimum": 1,
                "maximum": 255,
                "description": "Its slot, when it has one"
              },
              "then": {
                "type": "string",
                "enum": [
                  "stay",
                  "restore",
                  "off"
                ],
                "description": "What it does at its end. Only a scene other than `stay` is watched to its end, and has a `scene_ended`"
              },
              "until_clip_ends": {
                "type": "boolean",
                "description": "It ends when its clip has played"
              },
              "duration_s": {
                "type": "number",
                "description": "It ends after this long, when it has one"
              }
            }
          }
        ],
        "description": "A scene was applied, saved or given whole"
      }
    }
  }
}
