diff --git a/18-mqtt-broker-mosquitto-debian13.md b/18-mqtt-broker-mosquitto-debian13.md index f81c5ac..fc5cbe9 100644 --- a/18-mqtt-broker-mosquitto-debian13.md +++ b/18-mqtt-broker-mosquitto-debian13.md @@ -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 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 diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md index 0298612..72d9c84 100644 --- a/CODEX_HANDOFF.md +++ b/CODEX_HANDOFF.md @@ -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. diff --git a/DEBIAN13_APP_SERVER_SETUP.md b/DEBIAN13_APP_SERVER_SETUP.md index 3c7e8b6..75e6e7c 100644 --- a/DEBIAN13_APP_SERVER_SETUP.md +++ b/DEBIAN13_APP_SERVER_SETUP.md @@ -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": "", "user-name": "qris-backend", "password": "", - "subscribe-topic": "devices//downlink/qf100", - "publish-topic": "devices//uplink/qf100", + "subscribe-topic": "soundbox//down", + "publish-topic": "soundbox//up", "keep-alive": 60 } } diff --git a/scripts/smoke-qf100-adapter.mjs b/scripts/smoke-qf100-adapter.mjs index 9f0f0e5..3dbe927 100644 --- a/scripts/smoke-qf100-adapter.mjs +++ b/scripts/smoke-qf100-adapter.mjs @@ -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 }); diff --git a/soundbox-backend-mqtt-spec.md b/soundbox-backend-mqtt-spec.md new file mode 100755 index 0000000..ef862c2 --- /dev/null +++ b/soundbox-backend-mqtt-spec.md @@ -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. diff --git a/src/config/env.ts b/src/config/env.ts index fb3d55e..2fde221 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -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( diff --git a/src/routes/speaker.ts b/src/routes/speaker.ts index 49c481b..f467afe 100644 --- a/src/routes/speaker.ts +++ b/src/routes/speaker.ts @@ -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 }, diff --git a/src/shared/orchestrators/notificationOrchestrator.ts b/src/shared/orchestrators/notificationOrchestrator.ts index 468bbd6..b5feccd 100644 --- a/src/shared/orchestrators/notificationOrchestrator.ts +++ b/src/shared/orchestrators/notificationOrchestrator.ts @@ -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 || ""), diff --git a/src/shared/services/mqttPublisher.ts b/src/shared/services/mqttPublisher.ts index 75bb801..b0abe7b 100644 --- a/src/shared/services/mqttPublisher.ts +++ b/src/shared/services/mqttPublisher.ts @@ -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> { 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) { diff --git a/src/shared/services/mqttSubscriber.ts b/src/shared/services/mqttSubscriber.ts index 3dc0fdd..157f579 100644 --- a/src/shared/services/mqttSubscriber.ts +++ b/src/shared/services/mqttSubscriber.ts @@ -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 { @@ -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; diff --git a/ui/soundbox-ops/index.html b/ui/soundbox-ops/index.html index e6bbfa0..64011ac 100644 --- a/ui/soundbox-ops/index.html +++ b/ui/soundbox-ops/index.html @@ -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()