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
+13 -6
View File
@@ -47,7 +47,8 @@ export class DeviceControlService {
}
async playTts(context: CommandContext, input: {
content: string; volume?: number; playCount?: number; priority?: number;
content: string; volume?: number; firstPlay?: 0 | 1; loop?: number;
speaker?: number; style?: number; speed?: number; intonation?: number;
}) {
return this.issueControlBox(context, 'PlayTTS',
(id) => this.controlBox.playTts({ id, ...input }));
@@ -86,18 +87,24 @@ export class DeviceControlService {
}
async controlSubLock(context: CommandContext, input: {
subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; content?: string;
subtype: '14' | '15'; subID: string;
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
| 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; value?: string;
dangerConfirmation?: string;
}) {
if (['factoryreset'].includes(input.order)) {
const credentialOrder = ['setkey', 'delkey', 'setcard', 'delcard', 'factoryreset']
.includes(input.order);
if (credentialOrder && !this.isManager(context.access)) {
throw new DeviceControlError('DEVICE_CREDENTIAL_ACTION_FORBIDDEN');
}
if (input.order === 'factoryreset') {
if (!context.access.roles.includes('PLATFORM_ADMIN')
|| input.dangerConfirmation !== 'CONFIRM_FACTORY_RESET') {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
}
}
if (['delkey', 'delcard'].includes(input.order) && !input.content) {
if (['delkey', 'delcard'].includes(input.order) && input.value === 'all') {
if (!context.access.roles.includes('PLATFORM_ADMIN')
|| input.dangerConfirmation !== 'CONFIRM_CLEAR_CREDENTIALS') {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
+8 -2
View File
@@ -36,6 +36,7 @@ interface HardwareSmokeConfig {
controlBoxDeviceId: string;
smartSocketDeviceId: string;
subLockSubId: string;
subLockSubtype: '14' | '15' | '';
timeoutMs: number;
}
@@ -49,13 +50,17 @@ export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): H
controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '',
smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '',
subLockSubId: env.QIPAI_HARDWARE_SUB_LOCK_SUB_ID ?? '',
subLockSubtype: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '15'
? '15'
: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '14' ? '14' : '',
timeoutMs: Number(env.QIPAI_HARDWARE_SMOKE_TIMEOUT_MS ?? 8000)
};
}
export function buildHardwareSmokeCases(
config: Pick<HardwareSmokeConfig,
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId' | 'allowActions'>
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId'
| 'subLockSubtype' | 'allowActions'>
): HardwareSmokeCase[] {
const controlBox = new JilianControlBoxAdapter();
const socket = new JilianSmartSocketAdapter();
@@ -86,7 +91,7 @@ export function buildHardwareSmokeCases(
destructive: true
});
}
if (config.allowActions && config.subLockSubId) {
if (config.allowActions && config.subLockSubId && config.subLockSubtype) {
cases.push({
id: 'sub-lock-open',
deviceId: config.controlBoxDeviceId,
@@ -94,6 +99,7 @@ export function buildHardwareSmokeCases(
commandType: 'CtrlDevice',
payload: lock.control({
id: generateCommandId(Date.now(), cases.length),
subtype: config.subLockSubtype,
subID: config.subLockSubId,
order: 'open',
delayTime: 4
+90 -19
View File
@@ -32,7 +32,10 @@ export class IotMessageService {
const payloadHash = createHash('sha256').update(payload).digest('hex');
const topicMatch = /^\/(devicesend|devicewill)\/([A-Za-z0-9_-]{1,64})$/.exec(topic);
if (!topicMatch) {
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_TOPIC_INVALID');
await this.deadLetter(
null, null, topic, payloadHash, sanitizePayloadTextForStorage(payloadText),
'MQTT_TOPIC_INVALID'
);
return;
}
@@ -46,23 +49,30 @@ export class IotMessageService {
);
const device = devices[0];
if (!device) {
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_DEVICE_UNKNOWN');
await this.deadLetter(
null, null, topic, payloadHash, sanitizePayloadTextForStorage(payloadText),
'MQTT_DEVICE_UNKNOWN'
);
return;
}
let rawPayload: unknown;
let normalized: NormalizedVendorMessage;
try {
rawPayload = JSON.parse(payloadText);
normalized = topicMatch[1] === 'devicewill'
? normalizeWill(rawPayload)
: this.adapters[device.deviceType].parseUplink(rawPayload);
if (topicMatch[1] === 'devicewill') {
rawPayload = parseWillPayload(payloadText);
normalized = normalizeWill(rawPayload);
} else {
rawPayload = JSON.parse(payloadText);
normalized = this.adapters[device.deviceType].parseUplink(rawPayload);
}
if (normalized.deviceId && normalized.deviceId !== device.deviceId) {
throw new Error('Payload DeviceID does not match MQTT topic.');
}
} catch (error) {
await this.deadLetter(
device.tenantId, device.id, topic, payloadHash, payloadText,
device.tenantId, device.id, topic, payloadHash,
sanitizePayloadTextForStorage(payloadText),
'MQTT_PAYLOAD_INVALID', error instanceof Error ? error.message : 'Invalid payload'
);
return;
@@ -70,22 +80,32 @@ export class IotMessageService {
const safePayload = sanitizeSensitivePayload(rawPayload, normalized);
normalized = { ...normalized, payload: safePayload };
const processingStatus = normalized.result === 'UNKNOWN_VENDOR_RESULT'
? 'PROTOCOL_ERROR'
: 'PROCESSED';
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_iot_device_events
(tenant_id, device_id, store_id, room_id, command_id, topic, event_type,
payload_hash, raw_payload, normalized_payload, event_at, processing_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PROCESSED')
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE receive_count = receive_count + 1,
received_at = UTC_TIMESTAMP(3)`,
[device.tenantId, device.id, device.storeId, device.roomId,
normalized.commandId, topic, normalized.eventType, payloadHash,
JSON.stringify(safePayload), JSON.stringify(normalized),
normalized.eventAt]
normalized.eventAt, processingStatus]
);
if (result.affectedRows !== 1) return;
await this.updateDevice(device, normalized, safePayload);
await this.applyAlerts(device, normalized);
if (normalized.result === 'UNKNOWN_VENDOR_RESULT') {
await this.deadLetter(
device.tenantId, device.id, topic, payloadHash, JSON.stringify(safePayload),
'UNKNOWN_VENDOR_RESULT',
`Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? '<missing>'}`
);
}
if (normalized.kind === 'ACK' && normalized.commandId) {
await this.applyAcknowledgement(device, normalized);
}
@@ -106,7 +126,8 @@ export class IotMessageService {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, input.assetId, input.storeId, input.roomId ?? null,
input.orderId ?? null, input.commandId, input.commandType,
JSON.stringify(input.payload), input.traceId, input.expiresAt ?? null]
JSON.stringify(sanitizeSensitivePayload(input.payload)),
input.traceId, input.expiresAt ?? null]
);
return { recordId: String(result.insertId), commandId: input.commandId };
}
@@ -141,8 +162,17 @@ export class IotMessageService {
private async applyAcknowledgement(device: DeviceRow, message: NormalizedVendorMessage) {
const successful = message.result === 'ok';
if (successful && message.eventType === 'AddDevice'
&& (!readString(message.payload.subID ?? message.payload.subId)
|| !readString(message.payload.subtype))) {
return;
}
const status = successful ? 'ACKED' : 'FAILED';
const failureCode = successful ? '' : `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
const failureCode = successful
? ''
: message.result === 'UNKNOWN_VENDOR_RESULT'
? 'UNKNOWN_VENDOR_RESULT'
: `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
await this.pool.execute(
`UPDATE qipai_iot_commands SET status = ?, response_payload = ?,
acknowledged_at = UTC_TIMESTAMP(3), failure_code = ?
@@ -198,6 +228,10 @@ export class IotMessageService {
}
private async applyAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
if (message.result === 'UNKNOWN_VENDOR_RESULT') {
await this.upsertAlert(device, 'UNKNOWN_VENDOR_RESULT', 'MEDIUM',
`Unsupported vendor result for ${message.eventType}.`);
}
const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '')
? `DEVICE_${message.result?.toUpperCase()}`
: null;
@@ -305,18 +339,29 @@ export function generateCommandId(now = Date.now(), sequence = 0): string {
}
function normalizeWill(payload: unknown): NormalizedVendorMessage {
const record = zRecord(payload);
const record = typeof payload === 'string' ? { will: payload } : zRecord(payload);
return {
kind: 'EVENT',
commandId: null,
eventType: 'will',
result: null,
rawResult: null,
deviceId: readDeviceId(record),
eventAt: null,
payload: record
};
}
function parseWillPayload(payloadText: string): unknown {
try {
return JSON.parse(payloadText);
} catch {
const will = payloadText.trim();
if (!will || will.length > 64) throw new Error('MQTT will payload is invalid.');
return will;
}
}
function zRecord(payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('MQTT payload must be a JSON object.');
@@ -330,26 +375,52 @@ function readDeviceId(record: Record<string, unknown>): string | null {
}
function sanitizeSensitivePayload(
payload: unknown, normalized: NormalizedVendorMessage
payload: unknown, normalized?: NormalizedVendorMessage
): Record<string, unknown> {
const record = { ...zRecord(payload) };
if (normalized.eventType === 'record' && typeof record.content === 'string') {
const record: Record<string, unknown> = typeof payload === 'string'
? { will: payload }
: { ...zRecord(payload) };
const eventType = normalized?.eventType ?? readString(record.event);
if (eventType === 'record' && typeof record.content === 'string') {
const content = record.content;
record.contentHash = createHash('sha256').update(content).digest('hex');
record.contentMasked = content.length <= 4
? '*'.repeat(content.length)
: `${content.slice(0, 2)}${'*'.repeat(Math.min(8, content.length - 4))}${content.slice(-2)}`;
addMaskedSecret(record, 'content', content);
delete record.content;
}
const sensitiveLockOrder = ['setkey', 'delkey', 'setcard', 'delcard']
.includes(readString(record.order) ?? '');
if (record.action === 'CtrlDevice' && sensitiveLockOrder
&& typeof record.value === 'string') {
addMaskedSecret(record, 'value', record.value);
delete record.value;
}
if (typeof record.password === 'string') {
addMaskedSecret(record, 'password', record.password);
record.password = '<redacted>';
}
if (typeof record.card === 'string') {
addMaskedSecret(record, 'card', record.card);
record.card = '<redacted>';
}
return record;
}
function addMaskedSecret(
record: Record<string, unknown>, field: string, secret: string
): void {
record[`${field}Hash`] = createHash('sha256').update(secret).digest('hex');
record[`${field}Masked`] = secret.length <= 4
? '*'.repeat(secret.length)
: `${secret.slice(0, 2)}${'*'.repeat(Math.min(8, secret.length - 4))}${secret.slice(-2)}`;
}
function sanitizePayloadTextForStorage(payloadText: string): string {
try {
return JSON.stringify(sanitizeSensitivePayload(JSON.parse(payloadText)));
} catch {
return payloadText.slice(0, 1_000_000);
}
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
+123 -34
View File
@@ -1,26 +1,32 @@
import { z } from 'zod';
const commandId = z.string().regex(/^\d{1,13}$/);
const resultCode = z.enum([
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full', 'unknown'
]);
const vendorResult = z.string().trim().min(1).max(64);
const knownResultCodes = [
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full',
'paraerror', 'error', 'update'
] as const;
const knownResultCode = z.enum(knownResultCodes);
const vendorMessage = z.object({
id: commandId.optional(),
DeviceID: z.string().min(1).max(64).optional(),
deviceID: z.string().min(1).max(64).optional(),
IMEI: z.string().max(64).optional(),
result: resultCode.optional(),
result: vendorResult.optional(),
event: z.string().max(64).optional(),
action: z.string().max(64).optional(),
read: z.string().max(64).optional(),
setting: z.string().max(64).optional(),
timestamp: z.union([z.string(), z.number()]).optional()
}).passthrough();
export type KnownVendorResult = z.infer<typeof knownResultCode>;
export type NormalizedVendorMessage = {
kind: 'ACK' | 'EVENT' | 'SNAPSHOT';
commandId: string | null;
eventType: string;
result: z.infer<typeof resultCode> | null;
result: KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null;
rawResult: string | null;
deviceId: string | null;
eventAt: Date | null;
payload: Record<string, unknown>;
@@ -60,20 +66,32 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
}
playTts(input: {
id: string; content: string; volume?: number; playCount?: number;
priority?: number; speaker?: number; style?: number; speed?: number; pitch?: number;
id: string; content: string; volume?: number; firstPlay?: 0 | 1; loop?: number;
speaker?: number; style?: number; speed?: number; intonation?: number;
}) {
return z.object({
action: z.literal('PlayTTS'), id: commandId,
const domain = z.object({
id: commandId,
content: z.string().trim().min(1).max(500),
volume: z.number().int().min(0).max(100).default(80),
playCount: z.number().int().min(1).max(10).default(1),
priority: z.number().int().min(0).max(10).default(0),
speaker: z.number().int().min(0).max(20).default(0),
style: z.number().int().min(0).max(20).default(0),
speed: z.number().int().min(-500).max(500).default(0),
pitch: z.number().int().min(-500).max(500).default(0)
}).parse({ action: 'PlayTTS', ...input });
volume: z.number().int().min(0).max(10).default(8),
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
loop: z.number().int().positive().max(100).default(1),
speaker: z.number().int().min(0).max(5).default(0),
style: z.number().int().min(0).max(2).default(0),
speed: z.number().int().min(0).max(10).default(5),
intonation: z.number().int().min(0).max(10).default(5)
}).parse(input);
return {
action: 'PlayTTS' as const,
content: domain.content,
vol: domain.volume,
firstPlay: domain.firstPlay,
loop: domain.loop,
speaker: domain.speaker,
style: domain.style,
speed: domain.speed,
intona: domain.intonation,
id: domain.id
};
}
stopTts(id: string) {
@@ -121,31 +139,65 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
export class JilianSub1GLockAdapter implements ProtocolAdapter {
pair(input: { id: string; timeout?: number }) {
return z.object({
action: z.literal('AddDevice'), id: commandId,
const domain = z.object({
id: commandId,
timeout: z.number().int().min(10).max(300).default(60)
}).parse({ action: 'AddDevice', ...input });
}).parse(input);
return { action: 'AddDevice' as const, time: domain.timeout, id: domain.id };
}
control(input: {
id: string; subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; content?: string;
id: string; subtype: '14' | '15'; subID: string;
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
| 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; value?: string;
}) {
return z.object({
action: z.literal('CtrlDevice'), id: commandId,
const schema = z.object({
id: commandId,
subtype: z.enum(['14', '15']),
subID: z.string().min(1).max(64),
order: z.enum([
'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
'open', 'close', 'none', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
]),
holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
delayTime: z.number().int().min(1).max(14).optional(),
content: z.string().min(1).max(128).optional()
value: z.string().min(1).max(128).optional()
}).superRefine((value, context) => {
if (['setkey', 'setcard'].includes(value.order) && !value.content) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'content is required' });
const passwordList = /^(?:\d{6})+$/;
const cardList = /^(?:[A-Fa-f0-9]{8})+$/;
if (value.order === 'setkey' && !passwordList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 6-digit passwords' });
}
}).parse({ action: 'CtrlDevice', ...input });
if (value.order === 'delkey'
&& value.value !== 'all' && !passwordList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 6-digit passwords' });
}
if (value.order === 'setcard' && !cardList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 8-digit hex card IDs' });
}
if (value.order === 'delcard'
&& value.value !== 'all' && !cardList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 8-digit hex card IDs' });
}
if (['open', 'close', 'none', 'factoryreset'].includes(value.order) && value.value) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value is not allowed for this order' });
}
});
const domain = schema.parse(input);
return {
action: 'CtrlDevice' as const,
subtype: domain.subtype,
subID: domain.subID,
order: domain.order,
...(domain.holdopen === undefined ? {} : { holdopen: domain.holdopen }),
...(domain.delayTime === undefined ? {} : { delayTime: domain.delayTime }),
...(domain.value === undefined ? {} : {
value: domain.value !== 'all' && ['setcard', 'delcard'].includes(domain.order)
? domain.value.toUpperCase()
: domain.value
}),
id: domain.id
};
}
parseUplink(payload: unknown) {
@@ -196,19 +248,56 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter {
function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage {
const parsed = vendorMessage.parse(payload);
const record = parsed as Record<string, unknown>;
const eventName = parsed.event ?? parsed.action ?? parsed.read ?? 'snapshot';
const record = normalizeReceiveVariants(parsed as Record<string, unknown>);
const rawEventName = parsed.event ?? parsed.action ?? parsed.read ?? parsed.setting ?? 'snapshot';
const eventName = canonicalMessageName(rawEventName);
const rawResult = parsed.result ?? null;
const result = normalizeResult(eventName, rawResult);
return {
kind: parsed.result ? 'ACK' : parsed.event ? 'EVENT' : 'SNAPSHOT',
kind: parsed.event ? 'EVENT' : parsed.result ? 'ACK' : 'SNAPSHOT',
commandId: parsed.id ?? null,
eventType: eventName,
result: parsed.result ?? null,
result,
rawResult,
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
eventAt: parseEventAt(parsed.timestamp),
payload: record
};
}
function normalizeResult(
eventName: string, result: string | null
): KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null {
if (result === null) return null;
const parsed = knownResultCode.safeParse(result);
if (!parsed.success) return 'UNKNOWN_VENDOR_RESULT';
const allowedByCommand: Record<string, readonly KnownVendorResult[]> = {
PlayTTS: ['ok', 'paraerror'],
stopTTS: ['ok'],
task: ['ok', 'busy', 'fail', 'unconfirm'],
addtask: ['ok', 'fail'],
canceltask: ['ok'],
AddDevice: ['ok', 'fail', 'timeout'],
CtrlDevice: ['ok', 'fail', 'timeout', 'full']
};
const allowed = allowedByCommand[eventName];
return allowed && !allowed.includes(parsed.data) ? 'UNKNOWN_VENDOR_RESULT' : parsed.data;
}
function canonicalMessageName(value: string): string {
return value.toLowerCase() === 'mqttconfig' ? 'mqttConfig' : value;
}
function normalizeReceiveVariants(record: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...record };
if (normalized.welcomevoice === undefined && normalized.welvoice !== undefined) {
normalized.welcomevoice = normalized.welvoice;
}
if (normalized.setting === 'mqttconfig') normalized.setting = 'mqttConfig';
if (normalized.read === 'mqttconfig') normalized.read = 'mqttConfig';
return normalized;
}
function parseEventAt(value: string | number | undefined): Date | null {
if (value === undefined) return null;
const date = typeof value === 'number'
+10 -5
View File
@@ -32,9 +32,13 @@ const doorSchema = contextSchema.extend({
});
const ttsSchema = contextSchema.extend({
content: z.string().trim().min(1).max(500),
volume: z.number().int().min(0).max(100).default(80),
playCount: z.number().int().min(1).max(10).default(1),
priority: z.number().int().min(0).max(10).default(0)
volume: z.number().int().min(0).max(10).default(8),
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
loop: z.number().int().positive().max(100).default(1),
speaker: z.number().int().min(0).max(5).default(0),
style: z.number().int().min(0).max(2).default(0),
speed: z.number().int().min(0).max(10).default(5),
intonation: z.number().int().min(0).max(10).default(5)
});
const minuteSchema = contextSchema.extend({
minute: z.number().int().min(0).max(10080)
@@ -53,13 +57,14 @@ const pairSchema = contextSchema.extend({
timeout: z.number().int().min(10).max(300).default(60)
});
const subLockSchema = contextSchema.extend({
subtype: z.enum(['14', '15']),
subID: z.string().min(1).max(64),
order: z.enum([
'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
'open', 'close', 'none', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
]),
holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
delayTime: z.number().int().min(1).max(14).optional(),
content: z.string().min(1).max(128).optional(),
value: z.string().min(1).max(128).optional(),
dangerConfirmation: z.string().max(64).optional()
});
const socketReadSchema = contextSchema.extend({
+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',