{
  "openapi": "3.0.3",
  "info": {
    "title": "OWTS device HTTPS API",
    "version": "1.0.0",
    "description": "REST API exposed by **Open Winch Telemetry System (OWTS)** firmware on the ESP32-S3 node (Heltec WiFi LoRa 32 V3 and compatible builds).\n\n## Roles: aircraft vs winch\n\nThe same firmware image runs in one of two **device modes** (`aircraft` or `winch`), stored in NVS. Mode determines which radio path is active (TX vs RX) and **which JSON routes are registered** on the HTTP server.\n\n| Concern | Aircraft mode | Winch mode |\n|--------|----------------|------------|\n| `/api/aircraft` | **Available** (GET/PUT/PATCH) | **Not registered** — do not call; the wildcard static handler may return HTML |\n| `/api/aircraft/live` | **Available** — current IAS snapshot + ETag | **Not registered** |\n| `/api/winch` | Returns **404** with JSON `winch_mode_only` | **Available** — last decoded telemetry + caching headers |\n| Other `/api/*` routes below | Same in both modes | Same |\n\nChanging mode with `PUT /api/mode` persists NVS and **reboots** the device when the mode actually changes.\n\n## Build variants\n\nIf the firmware is built **without** SX1262 support (`CONFIG_OWTS_RADIO_USE_SX1262` off), **`/api/radio` is not registered** — treat like unimplemented (avoid relying on a JSON error).\n\n## Transport, discovery, and security\n\n- **HTTPS**: the UI and REST API are served on **port 443** using a **self-signed** certificate.\n- **HTTP**: port 80 exists only for UI bootstrapping/redirects; `/api/*` is not served on plain HTTP.\n- **Base URL**: typically `https://192.168.4.1` when using the onboard SoftAP, or the STA IPv4 address when connected as a client.\n- **mDNS**: `_http._tcp` (80) and `_https._tcp` (443) are advertised.\n- **Charset**: request and response bodies are **UTF-8** JSON unless noted.\n- **Auth model**:\n  - `GET` endpoints are public.\n  - **Mutating** endpoints (`PUT`, `PATCH`, `POST`, `DELETE`) require `Authorization: Bearer <password>` **only when** an API password is configured.\n  - When no password is configured (default), mutating endpoints are open.\n\n## JSON parsing\n\nObject keys are matched **case-insensitively** where noted in handler code (e.g. `mode`, `wifi_enabled`).\n\n## Static UI\n\n`GET /` and other non-`/api/*` paths serve the web UI from SPIFFS (SPA fallback to `index.html`).\n\n## Roadmap (not in this spec)\n\nFuture releases may add sensor catalog routes, winch station metadata (`PUT`/`PATCH` `/api/winch`), dedicated `/api/winch/current`, OTA upload, and additional WiFi `PATCH` operations — see project `implememtation_plan.md`."
  },
  "servers": [
    {
      "url": "https://192.168.4.1",
      "description": "Default SoftAP address (typical factory / field setup)"
    },
    {
      "url": "https://owts.local",
      "description": "Example mDNS hostname when advertised (actual name includes mode + ESP id)"
    }
  ],
  "tags": [
    {
      "name": "Device",
      "description": "Identity and build metadata (`/api/info`)."
    },
    {
      "name": "Mode",
      "description": "Read or change aircraft vs winch role (`/api/mode`). Changing mode may reboot the node."
    },
    {
      "name": "Security",
      "description": "API password management and validation (`/api/auth/*`)."
    },
    {
      "name": "Aircraft",
      "description": "On-probe configuration and live IAS (`/api/aircraft/live`). **Handlers exist only in aircraft mode.**"
    },
    {
      "name": "Winch telemetry",
      "description": "Winch-side view of last decoded aircraft telemetry. **Meaningful only in winch mode**; aircraft mode returns 404 JSON."
    },
    {
      "name": "Winch LED",
      "description": "WS2812 LED strip settings (winch mode)."
    },
    {
      "name": "Radio",
      "description": "SX1262 RF parameters persisted in NVS. **Only when built with `CONFIG_OWTS_RADIO_USE_SX1262`.**"
    },
    {
      "name": "Network",
      "description": "WiFi SoftAP / station settings and runtime hints."
    },
    {
      "name": "System",
      "description": "Reboot and NVS factory reset."
    }
  ],
  "paths": {
    "/api/info": {
      "get": {
        "tags": ["Device"],
        "summary": "Device identity and firmware version",
        "description": "Returns a stable device id (`serial`), active mode, firmware version, and **in aircraft mode only** the persisted registration/type/units mirrored from NVS (convenience for UI).\n\nWinch mode omits `registration`, `type`, and `units` from the payload.",
        "operationId": "getApiInfo",
        "responses": {
          "200": {
            "description": "Device information",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/DeviceInfoAircraft" },
                    { "$ref": "#/components/schemas/DeviceInfoWinch" }
                  ]
                },
                "examples": {
                  "aircraft": {
                    "summary": "Aircraft node",
                    "value": {
                      "serial": "a1b2c3d4e5f6",
                      "mode": "aircraft",
                      "fw_version": "1.0.0",
                      "registration": "D-1234",
                      "type": "ASK21",
                      "units": { "speed": "kmh", "height": "m" }
                    }
                  },
                  "winch": {
                    "summary": "Winch node",
                    "value": {
                      "serial": "f6e5d4c3b2a1",
                      "mode": "winch",
                      "fw_version": "1.0.0"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/mode": {
      "get": {
        "tags": ["Mode"],
        "summary": "Current device mode",
        "operationId": "getApiMode",
        "responses": {
          "200": {
            "description": "Current mode from NVS",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ModePayload" },
                "example": { "mode": "aircraft" }
              }
            }
          }
        }
      },
      "put": {
        "tags": ["Mode"],
        "summary": "Set device mode (persist + optional reboot)",
        "description": "Writes `aircraft` or `winch` to NVS. If the mode **changes**, the response includes `\"restarting\": true` and a background task reboots the device shortly after the response is sent (connection will drop).\n\nIf the mode is unchanged, `restarting` is false.",
        "operationId": "putApiMode",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ModePutRequest" },
              "example": { "mode": "winch" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Mode accepted (check `restarting`)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ModePutResponse" },
                "examples": {
                  "changed": {
                    "summary": "Mode changed — reboot follows",
                    "value": { "mode": "winch", "restarting": true }
                  },
                  "unchanged": {
                    "summary": "Already in requested mode",
                    "value": { "mode": "aircraft", "restarting": false }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid JSON, missing/invalid `mode` string",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          },
          "500": {
            "description": "NVS persist or AP SSID sync failure",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          }
        }
      }
    },
    "/api/auth/status": {
      "get": {
        "tags": ["Security"],
        "summary": "Auth status (is API password configured?)",
        "description": "Public endpoint. Returns whether a password hash is configured in NVS. If `configured` is false, mutating endpoints do not require `Authorization`.",
        "operationId": "getAuthStatus",
        "responses": {
          "200": {
            "description": "Auth status",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AuthStatusResponse" },
                "example": { "ok": true, "configured": false }
              }
            }
          }
        }
      }
    },
    "/api/auth/check": {
      "get": {
        "tags": ["Security"],
        "summary": "Validate Authorization header",
        "description": "When a password is configured, this endpoint returns 200 only if the request carries a valid `Authorization: Bearer <password>` header. When no password is configured, it returns 200 without requiring a header.",
        "operationId": "getAuthCheck",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Token is valid (or auth is disabled)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AuthCheckResponse" },
                "example": { "ok": true }
              }
            }
          },
          "401": {
            "description": "Missing/invalid Bearer header (when configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          }
        }
      }
    },
    "/api/auth/token": {
      "put": {
        "tags": ["Security"],
        "summary": "Set or rotate API password",
        "description": "Sets the API password (stored as SHA-256 hash in NVS).\n\nBootstrap behavior: if no password is configured yet, this call does **not** require `Authorization`. If a password is already configured, this call requires a valid `Authorization: Bearer <current-password>` header.",
        "operationId": "putAuthToken",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AuthTokenPutRequest" },
              "example": { "token": "example-password" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Password stored",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AuthTokenPutResponse" },
                "example": { "ok": true, "configured": true }
              }
            }
          },
          "400": {
            "description": "Invalid JSON or token length (1..128 required)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" } }
            }
          },
          "401": {
            "description": "Unauthorized (when already configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "500": {
            "description": "Hash or persist failure",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" } }
            }
          }
        }
      },
      "delete": {
        "tags": ["Security"],
        "summary": "Clear API password (disable auth)",
        "description": "Clears the stored password hash (returns to unsecured mode). Requires a valid Bearer header when currently configured.",
        "operationId": "deleteAuthToken",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Password cleared",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AuthTokenDeleteResponse" },
                "example": { "ok": true, "configured": false }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          }
        }
      }
    },
    "/api/aircraft": {
      "get": {
        "tags": ["Aircraft"],
        "summary": "Read aircraft profile and radio policy",
        "description": "**Only registered when NVS mode is `aircraft`.** In winch mode the URI is not handled by this API (see top-level description).",
        "operationId": "getApiAircraft",
        "x-owts-modes": ["aircraft"],
        "responses": {
          "200": {
            "description": "Full aircraft configuration",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AircraftConfigGet" },
                "example": {
                  "registration": "D-1234",
                  "type": "Discus2b",
                  "v_min": 55,
                  "v_opt": 60,
                  "v_max": 75,
                  "v_en": 55,
                  "t_send": 120,
                  "landing_hold_s": 120,
                  "t_send_min": 1,
                  "t_send_max": 86400,
                  "landing_hold_s_min": 1,
                  "landing_hold_s_max": 3600,
                  "units": { "speed": "kts", "height": "ft" }
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": ["Aircraft"],
        "summary": "Replace aircraft configuration",
        "description": "Full replace: **all** top-level fields and `units.speed` / `units.height` are required.\n\nSpeeds `v_min`, `v_opt`, `v_max`, `v_en` are unsigned integers in the **current** `units.speed` (km/h or knots). Firmware validates:\n- each in **50 … 200** (inclusive) in that unit\n- ordering: `v_min <= v_opt <= v_max`\n- `v_en` within the same bounds\n- `t_send`: **1 … 86400** seconds (telemetry burst duration after crossing `v_en`)\n- `landing_hold_s`: **1 … 3600** seconds (IAS must stay below `v_en` before next launch is allowed)\n- `registration`: up to 8 chars, letters/digits/`-`, normalized to uppercase\n- `type`: up to 8 characters\n\nIf `registration` changes, AP SSID may be recomputed and the device sets `\"restarting\": true` and reboots.",
        "operationId": "putApiAircraft",
        "x-owts-modes": ["aircraft"],
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AircraftConfig" },
              "example": {
                "registration": "D-ABCD",
                "type": "LS4",
                "v_min": 90,
                "v_opt": 100,
                "v_max": 120,
                "v_en": 90,
                "t_send": 90,
                "landing_hold_s": 120,
                "units": { "speed": "kmh", "height": "m" }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored configuration (see `restarting`)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AircraftWriteResponse" }
              }
            }
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "400": {
            "description": "Validation failure, missing field, or bad JSON",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          },
          "500": {
            "description": "AP SSID sync failed after registration change",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          }
        }
      },
      "patch": {
        "tags": ["Aircraft"],
        "summary": "Partial update of aircraft configuration",
        "description": "Any subset of fields may be sent.\n\nIf the request results in **no change** to the stored config, the device returns **204 No Content**.\n\nIf `units.speed` changes without new `v_*` values, existing speeds are **converted** (km/h ↔ knots with integer rounding). Then explicit `v_*` in the same request override.\n\n`units.height` affects display/telemetry metadata only (not speed math).\n\nRegistration change triggers the same reboot behavior as `PUT`.",
        "operationId": "patchApiAircraft",
        "x-owts-modes": ["aircraft"],
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AircraftPatchRequest" },
              "example": {
                "v_opt": 62,
                "t_send": 180,
                "landing_hold_s": 180
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated configuration",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AircraftWriteResponse" }
              }
            }
          },
          "204": {
            "description": "No changes (request was a no-op)."
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "400": {
            "description": "No changes, validation error, or invalid JSON",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          },
          "500": {
            "$ref": "#/paths/~1api~1aircraft/put/responses/500"
          }
        }
      }
    },
    "/api/aircraft/live": {
      "get": {
        "tags": ["Aircraft"],
        "summary": "Current indicated airspeed (aircraft runtime snapshot)",
        "description": "**Only registered when NVS mode is `aircraft`.** Returns the latest IAS and flight/landing state from the in-memory aircraft state machine (same values as the OLED), without reading NVS on each poll beyond the state's periodic config refresh.\n\nIncludes `ETag: W/\"<ias>-<speed_unit>-<valid>-<flight_phase>[-<landing_hold_elapsed_s>]\"` and `Cache-Control: private, max-age=0, must-revalidate`. During `landing_hold`, elapsed whole seconds are appended so polling clients see the counter advance even when IAS stays at 0. Send `If-None-Match` with the previous ETag to receive **304 Not Modified** when unchanged (efficient ~2 Hz polling).\n\n`is_landed` matches the OLED **LANDED** indication (`boot_landed` or `landed_confirmed`). `flight_phase` is `landed`, `airborne`, or `landing_hold` (post-flight timer below `v_en` before the next launch may be armed).\n\n`ias` is an integer in the configured `speed_unit` (`kmh` or `kts`), matching display/telemetry rounding. When `ias_valid` is false, `ias` is omitted. `landing_hold_elapsed_s` and `landing_hold_s` are present only during `landing_hold`.",
        "operationId": "getApiAircraftLive",
        "x-owts-modes": ["aircraft"],
        "parameters": [
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": { "type": "string" },
            "example": "W/\"72-kmh-1-airborne\"",
            "description": "Must match the latest `ETag` exactly to elicit 304."
          }
        ],
        "responses": {
          "200": {
            "description": "Live IAS snapshot",
            "headers": {
              "ETag": {
                "description": "Weak ETag tied to rounded IAS, speed unit, validity, and flight_phase",
                "schema": { "type": "string", "example": "W/\"72-kmh-1-airborne\"" }
              },
              "Cache-Control": {
                "schema": { "type": "string", "example": "private, max-age=0, must-revalidate" }
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AircraftLiveSnapshot" },
                "examples": {
                  "airborne": {
                    "summary": "In flight",
                    "value": {
                      "ok": true,
                      "ias_valid": true,
                      "ias": 72,
                      "is_landed": false,
                      "flight_phase": "airborne",
                      "speed_unit": "kmh"
                    }
                  },
                  "landed": {
                    "summary": "Landed (OLED LANDED)",
                    "value": {
                      "ok": true,
                      "ias_valid": true,
                      "ias": 12,
                      "is_landed": true,
                      "flight_phase": "landed",
                      "speed_unit": "kmh"
                    }
                  },
                  "landingHold": {
                    "summary": "Landing hold countdown",
                    "value": {
                      "ok": true,
                      "ias_valid": true,
                      "ias": 28,
                      "is_landed": false,
                      "flight_phase": "landing_hold",
                      "speed_unit": "kmh",
                      "landing_hold_elapsed_s": 18,
                      "landing_hold_s": 120
                    }
                  },
                  "invalid": {
                    "summary": "No valid IAS sample yet",
                    "value": {
                      "ok": true,
                      "ias_valid": false,
                      "is_landed": true,
                      "flight_phase": "landed",
                      "speed_unit": "kmh"
                    }
                  }
                }
              }
            }
          },
          "304": {
            "description": "Rounded IAS and validity unchanged since client's ETag",
            "headers": {
              "ETag": { "schema": { "type": "string" } },
              "Cache-Control": { "schema": { "type": "string" } }
            }
          }
        }
      }
    },
    "/api/aircraft/sim": {
      "get": {
        "tags": ["Aircraft"],
        "summary": "IAS simulator status (volatile)",
        "description": "**Only registered when NVS mode is `aircraft`.** Returns whether the volatile IAS simulator is currently enabled.",
        "operationId": "getApiAircraftSim",
        "x-owts-modes": ["aircraft"],
        "responses": {
          "200": {
            "description": "Simulator status",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AircraftSimStatus" },
                "examples": {
                  "enabled": { "value": { "enabled": true } },
                  "disabled": { "value": { "enabled": false } }
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": ["Aircraft"],
        "summary": "Enable IAS simulator (volatile)",
        "description": "**Only registered when NVS mode is `aircraft`.** Enables a volatile IAS simulator (not persisted in NVS). After reboot, simulation is disabled again.\n\nRequest body is intentionally empty; any payload is ignored.",
        "operationId": "putApiAircraftSim",
        "x-owts-modes": ["aircraft"],
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Simulator enabled",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AircraftSimStatus" },
                "example": { "enabled": true }
              }
            }
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "500": {
            "description": "Failed to enable simulator",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          }
        }
      },
      "patch": {
        "tags": ["Aircraft"],
        "summary": "Enable IAS simulator (volatile)",
        "description": "Alias of `PUT /api/aircraft/sim` (empty body).",
        "operationId": "patchApiAircraftSim",
        "x-owts-modes": ["aircraft"],
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "$ref": "#/paths/~1api~1aircraft~1sim/put/responses/200" },
          "401": { "$ref": "#/paths/~1api~1aircraft~1sim/put/responses/401" },
          "500": { "$ref": "#/paths/~1api~1aircraft~1sim/put/responses/500" }
        }
      },
      "delete": {
        "tags": ["Aircraft"],
        "summary": "Disable IAS simulator (volatile)",
        "description": "**Only registered when NVS mode is `aircraft`.** Disables the volatile IAS simulator. Request body is intentionally empty; any payload is ignored.",
        "operationId": "deleteApiAircraftSim",
        "x-owts-modes": ["aircraft"],
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Simulator disabled",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/AircraftSimStatus" },
                "example": { "enabled": false }
              }
            }
          },
          "401": { "$ref": "#/paths/~1api~1aircraft~1sim/put/responses/401" },
          "500": {
            "description": "Failed to disable simulator",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          }
        }
      }
    },
    "/api/winch": {
      "get": {
        "tags": ["Winch telemetry"],
        "summary": "Last decoded aircraft telemetry (winch RX snapshot)",
        "description": "- **Winch mode**: returns JSON plus `ETag: W/\"<revision>\"` and `Cache-Control: private, max-age=0, must-revalidate`. Send `If-None-Match` with the previous ETag to receive **304 Not Modified** when no new RF packet has been decoded (efficient ~10 Hz polling).\n- **Aircraft mode**: **404** with JSON body `winch_mode_only` (route is always registered so clients get a clear error).\n\nIf **no RF packet** is decoded for **10 seconds** (default `OWTS_WINCH_RX_IDLE_RESET_MS`), the next snapshot clears cached telemetry (`have_telemetry` false, fields omitted) and **increments `revision`** so the ETag changes and clients are not stuck on 304.\n\nWhen `have_telemetry` is false, only `ok`, `revision`, and `have_telemetry` are meaningful; telemetry fields are omitted.",
        "operationId": "getApiWinch",
        "parameters": [
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": { "type": "string" },
            "example": "W/\"42\"",
            "description": "Winch mode only. Must match the latest `ETag` exactly to elicit 304."
          }
        ],
        "responses": {
          "200": {
            "description": "Snapshot (winch mode)",
            "headers": {
              "ETag": {
                "description": "Weak ETag tied to monotonic `revision`",
                "schema": { "type": "string", "example": "W/\"12345\"" }
              },
              "Cache-Control": {
                "schema": { "type": "string", "example": "private, max-age=0, must-revalidate" }
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WinchSnapshot" },
                "examples": {
                  "withTelemetry": {
                    "summary": "Receiving traffic",
                    "value": {
                      "ok": true,
                      "revision": 1842,
                      "have_telemetry": true,
                      "receive_hz": 20,
                      "registration": "D-1234",
                      "aircraft_type": "ASK21",
                      "v_min": 90,
                      "v_opt": 100,
                      "v_max": 120,
                      "v_current": 98,
                      "speed_unit": "kmh"
                    }
                  },
                  "idle": {
                    "summary": "No packet decoded yet",
                    "value": {
                      "ok": true,
                      "revision": 0,
                      "have_telemetry": false
                    }
                  }
                }
              }
            }
          },
          "304": {
            "description": "Winch mode: telemetry revision unchanged since client's ETag",
            "headers": {
              "ETag": {
                "schema": { "type": "string" }
              },
              "Cache-Control": {
                "schema": { "type": "string" }
              }
            }
          },
          "404": {
            "description": "Aircraft mode — endpoint not applicable",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WinchWrongModeError" },
                "example": { "ok": false, "error": "winch_mode_only" }
              }
            }
          }
        }
      }
    },
    "/api/winch/led": {
      "get": {
        "tags": ["Winch LED"],
        "summary": "Winch WS2812 LED strip settings",
        "description": "Returns the persisted WS2812 LED strip settings (winch mode).\n\nNote: Unlike most GET endpoints, this handler enforces bearer auth when an API password is configured.\n\nIf the firmware is built without WS2812 support (`CONFIG_OWTS_WINCH_USE_WS2812` off), `enabled` is false.",
        "operationId": "getApiWinchLed",
        "x-owts-modes": ["winch"],
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Current LED strip settings",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WinchLedConfigGet" }
              }
            }
          },
          "401": { "$ref": "#/paths/~1api~1radio/patch/responses/401" },
          "404": {
            "description": "Aircraft mode — endpoint not applicable",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WinchWrongModeError" },
                "example": { "ok": false, "error": "winch_mode_only" }
              }
            }
          }
        }
      },
      "patch": {
        "tags": ["Winch LED"],
        "summary": "Update winch LED strip settings (persists + reboots)",
        "description": "Persists WS2812 settings to NVS and then schedules a device reboot.\n\nBody may include any subset of: `count`, `brightness`, `dim_below_factor`, `reversed`, `color_order`.\n\nOn success, returns the same JSON shape as GET.",
        "operationId": "patchApiWinchLed",
        "x-owts-modes": ["winch"],
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WinchLedPatchBody" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated settings (reboot scheduled)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WinchLedConfigGet" }
              }
            }
          },
          "400": {
            "description": "Invalid request",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" } }
            }
          },
          "401": { "$ref": "#/paths/~1api~1radio/patch/responses/401" },
          "404": { "$ref": "#/paths/~1api~1winch/get/responses/404" },
          "409": {
            "description": "WS2812 support disabled in this firmware build",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WinchLedDisabledError" },
                "example": { "ok": false, "error": "ws2812_disabled" }
              }
            }
          },
          "500": {
            "description": "Internal error",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" } }
            }
          }
        }
      }
    },
    "/api/log": {
      "get": {
        "tags": ["Device"],
        "summary": "Recent device logs (ring buffer)",
        "description": "Returns recent log lines captured by the firmware (in-RAM ring buffer). Only log lines that pass ESP-IDF's runtime log filtering are captured.\n\nResponse includes `ETag: W/\"<revision>\"` and `Cache-Control: private, max-age=0, must-revalidate`. Send `If-None-Match` with the previous ETag to receive **304 Not Modified** when no new lines have been stored.\n\nUse `after` + `seq` to incrementally fetch only newer lines (frontend can keep a larger cache than the device).",
        "operationId": "getApiLog",
        "parameters": [
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": { "type": "string" },
            "example": "W/\"123\"",
            "description": "Must match the latest `ETag` exactly to elicit 304."
          },
          {
            "name": "after",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 0 },
            "example": 275,
            "description": "Only return items with `seq` > after."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 1, "maximum": 100 },
            "example": 20,
            "description": "Maximum number of items to return (capped by firmware ring buffer size)."
          }
        ],
        "responses": {
          "200": {
            "description": "Log snapshot",
            "headers": {
              "ETag": {
                "description": "Weak ETag tied to monotonic `revision`",
                "schema": { "type": "string", "example": "W/\"123\"" }
              },
              "Cache-Control": {
                "schema": { "type": "string", "example": "private, max-age=0, must-revalidate" }
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/LogSnapshot" }
              }
            }
          },
          "304": {
            "description": "Revision unchanged since client's ETag",
            "headers": {
              "ETag": { "schema": { "type": "string" } },
              "Cache-Control": { "schema": { "type": "string" } }
            }
          },
          "500": {
            "description": "Internal error (e.g. out of memory)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/LogError" } }
            }
          }
        }
      }
    },
    "/api/radio": {
      "get": {
        "tags": ["Radio"],
        "summary": "Radio RF settings and regulatory hints",
        "description": "**Available only if firmware is built with `CONFIG_OWTS_RADIO_USE_SX1262`.** Otherwise the path is not registered.\n\nResponse includes the active `frequency_hz`, `tx_power_dbm`, NVS-selected `srd_profile_id`, `duty_cycle_lab_override`, rolling-window airtime fields (`duty_airtime_window_s`, `duty_airtime_est_ppm`, `duty_cycle_max_ppm_profile`), hardware tuning limits, `allowed_srd_profiles`, `phy_nominal_occupied_bandwidth_hz`, `regulatory`, and `forbidden_frequency_ranges`.\n\nNotes:\n- `frequency_hz_step` is the **user-facing** step used by the UI/API (GFSK RX BW or LoRa channel BW, depending on `modem_backend`).\n- `regulatory.tx_power_dbm_max` is a **hint** (max across table ERP caps); the active profile’s `tx_power_max_dbm` and hardware limits are enforced on write.\n- `duty_airtime_est_ppm`: on-air duty in ppm for the last `duty_airtime_window_s` (per transmission from modem TX start through TX_DONE, summed in **1 s buckets**; **RAM only** since boot). In **aircraft** mode the firmware compares this to `duty_cycle_max_ppm_profile` unless `duty_cycle_lab_override` is true.\n- `PUT`/`PATCH` reject any `frequency_hz` that falls inside an inclusive `[frequency_hz_min, frequency_hz_max]` entry in `forbidden_frequency_ranges`.",
        "operationId": "getApiRadio",
        "x-owts-build-flag": "CONFIG_OWTS_RADIO_USE_SX1262",
        "responses": {
          "200": {
            "description": "Current RF configuration",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RadioConfigGet" },
                "example": {
                  "frequency_hz": 869000000,
                  "tx_power_dbm": 14,
                  "srd_profile_id": 6,
                  "modem_backend": "lora",
                  "supports_test_profiles": false,
                  "frequency_hz_min": 430000000,
                  "frequency_hz_max": 960000000,
                  "frequency_hz_step": 100000,
                  "tx_power_dbm_min": -9,
                  "tx_power_dbm_max": 22,
                  "tx_power_dbm_step": 1,
                  "regulatory": {
                    "region": "EU-DE",
                    "frequency_hz_min": 863000000,
                    "frequency_hz_max": 870000000,
                    "tx_power_dbm_max": 27,
                    "note": "Harmonised EU SRD 863-870 MHz (ERC REC 70-03); sub-band, duty-cycle, and occupied bandwidth limits per ETSI EN 300 220 and national law. Not legal advice."
                  },
                  "forbidden_frequency_ranges": [
                    {
                      "name": "FLARM",
                      "frequency_hz_min": 868000000,
                      "frequency_hz_max": 868500000
                    }
                  ],
                  "allowed_srd_profiles": [
                    {
                      "id": "DE_VFG91_54",
                      "srd_profile_id": 6,
                      "vfg_band_nr": 54,
                      "frequency_hz_min": 869400000,
                      "frequency_hz_max": 869650000,
                      "min_occupied_bandwidth_hz": 0,
                      "max_occupied_bandwidth_hz": 0,
                      "duty_cycle_max_ppm": 100000,
                      "tx_power_max_dbm": 27,
                      "modem_compat": ["lora_125k", "gfsk_232k"]
                    }
                  ],
                  "phy_nominal_occupied_bandwidth_hz": 125000,
                  "duty_cycle_lab_override": false,
                  "duty_airtime_window_s": 3600,
                  "duty_airtime_est_ppm": 0,
                  "duty_cycle_max_ppm_profile": 100000
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": ["Radio"],
        "summary": "Replace RF settings",
        "description": "Body must contain **exactly** four properties: `frequency_hz` (uint32 Hz), `tx_power_dbm` (int8 dBm), `srd_profile_id` (uint8 index into `GET`’s `allowed_srd_profiles`), and `duty_cycle_lab_override` (boolean). No other keys allowed.\n\nValues are validated against hardware limits, the selected SRD profile (frequency segment, nominal power cap, modem/PHY compatibility), EU-DE band compiled into firmware, and **must not** lie inside any band listed in `GET`’s `forbidden_frequency_ranges` (inclusive min/max).\n\nOn success, returns the same JSON shape as `GET /api/radio`.\n\nIf the aircraft is in a TX burst and the driver refuses apply, response is **409** with JSON `aircraft_tx_burst_active_try_again`.",
        "operationId": "putApiRadio",
        "x-owts-build-flag": "CONFIG_OWTS_RADIO_USE_SX1262",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/RadioPutBody" },
              "example": {
                "frequency_hz": 869000000,
                "tx_power_dbm": 14,
                "srd_profile_id": 6,
                "duty_cycle_lab_override": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Applied and persisted",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RadioConfigGet" }
              }
            }
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "400": {
            "description": "Malformed JSON, wrong keys, out-of-range values",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          },
          "409": {
            "description": "Aircraft TX active — retry shortly",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RadioConflictError" },
                "example": {
                  "ok": false,
                  "error": "aircraft_tx_burst_active_try_again"
                }
              }
            }
          },
          "500": {
            "description": "Driver apply or NVS save failed",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          }
        }
      },
      "patch": {
        "tags": ["Radio"],
        "summary": "Partial RF update",
        "description": "Same key whitelist as PUT: only `frequency_hz`, `tx_power_dbm`, `srd_profile_id`, and/or `duty_cycle_lab_override`. Empty object or missing all keys → **400**.\n\nUnmentioned fields keep their current NVS values. Response matches `GET`.",
        "operationId": "patchApiRadio",
        "x-owts-build-flag": "CONFIG_OWTS_RADIO_USE_SX1262",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/RadioPatchBody" },
              "example": { "tx_power_dbm": 10 }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/paths/~1api~1radio/put/responses/200"
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "400": {
            "$ref": "#/paths/~1api~1radio/put/responses/400"
          },
          "409": {
            "$ref": "#/paths/~1api~1radio/put/responses/409"
          },
          "500": {
            "$ref": "#/paths/~1api~1radio/put/responses/500"
          }
        }
      }
    },
    "/api/reboot": {
      "post": {
        "tags": ["System"],
        "summary": "Soft reboot",
        "description": "Schedules reboot shortly after responding; TCP connection will drop.",
        "operationId": "postApiReboot",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Reboot scheduled",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/RebootResponse" },
                "example": { "status": "rebooting" }
              }
            }
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          }
        }
      }
    },
    "/api/storage": {
      "delete": {
        "tags": ["System"],
        "summary": "Factory reset (NVS) and reboot",
        "description": "Clears configuration in NVS. Optional JSON body `{ \"confirm\": false }` aborts with 400. Empty body or `confirm: true` proceeds.\n\nOn success, responds then reboots.",
        "operationId": "deleteApiStorage",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "confirm": {
                    "type": "boolean",
                    "description": "If present and false, request is rejected."
                  }
                }
              },
              "examples": {
                "explicit": {
                  "value": { "confirm": true }
                },
                "omit": {
                  "summary": "Empty body also accepted",
                  "value": {}
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reset done; reboot follows",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/StorageResetResponse" },
                "example": {
                  "status": "ok",
                  "action": "storage_reset",
                  "restarting": true
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "400": {
            "description": "JSON error or confirm false",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          },
          "500": {
            "description": "NVS reset failed",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          }
        }
      }
    },
    "/api/network/wifi": {
      "get": {
        "tags": ["Network"],
        "summary": "WiFi feature flags and stored credentials",
        "description": "Returns persisted settings including **plaintext** AP password and station password as stored in NVS — treat as sensitive.\n\n`auto_off_s`: positive enables timed WiFi shutdown; non-positive disables.",
        "operationId": "getApiNetworkWifi",
        "responses": {
          "200": {
            "description": "WiFi settings snapshot",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WifiRootSettings" },
                "example": {
                  "wifi_enabled": true,
                  "station_mode": true,
                  "ap_mode": true,
                  "auto_off_s": 180,
                  "country": "DE",
                  "station_ssid": "Clubhouse",
                  "ap_ssid": "OWTS-aircraft-a1b2c3",
                  "ap_password": "owts12345"
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": ["Network"],
        "summary": "Update WiFi settings (partial)",
        "description": "Despite using PUT, behavior is **merge/patch**: only keys present are applied (`wifi_enabled`, `station_mode`, `ap_mode`, `country`, `auto_off_s`).\n\nIf anything changes, WiFi stack is restarted.",
        "operationId": "putApiNetworkWifi",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WifiRootPatch" },
              "example": {
                "wifi_enabled": true,
                "station_mode": true,
                "ap_mode": true,
                "auto_off_s": 300,
                "country": "DE"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated settings",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WifiRootSettings" }
              }
            }
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "400": {
            "description": "Invalid JSON",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          },
          "500": {
            "description": "Persist failed",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HttpErrorTextBody" }
              }
            }
          }
        }
      }
    },
    "/api/network/wifi/ap": {
      "get": {
        "tags": ["Network"],
        "summary": "SoftAP parameters",
        "description": "`channel` and `max_clients` are **informational placeholders** in current firmware (fixed values in JSON).",
        "operationId": "getApiNetworkWifiAp",
        "responses": {
          "200": {
            "description": "AP configuration",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WifiApStatus" },
                "example": {
                  "ssid": "OWTS-winch-f6e5d4",
                  "password_set": true,
                  "channel": 1,
                  "max_clients": 4
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": ["Network"],
        "summary": "Set SoftAP SSID and password",
        "description": "Partial merge: only supplied string fields update NVS.\n\nIf SSID or password changes, the response includes `\"restarting\": true` and a background task reboots the device shortly after the response is sent (connection will drop). If no change occurred, `restarting` is omitted.",
        "operationId": "putApiNetworkWifiAp",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WifiApPut" },
              "example": {
                "ssid": "OWTS-Field-AP",
                "password": "club-secret-wifi"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated AP view",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WifiApStatus" }
              }
            }
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "400": {
            "$ref": "#/paths/~1api~1network~1wifi/put/responses/400"
          },
          "500": {
            "$ref": "#/paths/~1api~1network~1wifi/put/responses/500"
          }
        }
      }
    },
    "/api/network/wifi/station": {
      "get": {
        "tags": ["Network"],
        "summary": "Station (client) config and link state",
        "operationId": "getApiNetworkWifiStation",
        "responses": {
          "200": {
            "description": "STA settings + DHCP address when connected",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WifiStationStatus" },
                "examples": {
                  "connected": {
                    "value": {
                      "ssid": "Clubhouse",
                      "configured": true,
                      "connected": true,
                      "ip": "192.168.1.142"
                    }
                  },
                  "disconnected": {
                    "value": {
                      "ssid": "",
                      "configured": false,
                      "connected": false
                    }
                  }
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": ["Network"],
        "summary": "Set station SSID and password",
        "description": "Partial merge.\n\nIf SSID or password changes, the response includes `\"restarting\": true` and a background task reboots the device shortly after the response is sent (connection will drop). If no change occurred, `restarting` is omitted.",
        "operationId": "putApiNetworkWifiStation",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WifiStationPut" },
              "example": {
                "ssid": "Clubhouse",
                "password": "hanger-door-2026"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated STA view",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WifiStationStatus" }
              }
            }
          },
          "401": {
            "description": "Unauthorized (when password configured)",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/AuthError" } }
            }
          },
          "400": {
            "$ref": "#/paths/~1api~1network~1wifi/put/responses/400"
          },
          "500": {
            "$ref": "#/paths/~1api~1network~1wifi/put/responses/500"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Send `Authorization: Bearer <password>`. Required for mutating endpoints only when an API password is configured."
      }
    },
    "schemas": {
      "AuthError": {
        "type": "object",
        "required": ["ok", "error"],
        "properties": {
          "ok": { "type": "boolean", "enum": [false] },
          "error": { "type": "string", "example": "unauthorized" }
        }
      },
      "AuthStatusResponse": {
        "type": "object",
        "required": ["ok", "configured"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "configured": { "type": "boolean" }
        }
      },
      "AuthCheckResponse": {
        "type": "object",
        "required": ["ok"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] }
        }
      },
      "AuthTokenPutRequest": {
        "type": "object",
        "required": ["token"],
        "properties": {
          "token": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128,
            "description": "API password (stored as SHA-256 hash)."
          }
        }
      },
      "AuthTokenPutResponse": {
        "type": "object",
        "required": ["ok", "configured"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "configured": { "type": "boolean", "enum": [true] }
        }
      },
      "AuthTokenDeleteResponse": {
        "type": "object",
        "required": ["ok", "configured"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "configured": { "type": "boolean", "enum": [false] }
        }
      },
      "ModePayload": {
        "type": "object",
        "required": ["mode"],
        "properties": {
          "mode": {
            "type": "string",
            "enum": ["aircraft", "winch"],
            "description": "Active firmware role."
          }
        }
      },
      "ModePutRequest": {
        "allOf": [{ "$ref": "#/components/schemas/ModePayload" }]
      },
      "ModePutResponse": {
        "type": "object",
        "required": ["mode", "restarting"],
        "properties": {
          "mode": { "type": "string", "enum": ["aircraft", "winch"] },
          "restarting": {
            "type": "boolean",
            "description": "True if mode changed and a reboot was scheduled."
          }
        }
      },
      "DeviceInfoAircraft": {
        "type": "object",
        "required": ["serial", "mode", "fw_version", "registration", "type", "units"],
        "properties": {
          "serial": {
            "type": "string",
            "description": "Lowercase hex device id (no separators), from MAC.",
            "example": "a1b2c3d4e5f6"
          },
          "mode": { "type": "string", "enum": ["aircraft"] },
          "fw_version": { "type": "string" },
          "registration": { "type": "string" },
          "type": { "type": "string" },
          "units": { "$ref": "#/components/schemas/AircraftUnits" }
        }
      },
      "DeviceInfoWinch": {
        "type": "object",
        "required": ["serial", "mode", "fw_version"],
        "properties": {
          "serial": { "type": "string" },
          "mode": { "type": "string", "enum": ["winch"] },
          "fw_version": { "type": "string" }
        }
      },
      "AircraftUnits": {
        "type": "object",
        "required": ["speed", "height"],
        "properties": {
          "speed": {
            "type": "string",
            "enum": ["kmh", "kts"],
            "description": "Unit for all v_* speeds in aircraft config."
          },
          "height": {
            "type": "string",
            "enum": ["m", "ft"],
            "description": "Display/telemetry height unit (does not affect v_* validation range)."
          }
        }
      },
      "AircraftConfig": {
        "type": "object",
        "required": [
          "registration",
          "type",
          "v_min",
          "v_opt",
          "v_max",
          "v_en",
          "t_send",
          "landing_hold_s",
          "units"
        ],
        "properties": {
          "registration": { "type": "string", "maxLength": 8 },
          "type": { "type": "string", "maxLength": 8 },
          "v_min": { "type": "integer", "minimum": 0, "description": "Band low (50–200 in speed unit)." },
          "v_opt": { "type": "integer", "minimum": 0 },
          "v_max": { "type": "integer", "minimum": 0 },
          "v_en": {
            "type": "integer",
            "minimum": 0,
            "description": "Indicated airspeed at/above which RF telemetry burst starts."
          },
          "t_send": {
            "type": "integer",
            "minimum": 1,
            "maximum": 86400,
            "description": "Seconds to keep transmitting after crossing v_en."
          },
          "landing_hold_s": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3600,
            "description": "Seconds IAS must stay below v_en before the next launch can be armed."
          },
          "units": { "$ref": "#/components/schemas/AircraftUnits" }
        }
      },
      "AircraftConfigGet": {
        "allOf": [
          { "$ref": "#/components/schemas/AircraftConfig" },
          {
            "type": "object",
            "required": [
              "t_send_min",
              "t_send_max",
              "landing_hold_s_min",
              "landing_hold_s_max"
            ],
            "properties": {
              "t_send_min": {
                "type": "integer",
                "description": "Minimum allowed value for t_send (seconds)."
              },
              "t_send_max": {
                "type": "integer",
                "description": "Maximum allowed value for t_send (seconds)."
              },
              "landing_hold_s_min": {
                "type": "integer",
                "description": "Minimum allowed value for landing_hold_s (seconds)."
              },
              "landing_hold_s_max": {
                "type": "integer",
                "description": "Maximum allowed value for landing_hold_s (seconds)."
              }
            }
          }
        ]
      },
      "AircraftPatchRequest": {
        "type": "object",
        "description": "All properties optional; at least one must alter stored config.",
        "properties": {
          "registration": { "type": "string" },
          "type": { "type": "string" },
          "v_min": { "type": "integer" },
          "v_opt": { "type": "integer" },
          "v_max": { "type": "integer" },
          "v_en": { "type": "integer" },
          "t_send": { "type": "integer" },
          "landing_hold_s": { "type": "integer" },
          "units": {
            "type": "object",
            "properties": {
              "speed": { "type": "string", "enum": ["kmh", "kts"] },
              "height": { "type": "string", "enum": ["m", "ft"] }
            }
          }
        }
      },
      "AircraftWriteResponse": {
        "allOf": [
          { "$ref": "#/components/schemas/AircraftConfig" },
          {
            "type": "object",
            "required": ["restarting"],
            "properties": {
              "restarting": {
                "type": "boolean",
                "description": "True when registration changed (AP SSID sync + reboot)."
              }
            }
          }
        ]
      },
      "AircraftSimStatus": {
        "type": "object",
        "required": ["enabled"],
        "properties": {
          "enabled": {
            "type": "boolean",
            "description": "True if the volatile IAS simulator is currently enabled."
          }
        }
      },
      "AircraftLiveSnapshot": {
        "type": "object",
        "required": ["ok", "ias_valid", "is_landed", "flight_phase", "speed_unit"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "ias_valid": {
            "type": "boolean",
            "description": "False when no valid IAS sample is available yet."
          },
          "is_landed": {
            "type": "boolean",
            "description": "True when the OLED would show LANDED (boot or confirmed landing after landing_hold_s below v_en)."
          },
          "flight_phase": {
            "type": "string",
            "enum": ["landed", "airborne", "landing_hold"],
            "description": "Derived phase: landed (is_landed), landing_hold (post-burst timer below v_en), or airborne."
          },
          "ias": {
            "type": "integer",
            "minimum": 0,
            "description": "Rounded indicated airspeed in speed_unit. Only present when ias_valid is true."
          },
          "speed_unit": { "type": "string", "enum": ["kmh", "kts"] },
          "landing_hold_elapsed_s": {
            "type": "integer",
            "minimum": 0,
            "description": "Seconds IAS has been below v_en during the current landing hold. Only when flight_phase is landing_hold."
          },
          "landing_hold_s": {
            "type": "integer",
            "minimum": 1,
            "description": "Configured landing hold duration (seconds). Only when flight_phase is landing_hold."
          }
        }
      },
      "WinchSnapshot": {
        "type": "object",
        "required": ["ok", "revision", "have_telemetry"],
        "properties": {
          "ok": { "type": "boolean" },
          "revision": {
            "type": "integer",
            "minimum": 0,
            "description": "Increments on each successful decode; drives ETag."
          },
          "have_telemetry": { "type": "boolean" },
          "receive_hz": {
            "type": "integer",
            "description": "Estimated RX rate (0 if unknown or no telemetry)."
          },
          "registration": { "type": "string" },
          "aircraft_type": { "type": "string" },
          "v_min": { "type": "integer" },
          "v_opt": { "type": "integer" },
          "v_max": { "type": "integer" },
          "v_current": {
            "type": "integer",
            "description": "Last indicated airspeed from airborne telemetry, in speed_unit."
          },
          "rx_signal_rssi_dbm": {
            "type": "number",
            "description": "Last RX signal RSSI in dBm (winch radio quality). Only present when have_telemetry is true."
          },
          "speed_unit": { "type": "string", "enum": ["kmh", "kts"] }
        }
      },
      "WinchLedConfigGet": {
        "type": "object",
        "required": [
          "ok",
          "enabled",
          "gpio",
          "count",
          "count_min",
          "count_max",
          "brightness",
          "brightness_min",
          "brightness_max",
          "dim_below_factor",
          "dim_below_factor_min",
          "dim_below_factor_max",
          "reversed",
          "color_order",
          "supported_color_orders"
        ],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "enabled": { "type": "boolean", "description": "False if WS2812 support is disabled in this firmware build." },
          "gpio": { "type": "integer", "minimum": 0, "maximum": 48, "description": "Data GPIO used to drive the strip (build-time)." },
          "count": { "type": "integer", "minimum": 3, "maximum": 100 },
          "count_min": { "type": "integer", "enum": [3] },
          "count_max": { "type": "integer", "enum": [100] },
          "brightness": { "type": "number", "minimum": 0, "maximum": 1, "description": "Brightness scalar (0..1) for the active speed segment." },
          "brightness_min": { "type": "number", "minimum": 0, "maximum": 1 },
          "brightness_max": { "type": "number", "minimum": 0, "maximum": 1 },
          "dim_below_factor": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "Brightness scale (0..1) for LED segments mapped to speeds **below** current IAS (VU-style emphasis)."
          },
          "dim_below_factor_min": { "type": "number", "minimum": 0, "maximum": 1 },
          "dim_below_factor_max": { "type": "number", "minimum": 0, "maximum": 1 },
          "reversed": { "type": "boolean" },
          "color_order": { "type": "string", "enum": ["rgb", "grb", "brg", "rbg", "bgr", "gbr"] },
          "supported_color_orders": {
            "type": "array",
            "items": { "type": "string", "enum": ["rgb", "grb", "brg", "rbg", "bgr", "gbr"] }
          }
        }
      },
      "WinchLedPatchBody": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "count": { "type": "integer", "minimum": 3, "maximum": 100 },
          "brightness": { "type": "number", "minimum": 0, "maximum": 1 },
          "dim_below_factor": { "type": "number", "minimum": 0, "maximum": 1 },
          "reversed": { "type": "boolean" },
          "color_order": { "type": "string", "enum": ["rgb", "grb", "brg", "rbg", "bgr", "gbr"] }
        }
      },
      "WinchLedDisabledError": {
        "type": "object",
        "required": ["ok", "error"],
        "properties": {
          "ok": { "type": "boolean", "enum": [false] },
          "error": { "type": "string", "enum": ["ws2812_disabled"] }
        }
      },
      "WinchWrongModeError": {
        "type": "object",
        "required": ["ok", "error"],
        "properties": {
          "ok": { "type": "boolean", "enum": [false] },
          "error": { "type": "string", "enum": ["winch_mode_only"] }
        }
      },
      "LogLine": {
        "type": "object",
        "required": ["seq", "line"],
        "properties": {
          "seq": { "type": "integer", "minimum": 0 },
          "line": { "type": "string" }
        }
      },
      "LogSnapshot": {
        "type": "object",
        "required": ["ok", "revision", "count", "items"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "revision": {
            "type": "integer",
            "minimum": 0,
            "description": "Monotonic counter incremented when new lines are stored; drives ETag."
          },
          "count": { "type": "integer", "minimum": 0 },
          "items": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/LogLine" }
          }
        }
      },
      "LogError": {
        "type": "object",
        "required": ["ok", "error"],
        "properties": {
          "ok": { "type": "boolean", "enum": [false] },
          "error": { "type": "string", "example": "no_mem" }
        }
      },
      "SrdAllowedProfile": {
        "type": "object",
        "required": [
          "id",
          "srd_profile_id",
          "vfg_band_nr",
          "frequency_hz_min",
          "frequency_hz_max",
          "min_occupied_bandwidth_hz",
          "max_occupied_bandwidth_hz",
          "duty_cycle_max_ppm",
          "tx_power_max_dbm",
          "modem_compat"
        ],
        "properties": {
          "id": { "type": "string", "description": "Stable profile identifier (e.g. DE_VFG91_47)." },
          "srd_profile_id": { "type": "integer", "minimum": 0, "maximum": 255 },
          "vfg_band_nr": { "type": "integer" },
          "frequency_hz_min": { "type": "number" },
          "frequency_hz_max": { "type": "number" },
          "min_occupied_bandwidth_hz": { "type": "number", "description": "0 = not specified / unconstrained by table row for UI." },
          "max_occupied_bandwidth_hz": { "type": "number" },
          "duty_cycle_max_ppm": { "type": "number", "description": "Max duty cycle in parts per million (e.g. 1% = 10000)." },
          "tx_power_max_dbm": { "type": "integer", "description": "Nominal conducted cap aligned to table ERP; not full ERP." },
          "modem_compat": {
            "type": "array",
            "items": { "type": "string", "enum": ["lora_125k", "gfsk_232k"] }
          }
        }
      },
      "RadioPutBody": {
        "type": "object",
        "required": ["frequency_hz", "tx_power_dbm", "srd_profile_id", "duty_cycle_lab_override"],
        "additionalProperties": false,
        "properties": {
          "frequency_hz": { "type": "integer", "format": "int64", "example": 868000000 },
          "tx_power_dbm": { "type": "integer", "format": "int32", "minimum": -9, "maximum": 22 },
          "srd_profile_id": { "type": "integer", "minimum": 0, "maximum": 255 },
          "duty_cycle_lab_override": { "type": "boolean", "description": "When true, aircraft TX ignores SRD duty/airtime enforcement." }
        }
      },
      "RadioPatchBody": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "frequency_hz": { "type": "integer", "format": "int64" },
          "tx_power_dbm": { "type": "integer", "format": "int32" },
          "srd_profile_id": { "type": "integer", "minimum": 0, "maximum": 255 },
          "duty_cycle_lab_override": { "type": "boolean" }
        }
      },
      "RadioRegulatory": {
        "type": "object",
        "properties": {
          "region": { "type": "string" },
          "frequency_hz_min": { "type": "number" },
          "frequency_hz_max": { "type": "number" },
          "tx_power_dbm_max": { "type": "integer" },
          "note": { "type": "string" }
        }
      },
      "ForbiddenFrequencyRange": {
        "type": "object",
        "required": ["name", "frequency_hz_min", "frequency_hz_max"],
        "properties": {
          "name": { "type": "string", "description": "Stable identifier (e.g. coexistence system)." },
          "frequency_hz_min": { "type": "number" },
          "frequency_hz_max": { "type": "number" }
        }
      },
      "RadioConfigGet": {
        "type": "object",
        "required": [
          "frequency_hz",
          "tx_power_dbm",
          "srd_profile_id",
          "duty_cycle_lab_override",
          "duty_airtime_window_s",
          "duty_airtime_est_ppm",
          "duty_cycle_max_ppm_profile",
          "modem_backend",
          "supports_test_profiles",
          "frequency_hz_min",
          "frequency_hz_max",
          "frequency_hz_step",
          "tx_power_dbm_min",
          "tx_power_dbm_max",
          "tx_power_dbm_step",
          "regulatory",
          "forbidden_frequency_ranges",
          "allowed_srd_profiles",
          "phy_nominal_occupied_bandwidth_hz"
        ],
        "properties": {
          "frequency_hz": { "type": "integer", "format": "int64" },
          "tx_power_dbm": { "type": "integer" },
          "srd_profile_id": { "type": "integer", "minimum": 0, "maximum": 255 },
          "duty_cycle_lab_override": { "type": "boolean" },
          "duty_airtime_window_s": {
            "type": "number",
            "description": "Rolling duty observation window (seconds); firmware uses 3600."
          },
          "duty_airtime_est_ppm": {
            "type": "number",
            "description": "On-air duty in ppm over `duty_airtime_window_s` (per-packet interval from TX start through TX_DONE, aggregated in 1 s buckets; RAM since boot)."
          },
          "duty_cycle_max_ppm_profile": {
            "type": "number",
            "description": "Active profile duty cap (ppm); 0 means not limited by table."
          },
          "modem_backend": { "type": "string", "enum": ["gfsk", "lora"] },
          "supports_test_profiles": { "type": "boolean" },
          "frequency_hz_min": { "type": "number" },
          "frequency_hz_max": { "type": "number" },
          "frequency_hz_step": { "type": "number" },
          "tx_power_dbm_min": { "type": "integer" },
          "tx_power_dbm_max": { "type": "integer" },
          "tx_power_dbm_step": { "type": "number" },
          "regulatory": { "$ref": "#/components/schemas/RadioRegulatory" },
          "forbidden_frequency_ranges": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/ForbiddenFrequencyRange" },
            "description": "Bands rejected by firmware validation; inclusive Hz bounds."
          },
          "allowed_srd_profiles": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/SrdAllowedProfile" },
            "description": "Selectable SRD operating profiles; index matches `srd_profile_id`."
          },
          "phy_nominal_occupied_bandwidth_hz": {
            "type": "number",
            "description": "Nominal occupied bandwidth (Hz) of the compiled PHY build."
          }
        }
      },
      "RadioConflictError": {
        "type": "object",
        "required": ["ok", "error"],
        "properties": {
          "ok": { "type": "boolean", "enum": [false] },
          "error": { "type": "string", "enum": ["aircraft_tx_burst_active_try_again"] }
        }
      },
      "RebootResponse": {
        "type": "object",
        "required": ["status"],
        "properties": {
          "status": { "type": "string", "enum": ["rebooting"] }
        }
      },
      "StorageResetResponse": {
        "type": "object",
        "required": ["status", "action", "restarting"],
        "properties": {
          "status": { "type": "string", "enum": ["ok"] },
          "action": { "type": "string", "enum": ["storage_reset"] },
          "restarting": { "type": "boolean" }
        }
      },
      "WifiRootSettings": {
        "type": "object",
        "required": [
          "wifi_enabled",
          "station_mode",
          "ap_mode",
          "auto_off_s",
          "country",
          "station_ssid",
          "ap_ssid",
          "ap_password"
        ],
        "properties": {
          "wifi_enabled": { "type": "boolean" },
          "station_mode": { "type": "boolean" },
          "ap_mode": { "type": "boolean" },
          "auto_off_s": { "type": "integer" },
          "country": { "type": "string", "maxLength": 3 },
          "station_ssid": { "type": "string", "maxLength": 32 },
          "ap_ssid": { "type": "string", "maxLength": 32 },
          "ap_password": { "type": "string", "description": "Plaintext AP passphrase as stored." }
        }
      },
      "WifiRootPatch": {
        "type": "object",
        "properties": {
          "wifi_enabled": { "type": "boolean" },
          "station_mode": { "type": "boolean" },
          "ap_mode": { "type": "boolean" },
          "auto_off_s": { "type": "integer" },
          "country": { "type": "string" }
        }
      },
      "WifiApStatus": {
        "type": "object",
        "required": ["ssid", "password_set", "channel", "max_clients"],
        "properties": {
          "ssid": { "type": "string" },
          "password_set": { "type": "boolean" },
          "channel": { "type": "integer" },
          "max_clients": { "type": "integer" },
          "restarting": {
            "type": "boolean",
            "description": "Present and true when the request scheduled a reboot."
          }
        }
      },
      "WifiApPut": {
        "type": "object",
        "properties": {
          "ssid": { "type": "string" },
          "password": { "type": "string" }
        }
      },
      "WifiStationStatus": {
        "type": "object",
        "required": ["ssid", "configured", "connected"],
        "properties": {
          "ssid": { "type": "string" },
          "configured": { "type": "boolean" },
          "connected": { "type": "boolean" },
          "ip": { "type": "string", "description": "Present when STA has IPv4." },
          "restarting": {
            "type": "boolean",
            "description": "Present and true when the request scheduled a reboot."
          }
        }
      },
      "WifiStationPut": {
        "type": "object",
        "properties": {
          "ssid": { "type": "string" },
          "password": { "type": "string" }
        }
      },
      "HttpErrorTextBody": {
        "type": "string",
        "description": "ESP-IDF http_server error helper body (plain text reason)."
      }
    }
  }
}
