diff --git a/18-mqtt-broker-mosquitto-debian13.md b/18-mqtt-broker-mosquitto-debian13.md index fc5cbe9..be32500 100644 --- a/18-mqtt-broker-mosquitto-debian13.md +++ b/18-mqtt-broker-mosquitto-debian13.md @@ -158,6 +158,7 @@ pattern write devices/%u/uplink/# pattern read devices/%u/downlink/# pattern write devices/%u/heartbeat pattern read soundbox/%u/down +pattern write soundbox/%u/down/heartbeat ``` Untuk firmware QF100 sample saat ini, config server mengembalikan topic berbasis serial number: @@ -166,7 +167,7 @@ Untuk firmware QF100 sample saat ini, config server mengembalikan topic berbasis soundbox/{dev-sn}/down ``` -Jika masih memakai user MQTT bersama `qris-backend` untuk pilot, rule `topic readwrite soundbox/#` wajib ada. Jika nanti per-device credential memakai username sama dengan `dev-sn`, rule `pattern read soundbox/%u/down` bisa dipakai untuk membatasi tiap device hanya membaca topic miliknya sendiri. +Jika masih memakai user MQTT bersama `qris-backend` untuk pilot, rule `topic readwrite soundbox/#` wajib ada. Jika nanti per-device credential memakai username sama dengan `dev-sn`, rule `pattern read soundbox/%u/down` dan `pattern write soundbox/%u/down/heartbeat` bisa dipakai untuk membatasi tiap device hanya membaca/publish heartbeat topic miliknya sendiri. Permission: @@ -387,6 +388,7 @@ devices/{deviceId}/uplink/config/ack devices/{deviceId}/heartbeat soundbox/{dev-sn}/down soundbox/{dev-sn}/up +soundbox/{dev-sn}/down/heartbeat ``` ## Provisioning Credential Device diff --git a/DEBIAN13_APP_SERVER_SETUP.md b/DEBIAN13_APP_SERVER_SETUP.md index 75e6e7c..a73c4d4 100644 --- a/DEBIAN13_APP_SERVER_SETUP.md +++ b/DEBIAN13_APP_SERVER_SETUP.md @@ -160,7 +160,7 @@ MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD MQTT_CLIENT_ID=qris-platform-backend-prod MQTT_CONNECT_TIMEOUT_MS=5000 MQTT_SUBSCRIBE_ENABLED=true -MQTT_SUBSCRIBE_TOPICS=devices/+/uplink/#,soundbox/+/up +MQTT_SUBSCRIBE_TOPICS=devices/+/uplink/#,soundbox/+/up,soundbox/+/down/heartbeat MQTT_PUBLISH_FORCE_FAIL_ALL=false MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS= MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000 diff --git a/soundbox-backend-mqtt-spec.md b/soundbox-backend-mqtt-spec.md index ef862c2..bf85a5d 100755 --- a/soundbox-backend-mqtt-spec.md +++ b/soundbox-backend-mqtt-spec.md @@ -215,7 +215,89 @@ Behavior: - If `fw-version` is equal but `fw-build` is greater, the device starts OTA. - Otherwise, the device logs that no update is needed. -## 5. Unsupported Categories +## 5. MQTT Device Heartbeat + +The firmware publishes an application-level heartbeat over MQTT after it has connected and subscribed successfully. + +Publish topic used by the device: + +```text +{mqtt.subscribe-topic}/heartbeat +``` + +Example: + +```text +soundbox/QF100123456/down/heartbeat +``` + +Payload: + +```json +{ + "header": { + "category": 3 + }, + "data": { + "dev-sn": "QF100123456", + "client-id": "soundbox-QF100123456", + "fw-version": "1.0.0", + "fw-build": 1, + "time": "20260607123045", + "battery-level": 80, + "wifi-ap": { + "ssid": "testap1", + "mac": "00:0f:e2:4e:aa:3e", + "rssi": -13 + }, + "main-cell-info": { + "mcc": 460, + "mnc": 0, + "rssi": 20 + } + } +} +``` + +Timing: + +- The device publishes one heartbeat immediately after MQTT subscribe succeeds. +- The device then publishes periodically using `mqtt.keep-alive` seconds from the config response. +- If `mqtt.keep-alive` is `0`, firmware falls back to `60` seconds. + +Backend handling: + +- Subscribe to `{mqtt.subscribe-topic}/heartbeat`, or a wildcard such as `soundbox/+/down/heartbeat`. +- Treat each heartbeat message as the device's last-seen timestamp. +- This is separate from MQTT protocol `PINGREQ`/`PINGRESP`, which is handled by the broker and is not delivered as a subscribed message. + +### Heartbeat Fields + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `header.category` | number | yes | Always `3` for device heartbeat. | +| `data.dev-sn` | string | yes | Device serial number. | +| `data.client-id` | string | yes | MQTT client ID from config response. | +| `data.fw-version` | string | yes | Firmware version. | +| `data.fw-build` | number | yes | Firmware build number. | +| `data.time` | string | yes | Device local time in `YYYYMMDDHHMMSS` format. | +| `data.battery-level` | number | yes | Battery percentage calculated by firmware, `0` to `100`. | +| `data.wifi-ap` | object | optional | Present only when WiFi data is available. | +| `data.wifi-ap.ssid` | string | optional | Current configured WiFi SSID. | +| `data.wifi-ap.mac` | string | optional | AP MAC/BSSID, included only when the firmware can match it from WiFi scan result. | +| `data.wifi-ap.rssi` | number | optional | Connected WiFi RSSI from SDK. | +| `data.main-cell-info` | object | optional | Present only when GPRS/cellular data is available. | +| `data.main-cell-info.mcc` | number | optional | Parsed from IMSI when available. | +| `data.main-cell-info.mnc` | number | optional | Parsed from IMSI when available. | +| `data.main-cell-info.rssi` | number | optional | Cellular signal quality reported by the modem API. Current firmware does not expose LAC/cell-id through available SDK headers. | + +Important: + +- `wifi-ap` and `main-cell-info` are optional. Backend should not reject heartbeat if either object is absent. +- `wifi-ap.mac` is optional because the current SDK exposes AP MAC through scan results, not a direct "current BSSID" API. +- `main-cell-info.lac` and `main-cell-info.cell-id` are not sent by this firmware build because no SDK API for those values is available in this repo. + +## 6. Unsupported Categories For any category other than `1` or `2`, the firmware still requires: @@ -230,7 +312,7 @@ For any category other than `1` or `2`, the firmware still requires: The current firmware does not perform any action for unsupported categories. -## 6. OTA Check API Response +## 7. OTA Check API Response After an OTA trigger, the device calls the update check API configured by `UPDATE_ADDR`. @@ -263,7 +345,7 @@ Special error codes: The firmware treats both as no-update conditions. -## 7. OTA Result Upload +## 8. OTA Result Upload The device uploads OTA result to `RESULT_ADDR`. @@ -278,7 +360,7 @@ Request body: The current firmware sends the request but does not parse the response body. -## 8. Recommended Topic Design +## 9. Recommended Topic Design Use one downlink topic per device: @@ -309,7 +391,13 @@ Return the topic in config response: } ``` -## 9. End-to-End Example +Backend can listen for heartbeat on: + +```text +soundbox/{dev-sn}/down/heartbeat +``` + +## 10. End-to-End Example ### Config Response @@ -371,7 +459,33 @@ Payload: } ``` -## 10. Notes For Backend Implementation +### Heartbeat From Device + +Subscribe to: + +```text +soundbox/QF100123456/down/heartbeat +``` + +Payload received: + +```json +{ + "header": { + "category": 3 + }, + "data": { + "dev-sn": "QF100123456", + "client-id": "soundbox-QF100123456", + "fw-version": "1.0.0", + "fw-build": 1, + "time": "20260607123045", + "battery-level": 80 + } +} +``` + +## 11. Notes For Backend Implementation - Always publish valid JSON. - Always use JSON numbers for numeric fields. @@ -379,3 +493,4 @@ Payload: - Keep `client-id` unique per device. - Use the device serial number `dev-sn` as the main device identifier. - The firmware logs MQTT payloads through CATStudio/DIAG, useful for debugging invalid payloads. +- Use MQTT broker ACLs carefully: the device must be allowed to publish to `{subscribe-topic}/heartbeat`. diff --git a/src/config/env.ts b/src/config/env.ts index 2fde221..18426de 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -29,7 +29,7 @@ export const env = { MQTT_CLIENT_ID: process.env.MQTT_CLIENT_ID || "qris-platform-backend", MQTT_CONNECT_TIMEOUT_MS: Number(process.env.MQTT_CONNECT_TIMEOUT_MS || 5000), MQTT_SUBSCRIBE_ENABLED: process.env.MQTT_SUBSCRIBE_ENABLED || "false", - MQTT_SUBSCRIBE_TOPICS: process.env.MQTT_SUBSCRIBE_TOPICS || "devices/+/uplink/#,soundbox/+/up", + MQTT_SUBSCRIBE_TOPICS: process.env.MQTT_SUBSCRIBE_TOPICS || "devices/+/uplink/#,soundbox/+/up,soundbox/+/down/heartbeat", MQTT_PUBLISH_FORCE_FAIL_ALL: process.env.MQTT_PUBLISH_FORCE_FAIL_ALL || "false", MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS: process.env.MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS || "", MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number( diff --git a/src/shared/services/mqttSubscriber.ts b/src/shared/services/mqttSubscriber.ts index 157f579..aec5de4 100644 --- a/src/shared/services/mqttSubscriber.ts +++ b/src/shared/services/mqttSubscriber.ts @@ -1,7 +1,8 @@ import mqtt, { type IClientOptions, type MqttClient } from "mqtt"; import { env } from "../../config/env"; import { createMqttMessage } from "../store/mqttMessageStore"; -import { getDeviceBySerialNumber } from "../store/deviceStore"; +import { getDeviceBySerialNumber, patchDevice } from "../store/deviceStore"; +import { createDeviceHeartbeat } from "../store/heartbeatStore"; type SubscriberStatus = { enabled: boolean; @@ -34,7 +35,14 @@ const status: SubscriberStatus = { let clientRef: MqttClient | null = null; let started = false; -async function parseTopic(topic: string) { +type ParsedMqttTopic = { + device_id: string; + message_type: string; + serial_number?: string; + is_heartbeat?: boolean; +}; + +async function parseTopic(topic: string): Promise { const deviceTopicMatch = topic.match(/^devices\/([^/]+)\/uplink\/(.+)$/); if (deviceTopicMatch) { return { @@ -43,6 +51,21 @@ async function parseTopic(topic: string) { }; } + const soundboxHeartbeatMatch = topic.match(/^soundbox\/([^/]+)\/down\/heartbeat$/); + if (soundboxHeartbeatMatch) { + const device = await getDeviceBySerialNumber(soundboxHeartbeatMatch[1]); + if (!device) { + return null; + } + + return { + device_id: device.id, + message_type: "soundbox_heartbeat", + serial_number: soundboxHeartbeatMatch[1], + is_heartbeat: true + }; + } + const soundboxTopicMatch = topic.match(/^soundbox\/([^/]+)\/up$/); if (soundboxTopicMatch) { const device = await getDeviceBySerialNumber(soundboxTopicMatch[1]); @@ -59,6 +82,68 @@ async function parseTopic(topic: string) { return null; } +function parseDeviceTime(value: unknown) { + if (typeof value !== "string") { + return new Date().toISOString(); + } + + const match = value.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/); + if (!match) { + return new Date().toISOString(); + } + + const parsed = new Date( + Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6]) + ) + ); + return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : new Date().toISOString(); +} + +function numberOrNull(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +async function recordSoundboxHeartbeat(parsedTopic: ParsedMqttTopic, payload: Record) { + const data = payload.data && typeof payload.data === "object" && !Array.isArray(payload.data) + ? (payload.data as Record) + : {}; + const wifiAp = data["wifi-ap"] && typeof data["wifi-ap"] === "object" && !Array.isArray(data["wifi-ap"]) + ? (data["wifi-ap"] as Record) + : {}; + const mainCellInfo = data["main-cell-info"] && typeof data["main-cell-info"] === "object" && !Array.isArray(data["main-cell-info"]) + ? (data["main-cell-info"] as Record) + : {}; + const timestamp = parseDeviceTime(data.time); + const networkStrength = numberOrNull(wifiAp.rssi) ?? numberOrNull(mainCellInfo.rssi); + const batteryLevel = numberOrNull(data["battery-level"]); + const firmwareVersion = typeof data["fw-version"] === "string" ? data["fw-version"] : undefined; + + await patchDevice(parsedTopic.device_id, { + last_seen_at: new Date().toISOString(), + ...(firmwareVersion ? { firmware_version: firmwareVersion } : {}) + }); + await createDeviceHeartbeat({ + device_id: parsedTopic.device_id, + timestamp, + firmware_version: firmwareVersion, + network_strength: networkStrength, + battery_level: batteryLevel, + state: "mqtt_heartbeat", + payload_json: { + source: "soundbox_mqtt_heartbeat", + serial_number: parsedTopic.serial_number, + ...payload + } + }); +} + function parsePayload(raw: Buffer): Record { const text = raw.toString("utf8"); try { @@ -126,6 +211,11 @@ export function startMqttSubscriber() { correlation_id: typeof payload.correlation_id === "string" ? payload.correlation_id : undefined, payload_json: payload, publish_status: "recorded" + }).then((message) => { + if (parsedTopic.is_heartbeat) { + return recordSoundboxHeartbeat(parsedTopic, payload).then(() => message); + } + return message; }); }) .then((message) => {