From 3523ca2500d487d101bbcfff46cdfd4ee4b688e7 Mon Sep 17 00:00:00 2001 From: Wira Basalamah Date: Sat, 6 Jun 2026 23:53:42 +0700 Subject: [PATCH] Align speaker config server response --- DEBIAN13_APP_SERVER_SETUP.md | 3 + src/config/env.ts | 3 + src/routes/speaker.ts | 157 ++++++++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 3 deletions(-) diff --git a/DEBIAN13_APP_SERVER_SETUP.md b/DEBIAN13_APP_SERVER_SETUP.md index 4ffb7e6..3c7e8b6 100644 --- a/DEBIAN13_APP_SERVER_SETUP.md +++ b/DEBIAN13_APP_SERVER_SETUP.md @@ -170,6 +170,9 @@ QF100_MQTT_BROKER_PORT=8883 QF100_MQTT_USERNAME=qris-backend QF100_MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD QF100_MQTT_KEEP_ALIVE_SECONDS=60 +QF100_APP_CONFIG_VERSION=21 +QF100_DYNAMIC_QR_URL= +QF100_DYNAMIC_QR_GAP_SECONDS=0 DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED=true DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS=60000 diff --git a/src/config/env.ts b/src/config/env.ts index 398f541..fb3d55e 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -40,6 +40,9 @@ export const env = { QF100_MQTT_USERNAME: process.env.QF100_MQTT_USERNAME || "", QF100_MQTT_PASSWORD: process.env.QF100_MQTT_PASSWORD || "", QF100_MQTT_KEEP_ALIVE_SECONDS: Number(process.env.QF100_MQTT_KEEP_ALIVE_SECONDS || 60), + QF100_APP_CONFIG_VERSION: Number(process.env.QF100_APP_CONFIG_VERSION || 0), + QF100_DYNAMIC_QR_URL: process.env.QF100_DYNAMIC_QR_URL || "", + QF100_DYNAMIC_QR_GAP_SECONDS: Number(process.env.QF100_DYNAMIC_QR_GAP_SECONDS || 0), DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED: process.env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED || "true", DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000), DYNAMIC_QR_EXPIRY_SWEEP_LIMIT: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100), diff --git a/src/routes/speaker.ts b/src/routes/speaker.ts index b4236ae..49c481b 100644 --- a/src/routes/speaker.ts +++ b/src/routes/speaker.ts @@ -18,6 +18,16 @@ type Qf100ConfigRequest = { iccid?: string; }; +type SpeakerReportRequest = Qf100ConfigRequest & { + result?: number; + "success-list"?: string[]; + "fail-list"?: string[]; + "wifi-ap"?: Record; + "wifi-ap-list"?: Record[]; + "main-cell-info"?: Record; + "neighbor-cell-list"?: Record[]; +}; + function parseBrokerUrl() { if (!env.MQTT_BROKER_URL) { return { host: "", port: 0 }; @@ -48,6 +58,50 @@ function vendorError(res: Response, code: number, message: string) { }); } +function toDeviceDateTime(date: Date) { + const pad = (value: number) => String(value).padStart(2, "0"); + return [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()) + ].join(""); +} + +function numberFromPayload(value: unknown, fallback: number) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function getAppConfigVersion(payload: Qf100ConfigRequest) { + const requested = numberFromPayload(payload["app-config-version"], 1); + return Math.max(env.QF100_APP_CONFIG_VERSION || requested, requested); +} + +function buildDefaultAudioConfig() { + return { + "voice-type": 2, + "voice-volume": 80, + "voice-format": 1, + "dialect-id": 0 + }; +} + +function buildOptionalDynamicQrConfig(device: { capability_profile_json?: Record }) { + const profile = device.capability_profile_json || {}; + const url = String(profile.dynamic_qr_url || profile.dynamicQrUrl || env.QF100_DYNAMIC_QR_URL || "").trim(); + if (!url) { + return null; + } + + return { + url, + gap: numberFromPayload(profile.dynamic_qr_gap || profile.dynamicQrGap, env.QF100_DYNAMIC_QR_GAP_SECONDS) + }; +} + async function handleDevConfig(req: Request, res: Response, next: NextFunction) { try { const payload = getRequestPayload(req); @@ -101,12 +155,13 @@ async function handleDevConfig(req: Request, res: Response, next: NextFunction) return vendorError(res, 1003, "mqtt broker is not configured"); } + const dynamicQrConfig = buildOptionalDynamicQrConfig(device); res.json({ "error-code": 0, "bind-state": 1, - "app-config-version": 1, + "app-config-version": getAppConfigVersion(payload), "time-stamp": Math.floor(now.getTime() / 1000), - "date-time": now.toISOString().replace(/[-:TZ.]/g, "").slice(0, 14), + "date-time": toDeviceDateTime(now), mqtt: { "broker-ip": brokerHost, "broker-port": brokerPort, @@ -117,7 +172,98 @@ async function handleDevConfig(req: Request, res: Response, next: NextFunction) "publish-topic": `devices/${device.id}/uplink/qf100`, "keep-alive": env.QF100_MQTT_KEEP_ALIVE_SECONDS, "cert-update": 0 - } + }, + audio: buildDefaultAudioConfig(), + "trade-audio-templates": [], + "discount-audio-templates": [], + "verify-code-audio-templates": [], + "power-on-audio": { + "play-mode": 1, + "voice-format": 1, + name: "", + data: "" + }, + "power-off-audio": { + "play-mode": 1, + "voice-format": 1, + name: "", + data: "" + }, + "ad-audio": { + "play-mode": 0, + "time-period": { + start: 8, + end: 22 + }, + interval: 0, + "pay-count": 0, + "voice-format": 1, + name: "", + data: "" + }, + "customer-audio": { + "play-mode": 0, + "voice-format": 1, + name: "", + data: "" + }, + ...(dynamicQrConfig ? { "dynamic-qr": dynamicQrConfig } : {}) + }); + } catch (error) { + next(error); + } +} + +async function recordSpeakerReport(req: Request, state: string) { + const payload = getRequestPayload(req) as SpeakerReportRequest; + const serialNumber = String(payload["dev-sn"] || "").trim(); + if (!serialNumber) { + return null; + } + + const device = await getDeviceBySerialNumber(serialNumber); + if (!device) { + return null; + } + + const now = new Date(); + await patchDevice(device.id, { + last_seen_at: now.toISOString() + }); + await createDeviceHeartbeat({ + device_id: device.id, + timestamp: now.toISOString(), + firmware_version: payload["fw-version"] ? String(payload["fw-version"]) : device.firmware_version, + state, + payload_json: { + source: `speaker_${state}`, + ...payload + } + }); + + return device; +} + +async function handleAckReport(req: Request, res: Response, next: NextFunction) { + try { + await recordSpeakerReport(req, req.path.replace(/^\//, "").replace(/-/g, "_")); + res.json({ "error-code": 0 }); + } catch (error) { + next(error); + } +} + +async function handleCheckUpdate(req: Request, res: Response, next: NextFunction) { + try { + const payload = getRequestPayload(req) as SpeakerReportRequest; + await recordSpeakerReport(req, "check_update"); + res.json({ + "error-code": 1002, + version: payload["fw-version"] || "", + build: Number(payload["fw-build"] || 0), + "file-length": 0, + "file-hash": "", + "download-url": "" }); } catch (error) { next(error); @@ -126,5 +272,10 @@ async function handleDevConfig(req: Request, res: Response, next: NextFunction) router.get("/dev-config", handleDevConfig); router.post("/dev-config", handleDevConfig); +router.post("/lbs-info", handleAckReport); +router.post("/config-update-result", handleAckReport); +router.get("/check-update", handleCheckUpdate); +router.post("/check-update", handleCheckUpdate); +router.post("/fw-update-result", handleAckReport); export default router;