Record soundbox MQTT heartbeat
This commit is contained in:
@ -158,6 +158,7 @@ pattern write devices/%u/uplink/#
|
|||||||
pattern read devices/%u/downlink/#
|
pattern read devices/%u/downlink/#
|
||||||
pattern write devices/%u/heartbeat
|
pattern write devices/%u/heartbeat
|
||||||
pattern read soundbox/%u/down
|
pattern read soundbox/%u/down
|
||||||
|
pattern write soundbox/%u/down/heartbeat
|
||||||
```
|
```
|
||||||
|
|
||||||
Untuk firmware QF100 sample saat ini, config server mengembalikan topic berbasis serial number:
|
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
|
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:
|
Permission:
|
||||||
|
|
||||||
@ -387,6 +388,7 @@ devices/{deviceId}/uplink/config/ack
|
|||||||
devices/{deviceId}/heartbeat
|
devices/{deviceId}/heartbeat
|
||||||
soundbox/{dev-sn}/down
|
soundbox/{dev-sn}/down
|
||||||
soundbox/{dev-sn}/up
|
soundbox/{dev-sn}/up
|
||||||
|
soundbox/{dev-sn}/down/heartbeat
|
||||||
```
|
```
|
||||||
|
|
||||||
## Provisioning Credential Device
|
## Provisioning Credential Device
|
||||||
|
|||||||
@ -160,7 +160,7 @@ MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD
|
|||||||
MQTT_CLIENT_ID=qris-platform-backend-prod
|
MQTT_CLIENT_ID=qris-platform-backend-prod
|
||||||
MQTT_CONNECT_TIMEOUT_MS=5000
|
MQTT_CONNECT_TIMEOUT_MS=5000
|
||||||
MQTT_SUBSCRIBE_ENABLED=true
|
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_ALL=false
|
||||||
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS=
|
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS=
|
||||||
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000
|
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.
|
- If `fw-version` is equal but `fw-build` is greater, the device starts OTA.
|
||||||
- Otherwise, the device logs that no update is needed.
|
- 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:
|
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.
|
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`.
|
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.
|
The firmware treats both as no-update conditions.
|
||||||
|
|
||||||
## 7. OTA Result Upload
|
## 8. OTA Result Upload
|
||||||
|
|
||||||
The device uploads OTA result to `RESULT_ADDR`.
|
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.
|
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:
|
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
|
### 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 publish valid JSON.
|
||||||
- Always use JSON numbers for numeric fields.
|
- Always use JSON numbers for numeric fields.
|
||||||
@ -379,3 +493,4 @@ Payload:
|
|||||||
- Keep `client-id` unique per device.
|
- Keep `client-id` unique per device.
|
||||||
- Use the device serial number `dev-sn` as the main device identifier.
|
- 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.
|
- 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_CLIENT_ID: process.env.MQTT_CLIENT_ID || "qris-platform-backend",
|
||||||
MQTT_CONNECT_TIMEOUT_MS: Number(process.env.MQTT_CONNECT_TIMEOUT_MS || 5000),
|
MQTT_CONNECT_TIMEOUT_MS: Number(process.env.MQTT_CONNECT_TIMEOUT_MS || 5000),
|
||||||
MQTT_SUBSCRIBE_ENABLED: process.env.MQTT_SUBSCRIBE_ENABLED || "false",
|
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_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_FORCE_FAIL_DEVICE_IDS: process.env.MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS || "",
|
||||||
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number(
|
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number(
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import mqtt, { type IClientOptions, type MqttClient } from "mqtt";
|
import mqtt, { type IClientOptions, type MqttClient } from "mqtt";
|
||||||
import { env } from "../../config/env";
|
import { env } from "../../config/env";
|
||||||
import { createMqttMessage } from "../store/mqttMessageStore";
|
import { createMqttMessage } from "../store/mqttMessageStore";
|
||||||
import { getDeviceBySerialNumber } from "../store/deviceStore";
|
import { getDeviceBySerialNumber, patchDevice } from "../store/deviceStore";
|
||||||
|
import { createDeviceHeartbeat } from "../store/heartbeatStore";
|
||||||
|
|
||||||
type SubscriberStatus = {
|
type SubscriberStatus = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -34,7 +35,14 @@ const status: SubscriberStatus = {
|
|||||||
let clientRef: MqttClient | null = null;
|
let clientRef: MqttClient | null = null;
|
||||||
let started = false;
|
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\/(.+)$/);
|
const deviceTopicMatch = topic.match(/^devices\/([^/]+)\/uplink\/(.+)$/);
|
||||||
if (deviceTopicMatch) {
|
if (deviceTopicMatch) {
|
||||||
return {
|
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$/);
|
const soundboxTopicMatch = topic.match(/^soundbox\/([^/]+)\/up$/);
|
||||||
if (soundboxTopicMatch) {
|
if (soundboxTopicMatch) {
|
||||||
const device = await getDeviceBySerialNumber(soundboxTopicMatch[1]);
|
const device = await getDeviceBySerialNumber(soundboxTopicMatch[1]);
|
||||||
@ -59,6 +82,68 @@ async function parseTopic(topic: string) {
|
|||||||
return null;
|
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> {
|
function parsePayload(raw: Buffer): Record<string, unknown> {
|
||||||
const text = raw.toString("utf8");
|
const text = raw.toString("utf8");
|
||||||
try {
|
try {
|
||||||
@ -126,6 +211,11 @@ export function startMqttSubscriber() {
|
|||||||
correlation_id: typeof payload.correlation_id === "string" ? payload.correlation_id : undefined,
|
correlation_id: typeof payload.correlation_id === "string" ? payload.correlation_id : undefined,
|
||||||
payload_json: payload,
|
payload_json: payload,
|
||||||
publish_status: "recorded"
|
publish_status: "recorded"
|
||||||
|
}).then((message) => {
|
||||||
|
if (parsedTopic.is_heartbeat) {
|
||||||
|
return recordSoundboxHeartbeat(parsedTopic, payload).then(() => message);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then((message) => {
|
.then((message) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user