asyncapi: 3.0.0

info:
  title: Tekomi Device MQTT API
  version: 1.0.0
  description: |
    MQTT contract between a Tekomi device and api-server, topic tree `tekomi/v1`.
    The prose specification, including broker setup and the reasoning behind
    each rule, is [docs/mqtt-topics.md](../docs/mqtt-topics.md). Keep the two in sync.

    **Perspective.** This document describes the **device** (firmware):
    `send` operations are what the device publishes, `receive` operations are
    what it subscribes to. api-server does the opposite.

    **Payload rules**
    - JSON, UTF-8, `snake_case` field names, under 4 KB.
    - Every message carries `ts`: RFC 3339 in UTC, when the source produced it.
    - Receivers ignore unknown fields; adding an optional field is not breaking.
    - QoS 1 is at-least-once delivery, so every handler must be idempotent
      (deduplicate on `correlation_id` or `job_id`).
    - No payload carries the serial. The topic does, and the broker ACL
      vouches for it; a `serial` in a body is ignored.

    **Versioning.** Adding optional fields, control keys, `events/{type}` or
    `cmd/{name}` topics keeps `v1`. Renaming or removing a field or topic, or
    changing a field's type or meaning, needs `tekomi/v2`.
  tags:
    - name: telemetry
      description: Device to cloud reports.
    - name: control
      description: Cloud to device commands and their acknowledgements.

defaultContentType: application/json

servers:
  broker:
    host: '{host}:{port}'
    protocol: secure-mqtt
    protocolVersion: 3.1.1
    description: |
      EMQX broker. api-server reads its address from `HOSTEX_MQTT_ADDR`.
      A device connects with **username = client ID = serial**; the broker
      rejects a device whose client ID differs from its username. Device ACL
      (`{s}` = `${username}`): publish `tekomi/v1/{s}/sys_info`,
      `tekomi/v1/{s}/state`, `tekomi/v1/{s}/events/+`,
      `tekomi/v1/{s}/cmd/+/result`; subscribe `tekomi/v1/{s}/cmd/+`; deny
      everything else.
    variables:
      host:
        default: broker.example.com
      port:
        default: '8883'
    security:
      - $ref: '#/components/securitySchemes/deviceCredentials'
    bindings:
      mqtt:
        bindingVersion: 0.2.0
        clientId: '{serial}'
        cleanSession: false
        keepAlive: 30

channels:
  sysInfo:
    address: tekomi/v1/{serial}/sys_info
    title: Device info
    description: |
      Firmware, network and clock information. Retained, so a subscriber gets
      the latest report at once.
    parameters:
      serial:
        $ref: '#/components/parameters/serial'
    messages:
      sysInfo:
        $ref: '#/components/messages/SysInfo'

  state:
    address: tekomi/v1/{serial}/state
    title: Control state
    description: |
      The device's **full** current control state. The only topic that carries
      control state, and the acknowledgement of every control key command.
      Retained, so it must always carry every control key, never just the one
      that changed. An empty payload clears the retained copy and is skipped
      by receivers.
    parameters:
      serial:
        $ref: '#/components/parameters/serial'
    messages:
      state:
        $ref: '#/components/messages/State'

  jobEvent:
    address: tekomi/v1/{serial}/events/job
    title: Job lifecycle event
    description: |
      Job start and job end. Feeds History and the owner's notification.
      `events/` is the namespace for future device events (`events/{type}`).
    parameters:
      serial:
        $ref: '#/components/parameters/serial'
    messages:
      jobEvent:
        $ref: '#/components/messages/JobEvent'

  controlCommand:
    address: tekomi/v1/{serial}/cmd/{key}
    title: Control key command
    description: |
      Sets one control key. One topic per key (`cmd/power`, `cmd/paused`,
      `cmd/child_lock`). Not retained and not expired by the broker: the
      device must drop a command whose `ts` is more than 30 s old, without
      replying. It also ignores commands it does not know.
    parameters:
      serial:
        $ref: '#/components/parameters/serial'
      key:
        $ref: '#/components/parameters/controlKey'
    messages:
      controlCommand:
        $ref: '#/components/messages/ControlCommand'

  command:
    address: tekomi/v1/{serial}/cmd/{name}
    title: Other command (reserved)
    description: |
      Commands that are not control keys. **Reserved:** api-server sends none
      of them yet. Same envelope and freshness rule as control key commands,
      with command-specific `args`; answered on `cmd/{name}/result`.
    parameters:
      serial:
        $ref: '#/components/parameters/serial'
      name:
        $ref: '#/components/parameters/commandName'
    messages:
      command:
        $ref: '#/components/messages/Command'

  commandResult:
    address: tekomi/v1/{serial}/cmd/{name}/result
    title: Command result (reserved)
    description: |
      Result of a command that is not a control key command. **Reserved:**
      api-server does not subscribe to it yet.
    parameters:
      serial:
        $ref: '#/components/parameters/serial'
      name:
        $ref: '#/components/parameters/commandName'
    messages:
      commandResult:
        $ref: '#/components/messages/CommandResult'

operations:
  publishSysInfo:
    action: send
    channel:
      $ref: '#/channels/sysInfo'
    title: Publish device info
    summary: After every connect, and whenever a field changes.
    tags:
      - name: telemetry
    bindings:
      mqtt:
        bindingVersion: 0.2.0
        qos: 1
        retain: true
    messages:
      - $ref: '#/channels/sysInfo/messages/sysInfo'

  publishState:
    action: send
    channel:
      $ref: '#/channels/state'
    title: Publish control state
    summary: |
      After every connect, after any state change (command, physical button,
      schedule, firmware logic), and after every control key command even when
      nothing changed.
    tags:
      - name: telemetry
      - name: control
    bindings:
      mqtt:
        bindingVersion: 0.2.0
        qos: 1
        retain: true
    messages:
      - $ref: '#/channels/state/messages/state'

  publishJobEvent:
    action: send
    channel:
      $ref: '#/channels/jobEvent'
    title: Publish job event
    summary: At job start (`PENDING`) and at job end (`SUCCESS` or `ERROR`).
    tags:
      - name: telemetry
    bindings:
      mqtt:
        bindingVersion: 0.2.0
        qos: 1
        retain: false
    messages:
      - $ref: '#/channels/jobEvent/messages/jobEvent'

  receiveControlCommand:
    action: receive
    channel:
      $ref: '#/channels/controlCommand'
    title: Receive control key command
    summary: Set one control key, then publish the full state.
    description: |
      The device answers every fresh control key command with `state` carrying
      the command's `correlation_id` and `source: USER`, whether or not it
      applied the change. The app compares the key in `state` with the value
      it asked for: a match means applied, a mismatch means refused (child
      lock, busy, …). No reason is reported and there is no `result` topic
      for control keys.
    tags:
      - name: control
    bindings:
      mqtt:
        bindingVersion: 0.2.0
        qos: 1
    messages:
      - $ref: '#/channels/controlCommand/messages/controlCommand'
    reply:
      channel:
        $ref: '#/channels/state'
      messages:
        - $ref: '#/channels/state/messages/state'

  receiveCommand:
    action: receive
    channel:
      $ref: '#/channels/command'
    title: Receive other command (reserved)
    tags:
      - name: control
    bindings:
      mqtt:
        bindingVersion: 0.2.0
        qos: 1
    messages:
      - $ref: '#/channels/command/messages/command'
    reply:
      channel:
        $ref: '#/channels/commandResult'
      messages:
        - $ref: '#/channels/commandResult/messages/commandResult'

  publishCommandResult:
    action: send
    channel:
      $ref: '#/channels/commandResult'
    title: Publish command result (reserved)
    tags:
      - name: control
    bindings:
      mqtt:
        bindingVersion: 0.2.0
        qos: 1
        retain: false
    messages:
      - $ref: '#/channels/commandResult/messages/commandResult'

components:
  securitySchemes:
    deviceCredentials:
      type: userPassword
      description: |
        Per-device credentials, provisioned at the factory or during pairing.
        The username is the device serial.

  parameters:
    serial:
      description: |
        Device serial exactly as printed on the device (not the Mongo
        `deviceID`): 1–64 characters, no spaces, `/`, `+` or `#`. The server
        ignores messages under a serial that fails this rule.
      examples:
        - TK2409000123
    controlKey:
      description: Control key the command sets. One command per key.
      enum:
        - power
        - paused
        - child_lock
    commandName:
      description: Name of a command that is not a control key.
      enum:
        - reboot
        - ota
        - factory_reset

  correlationIds:
    command:
      description: |
        Copied from the app's command into `cmd/*` and back into the `state`
        or `cmd/{name}/result` that answers it.
      location: $message.payload#/correlation_id

  messages:
    SysInfo:
      name: SysInfo
      title: Device info
      payload:
        $ref: '#/components/schemas/SysInfo'
      examples:
        - name: cellular
          payload:
            firmware_version: 1.4.2
            network_provider: Viettel
            sim_imei: '356938035643809'
            connection_type: 1
            signal: -85
            time_config: '2026-09-23T08:00:00Z'
            rtc: 1790150400
            ts: '2026-09-23T08:00:00Z'

    State:
      name: State
      title: Control state
      correlationId:
        $ref: '#/components/correlationIds/command'
      payload:
        $ref: '#/components/schemas/State'
      examples:
        - name: commandAck
          summary: Reply to `cmd/power` with value false (applied).
          payload:
            state:
              power: false
              paused: false
              child_lock: false
            source: USER
            correlation_id: c0a8012e-5d1f-4c6b-9a57-1f0e2b3c4d5e
            ts: '2026-09-23T08:00:01Z'
        - name: deviceChange
          summary: Physical button, no correlation_id.
          payload:
            state:
              power: true
              paused: false
              child_lock: true
            source: DEVICE
            ts: '2026-09-23T09:12:00Z'

    JobEvent:
      name: JobEvent
      title: Job lifecycle event
      payload:
        $ref: '#/components/schemas/JobEvent'
      examples:
        - name: started
          payload:
            job_id: 01J8Z3K6W9Q2V7X4N5M1B0C3D2
            status: PENDING
            start_date: '2026-09-23T06:00:00Z'
            ts: '2026-09-23T06:00:00Z'
        - name: finished
          payload:
            job_id: 01J8Z3K6W9Q2V7X4N5M1B0C3D2
            status: SUCCESS
            start_date: '2026-09-23T06:00:00Z'
            end_date: '2026-09-23T07:30:00Z'
            worked_time_sec: 5400
            consumption: 320
            result: 1.8
            message: ''
            ts: '2026-09-23T07:30:00Z'

    ControlCommand:
      name: ControlCommand
      title: Control key command
      correlationId:
        $ref: '#/components/correlationIds/command'
      payload:
        $ref: '#/components/schemas/ControlCommand'
      examples:
        - name: powerOff
          summary: Published on `tekomi/v1/{serial}/cmd/power`.
          payload:
            correlation_id: c0a8012e-5d1f-4c6b-9a57-1f0e2b3c4d5e
            args:
              value: false
            ts: '2026-09-23T08:00:00Z'

    Command:
      name: Command
      title: Other command (reserved)
      correlationId:
        $ref: '#/components/correlationIds/command'
      payload:
        $ref: '#/components/schemas/Command'
      examples:
        - name: reboot
          payload:
            correlation_id: c0a8012e-5d1f-4c6b-9a57-1f0e2b3c4d5e
            args: {}
            ts: '2026-09-23T08:00:00Z'

    CommandResult:
      name: CommandResult
      title: Command result (reserved)
      correlationId:
        $ref: '#/components/correlationIds/command'
      payload:
        $ref: '#/components/schemas/CommandResult'
      examples:
        - name: busy
          payload:
            correlation_id: c0a8012e-5d1f-4c6b-9a57-1f0e2b3c4d5e
            ok: false
            code: BUSY
            message: cannot reboot mid-cycle
            ts: '2026-09-23T08:00:01Z'

  schemas:
    Timestamp:
      type: string
      format: date-time
      description: RFC 3339, UTC.

    CorrelationId:
      type: string
      description: |
        The ID the app sent with the command. Omitted when the app sent none.

    SysInfo:
      type: object
      required:
        - firmware_version
        - connection_type
        - signal
        - ts
      properties:
        firmware_version:
          type: string
        network_provider:
          type: string
          description: Cellular carrier; empty on Wi-Fi.
        sim_imei:
          type: string
          description: Empty on Wi-Fi.
        connection_type:
          type: integer
          enum: [0, 1, 2, 3]
          description: |
            `NetworkType`: `0` unknown, `1` cellular, `2` Wi-Fi, `3` ethernet
            (`internal/domain/tekomi/network_type.go`).
        signal:
          type: integer
          description: |
            Signal strength of the active link as the radio reports it.
            Signed, because dBm readings are negative.
        ssid:
          type: string
          description: Wi-Fi only.
        time_config:
          $ref: '#/components/schemas/Timestamp'
        rtc:
          type: integer
          minimum: 0
          description: Device RTC value.
        ts:
          $ref: '#/components/schemas/Timestamp'
      not:
        description: The Wi-Fi password must never leave the device.
        required:
          - wifi_password

    ControlState:
      type: object
      description: |
        Every control key. The server stores only known keys with boolean
        values; other keys, other types and `null` are ignored.
      required:
        - power
        - paused
        - child_lock
      properties:
        power:
          type: boolean
          description: Power on/off.
        paused:
          type: boolean
          description: Pause/resume the current job.
        child_lock:
          type: boolean
          description: Child lock.

    State:
      type: object
      required:
        - state
        - source
        - ts
      properties:
        state:
          $ref: '#/components/schemas/ControlState'
        source:
          type: string
          enum:
            - USER
            - DEVICE
          description: |
            `USER`: published in response to a control key command.
            `DEVICE`: physical button, schedule or firmware logic.
        correlation_id:
          $ref: '#/components/schemas/CorrelationId'
        ts:
          $ref: '#/components/schemas/Timestamp'

    JobEvent:
      type: object
      description: |
        One History row per device and `job_id`. Status only moves forward,
        from `PENDING` to `SUCCESS` or `ERROR`; the first final status wins, so
        redelivered and reordered events are harmless. `start_date` comes from
        the first event recorded for the job; the final event sets `end_date`,
        `worked_time_sec`, `consumption`, `result` and `message`.
      required:
        - job_id
        - status
        - ts
      properties:
        job_id:
          type: string
          minLength: 1
          maxLength: 128
          description: Generated by the device; unique per device.
        status:
          type: string
          enum:
            - PENDING
            - SUCCESS
            - ERROR
        start_date:
          $ref: '#/components/schemas/Timestamp'
        end_date:
          $ref: '#/components/schemas/Timestamp'
        worked_time_sec:
          type: integer
          minimum: 0
        consumption:
          type: integer
          minimum: 0
          maximum: 4294967295
        result:
          type: number
        message:
          type: string
        ts:
          $ref: '#/components/schemas/Timestamp'

    ControlCommand:
      type: object
      description: |
        `ts` is api-server's clock at publish. The device drops the command,
        without replying, when `ts` is more than 30 s old.
      required:
        - args
        - ts
      properties:
        correlation_id:
          $ref: '#/components/schemas/CorrelationId'
        args:
          type: object
          required:
            - value
          properties:
            value:
              type: boolean
              description: New value of the key named in the topic.
        ts:
          $ref: '#/components/schemas/Timestamp'

    Command:
      type: object
      required:
        - args
        - ts
      properties:
        correlation_id:
          $ref: '#/components/schemas/CorrelationId'
        args:
          type: object
          description: Command-specific.
        ts:
          $ref: '#/components/schemas/Timestamp'

    CommandResult:
      type: object
      required:
        - ok
        - ts
      properties:
        correlation_id:
          $ref: '#/components/schemas/CorrelationId'
        ok:
          type: boolean
        code:
          type: string
          enum:
            - INVALID_ARGS
            - BUSY
            - EXPIRED
          description: |
            `INVALID_ARGS`: `args` is missing a field or has the wrong type.
            `BUSY`: the device cannot run the command now (e.g. mid-cycle).
            `EXPIRED`: `ts` is older than the freshness window.
        message:
          type: string
        ts:
          $ref: '#/components/schemas/Timestamp'
