Align speaker config server response

This commit is contained in:
Wira Basalamah
2026-06-06 23:53:42 +07:00
parent 00580a98fc
commit 3523ca2500
3 changed files with 160 additions and 3 deletions

View File

@ -170,6 +170,9 @@ QF100_MQTT_BROKER_PORT=8883
QF100_MQTT_USERNAME=qris-backend QF100_MQTT_USERNAME=qris-backend
QF100_MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD QF100_MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD
QF100_MQTT_KEEP_ALIVE_SECONDS=60 QF100_MQTT_KEEP_ALIVE_SECONDS=60
QF100_APP_CONFIG_VERSION=21
QF100_DYNAMIC_QR_URL=
QF100_DYNAMIC_QR_GAP_SECONDS=0
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED=true DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED=true
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS=60000 DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS=60000

View File

@ -40,6 +40,9 @@ export const env = {
QF100_MQTT_USERNAME: process.env.QF100_MQTT_USERNAME || "", QF100_MQTT_USERNAME: process.env.QF100_MQTT_USERNAME || "",
QF100_MQTT_PASSWORD: process.env.QF100_MQTT_PASSWORD || "", QF100_MQTT_PASSWORD: process.env.QF100_MQTT_PASSWORD || "",
QF100_MQTT_KEEP_ALIVE_SECONDS: Number(process.env.QF100_MQTT_KEEP_ALIVE_SECONDS || 60), QF100_MQTT_KEEP_ALIVE_SECONDS: Number(process.env.QF100_MQTT_KEEP_ALIVE_SECONDS || 60),
QF100_APP_CONFIG_VERSION: Number(process.env.QF100_APP_CONFIG_VERSION || 0),
QF100_DYNAMIC_QR_URL: process.env.QF100_DYNAMIC_QR_URL || "",
QF100_DYNAMIC_QR_GAP_SECONDS: Number(process.env.QF100_DYNAMIC_QR_GAP_SECONDS || 0),
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED: process.env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED || "true", DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED: process.env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED || "true",
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000), DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000),
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100), DYNAMIC_QR_EXPIRY_SWEEP_LIMIT: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100),

View File

@ -18,6 +18,16 @@ type Qf100ConfigRequest = {
iccid?: string; iccid?: string;
}; };
type SpeakerReportRequest = Qf100ConfigRequest & {
result?: number;
"success-list"?: string[];
"fail-list"?: string[];
"wifi-ap"?: Record<string, unknown>;
"wifi-ap-list"?: Record<string, unknown>[];
"main-cell-info"?: Record<string, unknown>;
"neighbor-cell-list"?: Record<string, unknown>[];
};
function parseBrokerUrl() { function parseBrokerUrl() {
if (!env.MQTT_BROKER_URL) { if (!env.MQTT_BROKER_URL) {
return { host: "", port: 0 }; return { host: "", port: 0 };
@ -48,6 +58,50 @@ function vendorError(res: Response, code: number, message: string) {
}); });
} }
function toDeviceDateTime(date: Date) {
const pad = (value: number) => String(value).padStart(2, "0");
return [
date.getUTCFullYear(),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes()),
pad(date.getUTCSeconds())
].join("");
}
function numberFromPayload(value: unknown, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getAppConfigVersion(payload: Qf100ConfigRequest) {
const requested = numberFromPayload(payload["app-config-version"], 1);
return Math.max(env.QF100_APP_CONFIG_VERSION || requested, requested);
}
function buildDefaultAudioConfig() {
return {
"voice-type": 2,
"voice-volume": 80,
"voice-format": 1,
"dialect-id": 0
};
}
function buildOptionalDynamicQrConfig(device: { capability_profile_json?: Record<string, unknown> }) {
const profile = device.capability_profile_json || {};
const url = String(profile.dynamic_qr_url || profile.dynamicQrUrl || env.QF100_DYNAMIC_QR_URL || "").trim();
if (!url) {
return null;
}
return {
url,
gap: numberFromPayload(profile.dynamic_qr_gap || profile.dynamicQrGap, env.QF100_DYNAMIC_QR_GAP_SECONDS)
};
}
async function handleDevConfig(req: Request, res: Response, next: NextFunction) { async function handleDevConfig(req: Request, res: Response, next: NextFunction) {
try { try {
const payload = getRequestPayload(req); const payload = getRequestPayload(req);
@ -101,12 +155,13 @@ async function handleDevConfig(req: Request, res: Response, next: NextFunction)
return vendorError(res, 1003, "mqtt broker is not configured"); return vendorError(res, 1003, "mqtt broker is not configured");
} }
const dynamicQrConfig = buildOptionalDynamicQrConfig(device);
res.json({ res.json({
"error-code": 0, "error-code": 0,
"bind-state": 1, "bind-state": 1,
"app-config-version": 1, "app-config-version": getAppConfigVersion(payload),
"time-stamp": Math.floor(now.getTime() / 1000), "time-stamp": Math.floor(now.getTime() / 1000),
"date-time": now.toISOString().replace(/[-:TZ.]/g, "").slice(0, 14), "date-time": toDeviceDateTime(now),
mqtt: { mqtt: {
"broker-ip": brokerHost, "broker-ip": brokerHost,
"broker-port": brokerPort, "broker-port": brokerPort,
@ -117,7 +172,98 @@ async function handleDevConfig(req: Request, res: Response, next: NextFunction)
"publish-topic": `devices/${device.id}/uplink/qf100`, "publish-topic": `devices/${device.id}/uplink/qf100`,
"keep-alive": env.QF100_MQTT_KEEP_ALIVE_SECONDS, "keep-alive": env.QF100_MQTT_KEEP_ALIVE_SECONDS,
"cert-update": 0 "cert-update": 0
} },
audio: buildDefaultAudioConfig(),
"trade-audio-templates": [],
"discount-audio-templates": [],
"verify-code-audio-templates": [],
"power-on-audio": {
"play-mode": 1,
"voice-format": 1,
name: "",
data: ""
},
"power-off-audio": {
"play-mode": 1,
"voice-format": 1,
name: "",
data: ""
},
"ad-audio": {
"play-mode": 0,
"time-period": {
start: 8,
end: 22
},
interval: 0,
"pay-count": 0,
"voice-format": 1,
name: "",
data: ""
},
"customer-audio": {
"play-mode": 0,
"voice-format": 1,
name: "",
data: ""
},
...(dynamicQrConfig ? { "dynamic-qr": dynamicQrConfig } : {})
});
} catch (error) {
next(error);
}
}
async function recordSpeakerReport(req: Request, state: string) {
const payload = getRequestPayload(req) as SpeakerReportRequest;
const serialNumber = String(payload["dev-sn"] || "").trim();
if (!serialNumber) {
return null;
}
const device = await getDeviceBySerialNumber(serialNumber);
if (!device) {
return null;
}
const now = new Date();
await patchDevice(device.id, {
last_seen_at: now.toISOString()
});
await createDeviceHeartbeat({
device_id: device.id,
timestamp: now.toISOString(),
firmware_version: payload["fw-version"] ? String(payload["fw-version"]) : device.firmware_version,
state,
payload_json: {
source: `speaker_${state}`,
...payload
}
});
return device;
}
async function handleAckReport(req: Request, res: Response, next: NextFunction) {
try {
await recordSpeakerReport(req, req.path.replace(/^\//, "").replace(/-/g, "_"));
res.json({ "error-code": 0 });
} catch (error) {
next(error);
}
}
async function handleCheckUpdate(req: Request, res: Response, next: NextFunction) {
try {
const payload = getRequestPayload(req) as SpeakerReportRequest;
await recordSpeakerReport(req, "check_update");
res.json({
"error-code": 1002,
version: payload["fw-version"] || "",
build: Number(payload["fw-build"] || 0),
"file-length": 0,
"file-hash": "",
"download-url": ""
}); });
} catch (error) { } catch (error) {
next(error); next(error);
@ -126,5 +272,10 @@ async function handleDevConfig(req: Request, res: Response, next: NextFunction)
router.get("/dev-config", handleDevConfig); router.get("/dev-config", handleDevConfig);
router.post("/dev-config", handleDevConfig); router.post("/dev-config", handleDevConfig);
router.post("/lbs-info", handleAckReport);
router.post("/config-update-result", handleAckReport);
router.get("/check-update", handleCheckUpdate);
router.post("/check-update", handleCheckUpdate);
router.post("/fw-update-result", handleAckReport);
export default router; export default router;