Record soundbox MQTT heartbeat
This commit is contained in:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user