openapi: 3.1.0
info:
  title: FreeSTAR's Restful API
  version: "2.2.3"
  description: LastHeard for YSF, NXDN, P25, M17, DMR, D-STAR and AllStar. 60 public GETs per hour per IP. Prefer GET /directory for the catalog. V1 stays at `/v1/`.
  license:
    name: GNU General Public License v3.0 or later
    identifier: GPL-3.0-or-later

servers:
  - url: https://api.freestar.network/v2
    description: Production

tags:
  - name: Public
    description: 60 public GETs per hour per IP. Retry-After is the remainder of that hour. One request every 60 seconds is even-paced equivalent, not a required gap.
  - name: Ingest
    description: Agent snapshot upload
  - name: Control
    description: "DMR: `/control/dmr/*`. AllStar: `/control/asl/*`. YSF: `/control/ysf/*`."
  - name: Partners
    description: RadioID push and ETCC store
  - name: Meta
    description: Health and this specification

security: []

paths:
  /health:
    get:
      tags: [Meta]
      summary: Liveness
      description: Returns ok when v2 is up.
      operationId: getHealth
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
              example:
                ok: true
                version: "2.2.3"

  /docs:
    get:
      tags: [Meta]
      summary: Swagger UI
      description: Swagger UI for this specification.
      operationId: getDocs
      responses:
        "200":
          description: HTML documentation
          content:
            text/html:
              schema:
                type: string

  /metrics:
    get:
      tags: [Meta]
      summary: Metrics dashboard
      description: HTML dashboard. Not rate-limited. Not the JSON contract.
      operationId: getMetrics
      responses:
        "200":
          description: HTML dashboard
          content:
            text/html:
              schema:
                type: string

  /schema:
    get:
      tags: [Meta]
      summary: OpenAPI document
      description: OpenAPI 3.1 YAML. Not rate-limited.
      operationId: getSchema
      responses:
        "200":
          description: OpenAPI 3.1 YAML
          content:
            application/yaml:
              schema:
                type: string

  /directory:
    get:
      tags: [Public]
      summary: Network directory
      description: |
        Versioned catalog snapshot. Prefer this over GET /talkgroups, /bridges, and /servers.
        Stable ids are mode:kind:key (DMR talkgroups use tg in the id, for example dmr:tg:23426).
        Snake_case fields and integer ports. No IP addresses. No live occupancy.
        DMR server rows include the public join password from the V1 host list. Unique per-master secrets are never listed.
        If-None-Match matching ETag returns 304 and does not consume the public GET quota.
      operationId: getDirectory
      parameters:
        - name: If-None-Match
          in: header
          required: false
          description: Send the previous ETag to receive 304 when the catalog is unchanged.
          schema:
            type: string
      responses:
        "200":
          description: Catalog snapshot
          headers:
            ETag:
              schema:
                type: string
              description: Hash of overlay plus mapped V1 files. Not generated_at.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DirectoryResponse"
              example:
                generated_at: "2026-08-30T20:00:00Z"
                revision: "a1b2c3d4e5f67890"
                network:
                  name: FreeSTAR
                  docs: https://api.freestar.network/v2/docs
                servers:
                  - id: "dmr:server:dmr.freestar.network"
                    mode: dmr
                    kind: server
                    hostname: dmr.freestar.network
                    port: 62031
                    password: passw0rd
                    country: GB
                    opb_net_id: "2381"
                destinations:
                  - id: "dmr:tg:23426"
                    mode: dmr
                    kind: talkgroup
                    key: "23426"
                    name: FreeSTAR UK
                  - id: "ysf:reflector:23501"
                    mode: ysf
                    kind: reflector
                    key: "23501"
                    name: ChatterBOX 23501
                    hostname: dvserver1.freestar.network
                    port: 42025
                bridges:
                  - id: "bridge:23426"
                    name: FreeSTAR UK
                    legs:
                      - destination_id: "dmr:tg:23426"
                      - destination_id: "ysf:reflector:23426"
        "304":
          description: Catalog unchanged. Does not consume a rate-limit slot.
          headers:
            ETag:
              schema:
                type: string
        "429":
          $ref: "#/components/responses/RateLimited"

  /ingest/activity:
    post:
      tags: [Ingest]
      summary: Replace LastHeard snapshots
      description: |
        Each `source_id` in `records` replaces that source's snapshot. Include empty sources so they stay listed. Snapshots older than 48 hours drop from public views.
      operationId: postIngestActivity
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IngestRequest"
            example:
              source_host: dvserver1.freestar.network
              collected_at: "2026-08-28T20:00:00Z"
              records:
                - callsign_raw: M0VUB-Y
                  radio_id: 2345875
                  mode: ysf
                  status: heard
                  talkgroup: "ALL"
                  reflector_id: "23426"
                  source_id: ysf-23426
                  source_host: dvserver1.freestar.network
                  last_seen: "2026-08-28T20:00:00Z"
      responses:
        "200":
          description: Snapshots stored
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestResponse"
              example:
                ok: true
                sources: 1
                records: 1
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /lastheard:
    get:
      tags: [Public]
      summary: Merged LastHeard
      description: Deduped LastHeard. Default window 24h. Filter with `mode`.
      operationId: getLastheard
      parameters:
        - $ref: "#/components/parameters/mode"
        - $ref: "#/components/parameters/window"
      responses:
        "200":
          description: LastHeard list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LastHeardResponse"
              example:
                window: 24h
                generated_at: "2026-08-28T21:00:00Z"
                count: 1
                peers: 0
                records:
                  - callsign: M0VUB
                    callsign_raw: M0VUB-Y
                    radio_id: 2345875
                    mode: ysf
                    status: heard
                    talkgroup: "ALL"
                    reflector_id: "23426"
                    source_id: ysf-23426
                    source_host: dvserver1.freestar.network
                    last_seen: "2026-08-28T20:00:00Z"
        "429":
          $ref: "#/components/responses/RateLimited"

  /activity:
    get:
      tags: [Public]
      summary: LastHeard
      description: Same as `GET /lastheard`.
      operationId: getActivity
      parameters:
        - $ref: "#/components/parameters/mode"
        - $ref: "#/components/parameters/window"
      responses:
        "200":
          description: LastHeard list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LastHeardResponse"
        "429":
          $ref: "#/components/responses/RateLimited"

  /callsigns:
    get:
      tags: [Public]
      summary: Deduped callsigns
      description: Unique normalised callsigns in the window. Numeric-only NXDN IDs are omitted.
      operationId: getCallsigns
      parameters:
        - $ref: "#/components/parameters/mode"
        - $ref: "#/components/parameters/window"
      responses:
        "200":
          description: Callsign list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CallsignsResponse"
              example:
                window: 24h
                generated_at: "2026-08-28T21:00:00Z"
                count: 1
                callsigns:
                  - callsign: M0VUB
                    last_seen: "2026-08-28T20:00:00Z"
                    modes: [ysf]
        "429":
          $ref: "#/components/responses/RateLimited"

  /sources:
    get:
      tags: [Public]
      summary: Reporting sources
      description: Sources that posted within 48 hours. `records` is snapshot size; `peers` is `status=connected` rows.
      operationId: getSources
      responses:
        "200":
          description: Source list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SourcesResponse"
              example:
                generated_at: "2026-08-28T21:00:00Z"
                sources:
                  - source_id: ysf-23426
                    source_host: dvserver1.freestar.network
                    mode: ysf
                    reflector_id: "23426"
                    collected_at: "2026-08-28T20:00:00Z"
                    records: 12
                    peers: 4
        "429":
          $ref: "#/components/responses/RateLimited"

  /talkgroups:
    get:
      tags: [Public]
      summary: Talkgroups (V1)
      description: Pass-through of `/var/www/html/v1/talkgroup_ids.json`. Prefer GET /directory.
      operationId: getTalkgroups
      responses:
        "200":
          description: V1 talkgroup JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TalkgroupsV1"
              example:
                results:
                  - tgid: 23426
                    callsign: FreeSTAR UK
                    id: "23426"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /talkgroups/activity:
    get:
      tags: [Public]
      summary: Activity ledger
      description: Persistent last-seen per mode and destination (talkgroup, room, reflector, or node). Snapshots drop after 48 hours. This ledger does not. `never` is a V1 DMR id not yet heard. Six-month and one-year dormant buckets need that much history.
      operationId: getTalkgroupActivity
      parameters:
        - $ref: "#/components/parameters/mode"
        - $ref: "#/components/parameters/idle"
      responses:
        "200":
          description: Talkgroup activity list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TalkgroupActivityResponse"
        "429":
          $ref: "#/components/responses/RateLimited"

  /bridges:
    get:
      tags: [Public]
      summary: Bridges (V1)
      description: Pass-through of `/var/www/html/v1/bridge_ids.json`. Prefer GET /directory.
      operationId: getBridges
      responses:
        "200":
          description: V1 bridge JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BridgesV1"
              example:
                bridge:
                  "23426":
                    tgid: "23426"
                    name: FreeSTAR UK
                    ipsc2: "23426"
                    ysf: GB-FreeSTAR
                    p25: "23426"
                    nxdn: "23426"
                    m17: M17-DMR-X
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /servers:
    get:
      tags: [Public]
      summary: System-X hosts (V1)
      description: JSON of `/var/www/html/v1/SystemX_Hosts.csv`. Password column omitted. Prefer GET /directory.
      operationId: getServers
      responses:
        "200":
          description: Host list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServersResponse"
              example:
                servers:
                  - Country: United Kingdom
                    OPB Net ID: "2381"
                    IP/Hostname: dmr.freestar.network
                    Port: "62031"
                    TokenStatus: verified
                    LastUpdate: "1737052800"
                    RysenVersion: "1.2.3"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /peers:
    get:
      tags: [Public]
      summary: Connected peers
      description: LastHeard rows with `status=connected`. No IP addresses.
      operationId: getPeers
      parameters:
        - $ref: "#/components/parameters/mode"
        - $ref: "#/components/parameters/window"
      responses:
        "200":
          description: Connected peer list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PeersResponse"
        "429":
          $ref: "#/components/responses/RateLimited"

  /etcc:
    get:
      tags: [Partners]
      summary: ETCC write-back store
      description: Last stored ETCC payload. `GET /callsigns` is the join list.
      operationId: getEtcc
      responses:
        "200":
          description: Stored ETCC rows
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EtccResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Partners]
      summary: Replace ETCC store
      description: Replace the ETCC store. Body is a `rows` array.
      operationId: postEtcc
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EtccStoreRequest"
      responses:
        "200":
          description: Store replaced
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EtccStoreResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /control/dmr/disconnect:
    post:
      tags: [Control]
      summary: "DMR: disconnect a radio"
      description: Disconnect a radio on that DMR master. Body uses `radio_id` and `source_host`.
      operationId: postDmrDisconnect
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DmrControlRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /control/dmr/drop-dynamic:
    post:
      tags: [Control]
      summary: "DMR: drop dynamic links"
      description: Drop dial-a-TG and user-activated links for that radio.
      operationId: postDmrDropDynamic
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DmrControlRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /control/dmr/drop-call:
    post:
      tags: [Control]
      summary: "DMR: drop current route"
      description: Drop the radio's current call route.
      operationId: postDmrDropCall
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DmrControlRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /control/dmr/talkgroup:
    post:
      tags: [Control]
      summary: "DMR: activate a talkgroup"
      description: Activate a talkgroup for that radio. Slot defaults to 2.
      operationId: postDmrTalkgroup
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DmrTalkgroupRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Control]
      summary: "DMR: deactivate a talkgroup"
      description: Deactivate that talkgroup for the radio.
      operationId: deleteDmrTalkgroup
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DmrTalkgroupRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /control/asl/unlink:
    post:
      tags: [Control]
      summary: "AllStar: unlink a node"
      description: Unlink a node from the hub (`rpt cmd <hub> ilink 1 <node>`). Body uses `node` and `source_host`.
      operationId: postAslUnlink
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AslControlRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /control/asl/ban:
    post:
      tags: [Control]
      summary: "AllStar: deny a node"
      description: Write `denylist/<hub>/<node>` and unlink if connected. Optional `reason`.
      operationId: postAslBan
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AslBanRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /control/asl/unban:
    post:
      tags: [Control]
      summary: "AllStar: remove a deny"
      description: Delete `denylist/<hub>/<node>`. Does not reconnect.
      operationId: postAslUnban
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AslControlRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /control/ysf/block:
    post:
      tags: [Control]
      summary: "YSF: block a callsign"
      description: |
        Add a callsign to pYSFReflector `deny.db` on that YSF host (`CS:CALL`).
        Body uses `callsign` and `source_host`. Optional `reason` is stored as a comment.
        Default host is YSF 23426 on dvserver1. Shares `/etc/deny.db` with YSF 23501.
        Listener is TCP 8766 (8765 is AllStar 2196 on the same NAT).
      operationId: postYsfBlock
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/YsfBlockRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /control/ysf/unblock:
    post:
      tags: [Control]
      summary: "YSF: remove a block"
      description: Remove that callsign from pYSFReflector `deny.db`. Does not reconnect a gateway.
      operationId: postYsfUnblock
      security:
        - opsAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/YsfControlRequest"
      responses:
        "200":
          description: Action accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Per-agent token issued into `/etc/freestar-api-v2/agents.json`.
    opsAuth:
      type: http
      scheme: bearer
      description: Ops key in `/etc/freestar-api-v2/ops.json`.

  parameters:
    mode:
      name: mode
      in: query
      required: false
      description: Restrict to one mode.
      schema:
        $ref: "#/components/schemas/Mode"
    window:
      name: window
      in: query
      required: false
      description: How far back to include `last_seen`. Default `24h`.
      schema:
        type: string
        default: 24h
        examples: ["24h", "48h", "6h"]
    idle:
      name: idle
      in: query
      required: false
      description: Minimum idle days (`180d` or `365`).
      schema:
        type: string
        examples: ["180d", "365d"]

  responses:
    BadRequest:
      description: Invalid JSON or fields
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Invalid JSON body
    Unauthorized:
      description: Missing or unknown Bearer token
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Unauthorized
    UpstreamUnavailable:
      description: V1 file missing on this host
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: V1 talkgroup_ids.json not found
    NotFound:
      description: Host not mapped
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: No FreeSTAR host mapped for this request
    RateLimited:
      description: Public GET quota exceeded. Back off using Retry-After.
      headers:
        Retry-After:
          description: Seconds remaining in the current hour window.
          schema:
            type: integer
        X-RateLimit-Limit:
          schema:
            type: integer
        X-RateLimit-Remaining:
          schema:
            type: integer
        X-RateLimit-Reset:
          description: Unix time when the current hour window ends.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Rate limit exceeded

  schemas:
    Mode:
      type: string
      enum: [ysf, nxdn, dmr, dstar, p25, m17, asl]
      description: Mode.

    Status:
      type: string
      enum: [heard, connected]
      description: |
        heard is last occupancy (TX, or keyed on AllStar).
        connected is a linked gateway, client, or node.

    LastHeard:
      type: object
      additionalProperties: false
      required:
        - callsign
        - callsign_raw
        - radio_id
        - mode
        - status
        - talkgroup
        - reflector_id
        - source_id
        - source_host
        - last_seen
      properties:
        callsign:
          type: string
          description: Normalised callsign. Empty when only a numeric ID is known.
          examples: ["M0VUB"]
        callsign_raw:
          type: string
          description: Callsign as logged, before normalisation.
          examples: ["M0VUB-Y"]
        radio_id:
          type: [integer, "null"]
          description: |
            Numeric ID when the mode has one (DMR ID or NXDN ID).
            Null for AllStar and D-STAR — AllStar uses the node in `talkgroup`.
          examples: [2340189, 54336]
        mode:
          $ref: "#/components/schemas/Mode"
        status:
          $ref: "#/components/schemas/Status"
        talkgroup:
          type: [string, "null"]
          description: Occupancy key for that mode (talkgroup, room, dest, module, or AllStar node).
          examples: ["23426", "ALL", "2196"]
        reflector_id:
          type: string
          description: Source identity (reflector, talkgroup, XLX, or AllStar node).
        source_id:
          type: string
          description: Snapshot key, typically `{mode}-{reflector_id}`.
          examples: ["ysf-23426"]
        source_host:
          type: string
          description: Host that collected the snapshot.
          examples: ["dvserver1.freestar.network"]
        last_seen:
          type: string
          format: date-time
          description: UTC timestamp.

    IngestRecord:
      type: object
      additionalProperties: false
      required:
        - callsign_raw
        - mode
        - status
        - source_id
        - last_seen
      properties:
        callsign_raw:
          type: string
        radio_id:
          type: [integer, "null"]
        mode:
          $ref: "#/components/schemas/Mode"
        status:
          $ref: "#/components/schemas/Status"
        talkgroup:
          type: [string, "null"]
        reflector_id:
          type: [string, "null"]
        source_id:
          type: string
        source_host:
          type: string
        last_seen:
          type: string
          format: date-time

    IngestRequest:
      type: object
      additionalProperties: false
      required: [source_host, collected_at, records]
      properties:
        source_host:
          type: string
        collected_at:
          type: string
          format: date-time
        sources:
          type: array
          description: Declared sources for this run. Empty sources stay listed.
          items:
            type: object
            required: [source_id]
            properties:
              source_id:
                type: string
              mode:
                $ref: "#/components/schemas/Mode"
              reflector_id:
                type: string
        records:
          type: array
          items:
            $ref: "#/components/schemas/IngestRecord"

    IngestResponse:
      type: object
      required: [ok, sources, records]
      properties:
        ok:
          type: boolean
        sources:
          type: integer
        records:
          type: integer

    LastHeardResponse:
      type: object
      required: [window, generated_at, count, peers, records]
      properties:
        window:
          type: string
        generated_at:
          type: string
          format: date-time
        count:
          type: integer
          description: Length of records
        peers:
          type: integer
          description: How many records have status=connected
        records:
          type: array
          items:
            $ref: "#/components/schemas/LastHeard"

    CallsignEntry:
      type: object
      required: [callsign, last_seen, modes]
      properties:
        callsign:
          type: string
        last_seen:
          type: string
          format: date-time
        modes:
          type: array
          items:
            $ref: "#/components/schemas/Mode"

    CallsignsResponse:
      type: object
      required: [window, generated_at, count, callsigns]
      properties:
        window:
          type: string
        generated_at:
          type: string
          format: date-time
        count:
          type: integer
          description: Length of callsigns
        callsigns:
          type: array
          items:
            $ref: "#/components/schemas/CallsignEntry"

    SourceEntry:
      type: object
      required: [source_id, source_host, collected_at, records, peers]
      properties:
        source_id:
          type: string
        source_host:
          type: string
        mode:
          $ref: "#/components/schemas/Mode"
        reflector_id:
          type: string
        collected_at:
          type: string
          format: date-time
        records:
          type: integer
        peers:
          type: integer
          description: Connected rows in this snapshot (`status=connected`)

    SourcesResponse:
      type: object
      required: [generated_at, sources]
      properties:
        generated_at:
          type: string
          format: date-time
        sources:
          type: array
          items:
            $ref: "#/components/schemas/SourceEntry"

    Health:
      type: object
      required: [ok, version]
      properties:
        ok:
          type: boolean
        version:
          type: string

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string

    TalkgroupActivityResponse:
      type: object
      required: [generated_at, count, buckets, talkgroups]
      properties:
        generated_at:
          type: string
          format: date-time
        count:
          type: integer
        buckets:
          type: object
          properties:
            active:
              type: integer
            quiet:
              type: integer
            dormant_6m:
              type: integer
            dormant_1y:
              type: integer
            never:
              type: integer
        talkgroups:
          type: array
          items:
            type: object
            required: [mode, talkgroup, bucket]
            properties:
              mode:
                $ref: "#/components/schemas/Mode"
              talkgroup:
                type: string
              name:
                type: string
              first_seen:
                type: [string, "null"]
              last_seen:
                type: [string, "null"]
              idle_days:
                type: [integer, "null"]
              hits:
                type: integer
              last_source_id:
                type: string
              bucket:
                type: string
                enum: [active, quiet, dormant_6m, dormant_1y, never]

    TalkgroupsV1:
      type: object
      properties:
        results:
          type: array
          items:
            type: object
            properties:
              tgid:
                type: integer
              callsign:
                type: string
              id:
                type: string

    BridgesV1:
      type: object
      properties:
        bridge:
          type: object
          additionalProperties:
            type: object

    ServerEntry:
      type: object
      additionalProperties:
        type: string
      properties:
        Country:
          type: string
        OPB Net ID:
          type: string
        IP/Hostname:
          type: string
        Port:
          type: string
        TokenStatus:
          type: string
        LastUpdate:
          type: string
        RysenVersion:
          type: string

    ServersResponse:
      type: object
      required: [servers]
      properties:
        servers:
          type: array
          items:
            $ref: "#/components/schemas/ServerEntry"

    DirectoryKind:
      type: string
      enum: [server, talkgroup, reflector, node, module]

    DirectoryNetwork:
      type: object
      additionalProperties: false
      required: [name, docs]
      properties:
        name:
          type: string
        docs:
          type: string

    DirectoryServer:
      type: object
      additionalProperties: false
      required: [id, mode, kind, hostname, port, password]
      properties:
        id:
          type: string
          examples: ["dmr:server:dmr.freestar.network"]
        mode:
          $ref: "#/components/schemas/Mode"
        kind:
          $ref: "#/components/schemas/DirectoryKind"
        hostname:
          type: string
        port:
          type: integer
        password:
          type: string
          description: Public DMR join key from the V1 host list. Not an ops secret.
        country:
          type: string
          description: ISO 3166-1 alpha-2 when known.
        opb_net_id:
          type: string

    DirectoryDestination:
      type: object
      additionalProperties: false
      required: [id, mode, kind, key, name]
      properties:
        id:
          type: string
          examples: ["dmr:tg:23426", "ysf:reflector:23501"]
        mode:
          $ref: "#/components/schemas/Mode"
        kind:
          $ref: "#/components/schemas/DirectoryKind"
        key:
          type: string
        name:
          type: string
        hostname:
          type: string
        port:
          type: integer
        ports:
          type: object
          additionalProperties:
            type: integer
          description: Named listen ports (D-STAR DExtra, DPlus, DCS).

    DirectoryBridgeLeg:
      type: object
      additionalProperties: false
      required: [destination_id]
      properties:
        destination_id:
          type: string

    DirectoryBridge:
      type: object
      additionalProperties: false
      required: [id, name, legs]
      properties:
        id:
          type: string
        name:
          type: string
        legs:
          type: array
          items:
            $ref: "#/components/schemas/DirectoryBridgeLeg"

    DirectoryResponse:
      type: object
      additionalProperties: false
      required: [generated_at, revision, network, servers, destinations, bridges]
      properties:
        generated_at:
          type: string
          format: date-time
        revision:
          type: string
          description: Short sha256 of overlay plus mapped V1 files.
        network:
          $ref: "#/components/schemas/DirectoryNetwork"
        servers:
          type: array
          items:
            $ref: "#/components/schemas/DirectoryServer"
        destinations:
          type: array
          items:
            $ref: "#/components/schemas/DirectoryDestination"
        bridges:
          type: array
          items:
            $ref: "#/components/schemas/DirectoryBridge"

    PeerEntry:
      type: object
      required: [callsign, source_id, source_host, last_seen]
      properties:
        callsign:
          type: string
        callsign_raw:
          type: string
        radio_id:
          type: [integer, "null"]
        mode:
          $ref: "#/components/schemas/Mode"
        talkgroup:
          type: [string, "null"]
        reflector_id:
          type: [string, "null"]
        source_id:
          type: string
        source_host:
          type: string
        last_seen:
          type: string
          format: date-time

    PeersResponse:
      type: object
      required: [window, generated_at, count, peers]
      properties:
        window:
          type: string
        generated_at:
          type: string
          format: date-time
        count:
          type: integer
          description: Length of peers
        peers:
          type: array
          items:
            $ref: "#/components/schemas/PeerEntry"

    EtccResponse:
      type: object
      required: [generated_at, rows]
      properties:
        generated_at:
          type: string
          format: date-time
        rows:
          type: array
          items:
            type: object

    EtccStoreRequest:
      type: object
      properties:
        rows:
          type: array
          items:
            type: object

    EtccStoreResponse:
      type: object
      required: [ok, rows]
      properties:
        ok:
          type: boolean
        rows:
          type: integer

    DmrControlRequest:
      type: object
      required: [radio_id]
      properties:
        radio_id:
          type: integer
          description: DMR radio ID (hotspot or IPSC).
        source_host:
          type: string
          description: DMR master hostname from GET /servers.
        opb_net_id:
          type: string
        slot:
          type: integer
          description: Timeslot. 0 or omitted means slot 2.

    DmrTalkgroupRequest:
      allOf:
        - $ref: "#/components/schemas/DmrControlRequest"
        - type: object
          required: [talkgroup]
          properties:
            talkgroup:
              type: integer

    AslControlRequest:
      type: object
      required: [node]
      properties:
        node:
          type: integer
          description: AllStar node number.
        source_host:
          type: string
          description: AllStar hub hostname from GET /sources (e.g. 2196.nodes.allstarlink.org).

    AslBanRequest:
      allOf:
        - $ref: "#/components/schemas/AslControlRequest"
        - type: object
          properties:
            reason:
              type: string
              description: Stored in the AllStar denylist AstDB (short comment only).

    YsfControlRequest:
      type: object
      required: [callsign]
      properties:
        callsign:
          type: string
          description: Callsign to block or unblock (3 to 8 A-Z / 0-9).
        source_host:
          type: string
          description: YSF host from GET /sources (e.g. dvserver1.freestar.network).
        reflector_id:
          type: string
          description: YSF reflector id. Default 23426 on this listener.

    YsfBlockRequest:
      allOf:
        - $ref: "#/components/schemas/YsfControlRequest"
        - type: object
          properties:
            reason:
              type: string
              description: Stored as a comment above the deny.db entry.

    ControlResponse:
      type: object
      properties:
        ok:
          type: boolean
        action:
          type: string
        source_host:
          type: string
        system:
          type: string
