diff --git a/scripts/smoke-qf100-adapter.mjs b/scripts/smoke-qf100-adapter.mjs index 3dbe927..d244ddd 100644 --- a/scripts/smoke-qf100-adapter.mjs +++ b/scripts/smoke-qf100-adapter.mjs @@ -223,6 +223,45 @@ async function triggerDynamicMqttQr({ bundle, ts }) { return response; } +async function triggerDynamicQrDisplay({ bundle }) { + const result = await reqAdmin(`/admin/devices/${bundle.device.id}/commands`, { + method: "POST", + body: { + command: "dynamic_qr.display", + payload: { + "qr-url": "https://pay.example/qr/qf100-smoke", + amount: 25000, + "expire-seconds": 60 + } + }, + _label: "POST /admin/devices/:id/commands dynamic QR display" + }); + + assert(result.status === "delivered", "dynamic QR display command must be delivered"); + assert(result.result_payload?.topic === `soundbox/${bundle.device.serial_number}/down`, "dynamic QR display topic must use serial number"); + assert(result.result_payload?.payload?.header?.category === 4, "dynamic QR display category must be 4"); + return result; +} + +async function triggerRebootCommand({ bundle }) { + const result = await reqAdmin(`/admin/devices/${bundle.device.id}/commands`, { + method: "POST", + body: { + command: "device.reboot", + payload: { + requested_from: "qf100_smoke" + } + }, + _label: "POST /admin/devices/:id/commands reboot" + }); + + assert(result.status === "delivered", "reboot command must be delivered"); + assert(result.result_payload?.topic === `soundbox/${bundle.device.serial_number}/down`, "reboot topic must use serial number"); + assert(result.result_payload?.payload?.header?.category === 5, "reboot category must be 5"); + assert(result.result_payload?.payload?.data?.command === "reboot", "reboot command payload must be reboot"); + return result; +} + async function main() { await req("/health", { _label: "GET /health" }); const ts = Date.now(); @@ -253,6 +292,8 @@ async function main() { const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts }); const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts }); + const dynamicQrDisplay = await triggerDynamicQrDisplay({ bundle: dynamicBundle }); + const rebootCommand = await triggerRebootCommand({ bundle: staticBundle }); console.log("\nQF100 adapter smoke passed"); console.log(`static_sn=${STATIC_SN}`); @@ -261,6 +302,8 @@ async function main() { console.log(`dynamic_sn=${DYNAMIC_SN}`); console.log(`dynamic_device_id=${dynamicBundle.device.id}`); console.log(`dynamic_transaction_id=${dynamicQr.transaction_id}`); + console.log(`dynamic_qr_display_command=${dynamicQrDisplay.id || dynamicQrDisplay.command_id}`); + console.log(`reboot_command=${rebootCommand.id || rebootCommand.command_id}`); } main().catch((error) => { diff --git a/soundbox-backend-mqtt-spec.md b/soundbox-backend-mqtt-spec.md index bf85a5d..20ed236 100755 --- a/soundbox-backend-mqtt-spec.md +++ b/soundbox-backend-mqtt-spec.md @@ -215,7 +215,70 @@ Behavior: - If `fw-version` is equal but `fw-build` is greater, the device starts OTA. - Otherwise, the device logs that no update is needed. -## 5. MQTT Device Heartbeat +## 5. MQTT Dynamic QR Display Payload + +The backend can ask the device to display a dynamic QR code by publishing to `mqtt.subscribe-topic`: + +```json +{ + "header": { + "category": 4 + }, + "data": { + "qr-url": "https://pay.example/qr/abc123", + "amount": 25000, + "expire-seconds": 60 + } +} +``` + +### Required Fields + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `header.category` | number | yes | Use `4` for dynamic QR display. | +| `data.qr-url` | string | yes | Content encoded into the QR code. This can be a URL or any QR payload string. | +| `data.amount` | number | yes | Amount shown on the QR page. Must be greater than `0`. | +| `data.expire-seconds` | number | no | QR validity duration in seconds. Defaults to `60` if missing or invalid. | + +Behavior: + +- The device displays the QR code immediately after receiving a valid category `4` payload. +- The QR page shows `data.qr-url` as QR content and displays `data.amount`. +- When `expire-seconds` elapses, the device returns to the default `Bizone System` status screen. +- If a category `1` payment notification is received while the QR page is active, the device immediately hides the QR page and returns to the default status screen before playing the payment notification. +- This command only controls the display. It does not start the internal POS QR transaction flow. + +## 6. MQTT Reboot Command Payload + +The backend can ask the device to reboot by publishing to `mqtt.subscribe-topic`: + +```json +{ + "header": { + "category": 5 + }, + "data": { + "command": "reboot" + } +} +``` + +### Required Fields + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `header.category` | number | yes | Use `5` for device reboot command. | +| `data.command` | string | yes | Must be exactly `reboot`. | + +Behavior: + +- The device validates `data.command` before rebooting. +- If `data.command` is missing or not exactly `reboot`, the payload is ignored. +- When valid, the device plays the reboot audio, shows `Rebooting...`, waits about 2 seconds, then restarts. +- This command is backend-to-device only. + +## 7. MQTT Device Heartbeat The firmware publishes an application-level heartbeat over MQTT after it has connected and subscribed successfully. @@ -264,6 +327,7 @@ Timing: - The device publishes one heartbeat immediately after MQTT subscribe succeeds. - The device then publishes periodically using `mqtt.keep-alive` seconds from the config response. - If `mqtt.keep-alive` is `0`, firmware falls back to `60` seconds. +- Current firmware synchronizes device time with NTP timezone `UTC+7`, so `data.time` represents WIB local time. Backend handling: @@ -280,11 +344,11 @@ Backend handling: | `data.client-id` | string | yes | MQTT client ID from config response. | | `data.fw-version` | string | yes | Firmware version. | | `data.fw-build` | number | yes | Firmware build number. | -| `data.time` | string | yes | Device local time in `YYYYMMDDHHMMSS` format. | +| `data.time` | string | yes | Device local time in `YYYYMMDDHHMMSS` format, currently WIB/UTC+7. | | `data.battery-level` | number | yes | Battery percentage calculated by firmware, `0` to `100`. | | `data.wifi-ap` | object | optional | Present only when WiFi data is available. | | `data.wifi-ap.ssid` | string | optional | Current configured WiFi SSID. | -| `data.wifi-ap.mac` | string | optional | AP MAC/BSSID, included only when the firmware can match it from WiFi scan result. | +| `data.wifi-ap.mac` | string | optional | Reserved for AP MAC/BSSID. Current stability-focused firmware does not scan AP list during heartbeat, so this is usually absent. | | `data.wifi-ap.rssi` | number | optional | Connected WiFi RSSI from SDK. | | `data.main-cell-info` | object | optional | Present only when GPRS/cellular data is available. | | `data.main-cell-info.mcc` | number | optional | Parsed from IMSI when available. | @@ -294,12 +358,12 @@ Backend handling: Important: - `wifi-ap` and `main-cell-info` are optional. Backend should not reject heartbeat if either object is absent. -- `wifi-ap.mac` is optional because the current SDK exposes AP MAC through scan results, not a direct "current BSSID" API. +- `wifi-ap.mac` is optional because the current SDK exposes AP MAC through scan results, not a direct "current BSSID" API. The firmware avoids WiFi scanning inside routine heartbeat to keep heartbeat stable. - `main-cell-info.lac` and `main-cell-info.cell-id` are not sent by this firmware build because no SDK API for those values is available in this repo. -## 6. Unsupported Categories +## 8. Unsupported Categories -For any category other than `1` or `2`, the firmware still requires: +For unsupported categories, the firmware still requires: ```json { @@ -310,9 +374,9 @@ For any category other than `1` or `2`, the firmware still requires: } ``` -The current firmware does not perform any action for unsupported categories. +The current firmware handles categories `1`, `2`, `4`, and `5` from backend-to-device messages. Category `3` is used by device-to-backend heartbeat. Other categories do not perform any action. -## 7. OTA Check API Response +## 9. OTA Check API Response After an OTA trigger, the device calls the update check API configured by `UPDATE_ADDR`. @@ -345,7 +409,7 @@ Special error codes: The firmware treats both as no-update conditions. -## 8. OTA Result Upload +## 10. OTA Result Upload The device uploads OTA result to `RESULT_ADDR`. @@ -360,7 +424,7 @@ Request body: The current firmware sends the request but does not parse the response body. -## 9. Recommended Topic Design +## 11. Recommended Topic Design Use one downlink topic per device: @@ -397,7 +461,7 @@ Backend can listen for heartbeat on: soundbox/{dev-sn}/down/heartbeat ``` -## 10. End-to-End Example +## 12. End-to-End Example ### Config Response @@ -459,6 +523,50 @@ Payload: } ``` +### Dynamic QR Publish + +Publish to: + +```text +soundbox/QF100123456/down +``` + +Payload: + +```json +{ + "header": { + "category": 4 + }, + "data": { + "qr-url": "https://pay.example/qr/abc123", + "amount": 25000, + "expire-seconds": 60 + } +} +``` + +### Reboot Command Publish + +Publish to: + +```text +soundbox/QF100123456/down +``` + +Payload: + +```json +{ + "header": { + "category": 5 + }, + "data": { + "command": "reboot" + } +} +``` + ### Heartbeat From Device Subscribe to: @@ -485,11 +593,12 @@ Payload received: } ``` -## 11. Notes For Backend Implementation +## 13. 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`. +- Do not use strings for `category`, `pay-amount`, `amount`, `expire-seconds`, `fw-build`, `broker-port`, or `keep-alive`. +- For reboot, send `data.command` exactly as the string `reboot`. - 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/routes/admin.ts b/src/routes/admin.ts index d614669..81e7957 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -39,6 +39,7 @@ import { createDeviceHeartbeat } from "../shared/store/heartbeatStore"; import { + acknowledgeDeviceCommand, createDeviceCommand, getDeviceCommandById, listDeviceCommands, @@ -98,7 +99,7 @@ import { upsertDeviceConfig } from "../shared/store/deviceConfigStore"; import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore"; -import { getMqttPublisherStatus, publishConfigPush } from "../shared/services/mqttPublisher"; +import { getMqttPublisherStatus, publishConfigPush, publishQf100DynamicQrDisplay, publishQf100RebootCommand } from "../shared/services/mqttPublisher"; import { getMqttSubscriberStatus } from "../shared/services/mqttSubscriber"; import { getDatabaseHealth, getServiceHealth } from "../shared/services/health"; import { logger } from "../shared/services/logger"; @@ -1819,12 +1820,118 @@ router.post( return next(new ApiError("BAD_REQUEST", "command is required", 400)); } + const commandName = payload.command.trim(); const command = await createDeviceCommand({ device_id: device.id, - command: payload.command.trim(), + command: commandName, payload: payload.payload || {} }); + if (commandName === "dynamic_qr.test" || commandName === "dynamic_qr.display") { + if (!device.serial_number) { + const failed = await acknowledgeDeviceCommand({ + device_id: device.id, + command_id: command.id, + status: "failed", + reason: "DEVICE_SERIAL_NUMBER_REQUIRED", + result_payload: { command: commandName } + }); + return res.status(201).json(successResponse(req, toDeviceCommandPayload(failed || command))); + } + + const commandPayload = payload.payload || {}; + const commandData = commandPayload.data && typeof commandPayload.data === "object" + ? (commandPayload.data as Record) + : commandPayload; + const amount = Number(commandData.amount || 1000); + const expireSeconds = Number(commandData["expire-seconds"] || commandData.expire_seconds || commandData.expires_in_seconds || 60); + const qrUrl = String( + commandData["qr-url"] || + commandData.qr_url || + commandData.qr_payload || + commandData.qrPayload || + `https://sms.bizone.id/ui/device-ui-qr-payment-display?device_id=${encodeURIComponent(device.id)}&amount=${encodeURIComponent(String(amount))}` + ); + + if (!qrUrl || !Number.isFinite(amount) || amount <= 0) { + const failed = await acknowledgeDeviceCommand({ + device_id: device.id, + command_id: command.id, + status: "failed", + reason: "DYNAMIC_QR_PAYLOAD_INVALID", + result_payload: { command: commandName, qr_url: qrUrl, amount } + }); + return res.status(201).json(successResponse(req, toDeviceCommandPayload(failed || command))); + } + + const publishResult = await publishQf100DynamicQrDisplay(device.id, device.serial_number, { + qrUrl, + amount, + expireSeconds + }); + await createMqttMessage({ + direction: "downlink", + device_id: device.id, + topic: publishResult.topic, + message_type: "dynamic_qr_display", + correlation_id: command.id, + payload_json: publishResult.payload, + publish_status: publishResult.ok ? "sent" : "failed", + reason: publishResult.reason + }); + + const acknowledged = await acknowledgeDeviceCommand({ + device_id: device.id, + command_id: command.id, + status: publishResult.ok ? "delivered" : "failed", + reason: publishResult.reason, + result_payload: { + topic: publishResult.topic, + payload: publishResult.payload, + published_at: publishResult.publishedAt + } + }); + return res.status(201).json(successResponse(req, toDeviceCommandPayload(acknowledged || command))); + } + + if (commandName === "device.reboot") { + if (!device.serial_number) { + const failed = await acknowledgeDeviceCommand({ + device_id: device.id, + command_id: command.id, + status: "failed", + reason: "DEVICE_SERIAL_NUMBER_REQUIRED", + result_payload: { command: commandName } + }); + return res.status(201).json(successResponse(req, toDeviceCommandPayload(failed || command))); + } + + const publishResult = await publishQf100RebootCommand(device.id, device.serial_number); + await createMqttMessage({ + direction: "downlink", + device_id: device.id, + topic: publishResult.topic, + message_type: "reboot_command", + correlation_id: command.id, + payload_json: publishResult.payload, + publish_status: publishResult.ok ? "sent" : "failed", + reason: publishResult.reason + }); + + const acknowledged = await acknowledgeDeviceCommand({ + device_id: device.id, + command_id: command.id, + status: publishResult.ok ? "delivered" : "failed", + reason: publishResult.reason, + result_payload: { + topic: publishResult.topic, + payload: publishResult.payload, + published_at: publishResult.publishedAt + } + }); + return res.status(201).json(successResponse(req, toDeviceCommandPayload(acknowledged || command))); + } + res.status(201).json(successResponse(req, toDeviceCommandPayload(command))); } ); diff --git a/src/shared/services/deviceConfigStatus.ts b/src/shared/services/deviceConfigStatus.ts index cfbd337..edacce8 100644 --- a/src/shared/services/deviceConfigStatus.ts +++ b/src/shared/services/deviceConfigStatus.ts @@ -5,9 +5,10 @@ import { toDeviceConfigAckPayload, toDeviceConfigPayload } from "../store/deviceConfigStore"; +import { listHeartbeats } from "../store/heartbeatStore"; import { listMqttMessages, toMqttMessagePayload } from "../store/mqttMessageStore"; -type ConfigDriftStatus = "applied" | "pending_ack" | "failed_ack" | "stale_ack" | "never_pushed"; +type ConfigDriftStatus = "applied" | "pending_ack" | "failed_ack" | "stale_ack" | "pulled_not_pushed" | "never_pushed"; function deriveConfigDriftStatus(config: DeviceConfigEntity, latestAck: DeviceConfigAckEntity | null): ConfigDriftStatus { if (!latestAck) { @@ -40,8 +41,15 @@ export async function buildDeviceConfigStatus(config: DeviceConfigEntity) { limit: 1 }) )[0]; + const latestConfigPull = ( + await listHeartbeats({ + device_id: config.device_id, + state: "config_pull", + limit: 1 + }) + )[0]; - const driftStatus = latestPush ? deriveConfigDriftStatus(config, latestAck) : "never_pushed"; + const driftStatus = latestPush ? deriveConfigDriftStatus(config, latestAck) : latestConfigPull ? "pulled_not_pushed" : "never_pushed"; return { device_id: config.device_id, @@ -50,6 +58,7 @@ export async function buildDeviceConfigStatus(config: DeviceConfigEntity) { desired_config_version: config.config_version, latest_ack: latestAck ? toDeviceConfigAckPayload(latestAck) : null, latest_push: latestPush ? toMqttMessagePayload(latestPush) : null, - retry_recommended: driftStatus !== "applied" + latest_config_pull: latestConfigPull || null, + retry_recommended: !["applied", "pulled_not_pushed"].includes(driftStatus) }; } diff --git a/src/shared/services/mqttPublisher.ts b/src/shared/services/mqttPublisher.ts index b0abe7b..9fc2792 100644 --- a/src/shared/services/mqttPublisher.ts +++ b/src/shared/services/mqttPublisher.ts @@ -27,6 +27,26 @@ type Qf100PaymentSuccessPayload = { }; }; +type Qf100DynamicQrDisplayPayload = { + header: { + category: 4; + }; + data: { + "qr-url": string; + amount: number; + "expire-seconds": number; + }; +}; + +type Qf100RebootCommandPayload = { + header: { + category: 5; + }; + data: { + command: "reboot"; + }; +}; + type DynamicQrResponsePayload = { message_type: "dynamic_qr_response"; correlation_id: string; @@ -287,6 +307,45 @@ export async function publishDynamicQrResponse(deviceId: string, payload: Dynami return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload); } +export async function publishQf100DynamicQrDisplay( + deviceId: string, + serialNumber: string, + payload: { + qrUrl: string; + amount: number; + expireSeconds?: number; + } +): Promise> { + const qrPayload: Qf100DynamicQrDisplayPayload = { + header: { + category: 4 + }, + data: { + "qr-url": payload.qrUrl, + amount: payload.amount, + "expire-seconds": payload.expireSeconds && payload.expireSeconds > 0 ? payload.expireSeconds : 60 + } + }; + + return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), qrPayload); +} + +export async function publishQf100RebootCommand( + deviceId: string, + serialNumber: string +): Promise> { + const rebootPayload: Qf100RebootCommandPayload = { + header: { + category: 5 + }, + data: { + command: "reboot" + } + }; + + return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), rebootPayload); +} + export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) { return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload); } diff --git a/ui/device-registry-monitoring/index.html b/ui/device-registry-monitoring/index.html index 556f047..ed9194b 100644 --- a/ui/device-registry-monitoring/index.html +++ b/ui/device-registry-monitoring/index.html @@ -455,13 +455,14 @@ Rows Soundbox V2 Product

-

+

SN: -

Device
- +
@@ -495,8 +496,10 @@ Rows const detailDrawer = document.getElementById("device-detail-drawer"); const detailCloseButton = document.getElementById("device-detail-close"); const detailTitle = document.getElementById("device-detail-title"); + const detailSerial = document.getElementById("device-detail-serial"); const detailModel = document.getElementById("device-detail-model"); const detailContent = document.getElementById("device-detail-content"); + const drawerRebootButton = document.getElementById("drawer-reboot-device"); const registerModal = document.getElementById("device-register-modal"); const registerForm = document.getElementById("device-register-form"); const topbarRegisterOpenButton = document.getElementById("topbar-register-device-open"); @@ -612,6 +615,9 @@ Rows if (value === "applied") { return { label: "Applied", className: "bg-success/10 text-success border-success/20", icon: "check_circle" }; } + if (value === "pulled_not_pushed") { + return { label: "Pulled by Device", className: "bg-success/10 text-success border-success/20", icon: "check_circle" }; + } if (value === "pending_ack") { return { label: "Pending ACK", className: "bg-warning/10 text-warning border-warning/20", icon: "pending" }; } @@ -834,6 +840,7 @@ Rows let currentPage = 1; let pageSize = Number(pageSizeSelect?.value || 10); let currentSearchQuery = ""; + let activeDrawerDevice = null; let merchants = []; let outlets = []; let terminals = []; @@ -855,6 +862,7 @@ Rows tableBody.innerHTML = items .map((device) => { const id = device.device_code || device.id || ""; + const serialNumber = escapeHtml(device.serial_number || "-"); const model = device.model || "Unknown"; const binding = device.binding_summary || {}; const merchantName = merchantMap.get(binding.merchant_id) || "Unassigned"; @@ -866,7 +874,10 @@ Rows return ` - ${id || "-"} +
+ ${id || "-"} + SN: ${serialNumber} +
${model} @@ -1140,6 +1151,7 @@ Rows if (!detailDrawer || !detailOverlay || !detailTitle || !detailModel || !detailContent) { return; } + activeDrawerDevice = device; const binding = device.binding_summary || {}; const connection = connectionMeta(device.communication_mode); @@ -1152,7 +1164,11 @@ Rows : "No active warning"; const id = device.device_code || device.id || "-"; + const serialNumber = escapeHtml(device.serial_number || "-"); detailTitle.textContent = id; + if (detailSerial) { + detailSerial.textContent = `SN: ${serialNumber}`; + } detailModel.textContent = device.model || "Unknown"; detailModel.className = `inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold ${status.className}`; @@ -1160,6 +1176,10 @@ Rows
Device Detail
+
+ Serial Number + ${serialNumber} +
Model ${device.model || "Unknown"} @@ -1233,6 +1253,33 @@ Rows loadDrawerConfig(device.id); }; + const sendDrawerRebootCommand = async () => { + if (!activeDrawerDevice || !drawerRebootButton) { + return; + } + + const originalText = drawerRebootButton.textContent || "Reboot Device"; + drawerRebootButton.disabled = true; + drawerRebootButton.textContent = "Sending..."; + try { + const result = await api.createDeviceCommand(activeDrawerDevice.id, { + command: "device.reboot", + payload: { requested_from: "device_registry_drawer" } + }); + drawerRebootButton.textContent = result.status === "delivered" ? "Reboot Sent" : "Reboot Queued"; + window.setTimeout(() => { + drawerRebootButton.textContent = originalText; + drawerRebootButton.disabled = false; + }, 1800); + } catch (error) { + drawerRebootButton.textContent = "Reboot Failed"; + window.setTimeout(() => { + drawerRebootButton.textContent = originalText; + drawerRebootButton.disabled = false; + }, 2200); + } + }; + const loadDrawerConfig = async (deviceId) => { const box = document.getElementById("device-config-status-box"); if (!box || !deviceId) { @@ -1244,6 +1291,7 @@ Rows const meta = configStatusMeta(configStatus.drift_status); const latestPush = configStatus.latest_push; const latestAck = configStatus.latest_ack; + const latestPull = configStatus.latest_config_pull; const canRetry = configStatus.retry_recommended; box.innerHTML = `
@@ -1253,7 +1301,11 @@ Rows v${configStatus.desired_config_version || "-"}
-
+
+
+

Latest Pull

+

${latestPull ? formatLastSeen(latestPull.received_at || latestPull.timestamp) : "-"}

+

Latest Push

${latestPush ? formatLastSeen(latestPush.created_at) : "-"}

@@ -1472,6 +1524,7 @@ Rows if (!detailDrawer || !detailOverlay) { return; } + activeDrawerDevice = null; detailDrawer.classList.add("translate-x-full"); detailOverlay.classList.remove("opacity-100"); detailOverlay.classList.add("opacity-0", "pointer-events-none"); @@ -1578,6 +1631,7 @@ Rows }); detailOverlay?.addEventListener("click", closeDrawer); detailCloseButton?.addEventListener("click", closeDrawer); + drawerRebootButton?.addEventListener("click", sendDrawerRebootCommand); topbarRegisterOpenButton?.addEventListener("click", openRegisterModal); refreshButton?.addEventListener("click", refresh); registerCloseButton?.addEventListener("click", closeRegisterModal); diff --git a/ui/device-technical-detail/index.html b/ui/device-technical-detail/index.html index 0ae9b0e..607b785 100644 --- a/ui/device-technical-detail/index.html +++ b/ui/device-technical-detail/index.html @@ -211,6 +211,10 @@ Soundbox V2 Pro

+tag + SN: - +

+

schedule Last seen 2 mins ago

@@ -235,7 +239,7 @@
- + @@ -376,7 +380,7 @@ Loading
-
+

[14:02:11] INITIALIZING WEBSOCKET CONNECTION...

[14:02:12] CONNECTED TO SND-10293_GATEWAY_V4

[14:02:15] RECV: {"event": "heartbeat", "status": "online", "v_batt": 4.12, "rssi": -78, "ts": 1715421255}

@@ -492,7 +496,7 @@ Rotate Credential

QRIS Payment

-
+

Rp 1.000

-

@@ -582,7 +586,7 @@ Copy Command const qs = new URLSearchParams(window.location.search); const deviceId = qs.get("device_id") || qs.get("deviceId") || qs.get("id") || ""; let activeDeviceId = deviceId; - const stream = document.getElementById("payload-stream"); + const stream = document.getElementById("payload-stream") || document.getElementById("heartbeat-section"); const clearBtn = document.getElementById("clearConsole"); const exportBtn = document.getElementById("export-device-logs"); const refreshBtn = document.getElementById("refresh-device-state"); @@ -628,6 +632,7 @@ Copy Command let showingAllEvents = false; let confirmResolver = null; let latestCredentialCommand = ""; + let liveRefreshTimer = null; const els = { breadcrumbCode: document.getElementById("device-breadcrumb-code"), @@ -635,6 +640,7 @@ Copy Command statusBadge: document.getElementById("device-status-badge"), statusDot: document.getElementById("device-status-dot"), model: document.getElementById("device-model"), + serialNumber: document.getElementById("device-serial-number"), lastSeen: document.getElementById("device-last-seen"), location: document.getElementById("device-location"), signalStrength: document.getElementById("device-signal-strength"), @@ -739,6 +745,46 @@ Copy Command return new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short" }).format(ms); }; + const formatClock = (value) => { + const ms = normalizeTimestamp(value) || Date.now(); + return new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }).format(ms); + }; + + const escapeHtml = (value) => + String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + const formatEventTitle = (item) => { + const state = String(item?.state || item?.event || item?.type || item?.status || "heartbeat") + .replace(/_/g, " ") + .trim(); + return state ? state.replace(/\b\w/g, (char) => char.toUpperCase()) : "Heartbeat"; + }; + + const compactPayload = (item) => { + const payload = item?.payload && typeof item.payload === "object" ? item.payload : item; + const picked = { + id: payload?.id, + serial: payload?.["dev-sn"] || payload?.serial_number || currentDevice?.serial_number, + state: payload?.state || item?.state, + firmware: payload?.["fw-version"] || item?.firmware_version, + signal: item?.network_strength ?? payload?.network_strength ?? payload?.rssi ?? payload?.["wifi-ap"]?.rssi, + battery: item?.battery_level ?? payload?.battery_level ?? payload?.["battery-level"], + received_at: item?.received_at, + timestamp: item?.timestamp || payload?.time + }; + return Object.fromEntries(Object.entries(picked).filter(([, value]) => value !== undefined && value !== null && value !== "")); + }; + const extractHeartbeatMetrics = (heartbeat) => { if (!heartbeat || typeof heartbeat !== "object") { return {}; @@ -812,17 +858,21 @@ Copy Command item.timestamp || item.ts || item.created_at || item.updated_at, "unknown" ); - const title = item.event || item.type || item.status || "Heartbeat"; - const details = item.message || item.description || JSON.stringify(item.payload || item); - const marker = iconClass(item.status || item.event); - return `
+ const title = formatEventTitle(item); + const summary = compactPayload(item); + const marker = iconClass(item.status || item.event || item.state); + return `
${marker.icon}
-

${title}

-

Signal: ${metric.signal ?? "N/A"}, Battery: ${metric.battery ?? "N/A"}

-

${details}

-

${when}

+
+
+

${escapeHtml(title)}

+

${escapeHtml(when)}

+
+

Signal: ${escapeHtml(metric.signal ?? "N/A")}, Battery: ${escapeHtml(metric.battery ?? "N/A")}

+
${escapeHtml(JSON.stringify(summary, null, 2))}
+
`; }) .join(""); @@ -840,11 +890,11 @@ Copy Command } rows.slice(0, 20).forEach((item) => { - const when = formatDateTime(item.timestamp || item.ts || item.created_at || item.updated_at, "now"); - const p = document.createElement("p"); - p.className = "text-green-400"; - p.textContent = `[${when}] RECV: ${JSON.stringify(item)}`; - stream.appendChild(p); + const when = formatClock(item.timestamp || item.ts || item.created_at || item.updated_at || item.received_at); + const line = document.createElement("pre"); + line.className = "whitespace-pre-wrap break-words text-green-400 leading-5"; + line.textContent = `[${when}] RECV heartbeat\n${JSON.stringify(compactPayload(item), null, 2)}`; + stream.appendChild(line); }); stream.scrollTop = stream.scrollHeight; }; @@ -889,7 +939,7 @@ Copy Command const statusPillClass = (status) => { const normalized = String(status || "").toLowerCase(); - if (normalized === "online" || normalized === "applied") { + if (normalized === "online" || normalized === "applied" || normalized === "pulled_not_pushed") { return "bg-success/10 text-success border-success/20"; } if (normalized === "degraded" || normalized === "stale" || normalized === "pending_ack" || normalized === "stale_ack") { @@ -932,18 +982,21 @@ Copy Command const version = status.desired_config_version || status.config?.config_version || "-"; setText(els.configVersion, `Config v${version}`); if (els.configStatus) { - const label = String(drift).replace("_", " ").toUpperCase(); - const icon = drift === "applied" ? "check_circle" : drift === "failed_ack" ? "error" : "pending"; + const label = drift === "pulled_not_pushed" ? "PULLED BY DEVICE" : String(drift).replace(/_/g, " ").toUpperCase(); + const icon = drift === "applied" || drift === "pulled_not_pushed" ? "check_circle" : drift === "failed_ack" ? "error" : "pending"; els.configStatus.className = `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${statusPillClass(drift)}`; els.configStatus.innerHTML = `${icon}${label}`; } const ack = status.latest_ack ? `${status.latest_ack.status} at ${formatDateTime(status.latest_ack.acked_at, "-")}` : "No ACK"; const push = status.latest_push ? formatDateTime(status.latest_push.created_at, "-") : "No push"; - setText(els.configDetail, `Push: ${push} · ACK: ${ack}`); + const pull = status.latest_config_pull ? formatDateTime(status.latest_config_pull.received_at || status.latest_config_pull.timestamp, "-") : "No pull"; + setText(els.configDetail, `Pull: ${pull} · Push: ${push} · ACK: ${ack}`); if (els.configRetry) { els.configRetry.disabled = status.retry_recommended === false; - els.configRetry.textContent = status.retry_recommended === false ? "Applied" : "Retry Push"; + els.configRetry.textContent = status.retry_recommended === false + ? (drift === "pulled_not_pushed" ? "Pulled" : "Applied") + : "Retry Push"; } }; @@ -1049,9 +1102,10 @@ Copy Command if (!stream) { return; } - const p = document.createElement("p"); + const p = document.createElement("pre"); p.className = className; - p.textContent = `[${formatDateTime(Date.now())}] ${message}`; + p.classList.add("whitespace-pre-wrap", "break-words", "leading-5"); + p.textContent = `[${formatClock(Date.now())}] ${message}`; stream.appendChild(p); stream.scrollTop = stream.scrollHeight; }; @@ -1094,21 +1148,23 @@ Copy Command }; const buildQrPreviewPayload = () => ({ - device_id: activeDeviceId || "-", - device_code: currentDevice?.device_code || currentDevice?.id || "-", - amount: 1000, - currency: "IDR", - qr_mode: els.dynamicQrMode?.textContent || "dynamic", - expires_in_seconds: 60, - preview: true + header: { + category: 4 + }, + data: { + "qr-url": `https://sms.bizone.id/pay/test/${encodeURIComponent(currentDevice?.serial_number || activeDeviceId || "soundbox")}`, + amount: 1000, + "expire-seconds": 60 + } }); - const renderQrPreviewGrid = () => { + const renderFallbackQrPreviewGrid = (qrUrl) => { if (!qrPreviewGrid) { return; } - const seed = String(activeDeviceId || currentDevice?.device_code || "soundbox"); + const seed = String(qrUrl || activeDeviceId || currentDevice?.device_code || "soundbox"); qrPreviewGrid.innerHTML = ""; + qrPreviewGrid.className = "mx-auto my-4 grid h-40 w-40 grid-cols-9 grid-rows-9 gap-1 rounded-lg bg-white p-2"; for (let index = 0; index < 81; index += 1) { const char = seed.charCodeAt(index % seed.length) || 37; const dark = index < 9 || index % 9 === 0 || ((char + index * 7) % 5 < 2); @@ -1118,17 +1174,31 @@ Copy Command } }; + const renderQrPreviewCode = (qrUrl) => { + if (!qrPreviewGrid) { + return; + } + qrPreviewGrid.innerHTML = ""; + qrPreviewGrid.className = "mx-auto my-4 flex h-40 w-40 items-center justify-center rounded-lg bg-white p-2"; + const image = document.createElement("img"); + image.alt = "QR code preview"; + image.className = "h-full w-full object-contain"; + image.src = `https://api.qrserver.com/v1/create-qr-code/?size=192x192&margin=8&data=${encodeURIComponent(qrUrl)}`; + image.addEventListener("error", () => renderFallbackQrPreviewGrid(qrUrl), { once: true }); + qrPreviewGrid.appendChild(image); + }; + const openQrPreview = () => { const payload = buildQrPreviewPayload(); - renderQrPreviewGrid(); + renderQrPreviewCode(payload.data["qr-url"]); setText(qrPreviewAmount, new Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR", maximumFractionDigits: 0 - }).format(payload.amount)); - setText(qrPreviewDevice, payload.device_code); + }).format(payload.data.amount)); + setText(qrPreviewDevice, currentDevice?.device_code || currentDevice?.serial_number || activeDeviceId || "-"); setText(qrPreviewCommandPath, els.dynamicQrCommandPath?.textContent || "MQTT"); - setText(qrPreviewMode, payload.qr_mode); + setText(qrPreviewMode, els.dynamicQrMode?.textContent || "dynamic"); setText(qrPreviewPayload, JSON.stringify(payload, null, 2)); qrPreviewModal?.classList.remove("hidden"); qrPreviewModal?.classList.add("flex"); @@ -1219,7 +1289,7 @@ Copy Command setText(els.bindingSince, formatDateTime(bindingDate, "-")); }; - const loadDevice = async () => { + const loadDevice = async ({ preserveEventView = false } = {}) => { try { api.requireToken(); let selectedDeviceId = deviceId; @@ -1253,12 +1323,15 @@ Copy Command : []; currentDevice = device; currentHeartbeats = heartbeats; - showingAllEvents = false; + if (!preserveEventView) { + showingAllEvents = false; + } const modelCode = device.device_code || device.code || device.serial_number || device.id || "Unknown Device"; setText(els.breadcrumbCode, modelCode); setText(els.title, modelCode); setText(els.model, device.model || device.device_model || "Unknown model"); + setText(els.serialNumber, `SN: ${device.serial_number || "-"}`); setText(els.location, device.location || device.last_known_city || "Unknown"); const latest = Array.isArray(heartbeats) && heartbeats.length ? heartbeats[0] : null; @@ -1294,7 +1367,18 @@ Copy Command } }; - refreshBtn?.addEventListener("click", loadDevice); + const startLiveRefresh = () => { + if (liveRefreshTimer) { + window.clearInterval(liveRefreshTimer); + } + liveRefreshTimer = window.setInterval(() => { + if (document.visibilityState === "visible") { + loadDevice({ preserveEventView: true }); + } + }, 10000); + }; + + refreshBtn?.addEventListener("click", () => loadDevice()); rotateCredentialButtons.forEach((button) => button.addEventListener("click", rotateCredential)); credentialModalClose?.addEventListener("click", closeCredentialModal); credentialModalDone?.addEventListener("click", closeCredentialModal); @@ -1325,7 +1409,7 @@ Copy Command } }); els.dynamicQrSendTest?.addEventListener("click", () => { - sendDeviceCommand("dynamic_qr.test", { + sendDeviceCommand("dynamic_qr.display", { ...buildQrPreviewPayload(), source: "device_detail" }, els.dynamicQrSendTest); @@ -1409,7 +1493,7 @@ Copy Command } }); qrPreviewSendTest?.addEventListener("click", () => { - sendDeviceCommand("dynamic_qr.test", { + sendDeviceCommand("dynamic_qr.display", { ...buildQrPreviewPayload(), source: "qr_preview_modal" }, qrPreviewSendTest); @@ -1480,6 +1564,7 @@ Copy Command } loadDevice(); + startLiveRefresh(); })(); diff --git a/ui/soundbox-ops/index.html b/ui/soundbox-ops/index.html index 64011ac..09a44e1 100644 --- a/ui/soundbox-ops/index.html +++ b/ui/soundbox-ops/index.html @@ -181,6 +181,26 @@ Export worker -
+
+
+
+

Remote Actions

+

Send operational command to a selected soundbox

+
+ settings_remote +
+ + +

+
@@ -346,6 +366,27 @@ $("export-worker").className = `rounded-full px-2.5 py-1 text-xs font-bold ${worker?.enabled === false ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"}`; } + function renderCommandDevices() { + const select = $("command-device-select"); + const rebootButton = $("send-reboot-command"); + if (!select || !rebootButton) { + return; + } + + const current = select.value; + select.innerHTML = ''; + state.devices.forEach((device) => { + const option = document.createElement("option"); + option.value = device.id; + option.textContent = `${device.device_code || device.serial_number || device.id} · ${device.serial_number || device.model || "soundbox"}`; + select.appendChild(option); + }); + if (current && state.devices.some((device) => device.id === current)) { + select.value = current; + } + rebootButton.disabled = !select.value; + } + function renderMqtt() { const list = $("mqtt-list"); const messages = Array.isArray(state.mqtt?.last_messages) ? state.mqtt.last_messages : []; @@ -373,6 +414,7 @@ renderKpis(); renderTable(); renderOps(); + renderCommandDevices(); renderMqtt(); } @@ -517,9 +559,46 @@ } } + async function sendRebootCommand() { + const select = $("command-device-select"); + const button = $("send-reboot-command"); + const status = $("command-status"); + const deviceId = select?.value || ""; + const device = state.devices.find((item) => item.id === deviceId); + if (!deviceId || !button || !status) { + return; + } + + button.disabled = true; + button.classList.add("opacity-60"); + status.textContent = `Sending reboot to ${device?.device_code || device?.serial_number || deviceId}...`; + status.className = "mt-2 min-h-5 text-xs font-semibold text-slate-500"; + try { + const result = await api.createDeviceCommand(deviceId, { + command: "device.reboot", + payload: { requested_from: "soundbox_ops" } + }); + const topic = result?.result_payload?.topic || "-"; + status.textContent = `Reboot command ${result.status || "queued"} · ${topic}`; + status.className = "mt-2 min-h-5 text-xs font-semibold text-emerald-700"; + await refresh(); + } catch (error) { + status.textContent = error?.message || "Unable to send reboot command."; + status.className = "mt-2 min-h-5 text-xs font-semibold text-red-700"; + } finally { + button.classList.remove("opacity-60"); + button.disabled = !select.value; + } + } + $("refresh-button").addEventListener("click", refresh); $("search-input").addEventListener("input", renderTable); $("status-filter").addEventListener("change", renderTable); + $("command-device-select")?.addEventListener("change", () => { + $("send-reboot-command").disabled = !$("command-device-select").value; + $("command-status").textContent = ""; + }); + $("send-reboot-command")?.addEventListener("click", sendRebootCommand); $("logout-button").addEventListener("click", () => { api.clearToken(); window.location.href = "/ui/admin-login";