Prepare QF100 pilot and Debian app deploy

This commit is contained in:
Wira Basalamah
2026-06-04 11:20:16 +07:00
parent 648e77cee9
commit 8a2e202606
17 changed files with 1135 additions and 216 deletions

View File

@ -10,6 +10,7 @@ import adminRoutes from "./routes/admin";
import integrationRoutes from "./routes/integrations";
import deviceRoutes from "./routes/device";
import merchantRoutes from "./routes/merchant";
import speakerRoutes from "./routes/speaker";
import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator";
import { startDynamicQrExpiryScheduler } from "./shared/services/dynamicQrExpiryScheduler";
import { startExportJobWorker } from "./shared/services/exportJobWorker";
@ -135,12 +136,14 @@ app.use("/admin", (req, res, next) => {
return next();
});
app.use("/device", deviceLimiter);
app.use("/speaker", deviceLimiter);
app.use("/integrations", rateLimit({ name: "integrations", windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS, max: env.RATE_LIMIT_DEVICE_MAX }));
app.use("/admin", adminRoutes);
app.use("/merchant", merchantRoutes);
app.use("/integrations", integrationRoutes);
app.use("/device", deviceRoutes);
app.use("/speaker", speakerRoutes);
app.use((err: Error, _req: Request, res: Response, next: NextFunction) => {
handleErrors(err, _req, res, next);

View File

@ -35,6 +35,11 @@ export const env = {
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number(
process.env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000
),
QF100_MQTT_BROKER_HOST: process.env.QF100_MQTT_BROKER_HOST || "",
QF100_MQTT_BROKER_PORT: Number(process.env.QF100_MQTT_BROKER_PORT || 0),
QF100_MQTT_USERNAME: process.env.QF100_MQTT_USERNAME || "",
QF100_MQTT_PASSWORD: process.env.QF100_MQTT_PASSWORD || "",
QF100_MQTT_KEEP_ALIVE_SECONDS: Number(process.env.QF100_MQTT_KEEP_ALIVE_SECONDS || 60),
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED: process.env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED || "true",
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000),
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100),

125
src/routes/speaker.ts Normal file
View File

@ -0,0 +1,125 @@
import { Router, Request, Response, NextFunction } from "express";
import { env } from "../config/env";
import { createDeviceHeartbeat } from "../shared/store/heartbeatStore";
import { getDeviceBySerialNumber, patchDevice } from "../shared/store/deviceStore";
const router = Router();
type Qf100ConfigRequest = {
"dev-model"?: string;
"item-number"?: string;
"dev-sn"?: string;
"fw-version"?: string;
"fw-build"?: number;
"app-config-version"?: number;
imei?: string;
imsi?: string;
iccid?: string;
};
function parseBrokerUrl() {
if (!env.MQTT_BROKER_URL) {
return { host: "", port: 0 };
}
try {
const parsed = new URL(env.MQTT_BROKER_URL);
return {
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : parsed.protocol === "mqtts:" ? 8883 : 1883
};
} catch (_error) {
return { host: "", port: 0 };
}
}
function getRequestPayload(req: Request): Qf100ConfigRequest {
return {
...((req.query || {}) as Qf100ConfigRequest),
...((req.body || {}) as Qf100ConfigRequest)
};
}
function vendorError(res: Response, code: number, message: string) {
res.status(200).json({
"error-code": code,
message
});
}
router.get("/dev-config", async (req: Request, res: Response, next: NextFunction) => {
try {
const payload = getRequestPayload(req);
const serialNumber = String(payload["dev-sn"] || "").trim();
if (!serialNumber) {
return vendorError(res, 1001, "dev-sn is required");
}
const device = await getDeviceBySerialNumber(serialNumber);
if (!device || device.status !== "active") {
return vendorError(res, 1002, "device not registered or inactive");
}
const now = new Date();
const firmwareVersion = payload["fw-version"] ? String(payload["fw-version"]) : device.firmware_version;
await patchDevice(device.id, {
vendor: device.vendor || "QF",
model: payload["dev-model"] ? String(payload["dev-model"]) : device.model || "QF100",
communication_mode: "mqtt",
last_seen_at: now.toISOString(),
firmware_version: firmwareVersion
});
await createDeviceHeartbeat({
device_id: device.id,
timestamp: now.toISOString(),
firmware_version: firmwareVersion,
state: "config_pull",
payload_json: {
source: "qf100_dev_config",
dev_model: payload["dev-model"],
item_number: payload["item-number"],
dev_sn: serialNumber,
fw_build: payload["fw-build"],
app_config_version: payload["app-config-version"],
imei: payload.imei,
imsi: payload.imsi,
iccid: payload.iccid
}
});
const brokerFromUrl = parseBrokerUrl();
const brokerHost = env.QF100_MQTT_BROKER_HOST || brokerFromUrl.host;
const brokerPort = env.QF100_MQTT_BROKER_PORT || brokerFromUrl.port;
const username = env.QF100_MQTT_USERNAME || device.mqtt_username || device.id;
const password = env.QF100_MQTT_PASSWORD || env.MQTT_PASSWORD;
if (!brokerHost || !brokerPort) {
return vendorError(res, 1003, "mqtt broker is not configured");
}
res.json({
"error-code": 0,
"bind-state": 1,
"app-config-version": 1,
"time-stamp": Math.floor(now.getTime() / 1000),
"date-time": now.toISOString().replace(/[-:TZ.]/g, "").slice(0, 14),
mqtt: {
"broker-ip": brokerHost,
"broker-port": brokerPort,
"client-id": device.id,
"user-name": username,
password,
"subscribe-topic": `devices/${device.id}/downlink/qf100`,
"publish-topic": `devices/${device.id}/uplink/qf100`,
"keep-alive": env.QF100_MQTT_KEEP_ALIVE_SECONDS,
"cert-update": 0
}
});
} catch (error) {
next(error);
}
});
export default router;

View File

@ -159,6 +159,7 @@ CREATE TABLE IF NOT EXISTS devices (
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_devices_serial_number ON devices (serial_number);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS mqtt_username TEXT;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_secret_fingerprint TEXT;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_status TEXT NOT NULL DEFAULT 'not_issued';

View File

@ -10,8 +10,10 @@ import {
updateNotification
} from "../store/notificationStore";
import { getMerchantById } from "../store/merchantStore";
import { getDeviceById, type DeviceEntity } from "../store/deviceStore";
import { createMqttMessage } from "../store/mqttMessageStore";
import { getTransactionById, listTransactions, toTransactionPayload, TransactionEntity } from "../store/transactionStore";
import { buildPaymentSuccessPayload, publishPaymentSuccess, MqttPublishResult } from "../services/mqttPublisher";
import { buildPaymentSuccessPayload, publishPaymentSuccessForProtocol, MqttPublishResult } from "../services/mqttPublisher";
import type { TransactionPaidEvent, TransactionPaidPayload } from "../events/transactionEvents";
import { subscribeTransactionPaid } from "../events/transactionEvents";
import { env } from "../../config/env";
@ -81,6 +83,20 @@ function makeNextRetryDate(retryCount: number) {
return new Date(Date.now() + intervalMs).toISOString();
}
function resolvePaymentProtocol(device: DeviceEntity | null): "platform_v1" | "qf100" {
const profile = device?.capability_profile_json || {};
const configuredProfile = String(
profile.mqtt_payload_profile || profile.protocol_profile || profile.vendor_protocol || ""
).toLowerCase();
const model = String(device?.model || "").toLowerCase();
if (configuredProfile === "qf100" || model.includes("qf100")) {
return "qf100";
}
return "platform_v1";
}
async function getNotificationMerchantName(merchantId: string): Promise<string> {
const merchant = await getMerchantById(merchantId);
return merchant?.brand_name || merchant?.legal_name || merchantId;
@ -107,7 +123,7 @@ function mapMqttFailureState(
};
}
async function markNotificationSent(notification: NotificationEntity, publishResult: MqttPublishResult) {
async function markNotificationSent(notification: NotificationEntity, publishResult: MqttPublishResult<unknown>) {
await updateNotification(notification.id, {
delivery_status: "sent",
retry_count: notification.retry_count,
@ -116,7 +132,7 @@ async function markNotificationSent(notification: NotificationEntity, publishRes
});
}
async function markNotificationFailed(notification: NotificationEntity, publishResult: MqttPublishResult) {
async function markNotificationFailed(notification: NotificationEntity, publishResult: MqttPublishResult<unknown>) {
const next = mapMqttFailureState(notification.retry_count, publishResult.reason);
await updateNotification(notification.id, {
delivery_status: next.status,
@ -209,7 +225,18 @@ async function publishNotificationNow(notification: NotificationEntity, eventPay
event_id: notification.event_id
});
const result = await publishPaymentSuccess(mqttPayload);
const device = notification.device_id ? await getDeviceById(notification.device_id) : null;
const result = await publishPaymentSuccessForProtocol(mqttPayload, resolvePaymentProtocol(device));
await createMqttMessage({
direction: "downlink",
device_id: notification.device_id || String(effectivePayload.device_id || ""),
topic: result.topic,
message_type: "payment_success",
correlation_id: notification.event_id,
payload_json: result.payload as Record<string, unknown>,
publish_status: result.ok ? "sent" : "failed",
reason: result.reason
});
if (!result.ok) {
await markNotificationFailed(notification, result);
return;

View File

@ -16,6 +16,17 @@ type PaymentSuccessPayload = {
display_text: string;
};
type PaymentSuccessProtocol = "platform_v1" | "qf100";
type Qf100PaymentSuccessPayload = {
header: {
category: 1;
};
data: {
"pay-amount": number;
};
};
type DynamicQrResponsePayload = {
message_type: "dynamic_qr_response";
correlation_id: string;
@ -179,6 +190,10 @@ export function makePaymentSuccessTopic(deviceId: string) {
return `devices/${deviceId}/downlink/payment/success`;
}
export function makeQf100DownlinkTopic(deviceId: string) {
return `devices/${deviceId}/downlink/qf100`;
}
export function makeDynamicQrResponseTopic(deviceId: string) {
return `devices/${deviceId}/downlink/dynamic-qr/response`;
}
@ -241,6 +256,26 @@ export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Pro
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
}
export async function publishPaymentSuccessForProtocol(
payload: PaymentSuccessPayload,
protocol: PaymentSuccessProtocol
): Promise<MqttPublishResult<PaymentSuccessPayload | Qf100PaymentSuccessPayload>> {
if (protocol !== "qf100") {
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
}
const qf100Payload: Qf100PaymentSuccessPayload = {
header: {
category: 1
},
data: {
"pay-amount": payload.amount
}
};
return publishMqttPayload(payload.device_id, makeQf100DownlinkTopic(payload.device_id), qf100Payload);
}
export async function publishDynamicQrResponse(deviceId: string, payload: DynamicQrResponsePayload) {
return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload);
}

View File

@ -137,6 +137,13 @@ export async function getDeviceById(id: string): Promise<DeviceEntity | null> {
return rows[0] ? mapDevice(rows[0]) : null;
}
export async function getDeviceBySerialNumber(serialNumber: string): Promise<DeviceEntity | null> {
const { rows } = await getPool().query("SELECT * FROM devices WHERE serial_number = $1 ORDER BY created_at DESC LIMIT 1", [
serialNumber
]);
return rows[0] ? mapDevice(rows[0]) : null;
}
export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Promise<DeviceEntity> {
const existing = await getDeviceById(id);
if (!existing) {