feat(M06-G): 增加真实硬件烟测运行器
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"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",
|
"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: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": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
Reference in New Issue
Block a user