diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md index 57e1438..6fe3eb9 100644 --- a/CODEX_HANDOFF.md +++ b/CODEX_HANDOFF.md @@ -1,9 +1,46 @@ # Codex Handoff - QRIS Soundbox Platform -Tanggal update: 2026-06-07, Asia/Jakarta. +Tanggal update: 2026-06-08, Asia/Jakarta. Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat. +## Update Terbaru - 2026-06-08 + +- `soundbox-backend-mqtt-spec.md` sekarang mendokumentasikan device command QF100 category `5` untuk `reboot` dan `poweroff`. +- Backend sudah sinkron dengan spec command tersebut: + - `POST /admin/devices/{id}/commands` menerima `device.poweroff`; + - publisher MQTT membuat payload QF100 category `5` dengan `data.command = "poweroff"`; + - downlink tercatat sebagai `poweroff_command` di `mqtt_messages`. +- `scripts/smoke-qf100-adapter.mjs` sudah menambahkan assertion untuk command `device.poweroff`. +- Dashboard `/ui/soundbox-ops` dirapikan untuk operator: + - KPI warning sekarang menunjukkan breakdown stale vs degraded; + - KPI card bisa dipakai sebagai quick filter; + - tabel Fleet Status menampilkan health bar, reason, signal, dan battery; + - Device ID menjadi link langsung ke technical detail; + - Remote Actions menambahkan tombol `Power Off Device`; + - layout mobile header/filter dibuat full-width agar tidak overflow. +- Registry `/ui/device-registry-monitoring` sekarang mendukung koreksi device metadata: + - menu row punya `Edit Device`; + - modal edit bisa koreksi `serial_number/dev-sn`, vendor, model, communication mode, status, dan firmware version; + - perubahan model ikut memperbarui `capability_profile_json` dari katalog model aktif; + - backend menolak duplicate `serial_number` supaya config pull/MQTT lookup tidak ambigu. +- Search UI yang sebelumnya dekoratif sudah mulai difungsikan: + - Admin Dashboard global search route ke Device Registry, Merchant List, atau Transaction History dengan `?q=`; + - Transaction History dan Merchant List membaca `?q=` sebagai initial search; + - Admin Reconciliation top search route ke Transaction History; + - Settlement Batch search live-filter batch table; + - Merchant Settlement History search live-filter disbursement table dan membaca `?q=`; + - Merchant Dashboard search route ke Merchant Settlement History; + - Fee Pricing search route ke Audit Logs, dan Audit Logs membaca `?q=`; + - Merchant Detail search route ke Merchant List; + - Device QR Payment Display search live-filter transaction rows. +- Verifikasi lokal update ini: + - `npm run typecheck`: pass; + - `node --check scripts/smoke-qf100-adapter.mjs`: pass. + - `node scripts/ui-qa-check.mjs`: pass; + - direct script parse `ui/device-registry-monitoring/index.html`: pass; + - headless Chrome screenshot desktop/mobile `/ui/soundbox-ops/?preview=1`: pass visual sanity. + ## Update Terbaru - 2026-06-07 - Production saat ini fokus ke portal Soundbox Ops di `sms.bizone.id`, dengan MQTT broker `broker.bizone.id`. diff --git a/scripts/smoke-qf100-adapter.mjs b/scripts/smoke-qf100-adapter.mjs index d244ddd..d233dd8 100644 --- a/scripts/smoke-qf100-adapter.mjs +++ b/scripts/smoke-qf100-adapter.mjs @@ -262,6 +262,25 @@ async function triggerRebootCommand({ bundle }) { return result; } +async function triggerPoweroffCommand({ bundle }) { + const result = await reqAdmin(`/admin/devices/${bundle.device.id}/commands`, { + method: "POST", + body: { + command: "device.poweroff", + payload: { + requested_from: "qf100_smoke" + } + }, + _label: "POST /admin/devices/:id/commands poweroff" + }); + + assert(result.status === "delivered", "poweroff command must be delivered"); + assert(result.result_payload?.topic === `soundbox/${bundle.device.serial_number}/down`, "poweroff topic must use serial number"); + assert(result.result_payload?.payload?.header?.category === 5, "poweroff category must be 5"); + assert(result.result_payload?.payload?.data?.command === "poweroff", "poweroff command payload must be poweroff"); + return result; +} + async function main() { await req("/health", { _label: "GET /health" }); const ts = Date.now(); @@ -294,6 +313,7 @@ async function main() { const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts }); const dynamicQrDisplay = await triggerDynamicQrDisplay({ bundle: dynamicBundle }); const rebootCommand = await triggerRebootCommand({ bundle: staticBundle }); + const poweroffCommand = await triggerPoweroffCommand({ bundle: staticBundle }); console.log("\nQF100 adapter smoke passed"); console.log(`static_sn=${STATIC_SN}`); @@ -304,6 +324,7 @@ async function main() { 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}`); + console.log(`poweroff_command=${poweroffCommand.id || poweroffCommand.command_id}`); } main().catch((error) => { diff --git a/soundbox-backend-mqtt-spec.md b/soundbox-backend-mqtt-spec.md index 20ed236..d2edca1 100755 --- a/soundbox-backend-mqtt-spec.md +++ b/soundbox-backend-mqtt-spec.md @@ -20,6 +20,11 @@ Current firmware mode: - `SAMPLE_MQTT_DEMO` is enabled by default. - `MQTT_STRICT_TEST_DEMO` is disabled unless explicitly enabled in firmware. +- QF100 reports `dev-model` as `EDOTF1`. +- `DEFAULT_LANGUAGE` is `2`, used here as Indonesian/IDR mode. +- Payment and QR amounts are treated as whole rupiah, not cents. +- The QR amount label shown on screen is `Nominal: Rp `. +- The status screen shows battery percentage and network signal with `dBm`. ## 2. Device Config API @@ -41,7 +46,7 @@ Transport: ```json { - "dev-model": "QF100", + "dev-model": "EDOTF1", "item-number": "00", "dev-sn": "DEVICE_SN", "hardware-config": "0x0F", @@ -129,6 +134,8 @@ Behavior: - The device formats `pay-amount` into a 12-digit string. - Example: `15000` becomes `000000015000`. +- In the current Indonesian/IDR firmware, the value is treated as whole rupiah. +- Example: `pay-amount: 30000` is displayed as `30000` and spoken as `tiga puluh ribu rupiah`. - The device displays the amount and plays the payment audio. Do not send `pay-amount` as a string: @@ -245,13 +252,16 @@ 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`. +- In the current Indonesian/IDR firmware, the amount is shown as `Nominal: Rp `. - 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 +## 6. MQTT Device Command Payload -The backend can ask the device to reboot by publishing to `mqtt.subscribe-topic`: +The backend can ask the device to run a device-level command by publishing to `mqtt.subscribe-topic`. + +Reboot command: ```json { @@ -264,18 +274,32 @@ The backend can ask the device to reboot by publishing to `mqtt.subscribe-topic` } ``` +Power off command: + +```json +{ + "header": { + "category": 5 + }, + "data": { + "command": "poweroff" + } +} +``` + ### Required Fields | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `header.category` | number | yes | Use `5` for device reboot command. | -| `data.command` | string | yes | Must be exactly `reboot`. | +| `header.category` | number | yes | Use `5` for device command. | +| `data.command` | string | yes | Supported values: `reboot`, `poweroff`. | 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. +- The device validates `data.command` before executing the command. +- If `data.command` is missing or not one of the supported values, the payload is ignored. +- For `reboot`, the device plays the reboot audio, shows `Rebooting...`, waits about 2 seconds, then restarts. +- For `poweroff`, the device plays the power-off audio, shows `Shutting down...`, waits about 2 seconds, then powers off. - This command is backend-to-device only. ## 7. MQTT Device Heartbeat @@ -360,6 +384,7 @@ 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. 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. +- On the device status screen, GPRS signal quality `1..31` is converted to approximate dBm with `-113 + 2 * rssi` before display. The heartbeat payload still sends the SDK/modem `rssi` value. ## 8. Unsupported Categories @@ -374,7 +399,7 @@ For unsupported categories, the firmware still requires: } ``` -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. +The current firmware handles categories `1`, `2`, `4`, and `5` from backend-to-device messages. Category `5` supports `data.command` values `reboot` and `poweroff`. Category `3` is used by device-to-backend heartbeat. Other categories do not perform any action. ## 9. OTA Check API Response @@ -567,6 +592,27 @@ Payload: } ``` +### Power Off Command Publish + +Publish to: + +```text +soundbox/QF100123456/down +``` + +Payload: + +```json +{ + "header": { + "category": 5 + }, + "data": { + "command": "poweroff" + } +} +``` + ### Heartbeat From Device Subscribe to: @@ -598,7 +644,7 @@ Payload received: - Always publish valid JSON. - Always use JSON numbers for numeric fields. - 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`. +- For device commands, send `data.command` exactly as one of the supported strings: `reboot` or `poweroff`. - 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 626cb6e..71ddbca 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -25,6 +25,7 @@ import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBin import { createDevice, getDeviceById, + getDeviceBySerialNumber, listDevices, patchDevice, rotateDeviceMqttCredential, @@ -99,7 +100,7 @@ import { upsertDeviceConfig } from "../shared/store/deviceConfigStore"; import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore"; -import { getMqttPublisherStatus, publishConfigPush, publishQf100DynamicQrDisplay, publishQf100RebootCommand } from "../shared/services/mqttPublisher"; +import { getMqttPublisherStatus, publishConfigPush, publishQf100DeviceCommand, publishQf100DynamicQrDisplay } from "../shared/services/mqttPublisher"; import { getMqttSubscriberStatus } from "../shared/services/mqttSubscriber"; import { getDatabaseHealth, getServiceHealth } from "../shared/services/health"; import { logger } from "../shared/services/logger"; @@ -1523,6 +1524,29 @@ router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } + const serialNumber = payload.serial_number?.trim(); + if (payload.serial_number !== undefined) { + if (!serialNumber) { + return next(new ApiError("BAD_REQUEST", "serial_number must not be empty", 400)); + } + payload.serial_number = serialNumber; + } + if (payload.vendor !== undefined) { + payload.vendor = payload.vendor.trim() || undefined; + } + if (payload.model !== undefined) { + payload.model = payload.model.trim() || undefined; + } + if (payload.firmware_version !== undefined) { + payload.firmware_version = payload.firmware_version.trim() || undefined; + } + if (serialNumber) { + const existingSerialDevice = await getDeviceBySerialNumber(serialNumber); + if (existingSerialDevice) { + return next(new ApiError("CONFLICT", "serial_number already belongs to another device", 409)); + } + } + const created = await createDevice(payload); await auditAdminAction(req, { action: "device.create", @@ -1686,6 +1710,29 @@ router.patch("/devices/:deviceId", requireAdminToken, async (req: Request, res: return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } + const serialNumber = payload.serial_number?.trim(); + if (payload.serial_number !== undefined) { + if (!serialNumber) { + return next(new ApiError("BAD_REQUEST", "serial_number must not be empty", 400)); + } + payload.serial_number = serialNumber; + } + if (payload.vendor !== undefined) { + payload.vendor = payload.vendor.trim() || undefined; + } + if (payload.model !== undefined) { + payload.model = payload.model.trim() || undefined; + } + if (payload.firmware_version !== undefined) { + payload.firmware_version = payload.firmware_version.trim() || undefined; + } + if (serialNumber) { + const serialDevice = await getDeviceBySerialNumber(serialNumber); + if (serialDevice && serialDevice.id !== req.params.deviceId) { + return next(new ApiError("CONFLICT", "serial_number already belongs to another device", 409)); + } + } + try { const existing = await getDeviceById(req.params.deviceId); const updated = await patchDevice(req.params.deviceId, payload); @@ -1894,7 +1941,7 @@ router.post( return res.status(201).json(successResponse(req, toDeviceCommandPayload(acknowledged || command))); } - if (commandName === "device.reboot") { + if (commandName === "device.reboot" || commandName === "device.poweroff") { if (!device.serial_number) { const failed = await acknowledgeDeviceCommand({ device_id: device.id, @@ -1906,12 +1953,13 @@ router.post( return res.status(201).json(successResponse(req, toDeviceCommandPayload(failed || command))); } - const publishResult = await publishQf100RebootCommand(device.id, device.serial_number); + const deviceCommand = commandName === "device.poweroff" ? "poweroff" : "reboot"; + const publishResult = await publishQf100DeviceCommand(device.id, device.serial_number, deviceCommand); await createMqttMessage({ direction: "downlink", device_id: device.id, topic: publishResult.topic, - message_type: "reboot_command", + message_type: `${deviceCommand}_command`, correlation_id: command.id, payload_json: publishResult.payload, publish_status: publishResult.ok ? "sent" : "failed", diff --git a/src/shared/services/mqttPublisher.ts b/src/shared/services/mqttPublisher.ts index 9fc2792..ce68b2f 100644 --- a/src/shared/services/mqttPublisher.ts +++ b/src/shared/services/mqttPublisher.ts @@ -38,12 +38,14 @@ type Qf100DynamicQrDisplayPayload = { }; }; -type Qf100RebootCommandPayload = { +type Qf100DeviceCommand = "reboot" | "poweroff"; + +type Qf100DeviceCommandPayload = { header: { category: 5; }; data: { - command: "reboot"; + command: Qf100DeviceCommand; }; }; @@ -330,20 +332,28 @@ export async function publishQf100DynamicQrDisplay( return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), qrPayload); } -export async function publishQf100RebootCommand( +export async function publishQf100DeviceCommand( deviceId: string, - serialNumber: string -): Promise> { - const rebootPayload: Qf100RebootCommandPayload = { + serialNumber: string, + command: Qf100DeviceCommand +): Promise> { + const commandPayload: Qf100DeviceCommandPayload = { header: { category: 5 }, data: { - command: "reboot" + command } }; - return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), rebootPayload); + return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), commandPayload); +} + +export async function publishQf100RebootCommand( + deviceId: string, + serialNumber: string +): Promise> { + return publishQf100DeviceCommand(deviceId, serialNumber, "reboot"); } export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) { diff --git a/ui/admin-dashboard-overview/index.html b/ui/admin-dashboard-overview/index.html index c89ed79..bb4a1d8 100644 --- a/ui/admin-dashboard-overview/index.html +++ b/ui/admin-dashboard-overview/index.html @@ -182,7 +182,7 @@
Dashboard @@ -1004,6 +1004,23 @@ AdminDashboard.load(); + const dashboardSearch = document.getElementById("dashboard-global-search"); + dashboardSearch?.addEventListener("keydown", (event) => { + if (event.key === "Escape" && dashboardSearch.value) { + dashboardSearch.value = ""; + return; + } + if (event.key !== "Enter") return; + const q = dashboardSearch.value.trim(); + if (!q) return; + const target = /^(tx|txn|rrn|pay|ref)/i.test(q) + ? "/ui/transaction-history-monitoring" + : /^(mch|merchant|biz|store)/i.test(q) + ? "/ui/merchant-list-management" + : "/ui/device-registry-monitoring"; + window.location.href = `${target}?q=${encodeURIComponent(q)}`; + }); + // Micro-interactions for hovering and state visual feedback document.querySelectorAll(".hover\\:shadow-lg").forEach((card) => { card.addEventListener("mouseenter", () => { diff --git a/ui/admin-fee-pricing-management/index.html b/ui/admin-fee-pricing-management/index.html index 3298e4a..0e90c4d 100644 --- a/ui/admin-fee-pricing-management/index.html +++ b/ui/admin-fee-pricing-management/index.html @@ -177,7 +177,7 @@
- + search
' - \ No newline at end of file + diff --git a/ui/admin-reconciliation-management/index.html b/ui/admin-reconciliation-management/index.html index ac3efd9..f53c7ce 100644 --- a/ui/admin-reconciliation-management/index.html +++ b/ui/admin-reconciliation-management/index.html @@ -182,7 +182,7 @@
- + search