openapi: 3.2.0
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
info:
  title: MeshMap Lite API
  version: v1
  summary: Public HTTP and realtime contract for MeshMap Lite.
  license:
    name: MIT
  description: |
    This document is the source of truth for the public MeshMap Lite API.

    The HTTP surface is versioned under `/api/v1`. Realtime updates are emitted
    through a single broadcast-only WebSocket stream at `/api/v1/ws`.
servers:
  - url: /
security: []
tags:
  - name: Health
    description: Process health and readiness checks.
  - name: Meta
    description: UI bootstrap and runtime metadata.
  - name: Info
    description: Optional startup-loaded site information content.
  - name: Channels
    description: Configured channel metadata.
  - name: Map
    description: Map snapshot endpoints.
  - name: Chat
    description: Chat history endpoints.
  - name: Log
    description: Non-chat activity log endpoints.
  - name: Stats
    description: Aggregate packet activity statistics.
  - name: Topology
    description: Topology edge and neighbor evidence endpoints.
  - name: Nodes
    description: Node list and node details endpoints.
  - name: Realtime
    description: Broadcast-only WebSocket event stream.
paths:
  /healthz:
    get:
      tags: [Health]
      summary: Health check
      operationId: healthz
      responses:
        '200':
          description: Process is alive.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthStatus'
              example:
                status: ok
  /readyz:
    get:
      tags: [Health]
      summary: Readiness check
      operationId: readyz
      responses:
        '200':
          description: Application is ready.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthStatus'
              example:
                status: ready
        '503':
          description: Application is not ready yet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthStatus'
              example:
                status: not_ready
  /api/v1/meta:
    get:
      tags: [Meta]
      summary: Fetch UI and runtime metadata
      operationId: getMeta
      responses:
        '200':
          description: |
            Returns runtime metadata used to bootstrap the UI, including app
            identity, websocket path, chat defaults, log settings, and map defaults.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Meta'
  /api/v1/info:
    get:
      tags: [Info]
      summary: Fetch optional site information content
      operationId: getInfo
      parameters:
        - name: format
          in: query
          required: false
          description: Defaults to `html`.
          schema:
            type: string
            enum: [html, markdown]
      responses:
        '200':
          description: Cached site information content loaded at startup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Info'
        '400':
          description: Unknown format.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: invalid_format
        '404':
          description: Site information is not configured.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: info_not_configured
  /api/v1/updates:
    get:
      tags: [Updates]
      summary: Fetch cached release notes for an update-check source
      operationId: getUpdates
      parameters:
        - name: source
          in: query
          required: false
          description: |
            Source name (as configured under `update_check.sources[].name`).
            Defaults to the only/first registered source.
          schema:
            type: string
        - name: format
          in: query
          required: false
          description: Body encoding. `html` (default) is pre-rendered by goldmark; `markdown` is the raw body.
          schema:
            type: string
            enum: [html, markdown]
      responses:
        '200':
          description: Cached release list for the requested source, newest-first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Updates'
        '400':
          description: Unknown format.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: invalid_format
        '404':
          description: Update check is disabled or the named source is not registered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                disabled:
                  value:
                    error: update_check_not_configured
                unknown_source:
                  value:
                    error: update_check_source_not_found
        '503':
          description: The named source has not completed its first check yet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: update_check_not_ready
  /api/v1/channels:
    get:
      tags: [Channels]
      summary: List configured channels
      operationId: listChannels
      responses:
        '200':
          description: Configured channels as `{name, chat_enabled, is_primary}` items.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ChannelItem'
  /api/v1/map/nodes:
    get:
      tags: [Map]
      summary: Fetch the map snapshot
      operationId: listMapNodes
      responses:
        '200':
          description: |
            Returns `{node, position?, telemetry?}` items for nodes whose latest
            position is within `meta.relevance.map_position_max_age`.
            Telemetry older than `meta.relevance.telemetry_max_age` is omitted.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MapNode'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/chat/messages:
    get:
      tags: [Chat]
      summary: List chat history
      operationId: listChatMessages
      parameters:
        - name: channel
          in: query
          required: false
          description: Defaults to `meta.default_chat_channel`.
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Defaults to `meta.show_recent_messages`.
          schema:
            type: integer
            minimum: 1
        - name: before
          in: query
          required: false
          description: Pagination cursor. Returns rows with IDs older than this chat row ID.
          schema:
            type: integer
            minimum: 1
      responses:
        '200':
          description: |
            Chat history ordered newest-first. `node_display_name` is resolved as
            `long_name -> short_name -> node_id`. Rows older than the configured
            `web.chat.history_window` cutoff are excluded from both initial
            results and `before` pagination.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ChatEvent'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/log/events:
    get:
      tags: [Log]
      summary: List non-chat activity log rows
      operationId: listLogEvents
      parameters:
        - name: limit
          in: query
          required: false
          description: Defaults to `meta.log_page_size_default`.
          schema:
            type: integer
            minimum: 1
        - name: before
          in: query
          required: false
          description: Pagination cursor. Returns rows with IDs older than this log row ID.
          schema:
            type: integer
            minimum: 1
        - name: channel
          in: query
          required: false
          schema:
            type: string
        - name: node_id
          in: query
          required: false
          description: Exact node ID filter.
          schema:
            type: string
        - name: event_kind
          in: query
          required: false
          description: |
            Filter by event kind. The API accepts repeated `event_kind` keys or a
            comma-separated value. `event_kinds` is accepted as a compatibility alias.
          schema:
            oneOf:
              - type: integer
              - type: string
        - name: hops_min
          in: query
          required: false
          description: |
            Inclusive minimum traversed-hop filter, where traversed hops are
            `max(hop_start - hop_limit, 0)`. Rows without usable hop metadata
            and self-upload rows are excluded when any hop filter is active.
          schema:
            type: integer
            minimum: 0
        - name: hops_max
          in: query
          required: false
          description: |
            Inclusive maximum traversed-hop filter, where traversed hops are
            `max(hop_start - hop_limit, 0)`. Rows without usable hop metadata
            and self-upload rows are excluded when any hop filter is active.
          schema:
            type: integer
            minimum: 0
      responses:
        '200':
          description: |
            Log rows ordered newest-first. `details` is event-specific JSON.

            Traceroute lifecycle rows use semantic fields such as `role`, `status`,
            `request_id`, `from`, `to`, `forward_path`, `return_path`,
            `forward_snr`, `return_snr`, and `inferred_*` markers. Correlated
            traceroute lifecycle rows are emitted with `details.scope="lifecycle"`
            and may include lifecycle status values such as `partial`, `completed`,
            `failed`, or `timed_out`, plus `started_at`, `updated_at`,
            `completed_at`, `source_packets`, and `steps`.

            Unmatched traceroute replies or routing packets remain visible as raw
            packet rows. Routing rows may also include `request_id`, route arrays,
            and `traceroute_status="failed"` when a routing error packet refers to
            a traceroute request and `error_reason != "NONE"`.

            MQTT-derived traceroute paths can be partial; missing reply-side data
            must be treated as absent rather than fabricated.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/LogEvent'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/stats/activity:
    get:
      tags: [Stats]
      summary: Fetch packet activity chart buckets
      operationId: getStatsActivity
      responses:
        '200':
          description: |
            Returns two fixed all-channel activity periods for the Stats tab.
            Buckets are UTC-aligned and complete only; missing buckets are
            returned with explicit zero counts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivityStats'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/topology/edges:
    get:
      tags: [Topology]
      summary: List topology edge snapshots
      operationId: listTopologyEdges
      parameters:
        - name: node_id
          in: query
          required: false
          description: Matches edges where the node is either `from_node_id` or `to_node_id`.
          schema:
            type: string
        - name: channel
          in: query
          required: false
          schema:
            type: string
        - name: source_kind
          in: query
          required: false
          description: |
            Filter by source kind. The API accepts repeated `source_kind` keys or a
            comma-separated value. `source_kinds` is accepted as a compatibility alias.
          schema:
            type: string
      responses:
        '200':
          description: |
            Edge snapshots ordered newest-first, capped at `web.map.topology_max_edges`
            (default 2000). The response is wrapped: `items` is the edge list, and
            `truncated` is `true` when the cap was reached. Neighbor-info rows may
            include `snr`, `neighbor_last_rx_at`, and `neighbor_broadcast_interval_secs`.
          content:
            application/json:
              schema:
                type: object
                required: [items, truncated]
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/TopologyEdge'
                  truncated:
                    type: boolean
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/nodes:
    get:
      tags: [Nodes]
      summary: List nodes for the Nodes view
      operationId: listNodes
      responses:
        '200':
          description: Node list items for the Nodes view.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/NodeSummary'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/nodes/{node_id}:
    get:
      tags: [Nodes]
      summary: Fetch one node
      operationId: getNode
      parameters:
        - name: node_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: |
            Returns `{node, position?, telemetry?, neighbors?, previous_names?}`.

            `neighbors` is a collapsed per-peer topology view built from
            `neighbor_info`, `mqtt_direct`, `routing_forward`, and `routing_return`
            edges for the selected node. `traceroute_*` edges remain available from
            `/api/v1/topology/edges` but are excluded from `neighbors`. Stale
            position, telemetry, and topology evidence are omitted according to
            `meta.relevance`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeDetails'
        '404':
          description: Node not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: not_found
        '500':
          $ref: '#/components/responses/InternalError'
  /api/v1/ws:
    get:
      tags: [Realtime]
      summary: Connect to the realtime stream
      operationId: connectRealtime
      description: |
        Broadcast-only WebSocket stream for live events.

        Event envelope shape:
        - `type`: event type string
        - `ts`: server-side event timestamp in RFC3339 format
        - `payload`: event-specific JSON payload

        The stream emits `chat.message`, `chat.system`, `chat.reaction`, `node.upsert`,
        `node.position`, `node.telemetry`, `log.event`, `stats`, and `ws.heartbeat`.
      responses:
        '101':
          description: WebSocket upgrade accepted.
        '400':
          description: WebSocket upgrade failed.
      x-websocket:
        eventEnvelope:
          $ref: '#/components/schemas/RealtimeEvent'
        events:
          - type: chat.message
            payload:
              $ref: '#/components/schemas/ChatEvent'
          - type: chat.system
            payload:
              $ref: '#/components/schemas/ChatEvent'
          - type: chat.reaction
            payload:
              $ref: '#/components/schemas/ChatEvent'
          - type: node.upsert
            payload:
              $ref: '#/components/schemas/Node'
          - type: node.position
            payload:
              $ref: '#/components/schemas/NodePosition'
          - type: node.telemetry
            payload:
              $ref: '#/components/schemas/NodeTelemetry'
          - type: log.event
            payload:
              $ref: '#/components/schemas/LogEvent'
          - type: stats
            payload:
              $ref: '#/components/schemas/Stats'
          - type: ws.heartbeat
            payload:
              $ref: '#/components/schemas/Heartbeat'
components:
  responses:
    InternalError:
      description: Unexpected server-side failure.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: internal_error
  schemas:
    HealthStatus:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok, ready, not_ready]
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
    Heartbeat:
      type: object
      required: [status, mqtt_connection_status]
      properties:
        status:
          type: string
          const: ok
        mqtt_connection_status:
          type: string
          enum: [connected, disconnected]
    Meta:
      type: object
      required:
        - app_name
        - version
        - websocket_path
        - chat_enabled
        - default_chat_channel
        - show_recent_messages
        - log_live_updates
        - log_page_size_default
        - disconnected_threshold
        - info_available
        - update_check_available
        - map
        - relevance
      properties:
        app_name:
          type: string
        version:
          type: string
        websocket_path:
          type: string
          const: /api/v1/ws
        chat_enabled:
          type: boolean
        default_chat_channel:
          type: string
        show_recent_messages:
          type: integer
        log_live_updates:
          type: boolean
        log_page_size_default:
          type: integer
        disconnected_threshold:
          type: string
          description: Go duration string.
        info_available:
          type: boolean
          description: Whether `/api/v1/info` has configured content.
        info_source_hash:
          type: string
          description: SHA-256 of the Markdown source. Present when `info_available` is true.
        update_check_available:
          type: boolean
          description: Whether the multi-source update checker is enabled.
        update_check_sources:
          type: array
          description: |
            Per-source release summary (metadata only — no bodies). One entry per
            configured source. Absent when `update_check_available` is false.
          items:
            $ref: '#/components/schemas/UpdateSourceSummary'
        map:
          $ref: '#/components/schemas/MetaMap'
        relevance:
          $ref: '#/components/schemas/MetaRelevance'
    Info:
      type: object
      required: [format, source_hash, content]
      properties:
        format:
          type: string
          enum: [html, markdown]
        source_hash:
          type: string
          description: SHA-256 of the Markdown source.
        content:
          type: string
    Updates:
      type: object
      required: [format, source, source_hash, releases]
      properties:
        format:
          type: string
          enum: [html, markdown]
        source:
          type: string
          description: Echo of the resolved source name.
        source_hash:
          type: string
          description: SHA-256 of the latest release. Matches `meta.update_check_sources[].source_hash`.
        releases:
          type: array
          items:
            $ref: '#/components/schemas/UpdateRelease'
    UpdateSourceSummary:
      type: object
      required: [name, label, update_available, releases]
      properties:
        name:
          type: string
        label:
          type: string
        source_hash:
          type: string
          description: Empty when the source has not completed its first check.
        current_version:
          type: string
        latest_version:
          type: string
        update_available:
          type: boolean
        releases_page_url:
          type: string
        releases:
          type: array
          items:
            $ref: '#/components/schemas/UpdateReleaseMetadata'
    UpdateReleaseMetadata:
      type: object
      required: [version, published_at, prerelease]
      properties:
        version:
          type: string
        published_at:
          type: string
          format: date-time
        prerelease:
          type: boolean
          description: True when the upstream platform marks the release as a prerelease.
    UpdateRelease:
      type: object
      required: [version, published_at, body, prerelease]
      properties:
        version:
          type: string
        published_at:
          type: string
          format: date-time
        html_url:
          type: string
        body:
          type: string
          description: HTML when `format=html` (default), Markdown when `format=markdown`. Release Markdown may be post-processed per source before caching to link commit hashes, issue/PR references, issue/PR URLs, and username references.
        prerelease:
          type: boolean
          description: True when the upstream platform marks the release as a prerelease.
    MetaMap:
      type: object
      required:
        - clustering
        - topology_cache_ttl
        - precision_circles_mode
        - default_view
      properties:
        clustering:
          type: boolean
        topology_cache_ttl:
          type: string
          description: Go duration string.
        precision_circles_mode:
          type: string
          enum: [none, selected, always]
        default_view:
          $ref: '#/components/schemas/MetaDefaultView'
    MetaRelevance:
      type: object
      required:
        - telemetry_max_age
        - topology_evidence_max_age
        - map_position_max_age
      properties:
        telemetry_max_age:
          type: string
          description: Go duration string.
        topology_evidence_max_age:
          type: string
          description: Go duration string.
        map_position_max_age:
          type: string
          description: Go duration string.
    MetaDefaultView:
      type: object
      required: [latitude, longitude, zoom]
      properties:
        latitude:
          type: number
        longitude:
          type: number
        zoom:
          type: integer
    ChannelItem:
      type: object
      required: [name, chat_enabled, is_primary]
      properties:
        name:
          type: string
        chat_enabled:
          type: boolean
        is_primary:
          type: boolean
    Node:
      type: object
      required: [node_id, last_seen_any_event_at, first_seen_at, updated_at]
      properties:
        node_id:
          type: string
        node_num:
          type: integer
          minimum: 0
        long_name:
          type: string
        short_name:
          type: string
        role:
          type: string
        board_model:
          type: string
        firmware_version:
          type: string
        lora_region:
          type: string
        lora_frequency_desc:
          type: string
        modem_preset:
          type: string
        has_default_channel:
          type: boolean
        has_opted_report_location:
          type: boolean
        neighbor_nodes_count:
          type: integer
        mqtt_gateway_capable:
          type: boolean
        first_seen_at:
          type: string
          format: date-time
        last_seen_any_event_at:
          type: string
          format: date-time
        last_seen_mqtt_gateway_at:
          type: string
          format: date-time
        last_mqtt_uploader_node_id:
          type: string
        last_mqtt_uploader_display_name:
          type: string
          description: Resolved as `long_name -> short_name -> node_id`.
        last_mqtt_uploader_at:
          type: string
          format: date-time
        last_seen_position_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    NodePosition:
      type: object
      required:
        - node_id
        - latitude
        - longitude
        - source_kind
        - observed_at
        - updated_at
      properties:
        node_id:
          type: string
        latitude:
          type: number
        longitude:
          type: number
        altitude_m:
          type: number
        position_precision:
          type: integer
          minimum: 0
        source_kind:
          type: string
          enum: [channel_position, map_report]
        source_channel:
          type: string
        mqtt_uploader_node_id:
          type: string
        mqtt_uploader_display_name:
          type: string
          description: Resolved as `long_name -> short_name -> node_id`.
        reported_at:
          type: string
          format: date-time
        observed_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    MapNode:
      type: object
      required: [node]
      properties:
        node:
          $ref: '#/components/schemas/Node'
        position:
          $ref: '#/components/schemas/NodePosition'
        telemetry:
          $ref: '#/components/schemas/NodeTelemetry'
    ChatEvent:
      type: object
      required: [id, event_type, observed_at]
      properties:
        id:
          type: integer
        event_type:
          type: string
          enum: [message, system, reaction]
        channel_name:
          type: string
        node_id:
          type: string
        node_display_name:
          type: string
          description: Resolved as `long_name -> short_name -> node_id`.
        mqtt_uploader_node_id:
          type: string
        mqtt_uploader_display_name:
          type: string
          description: Resolved as `long_name -> short_name -> node_id`.
        system_code:
          type: string
          enum: [node_discovered]
        message_text:
          type: string
        reaction_emoji:
          type: string
          description: |
            Emoji character. Set on rows with `event_type=reaction`; always empty
            for `message`/`system` rows.
        reply_to_packet_id:
          type: integer
          minimum: 0
          description: |
            Packet id of the message being reacted to. Set on rows with
            `event_type=reaction`; null otherwise. May reference a packet that
            is not present in the current chat window (e.g. the target scrolled
            out of the most recent N messages).
        message_time:
          type: string
          format: date-time
        reported_at:
          type: string
          format: date-time
        observed_at:
          type: string
          format: date-time
        packet_id:
          type: integer
          minimum: 0
        created_at:
          type: string
          format: date-time
        hop_start:
          type: integer
          minimum: 0
          description: |
            Original `hop_start` value from the Meshtastic packet header
            (the initial hop budget the originator set on the packet).
            The number of LoRa rebroadcasts the packet went through
            between the originator and the MQTT gateway is
            `hop_start - hop_limit`. A direct LoRa transmission between
            two radios is zero hops; only relay rebroadcasts count, so a
            packet uploaded by a node that received it directly over LoRa
            from the originator will typically have `hop_start == hop_limit`.
            Omitted when the upstream parser did not provide a value.
        hop_limit:
          type: integer
          minimum: 0
          description: |
            Remaining `hop_limit` value observed when the MQTT gateway
            received the packet. The number of rebroadcasts the packet
            went through is `hop_start - hop_limit`. A value of `0`
            together with a positive `hop_start` indicates the packet
            has reached the very last of its configured hop budget
            (the gateway received it on the "consumed" side of the final
            relay's rebroadcast). Omitted when the upstream parser did
            not provide a value.
    LogEvent:
      type: object
      required:
        - id
        - observed_at
        - event_kind_value
        - event_kind_title
        - encrypted
        - channel_name
      properties:
        id:
          type: integer
        observed_at:
          type: string
          format: date-time
        node_id:
          type: string
        node_display_name:
          type: string
          description: Resolved display fallback for the node when available.
        mqtt_uploader_node_id:
          type: string
        mqtt_uploader_display_name:
          type: string
          description: Resolved display fallback for the MQTT uploader node when available.
        event_kind_value:
          type: integer
          enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
          description: |
            `1` Map report, `2` Node info, `3` Position, `4` Telemetry,
            `5` Traceroute, `6` Neighbor info, `7` Routing,
            `8` Other app packet, `9` Encrypted (undecryptable),
            `10` Range test, `11` PKI.
        event_kind_title:
          type: string
        encrypted:
          type: boolean
        channel_name:
          type:
            - string
            - 'null'
        details:
          type: object
          description: Event-specific JSON payload.
          additionalProperties: true
        hop_start:
          type: integer
          minimum: 0
          description: |
            Original `hop_start` value from the Meshtastic packet header
            (the initial hop budget the originator set on the packet).
            The number of LoRa rebroadcasts the packet went through
            between the originator and the MQTT gateway is
            `hop_start - hop_limit`. A direct LoRa transmission between
            two radios is zero hops; only relay rebroadcasts count, so a
            packet uploaded by a node that received it directly over LoRa
            from the originator will typically have `hop_start == hop_limit`.
            Omitted when the upstream parser did not provide a value.
        hop_limit:
          type: integer
          minimum: 0
          description: |
            Remaining `hop_limit` value observed when the MQTT gateway
            received the packet. The number of rebroadcasts the packet
            went through is `hop_start - hop_limit`. A value of `0`
            together with a positive `hop_start` indicates the packet
            has reached the very last of its configured hop budget
            (the gateway received it on the "consumed" side of the final
            relay's rebroadcast). Omitted when the upstream parser did
            not provide a value.
    TopologyEdge:
      type: object
      required:
        - source_kind
        - from_node_id
        - to_node_id
        - first_observed_at
        - last_observed_at
        - updated_at
      properties:
        source_kind:
          type: string
          enum:
            - neighbor_info
            - routing_forward
            - routing_return
            - traceroute_forward
            - traceroute_return
            - mqtt_direct
        channel_name:
          type: string
        from_node_id:
          type: string
        to_node_id:
          type: string
        reported_by_node_id:
          type: string
        inferred:
          type: boolean
        snr:
          type: number
        neighbor_last_rx_at:
          type: string
          format: date-time
        neighbor_broadcast_interval_secs:
          type: integer
          minimum: 0
        first_observed_at:
          type: string
          format: date-time
        last_observed_at:
          type: string
          format: date-time
        last_reported_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    NodeSummary:
      type: object
      required:
        - node_id
        - display_name
        - last_seen_any_event_at
        - has_position
      properties:
        node_id:
          type: string
        display_name:
          type: string
        long_name:
          type: string
        short_name:
          type: string
        last_seen_any_event_at:
          type: string
          format: date-time
        last_seen_position_at:
          type: string
          format: date-time
        last_seen_mqtt_gateway_at:
          type: string
          format: date-time
        last_mqtt_uploader_node_id:
          type: string
        last_mqtt_uploader_display_name:
          type: string
          description: Resolved as `long_name -> short_name -> node_id`.
        last_mqtt_uploader_at:
          type: string
          format: date-time
        has_position:
          type: boolean
        role:
          type: string
        board_model:
          type: string
    NodeTelemetry:
      type: object
      required: [node_id, power, environment, air_quality, observed_at, updated_at]
      properties:
        node_id:
          type: string
        power:
          $ref: '#/components/schemas/TelemetryPower'
        environment:
          $ref: '#/components/schemas/TelemetryEnvironment'
        air_quality:
          $ref: '#/components/schemas/TelemetryAirQuality'
        source_channel:
          type: string
        mqtt_uploader_node_id:
          type: string
        mqtt_uploader_display_name:
          type: string
          description: Resolved as `long_name -> short_name -> node_id`.
        reported_at:
          type: string
          format: date-time
        observed_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    TelemetryPower:
      type: object
      properties:
        voltage:
          type: number
        battery_level:
          type: number
    TelemetryEnvironment:
      type: object
      properties:
        temperature_c:
          type: number
        humidity:
          type: number
        pressure_hpa:
          type: number
    TelemetryAirQuality:
      type: object
      properties:
        pm25:
          type: number
        pm10:
          type: number
        co2:
          type: number
        iaq:
          type: number
    NodeNeighbor:
      type: object
      required: [node_id, display_name, has_position, evidence_kind, last_observed_at, updated_at]
      properties:
        node_id:
          type: string
        display_name:
          type: string
        long_name:
          type: string
        short_name:
          type: string
        has_position:
          type: boolean
        evidence_kind:
          type: string
          enum: [neighbor_info, mqtt_direct, inferred]
        snr:
          type: number
        channel_name:
          type: string
        reported_by_node_id:
          type: string
        neighbor_last_rx_at:
          type: string
          format: date-time
        neighbor_broadcast_interval_secs:
          type: integer
          minimum: 0
        last_observed_at:
          type: string
          format: date-time
        last_reported_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    NodeDetails:
      type: object
      required: [node]
      properties:
        node:
          $ref: '#/components/schemas/Node'
        position:
          $ref: '#/components/schemas/NodePosition'
        telemetry:
          $ref: '#/components/schemas/NodeTelemetry'
        neighbors:
          type: array
          items:
            $ref: '#/components/schemas/NodeNeighbor'
        previous_names:
          type: array
          items:
            $ref: '#/components/schemas/NodeNameHistory'
    NodeNameHistory:
      type: object
      required: [changed_at]
      properties:
        previous_long_name:
          type: string
        previous_short_name:
          type: string
        new_long_name:
          type: string
        new_short_name:
          type: string
        changed_at:
          type: string
          format: date-time
    Stats:
      type: object
      required: [known_nodes_count, online_nodes_count, ws_clients_count]
      properties:
        known_nodes_count:
          type: integer
        online_nodes_count:
          type: integer
          description: |
            Count of nodes recently confirmed as MQTT gateways, derived from
            `last_seen_mqtt_gateway_at`.
        ws_clients_count:
          type: integer
        last_ingest_at:
          type: string
          format: date-time
    ActivityStats:
      type: object
      required: [generated_at, periods]
      properties:
        generated_at:
          type: string
          format: date-time
        periods:
          type: array
          items:
            $ref: '#/components/schemas/ActivityPeriod'
    ActivityPeriod:
      type: object
      required: [key, title, window, bucket, buckets]
      properties:
        key:
          type: string
          enum: [daily, weekly]
        title:
          type: string
        window:
          type: string
          description: Go duration string.
        bucket:
          type: string
          description: Go duration string.
        buckets:
          type: array
          items:
            $ref: '#/components/schemas/ActivityBucket'
    ActivityBucket:
      type: object
      required:
        - bucket_start
        - text_messages
        - pki
        - node_info
        - telemetry
        - neighbor_info
        - range_test
        - traceroute
      properties:
        bucket_start:
          type: string
          format: date-time
        text_messages:
          type: integer
          minimum: 0
        pki:
          type: integer
          minimum: 0
        node_info:
          type: integer
          minimum: 0
        telemetry:
          type: integer
          minimum: 0
        neighbor_info:
          type: integer
          minimum: 0
        range_test:
          type: integer
          minimum: 0
        traceroute:
          type: integer
          minimum: 0
    RealtimeEvent:
      type: object
      required: [type, ts, payload]
      properties:
        type:
          type: string
          enum:
            - chat.message
            - chat.system
            - chat.reaction
            - node.upsert
            - node.position
            - node.telemetry
            - log.event
            - stats
            - ws.heartbeat
        ts:
          type: string
          format: date-time
        payload:
          description: Event-specific JSON payload. See the `/api/v1/ws` event catalog.
          oneOf:
            - $ref: '#/components/schemas/ChatEvent'
            - $ref: '#/components/schemas/Node'
            - $ref: '#/components/schemas/NodePosition'
            - $ref: '#/components/schemas/NodeTelemetry'
            - $ref: '#/components/schemas/LogEvent'
            - $ref: '#/components/schemas/Stats'
            - $ref: '#/components/schemas/Heartbeat'
