From d01f77151e3ec80790e2038df81cea78fe1112c4 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 24 Jun 2026 15:23:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(M06-G):=20=E5=A2=9E=E5=8A=A0=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=A1=AC=E4=BB=B6=E7=83=9F=E6=B5=8B=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/devices/hardware-smoke-runner.ts | 265 +++++++++++++++++++ backend/tests/hardware-smoke-runner.test.mjs | 43 +++ 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 backend/src/devices/hardware-smoke-runner.ts create mode 100644 backend/tests/hardware-smoke-runner.test.mjs diff --git a/backend/package.json b/backend/package.json index 9a1ba85..672ee65 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", - "test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs" + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/devices/hardware-smoke-runner.ts b/backend/src/devices/hardware-smoke-runner.ts new file mode 100644 index 0000000..d289f9c --- /dev/null +++ b/backend/src/devices/hardware-smoke-runner.ts @@ -0,0 +1,265 @@ +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; + password: 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 ?? '', + password: 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.password) { + 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.password, + 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); +} diff --git a/backend/tests/hardware-smoke-runner.test.mjs b/backend/tests/hardware-smoke-runner.test.mjs new file mode 100644 index 0000000..0aab8e7 --- /dev/null +++ b/backend/tests/hardware-smoke-runner.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { + buildHardwareSmokeCases, + loadHardwareSmokeConfig, + runHardwareSmoke +} from '../dist/devices/hardware-smoke-runner.js'; + +const config = loadHardwareSmokeConfig({ + QIPAI_HARDWARE_SMOKE_ENABLE: 'false', + QIPAI_HARDWARE_SMOKE_ALLOW_ACTIONS: 'false', + QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID: 'BOX_001', + QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID: 'SOCKET_001' +}); +const dryCases = buildHardwareSmokeCases(config); +assert.deepEqual(dryCases.map((item) => item.id), [ + 'control-box-basic-info', + 'smart-socket-work-info' +]); +assert.equal(dryCases.every((item) => item.destructive === false), true); + +const actionCases = buildHardwareSmokeCases({ + ...config, + allowActions: true, + subLockSubId: 'SUB001' +}); +assert.equal(actionCases.some((item) => item.id === 'control-box-power-slot1-off'), true); +assert.equal(actionCases.some((item) => item.id === 'sub-lock-open'), true); +assert.equal(actionCases.some((item) => item.id === 'smart-socket-switch-off'), true); +assert.equal(actionCases.some((item) => item.destructive), true); + +const dryRun = await runHardwareSmoke(config, dryCases); +assert.equal(dryRun.length, 2); +assert.equal(dryRun.every((item) => item.status === 'DRY_RUN'), true); +assert.equal(dryRun.every((item) => !String(item.reason).includes('password')), true); + +const skipped = await runHardwareSmoke(loadHardwareSmokeConfig({}), []); +assert.deepEqual(skipped, [{ + caseId: 'hardware-smoke-config', + status: 'SKIP', + reason: 'No DeviceID configured.' +}]); + +console.log('PASS: M06-G hardware smoke runner builds safe dry-run and action matrices.');