Record soundbox MQTT heartbeat

This commit is contained in:
Wira Basalamah
2026-06-07 01:49:36 +07:00
parent c15bffc1d2
commit 1550484d1d
5 changed files with 218 additions and 11 deletions

View File

@ -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

View File

@ -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

View File

@ -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`.

View File

@ -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(

View File

@ -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<ParsedMqttTopic | null> {
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<string, unknown>) {
const data = payload.data && typeof payload.data === "object" && !Array.isArray(payload.data)
? (payload.data as Record<string, unknown>)
: {};
const wifiAp = data["wifi-ap"] && typeof data["wifi-ap"] === "object" && !Array.isArray(data["wifi-ap"])
? (data["wifi-ap"] as Record<string, unknown>)
: {};
const mainCellInfo = data["main-cell-info"] && typeof data["main-cell-info"] === "object" && !Array.isArray(data["main-cell-info"])
? (data["main-cell-info"] as Record<string, unknown>)
: {};
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<string, unknown> {
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) => {