fix(M06-C-R1): 校正控制箱与门锁协议

This commit is contained in:
Codex
2026-08-10 16:01:38 +08:00
parent b71b48b3d0
commit dabc656f63
9 changed files with 574 additions and 86 deletions
+62 -8
View File
@@ -48,7 +48,11 @@ assert.equal(issued.at(-1).payload.action, 'ConctolPower');
await service.controlDoor(context, { order: 'open', holdopen: 1, delayTime: 4 });
assert.equal(issued.at(-1).payload.action, 'Crldoor');
await service.playTts(context, { content: '欢迎光临' });
assert.equal(issued.at(-1).payload.action, 'PlayTTS');
assert.deepEqual(issued.at(-1).payload, {
action: 'PlayTTS', content: '欢迎光临', vol: 8, firstPlay: 0,
loop: 1, speaker: 0, style: 0, speed: 5, intona: 5,
id: '1782120000001'
});
await service.stopTts(context);
assert.equal(issued.at(-1).payload.action, 'stopTTS');
await service.controlLed(context, 30);
@@ -60,11 +64,21 @@ assert.equal(issued.at(-1).payload.action, 'addtask');
await service.cancelTask(context);
assert.equal(issued.at(-1).payload.action, 'canceltask');
await service.pairSubLock(context, 60);
assert.equal(issued.at(-1).payload.action, 'AddDevice');
await service.controlSubLock(context, {
subID: 'SUB001', order: 'open', delayTime: 4
assert.deepEqual(issued.at(-1).payload, {
action: 'AddDevice', time: 60, id: '1782120000001'
});
assert.equal(issued.at(-1).payload.action, 'CtrlDevice');
await service.controlSubLock(context, {
subtype: '14', subID: 'SUB001', order: 'open', delayTime: 4
});
assert.deepEqual(issued.at(-1).payload, {
action: 'CtrlDevice', subtype: '14', subID: 'SUB001',
order: 'open', delayTime: 4, id: '1782120000001'
});
await service.controlSubLock(context, {
subtype: '14', subID: 'SUB001', order: 'setkey', value: '123456654321'
});
assert.equal(issued.at(-1).payload.value, '123456654321');
assert.equal('content' in issued.at(-1).payload, false);
await service.readSmartSocket(context, 'workInfo');
assert.deepEqual(issued.at(-1).payload, { read: 'workInfo' });
await service.switchSmartSocket(context, { on: false, slotNum: 2 });
@@ -78,12 +92,13 @@ assert.equal(issued.at(-1).payload.action, 'localtask');
assert.equal(issued.at(-1).payload.switch, 'on');
await service.clearSmartSocketTask(context, 1);
assert.equal(issued.at(-1).payload.action, 'clearTask');
assert.equal(audits.length, 14);
assert.equal(audits.length, 15);
assert.match(audits[0], /DEVICE_COMMAND_REQUESTED/);
await assert.rejects(
() => service.controlSubLock(context, {
subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET'
subtype: '14', subID: 'SUB001', order: 'factoryreset',
dangerConfirmation: 'CONFIRM_FACTORY_RESET'
}),
(error) => error instanceof DeviceControlError
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
@@ -93,11 +108,26 @@ const platformContext = {
access: { roles: ['PLATFORM_ADMIN'], capabilities: [], storeIds: [] }
};
await service.controlSubLock(platformContext, {
subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET'
subtype: '14', subID: 'SUB001', order: 'factoryreset',
dangerConfirmation: 'CONFIRM_FACTORY_RESET'
});
assert.equal(issued.at(-1).payload.order, 'factoryreset');
assert.equal('dangerConfirmation' in issued.at(-1).payload, false);
await assert.rejects(
() => service.controlSubLock(context, {
subtype: '14', subID: 'SUB001', order: 'delkey', value: 'all'
}),
(error) => error instanceof DeviceControlError
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
);
await service.controlSubLock(platformContext, {
subtype: '15', subID: 'SUB002', order: 'delcard', value: 'all',
dangerConfirmation: 'CONFIRM_CLEAR_CREDENTIALS'
});
assert.equal(issued.at(-1).payload.value, 'all');
assert.equal(issued.at(-1).payload.subtype, '15');
await assert.rejects(
() => service.controlPower({
...context,
@@ -192,6 +222,30 @@ const socketResponse = await app.inject({
});
assert.equal(socketResponse.statusCode, 200);
assert.equal(routed.on, true);
const subLockResponse = await app.inject({
method: 'POST',
url: '/admin-api/device-control/sub-lock/action',
headers: { authorization: `Bearer ${token}` },
payload: {
storeId: '11', roomId: '31', subtype: '14', subID: 'SUB001',
order: 'open', delayTime: 4
}
});
assert.equal(subLockResponse.statusCode, 200);
const invalidSubLock = await app.inject({
method: 'POST',
url: '/admin-api/device-control/sub-lock/action',
headers: { authorization: `Bearer ${token}` },
payload: { storeId: '11', roomId: '31', subID: 'SUB001', order: 'open' }
});
assert.equal(invalidSubLock.statusCode, 400);
const invalidTts = await app.inject({
method: 'POST',
url: '/admin-api/device-control/tts',
headers: { authorization: `Bearer ${token}` },
payload: { storeId: '11', roomId: '31', content: 'test', volume: 11 }
});
assert.equal(invalidTts.statusCode, 400);
const invalid = await app.inject({
method: 'POST',
url: '/admin-api/device-control/door',
+2 -1
View File
@@ -21,7 +21,8 @@ assert.equal(dryCases.every((item) => item.destructive === false), true);
const actionCases = buildHardwareSmokeCases({
...config,
allowActions: true,
subLockSubId: 'SUB001'
subLockSubId: 'SUB001',
subLockSubtype: '14'
});
assert.equal(actionCases.some((item) => item.id === 'control-box-power-slot1-off'), true);
assert.equal(actionCases.some((item) => item.id === 'sub-lock-open'), true);
+107 -9
View File
@@ -20,9 +20,14 @@ assert.deepEqual(control.controlPower({
assert.equal(control.controlDoor({
id: '1234567890123', order: 'open'
}).action, 'Crldoor');
assert.equal(control.playTts({
id: '1234567890123', content: '欢迎光临'
}).action, 'PlayTTS');
assert.deepEqual(control.playTts({
id: '1234567890123', content: '欢迎光临', volume: 7, firstPlay: 1,
loop: 2, speaker: 3, style: 2, speed: 6, intonation: 4
}), {
action: 'PlayTTS', content: '欢迎光临', vol: 7, firstPlay: 1,
loop: 2, speaker: 3, style: 2, speed: 6, intona: 4,
id: '1234567890123'
});
assert.deepEqual(control.stopTts('1234567890123'), {
action: 'stopTTS', id: '1234567890123'
});
@@ -45,14 +50,26 @@ assert.throws(
const lock = new JilianSub1GLockAdapter();
assert.deepEqual(lock.pair({ id: '123', timeout: 60 }), {
action: 'AddDevice', id: '123', timeout: 60
action: 'AddDevice', time: 60, id: '123'
});
assert.deepEqual(lock.control({
id: '123', subtype: '14', subID: 'SUB001', order: 'setcard',
value: 'a1b2c3d4'
}), {
action: 'CtrlDevice', subtype: '14', subID: 'SUB001', order: 'setcard',
value: 'A1B2C3D4', id: '123'
});
assert.equal(lock.control({
id: '123', subID: 'SUB001', order: 'open', delayTime: 4
}).action, 'CtrlDevice');
assert.throws(
() => lock.control({ id: '123', subID: 'SUB001', order: 'setkey' }),
/content is required/
() => lock.control({
id: '123', subtype: '14', subID: 'SUB001', order: 'setkey', value: '12345'
}),
/6-digit passwords/
);
assert.throws(
() => lock.control({
id: '123', subtype: '15', subID: 'SUB001', order: 'setcard', value: 'not-card'
}),
/8-digit hex card IDs/
);
const socket = new JilianSmartSocketAdapter();
@@ -74,6 +91,17 @@ const ack = control.parseUplink({
assert.equal(ack.kind, 'ACK');
assert.equal(ack.result, 'unconfirm');
assert.equal(ack.eventType, 'task');
assert.equal(control.parseUplink({
DeviceID: 'BOX_001', id: '124', result: 'paraerror', action: 'PlayTTS'
}).result, 'paraerror');
assert.equal(control.parseUplink({
DeviceID: 'BOX_001', id: '125', result: 'vendor-new-code', action: 'task'
}).result, 'UNKNOWN_VENDOR_RESULT');
const configVariant = control.parseUplink({
DeviceID: 'BOX_001', setting: 'mqttconfig', welvoice: 'hello'
});
assert.equal(configVariant.eventType, 'mqttConfig');
assert.equal(configVariant.payload.welcomevoice, 'hello');
const event = lock.parseUplink({
deviceID: 'BOX_001', event: 'record', type: 'card', state: 'open',
timestamp: 1782120000
@@ -81,6 +109,13 @@ const event = lock.parseUplink({
assert.equal(event.kind, 'EVENT');
assert.equal(event.eventType, 'record');
assert.equal(event.eventAt?.getUTCFullYear(), 2026);
for (const eventType of ['magstate', 'taskfinish', 'Poweron', 'connected']) {
const fixture = control.parseUplink({
DeviceID: 'BOX_001', event: eventType, state: 'open', timestamp: 1782120000
});
assert.equal(fixture.kind, 'EVENT');
assert.equal(fixture.eventType, eventType);
}
assert.equal(generateCommandId(1782120000000, 7), '1782120000007');
assert.match(generateCommandId(), /^\d{13}$/);
@@ -156,6 +191,55 @@ assert.equal(calls.some((item) =>
item.sql.includes('INSERT INTO qipai_device_alerts')
&& item.params.includes('DEVICE_UNCONFIRM')
), true);
for (const [id, result, failureCode] of [
['125', 'busy', 'DEVICE_BUSY'],
['126', 'fail', 'DEVICE_FAIL']
]) {
await service.handle('/devicesend/BOX_001', Buffer.from(JSON.stringify({
DeviceID: 'BOX_001', id, action: 'task', result
})));
assert.equal(calls.some((item) =>
item.sql.includes('UPDATE qipai_iot_commands')
&& item.params[0] === 'FAILED'
&& item.params[2] === failureCode
), true);
}
const commandUpdatesBeforeEvents = calls.filter((item) =>
item.sql.includes('UPDATE qipai_iot_commands')
).length;
for (const eventType of ['magstate', 'taskfinish']) {
await service.handle('/devicesend/BOX_001', Buffer.from(JSON.stringify({
DeviceID: 'BOX_001', event: eventType, state: 'close'
})));
}
assert.equal(calls.filter((item) =>
item.sql.includes('UPDATE qipai_iot_commands')
).length, commandUpdatesBeforeEvents);
const deadLettersBeforeWill = calls.filter((item) =>
item.sql.includes('qipai_iot_dead_letters')
).length;
await service.handle('/devicewill/BOX_001', Buffer.from('close'));
assert.equal(calls.some((item) =>
item.sql.includes('UPDATE qipai_devices') && item.params[0] === 'OFFLINE'
), true);
assert.equal(calls.filter((item) =>
item.sql.includes('qipai_iot_dead_letters')
).length, deadLettersBeforeWill);
await service.handle('/devicesend/BOX_001', Buffer.from(JSON.stringify({
DeviceID: 'BOX_001', id: '127', action: 'task', result: 'vendor-new-code'
})));
assert.equal(calls.some((item) =>
item.sql.includes('qipai_iot_dead_letters')
&& item.params.includes('UNKNOWN_VENDOR_RESULT')
), true);
assert.equal(calls.some((item) =>
item.sql.includes('UPDATE qipai_iot_commands')
&& item.params[0] === 'FAILED'
&& item.params[2] === 'UNKNOWN_VENDOR_RESULT'
), true);
await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({
DeviceID: 'SOCKET_001', event: 'workInfo', switch: 'on',
powerW: 3601, temperature: 82, overLoad: true
@@ -182,6 +266,20 @@ await assert.rejects(
/IOT_COMMAND_ID_INVALID/
);
await service.createCommand({
tenantId: '7', assetId: '51', storeId: '11', commandId: '128',
commandType: 'CtrlDevice', traceId: 'secret-test',
payload: {
action: 'CtrlDevice', subtype: '14', subID: 'SUB001',
order: 'setkey', value: '123456'
}
});
const secretCommandInsert = calls.findLast((item) =>
item.sql.includes('INSERT INTO qipai_iot_commands')
);
assert.equal(secretCommandInsert.params[7].includes('123456'), false);
assert.equal(secretCommandInsert.params[7].includes('valueHash'), true);
const commandCalls = [];
const commandService = new DeviceCommandService({
async createCommand(input) { commandCalls.push(['create', input]); },
@@ -2108,10 +2108,23 @@ async function assertIotMessages(pool, context) {
roomId: String(device.roomId),
commandId: '1782120000002',
commandType: 'AddDevice',
payload: { action: 'AddDevice', id: '1782120000002', timeout: 60 },
payload: { action: 'AddDevice', time: 60, id: '1782120000002' },
traceId: 'm06d-pair-test'
});
await service.markPublished(context.tenantId, '1782120000002');
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId,
id: '1782120000002',
action: 'AddDevice',
result: 'ok',
timestamp: 1782120002
})));
const [pairPendingRows] = await pool.query(
`SELECT status FROM qipai_iot_commands
WHERE tenant_id = ? AND command_id = '1782120000002'`,
[context.tenantId]
);
assert.deepEqual(pairPendingRows, [{ status: 'PUBLISHED' }]);
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId,
id: '1782120000002',
@@ -2130,6 +2143,144 @@ async function assertIotMessages(pool, context) {
[context.tenantId, device.id]
);
assert.deepEqual(linkRows, [{ subId: 'SUB-AUTO-001', subtype: '14', model: '701C' }]);
for (const [commandId, result, failureCode] of [
['1782120000003', 'busy', 'DEVICE_BUSY'],
['1782120000004', 'unconfirm', 'DEVICE_UNCONFIRM'],
['1782120000005', 'fail', 'DEVICE_FAIL']
]) {
await service.createCommand({
tenantId: context.tenantId,
assetId: String(device.id),
storeId: String(device.storeId),
roomId: String(device.roomId),
commandId,
commandType: 'task',
payload: { action: 'task', id: commandId, minute: 30, type: 1 },
traceId: `m06c-task-${result}`
});
await service.markPublished(context.tenantId, commandId);
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId, id: commandId, action: 'task', result
})));
const [failedRows] = await pool.query(
`SELECT status, failure_code AS failureCode
FROM qipai_iot_commands WHERE tenant_id = ? AND command_id = ?`,
[context.tenantId, commandId]
);
assert.deepEqual(failedRows, [{ status: 'FAILED', failureCode }]);
}
await service.createCommand({
tenantId: context.tenantId,
assetId: String(device.id),
storeId: String(device.storeId),
roomId: String(device.roomId),
commandId: '1782120000006',
commandType: 'task',
payload: { action: 'task', id: '1782120000006', minute: 30, type: 1 },
traceId: 'm06c-unknown-result'
});
await service.markPublished(context.tenantId, '1782120000006');
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId,
id: '1782120000006',
action: 'task',
result: 'vendor-new-code'
})));
const [unknownCommandRows] = await pool.query(
`SELECT status, failure_code AS failureCode
FROM qipai_iot_commands
WHERE tenant_id = ? AND command_id = '1782120000006'`,
[context.tenantId]
);
assert.deepEqual(unknownCommandRows, [{
status: 'FAILED', failureCode: 'UNKNOWN_VENDOR_RESULT'
}]);
const [unknownEventRows] = await pool.query(
`SELECT processing_status AS processingStatus,
JSON_UNQUOTE(JSON_EXTRACT(raw_payload, '$.result')) AS rawResult,
JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.result')) AS normalizedResult
FROM qipai_iot_device_events
WHERE tenant_id = ? AND command_id = '1782120000006'`,
[context.tenantId]
);
assert.deepEqual(unknownEventRows, [{
processingStatus: 'PROTOCOL_ERROR',
rawResult: 'vendor-new-code',
normalizedResult: 'UNKNOWN_VENDOR_RESULT'
}]);
const [unknownDeadRows] = await pool.query(
`SELECT error_code AS errorCode
FROM qipai_iot_dead_letters
WHERE tenant_id = ? AND topic = ? AND error_code = 'UNKNOWN_VENDOR_RESULT'`,
[context.tenantId, `/devicesend/${device.deviceId}`]
);
assert.deepEqual(unknownDeadRows, [{ errorCode: 'UNKNOWN_VENDOR_RESULT' }]);
await service.createCommand({
tenantId: context.tenantId,
assetId: String(device.id),
storeId: String(device.storeId),
roomId: String(device.roomId),
commandId: '1782120000007',
commandType: 'CtrlDevice',
payload: {
action: 'CtrlDevice', id: '1782120000007', subtype: '14', subID: 'SUB-AUTO-001',
order: 'setkey', value: '123456'
},
traceId: 'm06c-credential-redaction'
});
const [credentialRows] = await pool.query(
`SELECT JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.value')) AS value,
JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.valueHash')) AS valueHash
FROM qipai_iot_commands
WHERE tenant_id = ? AND command_id = '1782120000007'`,
[context.tenantId]
);
assert.equal(credentialRows[0].value, null);
assert.match(credentialRows[0].valueHash, /^[a-f0-9]{64}$/);
const [commandCountRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_iot_commands WHERE tenant_id = ?`,
[context.tenantId]
);
const commandCountBeforeEvents = Number(commandCountRows[0].total);
for (const [event, timestamp] of [['magstate', 1782120010], ['taskfinish', 1782120011]]) {
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId, event, state: 'close', timestamp
})));
}
const [commandCountAfterRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_iot_commands WHERE tenant_id = ?`,
[context.tenantId]
);
assert.equal(Number(commandCountAfterRows[0].total), commandCountBeforeEvents);
const [nonAckRows] = await pool.query(
`SELECT event_type AS eventType, command_id AS commandId
FROM qipai_iot_device_events
WHERE tenant_id = ? AND event_type IN ('magstate', 'taskfinish')
ORDER BY event_type`,
[context.tenantId]
);
assert.deepEqual(nonAckRows, [
{ eventType: 'magstate', commandId: null },
{ eventType: 'taskfinish', commandId: null }
]);
await service.handle(`/devicewill/${device.deviceId}`, Buffer.from('close'));
const [offlineRows] = await pool.query(
`SELECT status, JSON_UNQUOTE(JSON_EXTRACT(state_snapshot, '$.will')) AS willValue
FROM qipai_devices WHERE tenant_id = ? AND id = ?`,
[context.tenantId, device.id]
);
assert.deepEqual(offlineRows, [{ status: 'OFFLINE', willValue: 'close' }]);
const [willDeadRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_iot_dead_letters
WHERE tenant_id = ? AND topic = ? AND error_code = 'MQTT_PAYLOAD_INVALID'`,
[context.tenantId, `/devicewill/${device.deviceId}`]
);
assert.equal(Number(willDeadRows[0].total), 0);
}
async function assertCleaningTaskTransactions(pool, context) {
@@ -2756,7 +2907,13 @@ try {
,
'door record credential hashing and masking',
'low-battery alert upsert',
'AddDevice ACK creates 701C parent-child topology'
'AddDevice first ACK waits for final subtype and subID',
'AddDevice final ACK creates 701C parent-child topology',
'task busy unconfirm and fail remain failed command states',
'unknown vendor result event dead-letter alert and command failure',
'CtrlDevice credential request hashing and masking',
'magstate and taskfinish remain events rather than ACKs',
'raw devicewill close marks the device offline without invalid-payload dead-letter'
,
'concurrent cleaning claim has one winner',
'cleaning state and event transaction rollback',