Support soundbox firmware MQTT topics

This commit is contained in:
Wira Basalamah
2026-06-07 00:44:54 +07:00
parent 3523ca2500
commit c15bffc1d2
11 changed files with 595 additions and 55 deletions

View File

@ -11,7 +11,7 @@ Keputusan arsitektur terkait:
- Broker: Eclipse Mosquitto. - Broker: Eclipse Mosquitto.
- Domain: `broker.bizone.id`. - Domain: `broker.bizone.id`.
- MQTT TLS publik: `8883/tcp`. - 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. - TLS: Let's Encrypt.
- Auth: username/password. - Auth: username/password.
- Authorization: ACL topic per user/device. - Authorization: ACL topic per user/device.
@ -48,7 +48,28 @@ sudo ufw enable
sudo ufw status verbose 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 ## Sertifikat TLS
@ -131,12 +152,22 @@ Isi awal:
```conf ```conf
user qris-backend user qris-backend
topic readwrite devices/# topic readwrite devices/#
topic readwrite soundbox/#
pattern write devices/%u/uplink/# 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
``` ```
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: Permission:
```bash ```bash
@ -172,6 +203,21 @@ password_file /etc/mosquitto/passwd
acl_file /etc/mosquitto/acl 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: 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`. - 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`. - 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: Expected:
- `0.0.0.0:8883` - `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 ## Test Publish Subscribe
@ -240,6 +286,61 @@ mosquitto_pub \
Pesan ke topic device lain harus ditolak atau tidak sampai ke subscriber. 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 ## Monitoring
```bash ```bash
@ -268,6 +369,13 @@ MQTT_CONNECT_TIMEOUT_MS=5000
MQTT_TLS=true 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: Topic kontrak yang harus dipertahankan:
```text ```text
@ -277,6 +385,8 @@ devices/{deviceId}/downlink/payment/success
devices/{deviceId}/downlink/config/push devices/{deviceId}/downlink/config/push
devices/{deviceId}/uplink/config/ack devices/{deviceId}/uplink/config/ack
devices/{deviceId}/heartbeat devices/{deviceId}/heartbeat
soundbox/{dev-sn}/down
soundbox/{dev-sn}/up
``` ```
## Provisioning Credential Device ## Provisioning Credential Device

View File

@ -178,7 +178,7 @@ Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu
- `src/shared/services/mqttPublisher.ts` - `src/shared/services/mqttPublisher.ts`
- `src/shared/orchestrators/notificationOrchestrator.ts` - `src/shared/orchestrators/notificationOrchestrator.ts`
- Topic QF100: - Topic QF100:
- `devices/{deviceId}/downlink/qf100` - `soundbox/{dev-sn}/down`
- Adapter memilih format QF100 jika: - Adapter memilih format QF100 jika:
- `device.model` mengandung `QF100`; atau - `device.model` mengandung `QF100`; atau
- `capability_profile_json.mqtt_payload_profile`, `protocol_profile`, atau `vendor_protocol` bernilai `qf100`. - `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. 1. Device boot.
2. Firmware call backend `/speaker/dev-config`. 2. Firmware call backend `/speaker/dev-config`.
3. Backend balas MQTT 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. 5. QRIS callback paid masuk backend.
6. Backend publish payload QF100 `category: 1`. 6. Backend publish payload QF100 `category: 1`.
7. Device bunyi nominal. 7. Device bunyi nominal.

View File

@ -160,13 +160,13 @@ 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/# MQTT_SUBSCRIBE_TOPICS=devices/+/uplink/#,soundbox/+/up
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
QF100_MQTT_BROKER_HOST=broker.bizone.id QF100_MQTT_BROKER_HOST=broker.bizone.id
QF100_MQTT_BROKER_PORT=8883 QF100_MQTT_BROKER_PORT=1883
QF100_MQTT_USERNAME=qris-backend QF100_MQTT_USERNAME=qris-backend
QF100_MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD QF100_MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD
QF100_MQTT_KEEP_ALIVE_SECONDS=60 QF100_MQTT_KEEP_ALIVE_SECONDS=60
@ -320,6 +320,31 @@ sudo certbot --nginx -d sms.bizone.id
sudo certbot renew --dry-run 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 ## 13. Create Production Users
```bash ```bash
@ -374,8 +399,8 @@ Expected successful device config response:
"client-id": "<device-id>", "client-id": "<device-id>",
"user-name": "qris-backend", "user-name": "qris-backend",
"password": "<mqtt-password>", "password": "<mqtt-password>",
"subscribe-topic": "devices/<device-id>/downlink/qf100", "subscribe-topic": "soundbox/<dev-sn>/down",
"publish-topic": "devices/<device-id>/uplink/qf100", "publish-topic": "soundbox/<dev-sn>/up",
"keep-alive": 60 "keep-alive": 60
} }
} }

View File

@ -142,16 +142,16 @@ async function pullQf100Config(serialNumber, label) {
assert(config["error-code"] === 0, `${label} config error-code must be 0`); 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-ip"], `${label} config must include mqtt.broker-ip`);
assert(config.mqtt?.["broker-port"], `${label} config must include mqtt.broker-port`); 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; return config;
} }
async function waitForQf100PaymentMessage(deviceId) { async function waitForQf100PaymentMessage(deviceId, serialNumber) {
for (let i = 0; i < 20; i += 1) { 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`, { 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}` _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) { if (found) {
return found; return found;
} }
@ -196,7 +196,7 @@ async function triggerStaticPayment({ bundle, ts }) {
_label: "POST /integrations/qris/callback static paid" _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?.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"); assert(message.payload_json?.data?.["pay-amount"] === 15000, "QF100 payment payload pay-amount must match");
return message; return message;
@ -248,8 +248,8 @@ async function main() {
const staticConfig = await pullQf100Config(STATIC_SN, "static"); const staticConfig = await pullQf100Config(STATIC_SN, "static");
const dynamicConfig = await pullQf100Config(DYNAMIC_SN, "dynamic"); const dynamicConfig = await pullQf100Config(DYNAMIC_SN, "dynamic");
assert(staticConfig.mqtt["client-id"] === staticBundle.device.id, "static 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"] === dynamicBundle.device.id, "dynamic client-id must match device id"); assert(dynamicConfig.mqtt["client-id"] === `soundbox-${DYNAMIC_SN}`, "dynamic client-id must match serial number");
const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts }); const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts });
const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts }); const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts });

381
soundbox-backend-mqtt-spec.md Executable file
View 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.

View File

@ -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/#", 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_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(

View File

@ -52,10 +52,7 @@ function getRequestPayload(req: Request): Qf100ConfigRequest {
} }
function vendorError(res: Response, code: number, message: string) { function vendorError(res: Response, code: number, message: string) {
res.status(200).json({ res.status(200).json({ "error-code": code });
"error-code": code,
message
});
} }
function toDeviceDateTime(date: Date) { function toDeviceDateTime(date: Date) {
@ -165,11 +162,11 @@ async function handleDevConfig(req: Request, res: Response, next: NextFunction)
mqtt: { mqtt: {
"broker-ip": brokerHost, "broker-ip": brokerHost,
"broker-port": brokerPort, "broker-port": brokerPort,
"client-id": device.id, "client-id": `soundbox-${serialNumber}`,
"user-name": username, "user-name": username,
password, password,
"subscribe-topic": `devices/${device.id}/downlink/qf100`, "subscribe-topic": `soundbox/${serialNumber}/down`,
"publish-topic": `devices/${device.id}/uplink/qf100`, "publish-topic": `soundbox/${serialNumber}/up`,
"keep-alive": env.QF100_MQTT_KEEP_ALIVE_SECONDS, "keep-alive": env.QF100_MQTT_KEEP_ALIVE_SECONDS,
"cert-update": 0 "cert-update": 0
}, },

View File

@ -226,7 +226,9 @@ async function publishNotificationNow(notification: NotificationEntity, eventPay
}); });
const device = notification.device_id ? await getDeviceById(notification.device_id) : null; 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({ await createMqttMessage({
direction: "downlink", direction: "downlink",
device_id: notification.device_id || String(effectivePayload.device_id || ""), device_id: notification.device_id || String(effectivePayload.device_id || ""),

View File

@ -190,8 +190,8 @@ export function makePaymentSuccessTopic(deviceId: string) {
return `devices/${deviceId}/downlink/payment/success`; return `devices/${deviceId}/downlink/payment/success`;
} }
export function makeQf100DownlinkTopic(deviceId: string) { export function makeQf100DownlinkTopic(serialNumber: string) {
return `devices/${deviceId}/downlink/qf100`; return `soundbox/${serialNumber}/down`;
} }
export function makeDynamicQrResponseTopic(deviceId: string) { export function makeDynamicQrResponseTopic(deviceId: string) {
@ -258,7 +258,10 @@ export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Pro
export async function publishPaymentSuccessForProtocol( export async function publishPaymentSuccessForProtocol(
payload: PaymentSuccessPayload, payload: PaymentSuccessPayload,
protocol: PaymentSuccessProtocol protocol: PaymentSuccessProtocol,
options: {
serialNumber?: string;
} = {}
): Promise<MqttPublishResult<PaymentSuccessPayload | Qf100PaymentSuccessPayload>> { ): Promise<MqttPublishResult<PaymentSuccessPayload | Qf100PaymentSuccessPayload>> {
if (protocol !== "qf100") { if (protocol !== "qf100") {
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload); 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) { export async function publishDynamicQrResponse(deviceId: string, payload: DynamicQrResponsePayload) {

View File

@ -1,6 +1,7 @@
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";
type SubscriberStatus = { type SubscriberStatus = {
enabled: boolean; enabled: boolean;
@ -33,16 +34,29 @@ const status: SubscriberStatus = {
let clientRef: MqttClient | null = null; let clientRef: MqttClient | null = null;
let started = false; let started = false;
function parseTopic(topic: string) { async function parseTopic(topic: string) {
const match = topic.match(/^devices\/([^/]+)\/uplink\/(.+)$/); const deviceTopicMatch = topic.match(/^devices\/([^/]+)\/uplink\/(.+)$/);
if (!match) { if (deviceTopicMatch) {
return null; return {
device_id: deviceTopicMatch[1],
message_type: deviceTopicMatch[2].replace(/\//g, "_")
};
} }
return { const soundboxTopicMatch = topic.match(/^soundbox\/([^/]+)\/up$/);
device_id: match[1], if (soundboxTopicMatch) {
message_type: match[2].replace(/\//g, "_") 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> { function parsePayload(raw: Buffer): Record<string, unknown> {
@ -95,25 +109,29 @@ export function startMqttSubscriber() {
client.on("message", (topic, raw) => { client.on("message", (topic, raw) => {
status.received_count += 1; status.received_count += 1;
status.last_message_at = new Date().toISOString(); status.last_message_at = new Date().toISOString();
const parsedTopic = parseTopic(topic); parseTopic(topic)
if (!parsedTopic) { .then((parsedTopic) => {
status.failed_count += 1; if (!parsedTopic) {
status.last_error = { message: `UNSUPPORTED_TOPIC:${topic}`, at: new Date().toISOString() }; status.failed_count += 1;
return; status.last_error = { message: `UNSUPPORTED_TOPIC:${topic}`, at: new Date().toISOString() };
} return null;
}
const payload = parsePayload(raw); const payload = parsePayload(raw);
createMqttMessage({ return createMqttMessage({
direction: "uplink", direction: "uplink",
device_id: parsedTopic.device_id, device_id: parsedTopic.device_id,
topic, topic,
message_type: String(payload.message_type || parsedTopic.message_type), message_type: String(payload.message_type || parsedTopic.message_type),
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(() => { })
status.recorded_count += 1; .then((message) => {
if (message) {
status.recorded_count += 1;
}
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
status.failed_count += 1; status.failed_count += 1;

View File

@ -457,7 +457,7 @@
last_messages: [ last_messages: [
{ {
direction: "downlink", direction: "downlink",
topic: "devices/dev_qf100_static_01/downlink/qf100", topic: "soundbox/SN-QF100-0001/down",
message_type: "payment_success", message_type: "payment_success",
publish_status: "sent", publish_status: "sent",
created_at: new Date(now - 75 * 1000).toISOString() created_at: new Date(now - 75 * 1000).toISOString()