{
  "version": "1",
  "prefix": "tenants/{tenantId}/sources/gateway/{chipId}",
  "directPrefix": "tenants/{tenantId}/devices/{deviceId}",
  "topics": [
    {
      "id": "gateway.status",
      "direction": "up",
      "scope": "gateway",
      "suffix": "status",
      "schema": "source-status",
      "qos": 0,
      "retain": true,
      "level": "core",
      "type": "sourceStatus",
      "handler": "source_status.js",
      "notes": "Presence. Set the MQTT last will to exactly {\"online\":false} on this topic, retained. Publish {\"online\":true,…} on connect and then every 60 s; a gateway whose last heartbeat is older than 180 s is shown offline. online:false also marks every device behind the gateway offline."
    },
    {
      "id": "gateway.firmware-response",
      "direction": "up",
      "scope": "gateway",
      "suffix": "firmware/response",
      "schema": "firmware-response",
      "qos": 0,
      "retain": false,
      "level": "core",
      "type": "firmwareResponse",
      "handler": "firmware_response.js",
      "notes": "Capability report on every connect (and in reply to a firmware/request without `type`); OTA progress while updating. The report REPLACES the stored capabilities wholesale: a protocol you do not list cannot be configured on this gateway, a feature flag you omit reads as unsupported."
    },
    {
      "id": "gateway.config-request",
      "direction": "up",
      "scope": "gateway",
      "suffix": "config/request",
      "schema": "config-request",
      "qos": 0,
      "retain": false,
      "level": "core",
      "type": "configRequest",
      "handler": "config_request.js",
      "notes": "Send on every boot AND every reconnect (config/push is not retained). The answer arrives on config/push; the same request also triggers macros/push and the job/config command, so ignore those if you do not implement them."
    },
    {
      "id": "gateway.config-push",
      "direction": "down",
      "scope": "gateway",
      "suffix": "config/push",
      "schema": "config-push",
      "qos": 1,
      "retain": false,
      "level": "core",
      "builder": "configPush",
      "notes": "Reply to config/request: the full device list, {\"unchanged\":true}, or one chunk of a large config. Also pushed unprompted when the user edits the gateway's devices or network settings."
    },
    {
      "id": "gateway.device-data",
      "direction": "up",
      "scope": "gateway",
      "suffix": "devices/{deviceId}/data",
      "schema": "data",
      "qos": 0,
      "retain": false,
      "level": "core",
      "type": "data",
      "handler": "data.js",
      "notes": "Telemetry for one device. Each message also refreshes the device's presence. Publishing faster than the account's minimum interval or hourly quota gets messages dropped and, if sustained, the device suspended."
    },
    {
      "id": "gateway.device-status",
      "direction": "up",
      "scope": "gateway",
      "suffix": "devices/{deviceId}/status",
      "schema": "device-status",
      "qos": 0,
      "retain": true,
      "level": "core",
      "type": "deviceStatus",
      "handler": "device_status.js",
      "notes": "Per-device reachability, retained. A device behind a gateway goes offline 90 s after it was last seen, so republish reachable:true (or data) at least every ~30 s for each healthy device."
    },
    {
      "id": "device.data",
      "direction": "up",
      "scope": "device",
      "suffix": "data",
      "schema": "data",
      "qos": 0,
      "retain": false,
      "level": "core",
      "type": "data",
      "handler": "data.js",
      "notes": "Telemetry from a directly connected device (protocol mqtt-direct), using the per-device MQTT credentials issued when the device was created."
    },
    {
      "id": "device.status",
      "direction": "up",
      "scope": "device",
      "suffix": "status",
      "schema": "device-status",
      "qos": 0,
      "retain": true,
      "level": "core",
      "type": "deviceStatus",
      "handler": "device_status.js",
      "notes": "Reachability of a directly connected device. Set the MQTT last will to {\"ts\":…,\"reachable\":false,\"reason\":\"lwt\"} on this topic, retained."
    },
    {
      "id": "gateway.data-backfill",
      "direction": "up",
      "scope": "gateway",
      "suffix": "data/backfill",
      "schema": "data-backfill",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "dataBackfill",
      "handler": "data_backfill.js",
      "notes": "Replay of telemetry buffered while offline: at most 40 records per message, oldest first, each naming its deviceId. NEVER replay on the live data topic — the live rate gate would suspend the device. Stored only: no dashboards, rules or last-known values."
    },
    {
      "id": "gateway.cmd",
      "direction": "down",
      "scope": "gateway",
      "suffix": "cmd",
      "schema": "gateway-cmd",
      "qos": 1,
      "retain": false,
      "level": "optional",
      "builder": "gatewayCmd",
      "notes": "Commands to the gateway itself (restart, reset/config, debug, ble/scan, job/config, …). Ignore any `command` you do not recognise. job/config is re-sent after every config/request."
    },
    {
      "id": "gateway.device-cmd",
      "direction": "down",
      "scope": "gateway",
      "suffix": "devices/{deviceId}/cmd",
      "schema": "device-cmd",
      "qos": 1,
      "retain": false,
      "level": "optional",
      "builder": "cmd",
      "notes": "Commands to one device behind the gateway: read control, Modbus writes, actuator writes. Reply on devices/{deviceId}/cmd/ack when the body carries a correlationId."
    },
    {
      "id": "gateway.device-cmd-ack",
      "direction": "up",
      "scope": "gateway",
      "suffix": "devices/{deviceId}/cmd/ack",
      "schema": "cmd-ack",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "cmdAck",
      "handler": "cmd_ack.js",
      "notes": "Acknowledges a device command, echoing its correlationId."
    },
    {
      "id": "device.cmd",
      "direction": "down",
      "scope": "device",
      "suffix": "cmd",
      "schema": "device-cmd",
      "qos": 1,
      "retain": false,
      "level": "optional",
      "builder": "directCmd",
      "notes": "Commands to a directly connected device (Modbus and actuator writes; read control is gateway-only)."
    },
    {
      "id": "device.cmd-ack",
      "direction": "up",
      "scope": "device",
      "suffix": "cmd/ack",
      "schema": "cmd-ack",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "cmdAck",
      "handler": "cmd_ack.js",
      "notes": "Acknowledges a command sent to a directly connected device."
    },
    {
      "id": "gateway.device-alert",
      "direction": "up",
      "scope": "gateway",
      "suffix": "devices/{deviceId}/alert",
      "schema": "alert",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "alert",
      "handler": "alert.js",
      "notes": "An alert raised at the edge for one device, e.g. a tag outside its config/push threshold band."
    },
    {
      "id": "device.alert",
      "direction": "up",
      "scope": "device",
      "suffix": "alert",
      "schema": "alert",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "alert",
      "handler": "alert.js",
      "notes": "An alert raised by a directly connected device."
    },
    {
      "id": "gateway.firmware-request",
      "direction": "down",
      "scope": "gateway",
      "suffix": "firmware/request",
      "schema": "firmware-request",
      "qos": 1,
      "retain": false,
      "level": "optional",
      "builder": "firmwareRequest",
      "notes": "Over-the-air update request. Report progress on firmware/response. The gateway is held busy (restart/reset refused) until a terminal status or 5 minutes."
    },
    {
      "id": "gateway.debug-response",
      "direction": "up",
      "scope": "gateway",
      "suffix": "debug/response",
      "schema": "debug-response",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "debugResponse",
      "handler": "debug_response.js",
      "notes": "Diagnostics snapshot, the reply to the debug/diag gateway command. Relayed live, not stored."
    },
    {
      "id": "gateway.debug-log",
      "direction": "up",
      "scope": "gateway",
      "suffix": "debug/log",
      "schema": "debug-log",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "debugLog",
      "handler": "debug_log.js",
      "notes": "Live log lines between debug/logs/start and debug/logs/stop, filtered by the start command's category mask. Relayed live, not stored."
    },
    {
      "id": "gateway.macros-push",
      "direction": "down",
      "scope": "gateway",
      "suffix": "macros/push",
      "schema": "macros-push",
      "qos": 1,
      "retain": false,
      "level": "optional",
      "builder": "macrosPush",
      "notes": "The complete set of macro programs for this gateway. Re-sent after every config/request; replace your whole set each time."
    },
    {
      "id": "gateway.macros-run",
      "direction": "down",
      "scope": "gateway",
      "suffix": "macros/run",
      "schema": "macros-run",
      "qos": 1,
      "retain": false,
      "level": "optional",
      "builder": "macrosRun",
      "notes": "Start a macro run. Moves physical equipment. Report it on macro/run/status."
    },
    {
      "id": "gateway.macros-abort",
      "direction": "down",
      "scope": "gateway",
      "suffix": "macros/abort",
      "schema": "macros-abort",
      "qos": 1,
      "retain": false,
      "level": "optional",
      "builder": "macrosAbort",
      "notes": "Abort one run ({runId}) or every run ({}). A network message — not a safety function."
    },
    {
      "id": "gateway.macro-run-status",
      "direction": "up",
      "scope": "gateway",
      "suffix": "macro/run/status",
      "schema": "macro-run-status",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "macroRunStatus",
      "handler": "macro_run_status.js",
      "notes": "Run lifecycle: started, step, then done or error."
    },
    {
      "id": "gateway.macro-step",
      "direction": "up",
      "scope": "gateway",
      "suffix": "macro/step",
      "schema": "macro-step",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "macroStep",
      "handler": "macro_step.js",
      "notes": "A running macro's CALL instruction: asks the backend to run a backend step (a saved webhook/MQTT action). Fire-and-forget."
    },
    {
      "id": "gateway.job",
      "direction": "up",
      "scope": "gateway",
      "suffix": "job",
      "schema": "job-event",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "jobEvent",
      "handler": "job_event.js",
      "notes": "Job boundaries from the input configured by the job/config command. localJobId is the idempotency key — re-announce the same id after a reboot."
    },
    {
      "id": "gateway.discovery-ble",
      "direction": "up",
      "scope": "gateway",
      "suffix": "discovery/ble",
      "schema": "ble-discovery",
      "qos": 0,
      "retain": false,
      "level": "optional",
      "type": "bleDiscovery",
      "handler": "discovery_ble.js",
      "notes": "Result of a ble/scan gateway command: the Bluetooth LE sensors heard nearby."
    },
    {
      "id": "gateway.device-image-meta",
      "direction": "up",
      "scope": "gateway",
      "suffix": "devices/{deviceId}/image/meta",
      "schema": "image-meta",
      "qos": 0,
      "retain": false,
      "level": "experimental",
      "type": "imageMeta",
      "handler": "image.js",
      "notes": "Header of an event-triggered camera frame relayed by a fog node; image/chunk messages follow."
    },
    {
      "id": "gateway.device-image-chunk",
      "direction": "up",
      "scope": "gateway",
      "suffix": "devices/{deviceId}/image/chunk",
      "schema": "image-chunk",
      "qos": 0,
      "retain": false,
      "level": "experimental",
      "type": "imageChunk",
      "handler": "image.js",
      "notes": "One base64 slice of the image announced by image/meta, in idx order."
    }
  ]
}
