openapi: 3.0.3
info:
  title: Zephyr Wind Monitoring API
  version: 1.0.0
  description: |
    REST API for the Zephyr ESP32 wind monitoring network.

    ## Quick start

    1. **Register** your device via `POST /register` — you'll get back an API key.
    2. **Authenticate** ingest requests by passing that key in the `X-API-Key` HTTP header.
    3. **Send heartbeats** (`POST /heartbeat`) so the dashboard knows your device is alive.
    4. **Send wind readings** (`POST /wind`) with speed, gust, and direction.
    5. **Query** data from the read-only GET endpoints (no key needed).

    ## Authentication

    Ingest endpoints (`/heartbeat` and `/wind`) require the `X-API-Key` header.
    Click the **Authorize 🔒** button above and paste your key to try them here.

    ```
    X-API-Key: <your-64-char-hex-key>
    ```

servers:
  - url: /api/v1
    description: This server

tags:
  - name: Devices
    description: Register devices and list them
  - name: Ingest
    description: Push data from your ESP32 (requires `X-API-Key` header)
  - name: Query
    description: Read wind & heartbeat data (no auth required)

paths:
  # ── Devices ────────────────────────────────────────────
  /register:
    post:
      tags: [Devices]
      summary: Register a new device
      description: |
        Creates a new device and returns an API key.  
        If the device ID already exists the existing key is returned.

        **No authentication required** — this is how you *get* a key.
      operationId: registerDevice
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, name]
              properties:
                id:
                  type: string
                  description: Unique device identifier you choose (e.g. slug or serial number)
                  example: bondi-01
                name:
                  type: string
                  description: Human-readable label for the device
                  example: Bondi Beach
      responses:
        '200':
          description: Device registered (or already existed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        example: bondi-01
                      name:
                        type: string
                        example: Bondi Beach
                      api_key:
                        type: string
                        description: |
                          64-character hex key. **Save this** — it is only shown once.  
                          Pass it as `X-API-Key` header on ingest requests.
                        example: 483f4961d10736233ba04b8c0d2083d1c2200e87197ac2e3831d52b95e8b4fd1
                      message:
                        type: string
                        example: Device registered successfully
        '400':
          description: Missing `id` or `name`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /devices:
    get:
      tags: [Devices]
      summary: List all devices
      description: |
        Returns every registered device with its live status.  
        Status is computed from `last_seen`: **online** (<5 min), **stale** (<1 hr), **offline** (>1 hr).

        API keys are **never** included in this response.
      operationId: listDevices
      responses:
        '200':
          description: Device list
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Device'

  # ── Ingest ─────────────────────────────────────────────
  /heartbeat:
    post:
      tags: [Ingest]
      summary: Submit a heartbeat
      description: |
        Tell the server your device is alive and report its current state.  
        Updates `last_seen` and sets status to **online**.
        
        Include `lat`/`lng` to report the device's GPS position — the latest
        heartbeat location is used as the device's location on the map.

        **Requires `X-API-Key` header.**
      operationId: submitHeartbeat
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id]
              properties:
                device_id:
                  type: string
                  description: Must match the device that owns the API key
                  example: bondi-01
                timestamp:
                  type: string
                  format: date-time
                  description: Optional client-provided timestamp (RFC 3339). Defaults to server time.
                  example: '2026-05-24T15:13:42Z'
                battery_pct:
                  type: number
                  format: double
                  description: Battery level 0–100
                  example: 87.5
                rssi:
                  type: integer
                  description: WiFi RSSI in dBm (negative number)
                  example: -62
                uptime_secs:
                  type: integer
                  description: Seconds since last reboot
                  example: 3600
                lat:
                  type: number
                  format: double
                  description: Current GPS latitude
                  example: -33.8908
                lng:
                  type: number
                  format: double
                  description: Current GPS longitude
                  example: 151.2743
                tx_bytes:
                  type: integer
                  description: Bytes transmitted since last heartbeat
                  example: 4096
                rx_bytes:
                  type: integer
                  description: Bytes received since last heartbeat
                  example: 8192
      responses:
        '200':
          description: Heartbeat saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  data:
                    type: object
                    properties:
                      message:
                        type: string
                        example: Heartbeat recorded
        '401':
          description: Missing or invalid `X-API-Key`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: "`device_id` doesn't match the API key's device"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /wind:
    post:
      tags: [Ingest]
      summary: Submit a wind reading
      description: |
        Record a wind measurement.

        **Requires `X-API-Key` header.**

        Wind direction uses meteorological convention: the direction the wind
        is blowing **from** in degrees (0°=N, 90°=E, 180°=S, 270°=W).
      operationId: submitWindReading
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id, wind_speed_kts, wind_dir_deg]
              properties:
                device_id:
                  type: string
                  description: Must match the device that owns the API key
                  example: bondi-01
                timestamp:
                  type: string
                  format: date-time
                  description: Optional client-provided timestamp (RFC 3339). Defaults to server time.
                  example: '2026-05-24T15:13:42Z'
                wind_speed_kts:
                  type: number
                  format: double
                  description: Sustained wind speed in **knots**
                  example: 12.4
                wind_gust_kts:
                  type: number
                  format: double
                  description: Peak gust speed in **knots** (optional)
                  example: 18.1
                wind_dir_deg:
                  type: number
                  format: double
                  description: |
                    Direction wind is coming **from**, in degrees.  
                    0 = North, 90 = East, 180 = South, 270 = West.
                  example: 45.0
                lat:
                  type: number
                  format: double
                  description: Latitude of this reading (overrides device default)
                  example: -33.8908
                lng:
                  type: number
                  format: double
                  description: Longitude of this reading (overrides device default)
                  example: 151.2743
                temperature_c:
                  type: number
                  format: double
                  description: Air temperature in °C
                  example: 22.5
      responses:
        '200':
          description: Wind reading saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  data:
                    type: object
                    properties:
                      message:
                        type: string
                        example: Wind reading recorded
        '401':
          description: Missing or invalid `X-API-Key`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: "`device_id` doesn't match the API key's device"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ── Query ──────────────────────────────────────────────
  /devices/{id}/wind:
    get:
      tags: [Query]
      summary: Get wind readings for a device
      description: |
        Returns wind readings within a time range.  
        Defaults to the **last 24 hours** if `start`/`end` are omitted.
      operationId: getDeviceWind
      parameters:
        - name: id
          in: path
          required: true
          description: Device ID
          schema:
            type: string
          example: bondi-01
        - name: start
          in: query
          required: false
          description: |
            Start of time range.  
            Accepts **RFC 3339** (`2026-05-24T00:00:00Z`) or **Unix epoch seconds** (`1716508800`).
          schema:
            type: string
          example: '1716508800'
        - name: end
          in: query
          required: false
          description: |
            End of time range.  
            Same formats as `start`. Defaults to now.
          schema:
            type: string
          example: '1716595200'
      responses:
        '200':
          description: Array of wind readings, ordered oldest → newest
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WindReading'

  /devices/{id}/heartbeats:
    get:
      tags: [Query]
      summary: Get heartbeats for a device
      description: |
        Returns heartbeats for a device.

        **Time-range mode** (preferred): pass `start` and/or `end` to get heartbeats
        within a window, returned oldest → newest.

        **Limit mode** (fallback): omit `start`/`end` and pass `limit` to get the
        N most recent heartbeats, returned newest first.
      operationId: getDeviceHeartbeats
      parameters:
        - name: id
          in: path
          required: true
          description: Device ID
          schema:
            type: string
          example: bondi-01
        - name: start
          in: query
          required: false
          description: |
            Start of time range.
            Accepts **RFC 3339** or **Unix epoch seconds**.
          schema:
            type: string
          example: '1716508800'
        - name: end
          in: query
          required: false
          description: |
            End of time range.
            Same formats as `start`. Defaults to now.
          schema:
            type: string
          example: '1716595200'
        - name: limit
          in: query
          required: false
          description: Max heartbeats to return (only used when `start`/`end` are omitted)
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 1000
      responses:
        '200':
          description: Array of heartbeats, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Heartbeat'

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        64-character hex key returned by `POST /register`.  
        Required for `/heartbeat` and `/wind` endpoints.

        ```
        curl -H "X-API-Key: 483f4961d107..." ...
        ```

  schemas:
    Error:
      type: object
      description: Error response envelope
      required: [ok, error]
      properties:
        ok:
          type: boolean
          example: false
        error:
          type: string
          example: Missing required fields

    Device:
      type: object
      description: A registered wind monitoring device (API key omitted)
      properties:
        id:
          type: string
          description: Device identifier
          example: bondi-01
        name:
          type: string
          description: Human-readable name
          example: Bondi Beach
        lat:
          type: number
          format: double
          nullable: true
          description: Latitude
          example: -33.8908
        lng:
          type: number
          format: double
          nullable: true
          description: Longitude
          example: 151.2743
        created_at:
          type: string
          format: date-time
          description: When the device was registered
          example: '2026-05-24T10:30:00Z'
        last_seen:
          type: string
          format: date-time
          nullable: true
          description: Last time the server heard from this device
          example: '2026-05-24T15:13:42Z'
        status:
          type: string
          enum: [online, stale, offline, unknown]
          description: |
            Computed from `last_seen`:  
            • **online** — heard from within 5 minutes  
            • **stale** — within 1 hour  
            • **offline** — over 1 hour  
            • **unknown** — never heard from
          example: online

    WindReading:
      type: object
      description: A single wind measurement
      properties:
        id:
          type: integer
          example: 42
        device_id:
          type: string
          example: bondi-01
        timestamp:
          type: string
          format: date-time
          description: Server-assigned timestamp (UTC)
          example: '2026-05-24T15:13:42Z'
        wind_speed_kts:
          type: number
          format: double
          description: Sustained wind speed in knots
          example: 12.4
        wind_gust_kts:
          type: number
          format: double
          nullable: true
          description: Gust speed in knots
          example: 18.1
        wind_dir_deg:
          type: number
          format: double
          description: Direction wind is coming from (0°=N, 90°=E, 180°=S, 270°=W)
          example: 45.0
        lat:
          type: number
          format: double
          nullable: true
          example: -33.8908
        lng:
          type: number
          format: double
          nullable: true
          example: 151.2743
        temperature_c:
          type: number
          format: double
          nullable: true
          description: Air temperature in °C
          example: 22.5

    Heartbeat:
      type: object
      description: A device health check
      properties:
        id:
          type: integer
          example: 7
        device_id:
          type: string
          example: bondi-01
        timestamp:
          type: string
          format: date-time
          description: Server-assigned timestamp (UTC)
          example: '2026-05-24T15:13:42Z'
        battery_pct:
          type: number
          format: double
          nullable: true
          description: Battery level 0–100
          example: 87.5
        rssi:
          type: integer
          nullable: true
          description: WiFi signal in dBm
          example: -62
        uptime_secs:
          type: integer
          nullable: true
          description: Seconds since last reboot
          example: 3600
        lat:
          type: number
          format: double
          nullable: true
          description: GPS latitude
          example: -33.8908
        lng:
          type: number
          format: double
          nullable: true
          description: GPS longitude
          example: 151.2743
        tx_bytes:
          type: integer
          nullable: true
          description: Bytes transmitted since last heartbeat
          example: 4096
        rx_bytes:
          type: integer
          nullable: true
          description: Bytes received since last heartbeat
          example: 8192
