| const mqtt = require("mqtt"); |
|
|
| const BROKER_URL = |
| process.env.MQTT_BROKER_URL || "mqtts://i8e0f149.ala.asia-southeast1.emqxsl.com:8883"; |
| const MQTT_USER = process.env.MQTT_USERNAME || "cabin_node"; |
| const MQTT_PASS = process.env.MQTT_PASSWORD || "6HYUvbJEkeFr9m4"; |
| const OTA_STATUS_TOPIC_FILTER = "tenant/+/truck/+/ota/+/status"; |
| const GATEWAY_WIFI_TOPIC_BASE = |
| process.env.GATEWAY_TOPIC_BASE || "tenant/demo/truck/TRUCK01/gateway/wifi"; |
| const WIFI_SCAN_RESULT_TOPIC_FILTER = "tenant/+/truck/+/gateway/wifi/scan/result"; |
| const WIFI_STATUS_TOPIC_FILTER = "tenant/+/truck/+/gateway/wifi/status"; |
| const OTA_TOPIC_PATTERN = |
| /^tenant\/([^/]+)\/truck\/([^/]+)\/ota\/(gateway|container)\/(command|status)$/; |
| const WIFI_TOPIC_PATTERN = |
| /^tenant\/([^/]+)\/truck\/([^/]+)\/gateway\/wifi\/(scan\/result|status)$/; |
|
|
| let mqttClient = null; |
|
|
| const stagedFirmware = {}; |
| const otaStatusByKey = {}; |
| const latestStatusByTarget = {}; |
| const routeContextByKey = {}; |
| const sseClients = []; |
| let latestWifiNetworks = []; |
| let latestWifiStatus = { |
| state: "unknown", |
| receivedAt: null, |
| }; |
| const wifiNetworksByKey = {}; |
| const wifiStatusByKey = {}; |
| let mqttConnectionState = { |
| connected: false, |
| state: "initializing", |
| lastError: null, |
| updatedAt: new Date().toISOString(), |
| }; |
|
|
| function normalizeTarget(target) { |
| return String(target || "").trim().toLowerCase(); |
| } |
|
|
| function buildRouteKey(tenantCode, truckId, target) { |
| return `${tenantCode || ""}::${truckId || ""}::${normalizeTarget(target)}`; |
| } |
|
|
| function buildUnitKey(tenantCode, truckId, containerId) { |
| return `${tenantCode || ""}::${truckId || ""}::${containerId || ""}`; |
| } |
|
|
| function buildStatusKey(tenantCode, truckId, containerId, target) { |
| return `${buildUnitKey(tenantCode, truckId, containerId)}::${normalizeTarget(target)}`; |
| } |
|
|
| function otaTopic(tenantCode, truckId, target, direction) { |
| return `tenant/${tenantCode}/truck/${truckId}/ota/${normalizeTarget(target)}/${direction}`; |
| } |
|
|
| function parseOtaTopic(topic) { |
| const match = OTA_TOPIC_PATTERN.exec(String(topic || "")); |
| if (!match) { |
| return null; |
| } |
|
|
| return { |
| tenantCode: match[1], |
| truckId: match[2], |
| target: match[3], |
| direction: match[4], |
| }; |
| } |
|
|
| function buildWifiKey(tenantCode, truckId) { |
| return `${tenantCode || ""}::${truckId || ""}`; |
| } |
|
|
| function parseWifiTopic(topic) { |
| const match = WIFI_TOPIC_PATTERN.exec(String(topic || "")); |
| if (!match) { |
| return null; |
| } |
|
|
| return { |
| tenantCode: match[1], |
| truckId: match[2], |
| kind: match[3], |
| }; |
| } |
|
|
| function broadcastSse(payload) { |
| const data = `data: ${JSON.stringify(payload)}\n\n`; |
| for (const client of sseClients) { |
| try { |
| client.write(data); |
| } catch (_error) { |
| |
| } |
| } |
| } |
|
|
| function setUnitStatus(context, message) { |
| if (!context?.tenantCode || !context?.truckId || !context?.containerId || !context?.target) { |
| return null; |
| } |
|
|
| const payload = { |
| ...message, |
| target: normalizeTarget(context.target), |
| tenantCode: context.tenantCode, |
| tenantId: message?.tenantId || context.tenantCode, |
| truckId: context.truckId, |
| containerId: context.containerId, |
| receivedAt: new Date().toISOString(), |
| }; |
|
|
| const statusKey = buildStatusKey( |
| context.tenantCode, |
| context.truckId, |
| context.containerId, |
| context.target |
| ); |
|
|
| otaStatusByKey[statusKey] = payload; |
| latestStatusByTarget[normalizeTarget(context.target)] = payload; |
| broadcastSse({ type: "ota_status", ...payload }); |
|
|
| return payload; |
| } |
|
|
| function handleWifiScanResult(topic, message) { |
| const context = parseWifiTopic(topic); |
| const networks = Array.isArray(message) |
| ? message |
| : Array.isArray(message?.networks) |
| ? message.networks |
| : []; |
|
|
| latestWifiNetworks = networks |
| .filter((network) => network && typeof network === "object") |
| .map((network) => ({ |
| ssid: String(network.ssid || ""), |
| rssi: Number(network.rssi), |
| secure: Boolean(network.secure), |
| })) |
| .filter((network) => network.ssid); |
| if (context) { |
| wifiNetworksByKey[buildWifiKey(context.tenantCode, context.truckId)] = latestWifiNetworks; |
| } |
|
|
| broadcastSse({ |
| type: "wifi_scan", |
| tenantCode: context?.tenantCode || null, |
| truckId: context?.truckId || null, |
| networks: latestWifiNetworks, |
| receivedAt: new Date().toISOString(), |
| }); |
| } |
|
|
| function handleWifiStatus(topic, message) { |
| const context = parseWifiTopic(topic); |
| latestWifiStatus = { |
| ...(message && typeof message === "object" ? message : {}), |
| tenantCode: context?.tenantCode || message?.tenantCode || null, |
| truckId: context?.truckId || message?.truckId || null, |
| receivedAt: new Date().toISOString(), |
| }; |
| if (context) { |
| wifiStatusByKey[buildWifiKey(context.tenantCode, context.truckId)] = latestWifiStatus; |
| } |
|
|
| broadcastSse({ |
| type: "wifi_status", |
| ...latestWifiStatus, |
| }); |
| } |
|
|
| function resolveStatusContext(topic, message) { |
| const parsed = parseOtaTopic(topic); |
| if (!parsed) { |
| return null; |
| } |
|
|
| const routeKey = buildRouteKey(parsed.tenantCode, parsed.truckId, parsed.target); |
| const remembered = routeContextByKey[routeKey] || null; |
|
|
| const tenantCode = String(message?.tenantId || parsed.tenantCode || "").trim(); |
| const truckId = String(message?.truckId || parsed.truckId || "").trim(); |
| const containerId = String(message?.containerId || remembered?.containerId || "").trim(); |
|
|
| if (!tenantCode || !truckId || !containerId) { |
| return null; |
| } |
|
|
| return { |
| tenantCode, |
| truckId, |
| containerId, |
| target: parsed.target, |
| }; |
| } |
|
|
| function getMqttClient() { |
| if (mqttClient) { |
| return mqttClient; |
| } |
|
|
| mqttClient = mqtt.connect(BROKER_URL, { |
| username: MQTT_USER, |
| password: MQTT_PASS, |
| clientId: `ota-server-${Date.now()}`, |
| rejectUnauthorized: true, |
| reconnectPeriod: 5000, |
| }); |
|
|
| mqttClient.on("connect", () => { |
| mqttConnectionState = { |
| connected: true, |
| state: "connected", |
| lastError: null, |
| updatedAt: new Date().toISOString(), |
| }; |
| console.log("[OTA MQTT] Connected to broker"); |
| mqttClient.subscribe( |
| [OTA_STATUS_TOPIC_FILTER, WIFI_SCAN_RESULT_TOPIC_FILTER, WIFI_STATUS_TOPIC_FILTER], |
| { qos: 1 }, |
| (error) => { |
| if (error) { |
| console.error("[OTA MQTT] Subscribe error:", error.message); |
| return; |
| } |
|
|
| console.log("[OTA MQTT] Subscribed to status topic filter:", OTA_STATUS_TOPIC_FILTER); |
| console.log("[OTA MQTT] Subscribed to WiFi topic filters:", GATEWAY_WIFI_TOPIC_BASE); |
| } |
| ); |
| }); |
|
|
| mqttClient.on("message", (topic, payload) => { |
| try { |
| const message = JSON.parse(payload.toString()); |
| const wifiContext = parseWifiTopic(topic); |
| if (wifiContext?.kind === "scan/result") { |
| handleWifiScanResult(topic, message); |
| return; |
| } |
|
|
| if (wifiContext?.kind === "status") { |
| handleWifiStatus(topic, message); |
| return; |
| } |
|
|
| const context = resolveStatusContext(topic, message); |
|
|
| if (!context) { |
| console.warn("[OTA MQTT] Ignoring status with incomplete routing context:", topic); |
| return; |
| } |
|
|
| setUnitStatus(context, message); |
| } catch (error) { |
| console.error("[OTA MQTT] Bad status payload:", error.message); |
| } |
| }); |
|
|
| mqttClient.on("reconnect", () => { |
| mqttConnectionState = { |
| ...mqttConnectionState, |
| connected: false, |
| state: "reconnecting", |
| updatedAt: new Date().toISOString(), |
| }; |
| }); |
|
|
| mqttClient.on("close", () => { |
| mqttConnectionState = { |
| ...mqttConnectionState, |
| connected: false, |
| state: "closed", |
| updatedAt: new Date().toISOString(), |
| }; |
| }); |
|
|
| mqttClient.on("error", (error) => { |
| mqttConnectionState = { |
| connected: false, |
| state: "error", |
| lastError: error.message, |
| updatedAt: new Date().toISOString(), |
| }; |
| console.error("[OTA MQTT] Error:", error.message); |
| }); |
|
|
| return mqttClient; |
| } |
|
|
| function setStagedFirmware(target, info) { |
| stagedFirmware[normalizeTarget(target)] = info; |
| } |
|
|
| function getStagedFirmware(target) { |
| return stagedFirmware[normalizeTarget(target)] || null; |
| } |
|
|
| function getAllStagedFirmware() { |
| return { |
| gateway: getStagedFirmware("gateway"), |
| container: getStagedFirmware("container"), |
| }; |
| } |
|
|
| function getOtaStatus(target, context = null) { |
| const normalizedTarget = normalizeTarget(target); |
|
|
| if (context?.tenantCode && context?.truckId && context?.containerId) { |
| return ( |
| otaStatusByKey[ |
| buildStatusKey(context.tenantCode, context.truckId, context.containerId, normalizedTarget) |
| ] || null |
| ); |
| } |
|
|
| return latestStatusByTarget[normalizedTarget] || null; |
| } |
|
|
| function getUnitStatuses(context) { |
| return { |
| gateway: getOtaStatus("gateway", context), |
| container: getOtaStatus("container", context), |
| }; |
| } |
|
|
| function getWifiNetworks(context = null) { |
| if (context?.tenantCode && context?.truckId) { |
| return wifiNetworksByKey[buildWifiKey(context.tenantCode, context.truckId)] || []; |
| } |
|
|
| return latestWifiNetworks; |
| } |
|
|
| function getWifiStatus(context = null) { |
| if (context?.tenantCode && context?.truckId) { |
| return wifiStatusByKey[buildWifiKey(context.tenantCode, context.truckId)] || { |
| state: "unknown", |
| tenantCode: context.tenantCode, |
| truckId: context.truckId, |
| receivedAt: null, |
| }; |
| } |
|
|
| return latestWifiStatus; |
| } |
|
|
| function getMqttConnectionState() { |
| const client = getMqttClient(); |
| return { |
| ...mqttConnectionState, |
| connected: Boolean(client.connected), |
| }; |
| } |
|
|
| function addSseClient(res) { |
| sseClients.push(res); |
|
|
| for (const status of Object.values(otaStatusByKey)) { |
| res.write(`data: ${JSON.stringify({ type: "ota_status", ...status })}\n\n`); |
| } |
|
|
| res.write( |
| `data: ${JSON.stringify({ |
| type: "wifi_scan", |
| networks: latestWifiNetworks, |
| receivedAt: new Date().toISOString(), |
| })}\n\n` |
| ); |
| res.write(`data: ${JSON.stringify({ type: "wifi_status", ...latestWifiStatus })}\n\n`); |
| } |
|
|
| function removeSseClient(res) { |
| const index = sseClients.indexOf(res); |
| if (index !== -1) { |
| sseClients.splice(index, 1); |
| } |
| } |
|
|
| async function triggerOta({ tenantCode, truckId, containerId, target, firmwareUrl, staged }) { |
| return new Promise((resolve, reject) => { |
| const client = getMqttClient(); |
| const normalizedTarget = normalizeTarget(target); |
|
|
| if (!client.connected) { |
| reject(new Error("MQTT client not connected to broker")); |
| return; |
| } |
|
|
| const command = { |
| cmd: "ota_update", |
| target: normalizedTarget, |
| tenantId: tenantCode, |
| truckId, |
| containerId, |
| url: firmwareUrl, |
| size: staged.sizeBytes, |
| filename: staged.filename, |
| triggeredAt: new Date().toISOString(), |
| }; |
|
|
| const topic = otaTopic(tenantCode, truckId, normalizedTarget, "command"); |
| routeContextByKey[buildRouteKey(tenantCode, truckId, normalizedTarget)] = { |
| tenantCode, |
| truckId, |
| containerId, |
| target: normalizedTarget, |
| }; |
|
|
| client.publish(topic, JSON.stringify(command), { qos: 1, retain: false }, (error) => { |
| if (error) { |
| reject(new Error(`MQTT publish failed: ${error.message}`)); |
| return; |
| } |
|
|
| console.log(`[OTA] Command published -> ${topic}`, command); |
|
|
| setUnitStatus( |
| { |
| tenantCode, |
| truckId, |
| containerId, |
| target: normalizedTarget, |
| }, |
| { |
| state: "pending", |
| message: "Command sent, waiting for device acknowledgment", |
| filename: staged.filename, |
| progress: 0, |
| triggeredAt: command.triggeredAt, |
| } |
| ); |
|
|
| resolve(); |
| }); |
| }); |
| } |
|
|
| async function cancelOta({ tenantCode, truckId, containerId, target }) { |
| return new Promise((resolve, reject) => { |
| const client = getMqttClient(); |
| const normalizedTarget = normalizeTarget(target); |
|
|
| if (!client.connected) { |
| reject(new Error("MQTT client not connected to broker")); |
| return; |
| } |
|
|
| const command = { |
| cmd: "ota_cancel", |
| target: normalizedTarget, |
| tenantId: tenantCode, |
| truckId, |
| containerId, |
| cancelledAt: new Date().toISOString(), |
| }; |
|
|
| const topic = otaTopic(tenantCode, truckId, normalizedTarget, "command"); |
| routeContextByKey[buildRouteKey(tenantCode, truckId, normalizedTarget)] = { |
| tenantCode, |
| truckId, |
| containerId, |
| target: normalizedTarget, |
| }; |
|
|
| client.publish(topic, JSON.stringify(command), { qos: 1, retain: false }, (error) => { |
| if (error) { |
| reject(new Error(`MQTT publish failed: ${error.message}`)); |
| return; |
| } |
|
|
| console.log(`[OTA] Cancel published -> ${topic}`, command); |
|
|
| const status = setUnitStatus( |
| { |
| tenantCode, |
| truckId, |
| containerId, |
| target: normalizedTarget, |
| }, |
| { |
| state: "cancelling", |
| message: "Cancel command sent, waiting for device confirmation", |
| progress: 0, |
| cancelledAt: command.cancelledAt, |
| } |
| ); |
|
|
| resolve(status); |
| }); |
| }); |
| } |
|
|
| async function publishMqttMessage(topic, payload, options = {}) { |
| return new Promise((resolve, reject) => { |
| const client = getMqttClient(); |
| let settled = false; |
| const timeoutMs = options.timeoutMs || 5000; |
|
|
| if (!client.connected) { |
| reject(new Error("MQTT client not connected to broker")); |
| return; |
| } |
|
|
| const timer = setTimeout(() => { |
| if (settled) return; |
| settled = true; |
| reject(new Error(`MQTT publish timed out after ${timeoutMs}ms: ${topic}`)); |
| }, timeoutMs); |
|
|
| client.publish( |
| topic, |
| JSON.stringify(payload || {}), |
| { |
| qos: options.qos ?? 1, |
| retain: Boolean(options.retain), |
| }, |
| (error) => { |
| if (settled) return; |
| settled = true; |
| clearTimeout(timer); |
|
|
| if (error) { |
| reject(new Error(`MQTT publish failed: ${error.message}`)); |
| return; |
| } |
|
|
| console.log(`[MQTT] Published -> ${topic}`, payload || {}); |
| resolve(); |
| } |
| ); |
| }); |
| } |
|
|
| getMqttClient(); |
|
|
| module.exports = { |
| setStagedFirmware, |
| getStagedFirmware, |
| getAllStagedFirmware, |
| getOtaStatus, |
| getUnitStatuses, |
| getWifiNetworks, |
| getWifiStatus, |
| getMqttConnectionState, |
| triggerOta, |
| cancelOta, |
| publishMqttMessage, |
| addSseClient, |
| removeSseClient, |
| }; |
|
|