453 lines
17 KiB
JavaScript
453 lines
17 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import {
|
|
JilianControlBoxAdapter,
|
|
JilianSmartSocketAdapter,
|
|
JilianSub1GLockAdapter
|
|
} from '../dist/devices/jilian-adapters.js';
|
|
import {
|
|
generateCommandId,
|
|
IotMessageService
|
|
} from '../dist/devices/iot-message-service.js';
|
|
import { DeviceCommandService } from '../dist/devices/device-command-service.js';
|
|
|
|
const control = new JilianControlBoxAdapter();
|
|
assert.deepEqual(control.read('basicInfo'), { read: 'basicInfo' });
|
|
assert.deepEqual(control.controlPower({
|
|
id: '1234567890123', slot1: 'on', slot3: 'off'
|
|
}), {
|
|
action: 'ConctolPower', id: '1234567890123', slot1: 'on', slot3: 'off'
|
|
});
|
|
assert.equal(control.controlDoor({
|
|
id: '1234567890123', order: 'open'
|
|
}).action, 'Crldoor');
|
|
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'
|
|
});
|
|
assert.equal(control.controlLed({ id: '1234567890123', minute: 30 }).action, 'CrlLED');
|
|
assert.equal(control.startTask({
|
|
id: '1234567890123', minute: 120, type: 2, subID: 'SUB001'
|
|
}).action, 'task');
|
|
assert.equal(control.extendTask({
|
|
id: '1234567890123', addminute: 30
|
|
}).action, 'addtask');
|
|
assert.equal(control.cancelTask('1234567890123').action, 'canceltask');
|
|
assert.throws(
|
|
() => control.controlDoor({ id: '12345678901234', order: 'open' }),
|
|
/Invalid/
|
|
);
|
|
assert.throws(
|
|
() => control.controlDoor({ id: '123', order: 'open', delayTime: 15 }),
|
|
/less than or equal to 14/
|
|
);
|
|
|
|
const lock = new JilianSub1GLockAdapter();
|
|
assert.deepEqual(lock.pair({ 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.throws(
|
|
() => 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();
|
|
assert.deepEqual(socket.read('workInfo'), { read: 'workInfo', slotNum: 1 });
|
|
assert.deepEqual(socket.read({ target: 'localtask', slotNum: 2, taskNum: 7 }), {
|
|
read: 'localtask', slotNum: 2, taskNum: 7
|
|
});
|
|
assert.deepEqual(socket.read({ target: 'localtask', taskNum: 0 }), {
|
|
read: 'localtask', slotNum: 1, taskNum: 0
|
|
});
|
|
assert.deepEqual(socket.read({ target: 'mqttConfig' }), { read: 'mqttConfig' });
|
|
assert.deepEqual(socket.switch({ id: '123', on: true }), {
|
|
action: 'on', id: '123', slotNum: 1
|
|
});
|
|
assert.deepEqual(socket.localTask({
|
|
id: '123', taskNum: 20, action: 'off', mode: 'weekly',
|
|
time: '23:00', weekdays: [1, 5]
|
|
}), {
|
|
action: 'localtask', slotNum: 1, taskNum: 20, tasktype: 'off',
|
|
cyctype: 'weekcyc', weekdata: '1000100', actiontime: '2300', id: '123'
|
|
});
|
|
assert.throws(() => socket.localTask({
|
|
id: '123', taskNum: 0, action: 'on', mode: 'once', time: '12:00'
|
|
}), /greater than or equal to 1/);
|
|
assert.throws(() => socket.localTask({
|
|
id: '123', taskNum: 1, action: 'on', mode: 'weekly', time: '24:00',
|
|
weekdays: [1]
|
|
}), /Invalid/);
|
|
assert.throws(() => socket.localTask({
|
|
id: '123', taskNum: 1, action: 'on', mode: 'weekly', time: '12:00',
|
|
weekdays: [1, 1]
|
|
}), /weekdays must be unique/);
|
|
assert.deepEqual(socket.clearTask({ id: '123', taskNum: 0 }), {
|
|
action: 'clearTask', id: '123', slotNum: 1, taskNum: 0
|
|
});
|
|
assert.deepEqual(socket.parameter({
|
|
id: '123', resetHold: 1, keyLock: 0, keyOff: 1
|
|
}), { setting: 'parameter', resetHold: 1, keyLock: 0, KeyOff: 1, id: '123' });
|
|
assert.equal(socket.reboot('123').action, 'reboot');
|
|
assert.equal(socket.emptyPower('123').action, 'emptyPower');
|
|
assert.deepEqual(socket.returnFactory({ id: '123', password: 'secret' }), {
|
|
action: 'returnFactory', password: 'secret', id: '123'
|
|
});
|
|
assert.deepEqual(socket.setReturnKey({ id: '123', old: 'old-key', new: 'new-key' }), {
|
|
action: 'setReturnKey', old: 'old-key', new: 'new-key', id: '123'
|
|
});
|
|
const protection = socket.protectOff({
|
|
id: '123', ratedAmps: 16, maxPower: 3500, maxCurrent: 15,
|
|
maxTemperature: 80, pullOutStop: 1, pullOutPower: 5, pullOutSec: 10,
|
|
chargeFullStop: 1, chargeFullPower: 8, chargeFullSec: 30
|
|
});
|
|
assert.equal(protection.setting, 'protectOff');
|
|
assert.equal('ratedAmps' in protection, false);
|
|
for (const ratedAmps of [10, 63]) {
|
|
const ratedProtection = socket.protectOff({
|
|
id: '123', ratedAmps, maxPower: ratedAmps * 250, maxCurrent: ratedAmps,
|
|
maxTemperature: 100, pullOutStop: 0, pullOutPower: 0, pullOutSec: 1,
|
|
chargeFullStop: 0, chargeFullPower: 0, chargeFullSec: 1
|
|
});
|
|
assert.equal(ratedProtection.maxCurrent, ratedAmps);
|
|
}
|
|
assert.throws(() => socket.protectOff({
|
|
id: '123', ratedAmps: 10, maxPower: 2600, maxCurrent: 11,
|
|
maxTemperature: 80, pullOutStop: 1, pullOutPower: 5, pullOutSec: 10,
|
|
chargeFullStop: 1, chargeFullPower: 8, chargeFullSec: 30
|
|
}), /10A capability/);
|
|
|
|
const ack = control.parseUplink({
|
|
DeviceID: 'BOX_001', id: '123', result: 'unconfirm', action: 'task'
|
|
});
|
|
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
|
|
});
|
|
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);
|
|
}
|
|
const workInfo = socket.parseUplink({
|
|
DeviceID: 'SOCKET_001', read: 'workInfo', onOff: 'on',
|
|
vol: '0220.5000', cur: '01.250', pow: '00275.000', temp: '45.50', energy: '0012.3400'
|
|
});
|
|
assert.deepEqual({
|
|
switchOn: workInfo.payload.switchOn,
|
|
voltage: workInfo.payload.voltage,
|
|
current: workInfo.payload.current,
|
|
power: workInfo.payload.power,
|
|
temperature: workInfo.payload.temperature,
|
|
energy: workInfo.payload.energy
|
|
}, {
|
|
switchOn: true, voltage: '220.5', current: '1.25', power: '275',
|
|
temperature: '45.5', energy: '12.34'
|
|
});
|
|
const temperatureVariant = socket.parseUplink({
|
|
DeviceID: 'SOCKET_001', read: 'workInfo', temperature: '46.0', KeyOff: 1
|
|
});
|
|
assert.equal(temperatureVariant.payload.temperature, '46');
|
|
assert.equal(temperatureVariant.payload.keyOff, 1);
|
|
const conflictingTemperature = socket.parseUplink({
|
|
DeviceID: 'SOCKET_001', read: 'workInfo', temp: '45', temperature: '46'
|
|
});
|
|
assert.deepEqual(conflictingTemperature.protocolIssues, ['SOCKET_TEMPERATURE_CONFLICT']);
|
|
for (const [action, result] of [
|
|
['off', 'update'], ['off', 'paraerror'], ['localtask', 'error']
|
|
]) {
|
|
const response = socket.parseUplink({
|
|
DeviceID: 'SOCKET_001', id: `20${result.length}`, action, result
|
|
});
|
|
assert.equal(response.kind, 'ACK');
|
|
assert.equal(response.result, result);
|
|
}
|
|
|
|
assert.equal(generateCommandId(1782120000000, 7), '1782120000007');
|
|
assert.match(generateCommandId(), /^\d{13}$/);
|
|
|
|
const calls = [];
|
|
const eventHashes = new Set();
|
|
const service = new IotMessageService({
|
|
async execute(sql, params) {
|
|
calls.push({ sql, params });
|
|
if (sql.includes('FROM qipai_devices')) {
|
|
if (params[0] === 'SOCKET_001') {
|
|
return [[{
|
|
id: 52, tenantId: 7, storeId: 11, roomId: 31,
|
|
deviceId: 'SOCKET_001', deviceType: 'SMART_SOCKET'
|
|
}], []];
|
|
}
|
|
return [[{
|
|
id: 51, tenantId: 7, storeId: 11, roomId: 31,
|
|
deviceId: 'BOX_001', deviceType: 'CONTROL_BOX'
|
|
}], []];
|
|
}
|
|
if (sql.includes('INSERT INTO qipai_iot_device_events')) {
|
|
const hash = params[7];
|
|
const duplicate = eventHashes.has(hash);
|
|
eventHashes.add(hash);
|
|
return [{ affectedRows: duplicate ? 2 : 1 }, []];
|
|
}
|
|
return [{ affectedRows: 1, insertId: 81 }, []];
|
|
}
|
|
});
|
|
const payload = Buffer.from(JSON.stringify({
|
|
DeviceID: 'BOX_001', id: '123', result: 'ok', action: 'ConctolPower',
|
|
slot1: 'on'
|
|
}));
|
|
await service.handle('/devicesend/BOX_001', payload);
|
|
await service.handle('/devicesend/BOX_001', payload);
|
|
assert.equal(
|
|
calls.filter((item) => item.sql.includes('UPDATE qipai_iot_commands')).length,
|
|
1,
|
|
'duplicate QoS 1 payload must not repeat command/device side effects'
|
|
);
|
|
assert.equal(
|
|
calls.some((item) => item.sql.includes('qipai_iot_dead_letters')),
|
|
false
|
|
);
|
|
|
|
await service.handle('/bad-topic/BOX_001', Buffer.from('{}'));
|
|
assert.equal(
|
|
calls.some((item) => item.sql.includes('qipai_iot_dead_letters')),
|
|
true
|
|
);
|
|
|
|
const recordPayload = Buffer.from(JSON.stringify({
|
|
DeviceID: 'BOX_001', event: 'record', type: 'card',
|
|
state: 'open', content: 'CARD-PRIVATE-1234', battery: 9
|
|
}));
|
|
await service.handle('/devicesend/BOX_001', recordPayload);
|
|
const recordInsert = calls.findLast((item) =>
|
|
item.sql.includes('INSERT INTO qipai_iot_device_events')
|
|
);
|
|
assert.equal(recordInsert.params.some((value) =>
|
|
typeof value === 'string' && value.includes('CARD-PRIVATE-1234')
|
|
), false);
|
|
assert.equal(calls.some((item) =>
|
|
item.sql.includes('INSERT INTO qipai_device_alerts')
|
|
&& item.params.includes('LOW_BATTERY')
|
|
), true);
|
|
|
|
await service.handle('/devicesend/BOX_001', Buffer.from(JSON.stringify({
|
|
DeviceID: 'BOX_001', id: '124', action: 'task', result: 'unconfirm'
|
|
})));
|
|
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);
|
|
|
|
const socketAlertsBeforeFakeFields = calls.filter((item) =>
|
|
item.sql.includes('INSERT INTO qipai_device_alerts')
|
|
).length;
|
|
await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({
|
|
DeviceID: 'SOCKET_001', event: 'workInfo', switch: 'on',
|
|
powerW: 3601, temperature: 82, overLoad: true
|
|
})));
|
|
assert.equal(calls.filter((item) =>
|
|
item.sql.includes('INSERT INTO qipai_device_alerts')
|
|
).length, socketAlertsBeforeFakeFields, 'legacy fake fields must not generate alerts');
|
|
|
|
await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({
|
|
DeviceID: 'SOCKET_001', read: 'workInfo', onOff: 'on',
|
|
vol: '220.50', cur: '1.250', pow: '275.000', temp: '45.5', energy: '12.340'
|
|
})));
|
|
const workInfoInsert = calls.findLast((item) =>
|
|
item.sql.includes('INSERT INTO qipai_iot_device_events')
|
|
);
|
|
const persistedWorkInfo = JSON.parse(workInfoInsert.params[9]).payload;
|
|
assert.deepEqual({
|
|
switchOn: persistedWorkInfo.switchOn,
|
|
voltage: persistedWorkInfo.voltage,
|
|
current: persistedWorkInfo.current,
|
|
power: persistedWorkInfo.power,
|
|
temperature: persistedWorkInfo.temperature,
|
|
energy: persistedWorkInfo.energy
|
|
}, {
|
|
switchOn: true, voltage: '220.5', current: '1.25', power: '275',
|
|
temperature: '45.5', energy: '12.34'
|
|
});
|
|
|
|
const protectionAlerts = new Map([
|
|
[3, 'SOCKET_PULL_OUT_STOP'], [4, 'SOCKET_CHARGE_FULL_STOP'],
|
|
[5, 'SOCKET_OVERPOWER'], [6, 'SOCKET_OVERCURRENT'],
|
|
[7, 'SOCKET_OVERTEMPERATURE']
|
|
]);
|
|
for (let closeReason = 1; closeReason <= 7; closeReason += 1) {
|
|
const alertCountBefore = calls.filter((item) =>
|
|
item.sql.includes('INSERT INTO qipai_device_alerts')
|
|
).length;
|
|
await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({
|
|
DeviceID: 'SOCKET_001', event: 'special', slotNum: 1,
|
|
state: 'off', closeReason, energy: `${closeReason}.0`
|
|
})));
|
|
const alertType = protectionAlerts.get(closeReason);
|
|
if (alertType) {
|
|
assert.equal(calls.some((item) =>
|
|
item.sql.includes('INSERT INTO qipai_device_alerts') && item.params.includes(alertType)
|
|
), true);
|
|
} else {
|
|
assert.equal(calls.filter((item) =>
|
|
item.sql.includes('INSERT INTO qipai_device_alerts')
|
|
).length, alertCountBefore, `closeReason ${closeReason} is only a local operation`);
|
|
}
|
|
}
|
|
|
|
await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({
|
|
DeviceID: 'SOCKET_001', read: 'workInfo', temp: '45', temperature: '46'
|
|
})));
|
|
assert.equal(calls.some((item) =>
|
|
item.sql.includes('qipai_iot_dead_letters')
|
|
&& item.params.includes('SOCKET_TEMPERATURE_CONFLICT')
|
|
), true);
|
|
|
|
await assert.rejects(
|
|
() => service.createCommand({
|
|
tenantId: '7', assetId: '51', storeId: '11',
|
|
commandId: '12345678901234', commandType: 'ConctolPower',
|
|
payload: {}, traceId: 'trace'
|
|
}),
|
|
/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);
|
|
|
|
await service.createCommand({
|
|
tenantId: '7', assetId: '52', storeId: '11', commandId: '129',
|
|
commandType: 'setReturnKey', traceId: 'socket-secret-test',
|
|
payload: { action: 'setReturnKey', id: '129', old: 'old-secret', new: 'new-secret' }
|
|
});
|
|
const socketSecretInsert = calls.findLast((item) =>
|
|
item.sql.includes('INSERT INTO qipai_iot_commands')
|
|
);
|
|
assert.equal(socketSecretInsert.params[7].includes('old-secret'), false);
|
|
assert.equal(socketSecretInsert.params[7].includes('new-secret'), false);
|
|
assert.equal(socketSecretInsert.params[7].includes('oldHash'), true);
|
|
assert.equal(socketSecretInsert.params[7].includes('newHash'), true);
|
|
|
|
const commandCalls = [];
|
|
const commandService = new DeviceCommandService({
|
|
async createCommand(input) { commandCalls.push(['create', input]); },
|
|
async markPublished(tenantId, commandId) {
|
|
commandCalls.push(['published', tenantId, commandId]);
|
|
},
|
|
async markPublishFailed() { throw new Error('unexpected publish failure'); }
|
|
}, {
|
|
async publishDeviceCommand(deviceId, payloadText) {
|
|
commandCalls.push(['mqtt', deviceId, JSON.parse(payloadText)]);
|
|
}
|
|
});
|
|
const issued = await commandService.issue({
|
|
tenantId: '7', assetId: '51', deviceId: 'BOX_001', storeId: '11',
|
|
commandType: 'ConctolPower',
|
|
payloadFactory: (id) => control.controlPower({ id, slot1: 'on' }),
|
|
traceId: 'trace'
|
|
});
|
|
assert.equal(issued.status, 'PUBLISHED');
|
|
assert.match(issued.commandId, /^\d{13}$/);
|
|
assert.equal(commandCalls[0][0], 'create');
|
|
assert.equal(commandCalls[1][0], 'mqtt');
|
|
assert.equal(commandCalls[2][0], 'published');
|
|
|
|
console.log('PASS: M06-C/E-R1 exact wire, normalized events, ACKs and QoS 1 dedup work.');
|