Prepare QF100 pilot and Debian app deploy
This commit is contained in:
@ -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);
|
||||
|
||||
@ -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
125
src/routes/speaker.ts
Normal 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;
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user