import { connect, type MqttClient } from 'mqtt'; import { JilianControlBoxAdapter, JilianSmartSocketAdapter, JilianSub1GLockAdapter } from './jilian-adapters.js'; import { generateCommandId } from './iot-message-service.js'; export type SmokeStatus = 'PASS' | 'FAIL' | 'SKIP' | 'DRY_RUN'; export interface HardwareSmokeCase { id: string; deviceId: string; deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET'; commandType: string; payload: Record; topic: string; expectAck: boolean; destructive: boolean; } export interface HardwareSmokeResult { caseId: string; status: SmokeStatus; reason: string; commandId?: string; ack?: Record; } interface HardwareSmokeConfig { enabled: boolean; allowActions: boolean; mqttUrl: string; username: string; mqttPassword: string; controlBoxDeviceId: string; smartSocketDeviceId: string; subLockSubId: string; timeoutMs: number; } export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): HardwareSmokeConfig { return { enabled: isTrue(env.QIPAI_HARDWARE_SMOKE_ENABLE), allowActions: isTrue(env.QIPAI_HARDWARE_SMOKE_ALLOW_ACTIONS), mqttUrl: env.QIPAI_HARDWARE_MQTT_URL ?? '', username: env.QIPAI_HARDWARE_MQTT_USERNAME ?? '', mqttPassword: env.QIPAI_HARDWARE_MQTT_PASSWORD ?? '', controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '', smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '', subLockSubId: env.QIPAI_HARDWARE_SUB_LOCK_SUB_ID ?? '', timeoutMs: Number(env.QIPAI_HARDWARE_SMOKE_TIMEOUT_MS ?? 8000) }; } export function buildHardwareSmokeCases( config: Pick ): HardwareSmokeCase[] { const controlBox = new JilianControlBoxAdapter(); const socket = new JilianSmartSocketAdapter(); const lock = new JilianSub1GLockAdapter(); const cases: HardwareSmokeCase[] = []; if (config.controlBoxDeviceId) { const id = generateCommandId(Date.now(), cases.length); cases.push({ id: 'control-box-basic-info', deviceId: config.controlBoxDeviceId, deviceType: 'CONTROL_BOX', commandType: 'basicInfo', payload: controlBox.read('basicInfo'), topic: commandTopic(config.controlBoxDeviceId), expectAck: true, destructive: false }); if (config.allowActions) { cases.push({ id: 'control-box-power-slot1-off', deviceId: config.controlBoxDeviceId, deviceType: 'CONTROL_BOX', commandType: 'ConctolPower', payload: controlBox.controlPower({ id, slot1: 'off' }), topic: commandTopic(config.controlBoxDeviceId), expectAck: true, destructive: true }); } if (config.allowActions && config.subLockSubId) { cases.push({ id: 'sub-lock-open', deviceId: config.controlBoxDeviceId, deviceType: 'SUB_LOCK', commandType: 'CtrlDevice', payload: lock.control({ id: generateCommandId(Date.now(), cases.length), subID: config.subLockSubId, order: 'open', delayTime: 4 }), topic: commandTopic(config.controlBoxDeviceId), expectAck: true, destructive: true }); } } if (config.smartSocketDeviceId) { cases.push({ id: 'smart-socket-work-info', deviceId: config.smartSocketDeviceId, deviceType: 'SMART_SOCKET', commandType: 'workInfo', payload: socket.read('workInfo'), topic: commandTopic(config.smartSocketDeviceId), expectAck: true, destructive: false }); if (config.allowActions) { cases.push({ id: 'smart-socket-switch-off', deviceId: config.smartSocketDeviceId, deviceType: 'SMART_SOCKET', commandType: 'off', payload: socket.switch({ id: generateCommandId(Date.now(), cases.length), on: false, slotNum: 1 }), topic: commandTopic(config.smartSocketDeviceId), expectAck: true, destructive: true }); } } return cases; } export async function runHardwareSmoke( config = loadHardwareSmokeConfig(), cases = buildHardwareSmokeCases(config) ): Promise { if (cases.length === 0) { return [{ caseId: 'hardware-smoke-config', status: 'SKIP', reason: 'No DeviceID configured.' }]; } if (!config.enabled) { return cases.map((item) => ({ caseId: item.id, status: 'DRY_RUN', reason: 'Set QIPAI_HARDWARE_SMOKE_ENABLE=true to publish to real hardware.', commandId: readCommandId(item.payload) })); } if (!config.mqttUrl || !config.username || !config.mqttPassword) { return [{ caseId: 'hardware-smoke-auth', status: 'SKIP', reason: 'MQTT credentials are not configured.' }]; } const client = await connectMqtt(config); try { return await runCases(client, cases, config.timeoutMs); } finally { await new Promise((resolve, reject) => { client.end(false, {}, (error?: Error) => error ? reject(error) : resolve()); }); } } async function runCases( client: MqttClient, cases: HardwareSmokeCase[], timeoutMs: number ): Promise { const results: HardwareSmokeResult[] = []; for (const item of cases) { const commandId = readCommandId(item.payload); const ackTopic = `/devicesend/${item.deviceId}`; await subscribe(client, ackTopic); await publish(client, item.topic, item.payload); const ack = commandId ? await waitForAck(client, ackTopic, commandId, timeoutMs) : null; results.push(ack ? { caseId: item.id, status: 'PASS', reason: 'ACK received.', commandId, ack } : { caseId: item.id, status: 'FAIL', reason: 'ACK timeout.', commandId }); } return results; } function commandTopic(deviceId: string) { return `/deviceaccept/${deviceId}`; } function readCommandId(payload: Record) { return typeof payload.id === 'string' ? payload.id : undefined; } async function connectMqtt(config: HardwareSmokeConfig) { return new Promise((resolve, reject) => { const client = connect(config.mqttUrl, { username: config.username, ['password']: config.mqttPassword, clientId: `qipai-hardware-smoke-${process.pid}-${Date.now()}`, protocolVersion: 3, clean: true, connectTimeout: config.timeoutMs }); client.once('connect', () => resolve(client)); client.once('error', reject); }); } async function subscribe(client: MqttClient, topic: string) { await new Promise((resolve, reject) => { client.subscribe(topic, { qos: 1 }, (error) => error ? reject(error) : resolve()); }); } async function publish(client: MqttClient, topic: string, payload: Record) { await new Promise((resolve, reject) => { client.publish(topic, JSON.stringify(payload), { qos: 1, retain: false }, (error) => error ? reject(error) : resolve()); }); } async function waitForAck( client: MqttClient, topic: string, commandId: string, timeoutMs: number ) { return new Promise | null>((resolve) => { const timer = setTimeout(() => { client.off('message', handler); resolve(null); }, timeoutMs); const handler = (receivedTopic: string, payload: Buffer) => { if (receivedTopic !== topic) return; try { const body = JSON.parse(payload.toString('utf8')) as Record; if (body.id === commandId) { clearTimeout(timer); client.off('message', handler); resolve(body); } } catch { // Ignore non-JSON hardware noise during smoke tests. } }; client.on('message', handler); }); } function isTrue(value: string | undefined) { return ['1', 'true', 'yes', 'on'].includes((value ?? '').toLowerCase()); } if (process.argv[1]?.endsWith('hardware-smoke-runner.js')) { const results = await runHardwareSmoke(); console.log(JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2)); process.exit(results.some((item) => item.status === 'FAIL') ? 1 : 0); }