diff --git a/backend/src/devices/device-control-service.ts b/backend/src/devices/device-control-service.ts index 68a7afd..9894b14 100644 --- a/backend/src/devices/device-control-service.ts +++ b/backend/src/devices/device-control-service.ts @@ -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'); diff --git a/backend/src/devices/hardware-smoke-runner.ts b/backend/src/devices/hardware-smoke-runner.ts index 5fa25a4..52979de 100644 --- a/backend/src/devices/hardware-smoke-runner.ts +++ b/backend/src/devices/hardware-smoke-runner.ts @@ -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 + '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 diff --git a/backend/src/devices/iot-message-service.ts b/backend/src/devices/iot-message-service.ts index f487858..69a8251 100644 --- a/backend/src/devices/iot-message-service.ts +++ b/backend/src/devices/iot-message-service.ts @@ -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( `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 ?? ''}` + ); + } 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 { 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 | null { } function sanitizeSensitivePayload( - payload: unknown, normalized: NormalizedVendorMessage + payload: unknown, normalized?: NormalizedVendorMessage ): Record { - const record = { ...zRecord(payload) }; - if (normalized.eventType === 'record' && typeof record.content === 'string') { + const record: Record = 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 = ''; } if (typeof record.card === 'string') { + addMaskedSecret(record, 'card', record.card); record.card = ''; } return record; } +function addMaskedSecret( + record: Record, 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; } diff --git a/backend/src/devices/jilian-adapters.ts b/backend/src/devices/jilian-adapters.ts index 710e8ee..34d0494 100644 --- a/backend/src/devices/jilian-adapters.ts +++ b/backend/src/devices/jilian-adapters.ts @@ -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; export type NormalizedVendorMessage = { kind: 'ACK' | 'EVENT' | 'SNAPSHOT'; commandId: string | null; eventType: string; - result: z.infer | null; + result: KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null; + rawResult: string | null; deviceId: string | null; eventAt: Date | null; payload: Record; @@ -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; - const eventName = parsed.event ?? parsed.action ?? parsed.read ?? 'snapshot'; + const record = normalizeReceiveVariants(parsed as Record); + 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 = { + 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): Record { + 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' diff --git a/backend/src/routes/device-control.ts b/backend/src/routes/device-control.ts index c8fb41e..99d7cf3 100644 --- a/backend/src/routes/device-control.ts +++ b/backend/src/routes/device-control.ts @@ -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({ diff --git a/backend/tests/device-control.test.mjs b/backend/tests/device-control.test.mjs index 07e3307..70729dc 100644 --- a/backend/tests/device-control.test.mjs +++ b/backend/tests/device-control.test.mjs @@ -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', diff --git a/backend/tests/hardware-smoke-runner.test.mjs b/backend/tests/hardware-smoke-runner.test.mjs index 0aab8e7..30fa1ff 100644 --- a/backend/tests/hardware-smoke-runner.test.mjs +++ b/backend/tests/hardware-smoke-runner.test.mjs @@ -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); diff --git a/backend/tests/iot-protocol.test.mjs b/backend/tests/iot-protocol.test.mjs index 2c72ea9..08b6ae1 100644 --- a/backend/tests/iot-protocol.test.mjs +++ b/backend/tests/iot-protocol.test.mjs @@ -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]); }, diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index b1aed28..826acfd 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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',