feat(M06-G): 增加真实硬件烟测运行器

This commit is contained in:
Codex
2026-06-24 15:23:48 +08:00
parent c00526f3e3
commit d01f77151e
3 changed files with 309 additions and 1 deletions
@@ -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<string, unknown>;
topic: string;
expectAck: boolean;
destructive: boolean;
}
export interface HardwareSmokeResult {
caseId: string;
status: SmokeStatus;
reason: string;
commandId?: string;
ack?: Record<string, unknown>;
}
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<HardwareSmokeConfig,
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId' | 'allowActions'>
): 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<HardwareSmokeResult[]> {
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<void>((resolve, reject) => {
client.end(false, {}, (error?: Error) => error ? reject(error) : resolve());
});
}
}
async function runCases(
client: MqttClient,
cases: HardwareSmokeCase[],
timeoutMs: number
): Promise<HardwareSmokeResult[]> {
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<string, unknown>) {
return typeof payload.id === 'string' ? payload.id : undefined;
}
async function connectMqtt(config: HardwareSmokeConfig) {
return new Promise<MqttClient>((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<void>((resolve, reject) => {
client.subscribe(topic, { qos: 1 }, (error) => error ? reject(error) : resolve());
});
}
async function publish(client: MqttClient, topic: string, payload: Record<string, unknown>) {
await new Promise<void>((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<Record<string, unknown> | 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<string, unknown>;
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);
}