Complete QF100 ops commands and detail UI
This commit is contained in:
@ -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) => {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<string, unknown>)
|
||||
: 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)));
|
||||
}
|
||||
);
|
||||
|
||||
@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<MqttPublishResult<Qf100DynamicQrDisplayPayload>> {
|
||||
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<MqttPublishResult<Qf100RebootCommandPayload>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -455,13 +455,14 @@ Rows
|
||||
<img alt="Soundbox V2 Product" class="w-20 h-20 rounded-xl bg-slate-100" data-alt="A clean professional studio product shot of a minimalist electronic soundbox speaker device with a small LCD screen and premium matte plastic finish. The lighting is soft and corporate with subtle blue reflections on the surface consistent with a high-end fintech hardware brand. The background is a clean neutral white studio setting." src="https://lh3.googleusercontent.com/aida-public/AB6AXuC-CSPTCnxQuDTN1XM0atRPM9hIcVzf3zpbuxEUGTIlC-c1BivDqPa9osmBscvoiUcJeMBwUaXbZ6Ut5FuG2a91sVtZjzWRTgLck34kJJJy3N2E9O3uVtZw6InOpX9Gkph2OJxu_Z-PkR_t3F56EVZY3u8o2iZO3iH8hj9_ajrku7g1r_l54uobcRoN3dRH3k_at6GTuGbMtSSD4ew24sX8nePUsVvILKJauQLcMKD14J6mtAGm0x5PfViQQKdJzf_pYMqKswr3Yz4"/>
|
||||
<div>
|
||||
<h4 class="font-bold text-headline-md mb-1" id="device-detail-title">-</h4>
|
||||
<p class="font-mono text-body-md text-slate-500" id="device-detail-serial">SN: -</p>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-slate-100 text-slate-600" id="device-detail-model">Device</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6" id="device-detail-content"></div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-slate-200 grid grid-cols-2 gap-4">
|
||||
<button class="w-full py-2.5 border border-slate-200 rounded-xl font-bold hover:bg-slate-50 transition-colors">Reboot Device</button>
|
||||
<button id="drawer-reboot-device" class="w-full py-2.5 border border-slate-200 rounded-xl font-bold hover:bg-slate-50 transition-colors">Reboot Device</button>
|
||||
<button class="w-full py-2.5 bg-danger/10 text-danger rounded-xl font-bold hover:bg-danger/20 transition-colors">Unbind Merchant</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -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 `
|
||||
<tr class="hover:bg-slate-50 transition-colors group">
|
||||
<td class="px-6 py-row-height">
|
||||
<span class="font-mono text-primary font-bold">${id || "-"}</span>
|
||||
<div class="space-y-1">
|
||||
<span class="block font-mono text-primary font-bold">${id || "-"}</span>
|
||||
<span class="block font-mono text-[12px] text-slate-500">SN: ${serialNumber}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-row-height">${model}</td>
|
||||
<td class="px-6 py-row-height">
|
||||
@ -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
|
||||
<section>
|
||||
<h5 class="text-label-md font-label-md text-slate-500 uppercase mb-3">Device Detail</h5>
|
||||
<div class="bg-slate-50 p-4 rounded-xl border border-slate-100">
|
||||
<div class="flex justify-between gap-4 mb-2">
|
||||
<span class="text-on-surface-variant">Serial Number</span>
|
||||
<span class="font-mono font-bold text-right">${serialNumber}</span>
|
||||
</div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-on-surface-variant">Model</span>
|
||||
<span class="font-bold">${device.model || "Unknown"}</span>
|
||||
@ -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 = `
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
@ -1253,7 +1301,11 @@ Rows
|
||||
</span>
|
||||
<span class="font-mono text-slate-500">v${configStatus.desired_config_version || "-"}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm mb-4">
|
||||
<div class="grid grid-cols-3 gap-3 text-sm mb-4">
|
||||
<div>
|
||||
<p class="text-slate-500">Latest Pull</p>
|
||||
<p class="font-bold">${latestPull ? formatLastSeen(latestPull.received_at || latestPull.timestamp) : "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-slate-500">Latest Push</p>
|
||||
<p class="font-bold">${latestPush ? formatLastSeen(latestPush.created_at) : "-"}</p>
|
||||
@ -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);
|
||||
|
||||
@ -211,6 +211,10 @@
|
||||
<span id="device-model">Soundbox V2 Pro</span>
|
||||
</p>
|
||||
<p class="text-body-md text-on-surface-variant flex items-center gap-1.5">
|
||||
<span class="material-symbols-outlined text-sm">tag</span>
|
||||
<span id="device-serial-number">SN: -</span>
|
||||
</p>
|
||||
<p class="text-body-md text-on-surface-variant flex items-center gap-1.5">
|
||||
<span class="material-symbols-outlined text-sm">schedule</span>
|
||||
<span id="device-last-seen">Last seen 2 mins ago</span>
|
||||
</p>
|
||||
@ -235,7 +239,7 @@
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-slate-200 mb-8 flex gap-8">
|
||||
<button class="pb-4 text-body-md font-bold text-primary border-b-2 border-primary" data-scroll-target="overview-section">Overview</button>
|
||||
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="heartbeat-section">Heartbeat</button>
|
||||
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="payload-stream">Heartbeat</button>
|
||||
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="configuration-section">Configuration</button>
|
||||
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="binding-section">Binding History</button>
|
||||
<button id="dynamic-qr-tab" class="hidden pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="dynamic-qr-panel">Dynamic QR</button>
|
||||
@ -376,7 +380,7 @@ Loading
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="heartbeat-section" class="p-4 flex-1 overflow-y-auto code-font text-[13px] text-green-400 space-y-1 custom-scroll">
|
||||
<div id="payload-stream" data-section="heartbeat-section" class="p-4 flex-1 overflow-y-auto code-font text-[13px] text-green-400 space-y-1 custom-scroll">
|
||||
<p class="text-slate-500">[14:02:11] INITIALIZING WEBSOCKET CONNECTION...</p>
|
||||
<p class="text-slate-500">[14:02:12] CONNECTED TO SND-10293_GATEWAY_V4</p>
|
||||
<p class="text-success">[14:02:15] RECV: {"event": "heartbeat", "status": "online", "v_batt": 4.12, "rssi": -78, "ts": 1715421255}</p>
|
||||
@ -492,7 +496,7 @@ Rotate Credential
|
||||
<div class="rounded-[28px] border-[10px] border-slate-900 bg-slate-950 p-4 shadow-xl">
|
||||
<div class="rounded-2xl bg-white p-4 text-center">
|
||||
<p class="text-[11px] font-bold uppercase tracking-wider text-slate-500">QRIS Payment</p>
|
||||
<div id="qr-preview-grid" class="mx-auto my-4 grid h-40 w-40 grid-cols-9 grid-rows-9 gap-1 rounded-lg bg-white p-2"></div>
|
||||
<div id="qr-preview-grid" class="mx-auto my-4 flex h-40 w-40 items-center justify-center rounded-lg bg-white p-2"></div>
|
||||
<p id="qr-preview-amount" class="text-xl font-extrabold text-slate-950">Rp 1.000</p>
|
||||
<p id="qr-preview-device" class="mt-1 font-mono text-[11px] text-slate-500">-</p>
|
||||
</div>
|
||||
@ -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, """)
|
||||
.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 `<div class="relative pl-8">
|
||||
const title = formatEventTitle(item);
|
||||
const summary = compactPayload(item);
|
||||
const marker = iconClass(item.status || item.event || item.state);
|
||||
return `<div class="relative pl-8 min-w-0">
|
||||
<div class="absolute left-0 top-1 w-6 h-6 rounded-full ${marker.color} border-4 border-white shadow-sm flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[12px] text-white" style="font-variation-settings: 'FILL' 1;">${marker.icon}</span>
|
||||
</div>
|
||||
<p class="text-label-md font-bold">${title}</p>
|
||||
<p class="text-[12px] text-on-surface-variant">Signal: ${metric.signal ?? "N/A"}, Battery: ${metric.battery ?? "N/A"}</p>
|
||||
<p class="text-[12px] text-on-surface-variant">${details}</p>
|
||||
<p class="text-[10px] text-slate-400 mt-1">${when}</p>
|
||||
<div class="min-w-0 rounded-lg border border-slate-100 bg-slate-50/60 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<p class="text-label-md font-bold text-slate-900">${escapeHtml(title)}</p>
|
||||
<p class="shrink-0 text-[10px] text-slate-400">${escapeHtml(when)}</p>
|
||||
</div>
|
||||
<p class="mt-1 text-[12px] text-on-surface-variant">Signal: ${escapeHtml(metric.signal ?? "N/A")}, Battery: ${escapeHtml(metric.battery ?? "N/A")}</p>
|
||||
<pre class="mt-2 max-h-24 overflow-auto whitespace-pre-wrap break-words rounded-md bg-white px-2 py-1.5 text-[11px] leading-5 text-slate-600">${escapeHtml(JSON.stringify(summary, null, 2))}</pre>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.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 = `<span class="material-symbols-outlined text-[16px]">${icon}</span>${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();
|
||||
})();
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
@ -181,6 +181,26 @@
|
||||
<span class="text-sm font-semibold text-slate-600">Export worker</span>
|
||||
<span id="export-worker" class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">-</span>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-extrabold text-slate-950">Remote Actions</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Send operational command to a selected soundbox</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-blue-700">settings_remote</span>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Device</span>
|
||||
<select id="command-device-select" class="w-full rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-600 focus:ring-blue-600">
|
||||
<option value="">Select device</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="send-reboot-command" class="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-extrabold text-slate-800 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50" disabled>
|
||||
<span class="material-symbols-outlined text-[20px]">restart_alt</span>
|
||||
Reboot Device
|
||||
</button>
|
||||
<p id="command-status" class="mt-2 min-h-5 text-xs font-semibold text-slate-500"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -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 = '<option value="">Select device</option>';
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user