Support soundbox firmware MQTT topics
This commit is contained in:
@ -11,7 +11,7 @@ Keputusan arsitektur terkait:
|
||||
- Broker: Eclipse Mosquitto.
|
||||
- Domain: `broker.bizone.id`.
|
||||
- MQTT TLS publik: `8883/tcp`.
|
||||
- MQTT local-only: `1883/tcp` pada `127.0.0.1`.
|
||||
- MQTT non-TLS pilot: `1883/tcp`, default disarankan local-only; boleh dibuka publik sementara jika firmware device belum support SSL/TLS.
|
||||
- TLS: Let's Encrypt.
|
||||
- Auth: username/password.
|
||||
- Authorization: ACL topic per user/device.
|
||||
@ -48,7 +48,28 @@ sudo ufw enable
|
||||
sudo ufw status verbose
|
||||
```
|
||||
|
||||
Jangan buka `1883/tcp` ke internet. Listener `1883` hanya untuk localhost/internal test.
|
||||
Default paling aman: jangan buka `1883/tcp` ke internet. Listener `1883` cukup untuk localhost/internal test.
|
||||
|
||||
Jika firmware device belum support SSL/TLS dan harus memakai MQTT non-SSL, port `1883/tcp` boleh dibuka untuk pilot dengan risiko credential MQTT lewat clear-text. Pastikan:
|
||||
|
||||
- `allow_anonymous false`;
|
||||
- password kuat dan unik;
|
||||
- ACL aktif;
|
||||
- tidak memakai credential admin/backend untuk device;
|
||||
- segera pindah ke `8883` setelah firmware support TLS.
|
||||
|
||||
Untuk pilot public non-TLS:
|
||||
|
||||
```bash
|
||||
sudo ufw allow 1883/tcp
|
||||
sudo ufw status verbose
|
||||
```
|
||||
|
||||
Jika sumber IP device bisa diprediksi, batasi firewall:
|
||||
|
||||
```bash
|
||||
sudo ufw allow from <DEVICE_OR_NAT_PUBLIC_IP> to any port 1883 proto tcp
|
||||
```
|
||||
|
||||
## Sertifikat TLS
|
||||
|
||||
@ -131,12 +152,22 @@ Isi awal:
|
||||
```conf
|
||||
user qris-backend
|
||||
topic readwrite devices/#
|
||||
topic readwrite soundbox/#
|
||||
|
||||
pattern write devices/%u/uplink/#
|
||||
pattern read devices/%u/downlink/#
|
||||
pattern write devices/%u/heartbeat
|
||||
pattern read soundbox/%u/down
|
||||
```
|
||||
|
||||
Untuk firmware QF100 sample saat ini, config server mengembalikan topic berbasis serial number:
|
||||
|
||||
```text
|
||||
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.
|
||||
|
||||
Permission:
|
||||
|
||||
```bash
|
||||
@ -172,6 +203,21 @@ password_file /etc/mosquitto/passwd
|
||||
acl_file /etc/mosquitto/acl
|
||||
```
|
||||
|
||||
Untuk device yang belum support SSL/TLS dan harus connect dari internet, ubah listener `1883` menjadi publik:
|
||||
|
||||
```conf
|
||||
listener 1883 0.0.0.0
|
||||
protocol mqtt
|
||||
allow_anonymous false
|
||||
password_file /etc/mosquitto/passwd
|
||||
acl_file /etc/mosquitto/acl
|
||||
```
|
||||
|
||||
Jangan jalankan dua listener `1883` sekaligus. Pilih salah satu:
|
||||
|
||||
- `listener 1883 127.0.0.1` untuk local-only;
|
||||
- `listener 1883 0.0.0.0` untuk pilot public non-TLS.
|
||||
|
||||
Catatan Debian:
|
||||
- Jangan set ulang `persistence`, `persistence_location`, `log_dest`, atau `log_type` di `conf.d/qris.conf` jika sudah ada di `/etc/mosquitto/mosquitto.conf`.
|
||||
- Jika muncul error `Duplicate persistence_location value`, hapus `persistence` dan `persistence_location` dari `qris.conf`.
|
||||
@ -198,7 +244,7 @@ sudo ss -lntp | grep mosquitto
|
||||
|
||||
Expected:
|
||||
- `0.0.0.0:8883`
|
||||
- `127.0.0.1:1883`
|
||||
- `127.0.0.1:1883` untuk local-only, atau `0.0.0.0:1883` untuk pilot public non-TLS.
|
||||
|
||||
## Test Publish Subscribe
|
||||
|
||||
@ -240,6 +286,61 @@ mosquitto_pub \
|
||||
|
||||
Pesan ke topic device lain harus ditolak atau tidak sampai ke subscriber.
|
||||
|
||||
## Test Non-TLS 1883
|
||||
|
||||
Jika port `1883` dibuka untuk device non-SSL, test tanpa parameter TLS:
|
||||
|
||||
Terminal 1, subscribe sebagai backend:
|
||||
|
||||
```bash
|
||||
mosquitto_sub \
|
||||
-h broker.bizone.id \
|
||||
-p 1883 \
|
||||
-u qris-backend \
|
||||
-P 'PASSWORD_BACKEND' \
|
||||
-t 'devices/DEVICE_UUID_FROM_PLATFORM/uplink/#' \
|
||||
-v
|
||||
```
|
||||
|
||||
Terminal 2, publish sebagai device:
|
||||
|
||||
```bash
|
||||
mosquitto_pub \
|
||||
-h broker.bizone.id \
|
||||
-p 1883 \
|
||||
-u DEVICE_UUID_FROM_PLATFORM \
|
||||
-P 'PASSWORD_DEVICE' \
|
||||
-t 'devices/DEVICE_UUID_FROM_PLATFORM/uplink/dynamic-qr/request' \
|
||||
-m '{"request_id":"test-1883","amount":10000}'
|
||||
```
|
||||
|
||||
Jika device memakai config server `/speaker/dev-config`, set app server agar response MQTT ke device memakai port 1883:
|
||||
|
||||
```env
|
||||
QF100_MQTT_BROKER_HOST=broker.bizone.id
|
||||
QF100_MQTT_BROKER_PORT=1883
|
||||
QF100_MQTT_USERNAME=qris-backend
|
||||
QF100_MQTT_PASSWORD=...
|
||||
```
|
||||
|
||||
Response config device akan mengirim topic:
|
||||
|
||||
```json
|
||||
{
|
||||
"mqtt": {
|
||||
"client-id": "soundbox-DEVICE_SN",
|
||||
"subscribe-topic": "soundbox/DEVICE_SN/down",
|
||||
"publish-topic": "soundbox/DEVICE_SN/up"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Jika firmware tidak bisa resolve domain, isi `QF100_MQTT_BROKER_HOST` dengan IP broker:
|
||||
|
||||
```bash
|
||||
dig +short broker.bizone.id
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
```bash
|
||||
@ -268,6 +369,13 @@ MQTT_CONNECT_TIMEOUT_MS=5000
|
||||
MQTT_TLS=true
|
||||
```
|
||||
|
||||
Backend sebaiknya tetap memakai TLS `8883`. Untuk device non-SSL, cukup ubah env khusus response config device:
|
||||
|
||||
```env
|
||||
QF100_MQTT_BROKER_HOST=broker.bizone.id
|
||||
QF100_MQTT_BROKER_PORT=1883
|
||||
```
|
||||
|
||||
Topic kontrak yang harus dipertahankan:
|
||||
|
||||
```text
|
||||
@ -277,6 +385,8 @@ devices/{deviceId}/downlink/payment/success
|
||||
devices/{deviceId}/downlink/config/push
|
||||
devices/{deviceId}/uplink/config/ack
|
||||
devices/{deviceId}/heartbeat
|
||||
soundbox/{dev-sn}/down
|
||||
soundbox/{dev-sn}/up
|
||||
```
|
||||
|
||||
## Provisioning Credential Device
|
||||
|
||||
@ -178,7 +178,7 @@ Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu
|
||||
- `src/shared/services/mqttPublisher.ts`
|
||||
- `src/shared/orchestrators/notificationOrchestrator.ts`
|
||||
- Topic QF100:
|
||||
- `devices/{deviceId}/downlink/qf100`
|
||||
- `soundbox/{dev-sn}/down`
|
||||
- Adapter memilih format QF100 jika:
|
||||
- `device.model` mengandung `QF100`; atau
|
||||
- `capability_profile_json.mqtt_payload_profile`, `protocol_profile`, atau `vendor_protocol` bernilai `qf100`.
|
||||
@ -221,7 +221,7 @@ Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu
|
||||
1. Device boot.
|
||||
2. Firmware call backend `/speaker/dev-config`.
|
||||
3. Backend balas MQTT config.
|
||||
4. Device connect MQTT dan subscribe `devices/{deviceId}/downlink/qf100`.
|
||||
4. Device connect MQTT dan subscribe `soundbox/{dev-sn}/down`.
|
||||
5. QRIS callback paid masuk backend.
|
||||
6. Backend publish payload QF100 `category: 1`.
|
||||
7. Device bunyi nominal.
|
||||
|
||||
@ -160,13 +160,13 @@ 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/#
|
||||
MQTT_SUBSCRIBE_TOPICS=devices/+/uplink/#,soundbox/+/up
|
||||
MQTT_PUBLISH_FORCE_FAIL_ALL=false
|
||||
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS=
|
||||
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000
|
||||
|
||||
QF100_MQTT_BROKER_HOST=broker.bizone.id
|
||||
QF100_MQTT_BROKER_PORT=8883
|
||||
QF100_MQTT_BROKER_PORT=1883
|
||||
QF100_MQTT_USERNAME=qris-backend
|
||||
QF100_MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD
|
||||
QF100_MQTT_KEEP_ALIVE_SECONDS=60
|
||||
@ -320,6 +320,31 @@ sudo certbot --nginx -d sms.bizone.id
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
Jika firmware soundbox memakai config URL non-TLS `http://sms.bizone.id/speaker/dev-config` dan tidak bisa follow redirect ke HTTPS, pastikan Nginx tetap melayani `/speaker/` di port 80. Contoh server block HTTP:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name sms.bizone.id;
|
||||
|
||||
location /speaker/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Portal dashboard tetap pakai HTTPS, tetapi device config API bisa tetap diakses via HTTP khusus path `/speaker/`.
|
||||
|
||||
## 13. Create Production Users
|
||||
|
||||
```bash
|
||||
@ -374,8 +399,8 @@ Expected successful device config response:
|
||||
"client-id": "<device-id>",
|
||||
"user-name": "qris-backend",
|
||||
"password": "<mqtt-password>",
|
||||
"subscribe-topic": "devices/<device-id>/downlink/qf100",
|
||||
"publish-topic": "devices/<device-id>/uplink/qf100",
|
||||
"subscribe-topic": "soundbox/<dev-sn>/down",
|
||||
"publish-topic": "soundbox/<dev-sn>/up",
|
||||
"keep-alive": 60
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,16 +142,16 @@ async function pullQf100Config(serialNumber, label) {
|
||||
assert(config["error-code"] === 0, `${label} config error-code must be 0`);
|
||||
assert(config.mqtt?.["broker-ip"], `${label} config must include mqtt.broker-ip`);
|
||||
assert(config.mqtt?.["broker-port"], `${label} config must include mqtt.broker-port`);
|
||||
assert(config.mqtt?.["subscribe-topic"]?.includes("/downlink/qf100"), `${label} must subscribe qf100 topic`);
|
||||
assert(config.mqtt?.["subscribe-topic"] === `soundbox/${serialNumber}/down`, `${label} must subscribe soundbox SN topic`);
|
||||
return config;
|
||||
}
|
||||
|
||||
async function waitForQf100PaymentMessage(deviceId) {
|
||||
async function waitForQf100PaymentMessage(deviceId, serialNumber) {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const data = await reqAdmin(`/admin/devices/${deviceId}/mqtt-messages?direction=downlink&message_type=payment_success&limit=10`, {
|
||||
_label: `GET /admin/devices/:id/mqtt-messages attempt ${i + 1}`
|
||||
});
|
||||
const found = data.messages?.find((message) => message.topic === `devices/${deviceId}/downlink/qf100`);
|
||||
const found = data.messages?.find((message) => message.topic === `soundbox/${serialNumber}/down`);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
@ -196,7 +196,7 @@ async function triggerStaticPayment({ bundle, ts }) {
|
||||
_label: "POST /integrations/qris/callback static paid"
|
||||
});
|
||||
|
||||
const message = await waitForQf100PaymentMessage(bundle.device.id);
|
||||
const message = await waitForQf100PaymentMessage(bundle.device.id, bundle.device.serial_number);
|
||||
assert(message.payload_json?.header?.category === 1, "QF100 payment payload header.category must be 1");
|
||||
assert(message.payload_json?.data?.["pay-amount"] === 15000, "QF100 payment payload pay-amount must match");
|
||||
return message;
|
||||
@ -248,8 +248,8 @@ async function main() {
|
||||
|
||||
const staticConfig = await pullQf100Config(STATIC_SN, "static");
|
||||
const dynamicConfig = await pullQf100Config(DYNAMIC_SN, "dynamic");
|
||||
assert(staticConfig.mqtt["client-id"] === staticBundle.device.id, "static client-id must match device id");
|
||||
assert(dynamicConfig.mqtt["client-id"] === dynamicBundle.device.id, "dynamic client-id must match device id");
|
||||
assert(staticConfig.mqtt["client-id"] === `soundbox-${STATIC_SN}`, "static client-id must match serial number");
|
||||
assert(dynamicConfig.mqtt["client-id"] === `soundbox-${DYNAMIC_SN}`, "dynamic client-id must match serial number");
|
||||
|
||||
const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts });
|
||||
const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts });
|
||||
|
||||
381
soundbox-backend-mqtt-spec.md
Executable file
381
soundbox-backend-mqtt-spec.md
Executable file
@ -0,0 +1,381 @@
|
||||
# Soundbox Backend MQTT Specification
|
||||
|
||||
This document describes the backend contract used by the QF100 soundbox firmware in this project.
|
||||
|
||||
Source reference:
|
||||
|
||||
- MQTT payload parser: `app/source/MainApp/demo.c`
|
||||
- Config endpoint constants: `app/source/MainApp/globalDefine.h`
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The device communicates with the backend in two stages:
|
||||
|
||||
1. The device calls the config API to receive MQTT connection settings.
|
||||
2. The device connects to MQTT and subscribes to the topic returned by the config API.
|
||||
|
||||
The backend then publishes JSON payloads to that topic.
|
||||
|
||||
Current firmware mode:
|
||||
|
||||
- `SAMPLE_MQTT_DEMO` is enabled by default.
|
||||
- `MQTT_STRICT_TEST_DEMO` is disabled unless explicitly enabled in firmware.
|
||||
|
||||
## 2. Device Config API
|
||||
|
||||
The device sends a JSON request body to `CONFIG_ADDR`.
|
||||
|
||||
Current configured endpoint:
|
||||
|
||||
```text
|
||||
http://sms.bizone.id/speaker/dev-config
|
||||
```
|
||||
|
||||
Transport:
|
||||
|
||||
- HTTP is currently configured for the device config API.
|
||||
- The firmware calls `sdk_http_get()` with the `http://` config URL.
|
||||
- This avoids TLS/certificate validation issues during config fetch.
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"dev-model": "QF100",
|
||||
"item-number": "00",
|
||||
"dev-sn": "DEVICE_SN",
|
||||
"hardware-config": "0x0F",
|
||||
"fw-version": "1.0.0",
|
||||
"fw-build": 1,
|
||||
"app-config-version": 0,
|
||||
"imei": "IMEI",
|
||||
"imsi": "IMSI",
|
||||
"iccid": "ICCID"
|
||||
}
|
||||
```
|
||||
|
||||
### Successful Response
|
||||
|
||||
```json
|
||||
{
|
||||
"error-code": 0,
|
||||
"mqtt": {
|
||||
"broker-ip": "broker.example.com",
|
||||
"broker-port": 1883,
|
||||
"client-id": "soundbox-DEVICE_SN",
|
||||
"user-name": "mqtt_user",
|
||||
"password": "mqtt_password",
|
||||
"subscribe-topic": "soundbox/DEVICE_SN/down",
|
||||
"keep-alive": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Required Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `error-code` | number | yes | Must be `0` for success. |
|
||||
| `mqtt` | object | yes | Required when `error-code` is `0`. |
|
||||
| `mqtt.broker-ip` | string | yes | Hostname or IP. |
|
||||
| `mqtt.broker-port` | number | yes | MQTT port. |
|
||||
| `mqtt.client-id` | string | yes | MQTT client ID. |
|
||||
| `mqtt.user-name` | string | yes | MQTT username. |
|
||||
| `mqtt.password` | string | yes | MQTT password. |
|
||||
| `mqtt.subscribe-topic` | string | yes | Topic the device will subscribe to. |
|
||||
| `mqtt.keep-alive` | number | yes | Keep-alive interval in seconds. |
|
||||
|
||||
Important:
|
||||
|
||||
- Numeric fields must be JSON numbers, not strings.
|
||||
- If any required MQTT field is missing, the device treats the config as invalid.
|
||||
- `app-config-version` parsing is present but commented out in firmware, so it is not currently applied.
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"error-code": 1001
|
||||
}
|
||||
```
|
||||
|
||||
Any non-zero `error-code` is treated as config failure by the firmware.
|
||||
|
||||
## 3. MQTT Payment Payload
|
||||
|
||||
The backend publishes this payload to `mqtt.subscribe-topic`.
|
||||
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"category": 1
|
||||
},
|
||||
"data": {
|
||||
"pay-amount": 15000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Required Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `header` | object | yes | Container for message metadata. |
|
||||
| `header.category` | number | yes | Use `1` for payment amount playback. |
|
||||
| `data` | object | yes | Container for message body. |
|
||||
| `data.pay-amount` | number | yes | Must be greater than `0`. |
|
||||
|
||||
Behavior:
|
||||
|
||||
- The device formats `pay-amount` into a 12-digit string.
|
||||
- Example: `15000` becomes `000000015000`.
|
||||
- The device displays the amount and plays the payment audio.
|
||||
|
||||
Do not send `pay-amount` as a string:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"pay-amount": "15000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use a number instead:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"pay-amount": 15000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.1 MQTT Transport Security
|
||||
|
||||
Current firmware setting:
|
||||
|
||||
```c
|
||||
#define MQTT_TLS_ENABLE (0)
|
||||
```
|
||||
|
||||
This means MQTT currently connects without TLS.
|
||||
|
||||
The SDK and firmware code do support MQTT TLS:
|
||||
|
||||
- `sdk_MQTT_connect(..., tls, ...)` accepts a TLS flag.
|
||||
- The firmware already passes `MQTT_TLS_ENABLE` into `sdk_MQTT_connect()`.
|
||||
- Certificate setup hooks exist in firmware:
|
||||
- `MQTT_SERVER_CA_CRT`
|
||||
- `MQTT_CLIENT_CA_CRT`
|
||||
- `MQTT_CLIENT_PRIKEY`
|
||||
- `sdk_setx509cer(...)`
|
||||
- `sdk_setx509_own_cer(...)`
|
||||
- `sdk_setx509_prikey(...)`
|
||||
|
||||
To use MQTTS, firmware must be rebuilt with:
|
||||
|
||||
```c
|
||||
#define MQTT_TLS_ENABLE (1)
|
||||
```
|
||||
|
||||
Backend recommendation:
|
||||
|
||||
- For current firmware, expose plain MQTT, usually port `1883`.
|
||||
- For TLS firmware, expose MQTTS, usually port `8883`, and provide the correct server CA chain expected by the firmware.
|
||||
- Do not assume MQTTS is active unless the firmware was rebuilt with `MQTT_TLS_ENABLE (1)`.
|
||||
|
||||
## 4. MQTT OTA Trigger Payload
|
||||
|
||||
The backend can trigger an OTA check by publishing:
|
||||
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"category": 2
|
||||
},
|
||||
"data": {
|
||||
"fw-version": "1.0.1",
|
||||
"fw-build": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Required Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `header.category` | number | yes | Use `2` for OTA trigger. |
|
||||
| `data.fw-version` | string | yes | Target firmware version. |
|
||||
| `data.fw-build` | number | yes | Target firmware build, must be greater than `0`. |
|
||||
|
||||
Behavior:
|
||||
|
||||
- If `fw-version` is greater than the current firmware version, 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.
|
||||
|
||||
## 5. Unsupported Categories
|
||||
|
||||
For any category other than `1` or `2`, the firmware still requires:
|
||||
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"category": 99
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
The current firmware does not perform any action for unsupported categories.
|
||||
|
||||
## 6. OTA Check API Response
|
||||
|
||||
After an OTA trigger, the device calls the update check API configured by `UPDATE_ADDR`.
|
||||
|
||||
The response parsed by firmware is:
|
||||
|
||||
```json
|
||||
{
|
||||
"error-code": 0,
|
||||
"version": "1.0.1",
|
||||
"build": 2,
|
||||
"file-length": 123456,
|
||||
"download-url": "https://example.com/app_fota.bin"
|
||||
}
|
||||
```
|
||||
|
||||
### Required Fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `error-code` | number | yes | Must be `0` for update available. |
|
||||
| `version` | string | yes | New firmware version. |
|
||||
| `build` | number | yes | New firmware build. |
|
||||
| `file-length` | number | yes | Must be greater than `0`. |
|
||||
| `download-url` | string | yes | Firmware download URL. |
|
||||
|
||||
Special error codes:
|
||||
|
||||
- `1002`: no update needed
|
||||
- `1005`: download version not found
|
||||
|
||||
The firmware treats both as no-update conditions.
|
||||
|
||||
## 7. OTA Result Upload
|
||||
|
||||
The device uploads OTA result to `RESULT_ADDR`.
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"dev-sn": "DEVICE_SN",
|
||||
"result": 0
|
||||
}
|
||||
```
|
||||
|
||||
The current firmware sends the request but does not parse the response body.
|
||||
|
||||
## 8. Recommended Topic Design
|
||||
|
||||
Use one downlink topic per device:
|
||||
|
||||
```text
|
||||
soundbox/{dev-sn}/down
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
soundbox/QF100123456/down
|
||||
```
|
||||
|
||||
Return the topic in config response:
|
||||
|
||||
```json
|
||||
{
|
||||
"error-code": 0,
|
||||
"mqtt": {
|
||||
"broker-ip": "broker.example.com",
|
||||
"broker-port": 1883,
|
||||
"client-id": "soundbox-QF100123456",
|
||||
"user-name": "soundbox_user",
|
||||
"password": "secret",
|
||||
"subscribe-topic": "soundbox/QF100123456/down",
|
||||
"keep-alive": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 9. End-to-End Example
|
||||
|
||||
### Config Response
|
||||
|
||||
```json
|
||||
{
|
||||
"error-code": 0,
|
||||
"mqtt": {
|
||||
"broker-ip": "broker.bizone.id",
|
||||
"broker-port": 1883,
|
||||
"client-id": "soundbox-QF100123456",
|
||||
"user-name": "soundbox_user",
|
||||
"password": "secret",
|
||||
"subscribe-topic": "soundbox/QF100123456/down",
|
||||
"keep-alive": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Payment Publish
|
||||
|
||||
Publish to:
|
||||
|
||||
```text
|
||||
soundbox/QF100123456/down
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"category": 1
|
||||
},
|
||||
"data": {
|
||||
"pay-amount": 25000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OTA Trigger Publish
|
||||
|
||||
Publish to:
|
||||
|
||||
```text
|
||||
soundbox/QF100123456/down
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"category": 2
|
||||
},
|
||||
"data": {
|
||||
"fw-version": "1.0.1",
|
||||
"fw-build": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Notes For Backend Implementation
|
||||
|
||||
- Always publish valid JSON.
|
||||
- Always use JSON numbers for numeric fields.
|
||||
- Do not use strings for `category`, `pay-amount`, `fw-build`, `broker-port`, or `keep-alive`.
|
||||
- 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.
|
||||
@ -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/#",
|
||||
MQTT_SUBSCRIBE_TOPICS: process.env.MQTT_SUBSCRIBE_TOPICS || "devices/+/uplink/#,soundbox/+/up",
|
||||
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(
|
||||
|
||||
@ -52,10 +52,7 @@ function getRequestPayload(req: Request): Qf100ConfigRequest {
|
||||
}
|
||||
|
||||
function vendorError(res: Response, code: number, message: string) {
|
||||
res.status(200).json({
|
||||
"error-code": code,
|
||||
message
|
||||
});
|
||||
res.status(200).json({ "error-code": code });
|
||||
}
|
||||
|
||||
function toDeviceDateTime(date: Date) {
|
||||
@ -165,11 +162,11 @@ async function handleDevConfig(req: Request, res: Response, next: NextFunction)
|
||||
mqtt: {
|
||||
"broker-ip": brokerHost,
|
||||
"broker-port": brokerPort,
|
||||
"client-id": device.id,
|
||||
"client-id": `soundbox-${serialNumber}`,
|
||||
"user-name": username,
|
||||
password,
|
||||
"subscribe-topic": `devices/${device.id}/downlink/qf100`,
|
||||
"publish-topic": `devices/${device.id}/uplink/qf100`,
|
||||
"subscribe-topic": `soundbox/${serialNumber}/down`,
|
||||
"publish-topic": `soundbox/${serialNumber}/up`,
|
||||
"keep-alive": env.QF100_MQTT_KEEP_ALIVE_SECONDS,
|
||||
"cert-update": 0
|
||||
},
|
||||
|
||||
@ -226,7 +226,9 @@ async function publishNotificationNow(notification: NotificationEntity, eventPay
|
||||
});
|
||||
|
||||
const device = notification.device_id ? await getDeviceById(notification.device_id) : null;
|
||||
const result = await publishPaymentSuccessForProtocol(mqttPayload, resolvePaymentProtocol(device));
|
||||
const result = await publishPaymentSuccessForProtocol(mqttPayload, resolvePaymentProtocol(device), {
|
||||
serialNumber: device?.serial_number
|
||||
});
|
||||
await createMqttMessage({
|
||||
direction: "downlink",
|
||||
device_id: notification.device_id || String(effectivePayload.device_id || ""),
|
||||
|
||||
@ -190,8 +190,8 @@ export function makePaymentSuccessTopic(deviceId: string) {
|
||||
return `devices/${deviceId}/downlink/payment/success`;
|
||||
}
|
||||
|
||||
export function makeQf100DownlinkTopic(deviceId: string) {
|
||||
return `devices/${deviceId}/downlink/qf100`;
|
||||
export function makeQf100DownlinkTopic(serialNumber: string) {
|
||||
return `soundbox/${serialNumber}/down`;
|
||||
}
|
||||
|
||||
export function makeDynamicQrResponseTopic(deviceId: string) {
|
||||
@ -258,7 +258,10 @@ export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Pro
|
||||
|
||||
export async function publishPaymentSuccessForProtocol(
|
||||
payload: PaymentSuccessPayload,
|
||||
protocol: PaymentSuccessProtocol
|
||||
protocol: PaymentSuccessProtocol,
|
||||
options: {
|
||||
serialNumber?: string;
|
||||
} = {}
|
||||
): Promise<MqttPublishResult<PaymentSuccessPayload | Qf100PaymentSuccessPayload>> {
|
||||
if (protocol !== "qf100") {
|
||||
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
|
||||
@ -273,7 +276,11 @@ export async function publishPaymentSuccessForProtocol(
|
||||
}
|
||||
};
|
||||
|
||||
return publishMqttPayload(payload.device_id, makeQf100DownlinkTopic(payload.device_id), qf100Payload);
|
||||
return publishMqttPayload(
|
||||
payload.device_id,
|
||||
makeQf100DownlinkTopic(options.serialNumber || payload.device_id),
|
||||
qf100Payload
|
||||
);
|
||||
}
|
||||
|
||||
export async function publishDynamicQrResponse(deviceId: string, payload: DynamicQrResponsePayload) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import mqtt, { type IClientOptions, type MqttClient } from "mqtt";
|
||||
import { env } from "../../config/env";
|
||||
import { createMqttMessage } from "../store/mqttMessageStore";
|
||||
import { getDeviceBySerialNumber } from "../store/deviceStore";
|
||||
|
||||
type SubscriberStatus = {
|
||||
enabled: boolean;
|
||||
@ -33,16 +34,29 @@ const status: SubscriberStatus = {
|
||||
let clientRef: MqttClient | null = null;
|
||||
let started = false;
|
||||
|
||||
function parseTopic(topic: string) {
|
||||
const match = topic.match(/^devices\/([^/]+)\/uplink\/(.+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
async function parseTopic(topic: string) {
|
||||
const deviceTopicMatch = topic.match(/^devices\/([^/]+)\/uplink\/(.+)$/);
|
||||
if (deviceTopicMatch) {
|
||||
return {
|
||||
device_id: deviceTopicMatch[1],
|
||||
message_type: deviceTopicMatch[2].replace(/\//g, "_")
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
device_id: match[1],
|
||||
message_type: match[2].replace(/\//g, "_")
|
||||
};
|
||||
const soundboxTopicMatch = topic.match(/^soundbox\/([^/]+)\/up$/);
|
||||
if (soundboxTopicMatch) {
|
||||
const device = await getDeviceBySerialNumber(soundboxTopicMatch[1]);
|
||||
if (!device) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
device_id: device.id,
|
||||
message_type: "soundbox_up"
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePayload(raw: Buffer): Record<string, unknown> {
|
||||
@ -95,25 +109,29 @@ export function startMqttSubscriber() {
|
||||
client.on("message", (topic, raw) => {
|
||||
status.received_count += 1;
|
||||
status.last_message_at = new Date().toISOString();
|
||||
const parsedTopic = parseTopic(topic);
|
||||
if (!parsedTopic) {
|
||||
status.failed_count += 1;
|
||||
status.last_error = { message: `UNSUPPORTED_TOPIC:${topic}`, at: new Date().toISOString() };
|
||||
return;
|
||||
}
|
||||
parseTopic(topic)
|
||||
.then((parsedTopic) => {
|
||||
if (!parsedTopic) {
|
||||
status.failed_count += 1;
|
||||
status.last_error = { message: `UNSUPPORTED_TOPIC:${topic}`, at: new Date().toISOString() };
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = parsePayload(raw);
|
||||
createMqttMessage({
|
||||
direction: "uplink",
|
||||
device_id: parsedTopic.device_id,
|
||||
topic,
|
||||
message_type: String(payload.message_type || parsedTopic.message_type),
|
||||
correlation_id: typeof payload.correlation_id === "string" ? payload.correlation_id : undefined,
|
||||
payload_json: payload,
|
||||
publish_status: "recorded"
|
||||
})
|
||||
.then(() => {
|
||||
status.recorded_count += 1;
|
||||
const payload = parsePayload(raw);
|
||||
return createMqttMessage({
|
||||
direction: "uplink",
|
||||
device_id: parsedTopic.device_id,
|
||||
topic,
|
||||
message_type: String(payload.message_type || parsedTopic.message_type),
|
||||
correlation_id: typeof payload.correlation_id === "string" ? payload.correlation_id : undefined,
|
||||
payload_json: payload,
|
||||
publish_status: "recorded"
|
||||
});
|
||||
})
|
||||
.then((message) => {
|
||||
if (message) {
|
||||
status.recorded_count += 1;
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
status.failed_count += 1;
|
||||
|
||||
@ -457,7 +457,7 @@
|
||||
last_messages: [
|
||||
{
|
||||
direction: "downlink",
|
||||
topic: "devices/dev_qf100_static_01/downlink/qf100",
|
||||
topic: "soundbox/SN-QF100-0001/down",
|
||||
message_type: "payment_success",
|
||||
publish_status: "sent",
|
||||
created_at: new Date(now - 75 * 1000).toISOString()
|
||||
|
||||
Reference in New Issue
Block a user