diff --git a/backend/src/devices/device-control-service.ts b/backend/src/devices/device-control-service.ts index 9894b14..b39ffa8 100644 --- a/backend/src/devices/device-control-service.ts +++ b/backend/src/devices/device-control-service.ts @@ -11,7 +11,7 @@ import { interface DeviceRow extends RowDataPacket { id: string; deviceId: string; storeId: string; roomId: string | null; deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET'; - status: string; + status: string; model?: string | null; capabilities?: unknown; } export class DeviceControlError extends Error { @@ -115,9 +115,13 @@ export class DeviceControlService { (id) => this.subLock.control({ id, ...vendorInput })); } - async readSmartSocket(context: CommandContext, target: 'basicInfo' | 'workInfo') { - return this.issueSmartSocket(context, `socket:${target}`, - () => this.smartSocket.read(target)); + async readSmartSocket(context: CommandContext, input: 'basicInfo' | 'workInfo' | { + target: 'basicInfo' | 'workInfo' | 'localtask' | 'mqttConfig'; + slotNum?: number; taskNum?: number; + }) { + const request = typeof input === 'string' ? { target: input } : input; + return this.issueSmartSocket(context, `socket:${request.target}`, + () => this.smartSocket.read(request)); } async switchSmartSocket(context: CommandContext, input: { @@ -129,16 +133,77 @@ export class DeviceControlService { } async scheduleSmartSocket(context: CommandContext, input: { - taskNum: number; action: 'on' | 'off'; mode: 'once' | 'daily' | 'weekly'; + slotNum?: number; taskNum: number; action: 'on' | 'off'; + mode: 'once' | 'daily' | 'weekly'; time: string; weekdays?: number[]; }) { return this.issueSmartSocket(context, 'localtask', (id) => this.smartSocket.localTask({ id, ...input }), context.orderId); } - async clearSmartSocketTask(context: CommandContext, taskNum: number) { + async clearSmartSocketTask(context: CommandContext, input: number | { + slotNum?: number; taskNum: number; dangerConfirmation?: string; + }) { + const request = typeof input === 'number' ? { taskNum: input } : input; + if (request.taskNum === 0) { + this.assertDangerousSocketAction( + context, request.dangerConfirmation, 'CONFIRM_CLEAR_ALL_SOCKET_TASKS' + ); + } + const { dangerConfirmation: _, ...vendorInput } = request; return this.issueSmartSocket(context, 'clearTask', - (id) => this.smartSocket.clearTask({ id, taskNum }), context.orderId); + (id) => this.smartSocket.clearTask({ id, ...vendorInput }), context.orderId); + } + + async rebootSmartSocket(context: CommandContext, dangerConfirmation?: string) { + this.assertDangerousSocketAction(context, dangerConfirmation, 'CONFIRM_REBOOT_SOCKET'); + return this.issueSmartSocket(context, 'reboot', (id) => this.smartSocket.reboot(id)); + } + + async emptySmartSocketEnergy(context: CommandContext, dangerConfirmation?: string) { + this.assertDangerousSocketAction( + context, dangerConfirmation, 'CONFIRM_EMPTY_SOCKET_ENERGY' + ); + return this.issueSmartSocket(context, 'emptyPower', + (id) => this.smartSocket.emptyPower(id)); + } + + async resetSmartSocket(context: CommandContext, input: { + password: string; dangerConfirmation?: string; + }) { + this.assertDangerousSocketAction( + context, input.dangerConfirmation, 'CONFIRM_SOCKET_FACTORY_RESET' + ); + return this.issueSmartSocket(context, 'returnFactory', + (id) => this.smartSocket.returnFactory({ id, password: input.password })); + } + + async changeSmartSocketReturnKey(context: CommandContext, input: { + old: string; new: string; dangerConfirmation?: string; + }) { + this.assertDangerousSocketAction( + context, input.dangerConfirmation, 'CONFIRM_CHANGE_SOCKET_RETURN_KEY' + ); + return this.issueSmartSocket(context, 'setReturnKey', + (id) => this.smartSocket.setReturnKey({ id, old: input.old, new: input.new })); + } + + async configureSmartSocketProtection(context: CommandContext, input: { + maxPower: number; maxCurrent: number; maxTemperature: number; + pullOutStop: 0 | 1; pullOutPower: number; pullOutSec: number; + chargeFullStop: 0 | 1; chargeFullPower: number; chargeFullSec: number; + }) { + return this.issueSmartSocket(context, 'protectOff', + (id, device) => this.smartSocket.protectOff({ + id, ratedAmps: readRatedAmps(device), ...input + })); + } + + async configureSmartSocketParameter(context: CommandContext, input: { + resetHold: 0 | 1; keyLock: 0 | 1; keyOff: 0 | 1; + }) { + return this.issueSmartSocket(context, 'parameter', + (id) => this.smartSocket.parameter({ id, ...input })); } private async issueControlBox( @@ -168,7 +233,7 @@ export class DeviceControlService { private async issueSmartSocket( context: CommandContext, commandType: string, - payloadFactory: (id: string) => Record, + payloadFactory: (id: string, device: DeviceRow) => Record, orderId?: string | null ) { this.assertWriteScope(context.access, context.storeId); @@ -183,7 +248,7 @@ export class DeviceControlService { roomId: context.roomId, orderId, commandType, - payloadFactory, + payloadFactory: (id) => payloadFactory(id, device), traceId: context.traceId, expiresAt: context.expiresAt }); @@ -192,7 +257,7 @@ export class DeviceControlService { private async resolveControlBox(context: CommandContext) { const [rows] = await this.pool.execute( `SELECT id, device_id AS deviceId, store_id AS storeId, room_id AS roomId, - device_type AS deviceType, status + device_type AS deviceType, status, model, capabilities FROM qipai_devices WHERE tenant_id = ? AND store_id = ? AND room_id = ? AND device_type = 'CONTROL_BOX' AND deleted_at IS NULL @@ -226,6 +291,16 @@ export class DeviceControlService { } } + private assertDangerousSocketAction( + context: CommandContext, actual: string | undefined, expected: string + ) { + if (context.actorType === 'CUSTOMER' + || !context.access.roles.includes('PLATFORM_ADMIN') + || actual !== expected) { + throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN'); + } + } + private async auditCommand( context: CommandContext, device: DeviceRow, @@ -277,3 +352,12 @@ export interface CommandContext { access: AccessProfile; expiresAt?: Date | null; } + +function readRatedAmps(device: DeviceRow): 10 | 16 | 63 { + const source = `${device.model ?? ''} ${JSON.stringify(device.capabilities ?? '')}`; + const match = /(?:^|[^0-9])(10|16|63)A(?:$|[^0-9])/i.exec(source); + if (match?.[1] === '10') return 10; + if (match?.[1] === '16') return 16; + if (match?.[1] === '63') return 63; + throw new DeviceControlError('SOCKET_RATED_CURRENT_UNKNOWN'); +} diff --git a/backend/src/devices/iot-message-service.ts b/backend/src/devices/iot-message-service.ts index 69a8251..7359ca7 100644 --- a/backend/src/devices/iot-message-service.ts +++ b/backend/src/devices/iot-message-service.ts @@ -78,9 +78,13 @@ export class IotMessageService { return; } - const safePayload = sanitizeSensitivePayload(rawPayload, normalized); - normalized = { ...normalized, payload: safePayload }; - const processingStatus = normalized.result === 'UNKNOWN_VENDOR_RESULT' + const safeRawPayload = sanitizeSensitivePayload(rawPayload, normalized); + const safeNormalizedPayload = sanitizeSensitivePayload(normalized.payload, normalized); + normalized = { ...normalized, payload: safeNormalizedPayload }; + const protocolCode = normalized.result === 'UNKNOWN_VENDOR_RESULT' + ? 'UNKNOWN_VENDOR_RESULT' + : normalized.protocolIssues[0] ?? null; + const processingStatus = protocolCode ? 'PROTOCOL_ERROR' : 'PROCESSED'; const [result] = await this.pool.execute( @@ -92,18 +96,20 @@ export class IotMessageService { 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), + JSON.stringify(safeRawPayload), JSON.stringify(normalized), normalized.eventAt, processingStatus] ); if (result.affectedRows !== 1) return; - await this.updateDevice(device, normalized, safePayload); + await this.updateDevice(device, normalized, safeNormalizedPayload); await this.applyAlerts(device, normalized); - if (normalized.result === 'UNKNOWN_VENDOR_RESULT') { + if (protocolCode) { await this.deadLetter( - device.tenantId, device.id, topic, payloadHash, JSON.stringify(safePayload), - 'UNKNOWN_VENDOR_RESULT', - `Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? ''}` + device.tenantId, device.id, topic, payloadHash, JSON.stringify(safeRawPayload), + protocolCode, + protocolCode === 'UNKNOWN_VENDOR_RESULT' + ? `Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? ''}` + : `Protocol issue for ${normalized.eventType}: ${protocolCode}` ); } if (normalized.kind === 'ACK' && normalized.commandId) { @@ -232,6 +238,10 @@ export class IotMessageService { await this.upsertAlert(device, 'UNKNOWN_VENDOR_RESULT', 'MEDIUM', `Unsupported vendor result for ${message.eventType}.`); } + for (const issue of message.protocolIssues) { + await this.upsertAlert(device, issue, 'MEDIUM', + `Device payload reported protocol issue ${issue}.`); + } const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '') ? `DEVICE_${message.result?.toUpperCase()}` : null; @@ -251,41 +261,17 @@ export class IotMessageService { } private async applySmartSocketAlerts(device: DeviceRow, message: NormalizedVendorMessage) { - const payload = message.payload; - const eventType = message.eventType.toLowerCase(); - const triggered = (names: string[]) => names.some((name) => - eventType === name.toLowerCase() || readBoolean(payload[name]) - ); - if (triggered(['overload', 'overLoad', 'overpower', 'overPower'])) { - await this.upsertAlert(device, 'SOCKET_OVERLOAD', 'HIGH', - 'Smart socket reported overload or overpower protection.'); - } - if (triggered(['overheat', 'overHeat', 'overTemperature'])) { - await this.upsertAlert(device, 'SOCKET_OVERHEAT', 'HIGH', - 'Smart socket reported over-temperature protection.'); - } - if (triggered(['overcurrent', 'overCurrent'])) { - await this.upsertAlert(device, 'SOCKET_OVERCURRENT', 'HIGH', - 'Smart socket reported over-current protection.'); - } - if (triggered(['overvoltage', 'overVoltage'])) { - await this.upsertAlert(device, 'SOCKET_OVERVOLTAGE', 'MEDIUM', - 'Smart socket reported over-voltage protection.'); - } - if (triggered(['undervoltage', 'underVoltage'])) { - await this.upsertAlert(device, 'SOCKET_UNDERVOLTAGE', 'MEDIUM', - 'Smart socket reported under-voltage protection.'); - } - const powerWatts = readNumber(payload.powerW ?? payload.power ?? payload.watt); - if (powerWatts !== null && powerWatts > 3500) { - await this.upsertAlert(device, 'SOCKET_POWER_LIMIT', 'HIGH', - `Smart socket power is ${powerWatts}W.`); - } - const temperature = readNumber(payload.temperature ?? payload.temp); - if (temperature !== null && temperature >= 75) { - await this.upsertAlert(device, 'SOCKET_TEMPERATURE_LIMIT', 'HIGH', - `Smart socket temperature is ${temperature}C.`); - } + if (message.eventType !== 'special') return; + const closeReason = readInteger(message.payload.closeReason); + const alerts: Record = { + 3: ['SOCKET_PULL_OUT_STOP', 'Smart socket stopped after appliance removal.'], + 4: ['SOCKET_CHARGE_FULL_STOP', 'Smart socket stopped after full charge.'], + 5: ['SOCKET_OVERPOWER', 'Smart socket stopped on maximum power protection.'], + 6: ['SOCKET_OVERCURRENT', 'Smart socket stopped on maximum current protection.'], + 7: ['SOCKET_OVERTEMPERATURE', 'Smart socket stopped on maximum temperature protection.'] + }; + const alert = closeReason === null ? undefined : alerts[closeReason]; + if (alert) await this.upsertAlert(device, alert[0], 'HIGH', alert[1]); } private async upsertAlert( @@ -348,7 +334,8 @@ function normalizeWill(payload: unknown): NormalizedVendorMessage { rawResult: null, deviceId: readDeviceId(record), eventAt: null, - payload: record + payload: record, + protocolIssues: [] }; } @@ -401,6 +388,14 @@ function sanitizeSensitivePayload( addMaskedSecret(record, 'card', record.card); record.card = ''; } + if (record.action === 'setReturnKey') { + for (const field of ['old', 'new']) { + if (typeof record[field] === 'string') { + addMaskedSecret(record, field, record[field]); + delete record[field]; + } + } + } return record; } @@ -433,11 +428,7 @@ function readNumber(value: unknown): number | null { return null; } -function readBoolean(value: unknown): boolean { - if (typeof value === 'boolean') return value; - if (typeof value === 'number') return value !== 0; - if (typeof value === 'string') { - return ['1', 'true', 'yes', 'on', 'alarm'].includes(value.trim().toLowerCase()); - } - return false; +function readInteger(value: unknown): number | null { + const number = readNumber(value); + return number !== null && Number.isInteger(number) ? number : null; } diff --git a/backend/src/devices/jilian-adapters.ts b/backend/src/devices/jilian-adapters.ts index 34d0494..6e2f550 100644 --- a/backend/src/devices/jilian-adapters.ts +++ b/backend/src/devices/jilian-adapters.ts @@ -30,6 +30,7 @@ export type NormalizedVendorMessage = { deviceId: string | null; eventAt: Date | null; payload: Record; + protocolIssues: string[]; }; export interface ProtocolAdapter { @@ -206,8 +207,25 @@ export class JilianSub1GLockAdapter implements ProtocolAdapter { } export class JilianSmartSocketAdapter implements ProtocolAdapter { - read(target: 'basicInfo' | 'workInfo') { - return z.object({ read: z.literal(target) }).parse({ read: target }); + read(input: 'basicInfo' | 'workInfo' | { + target: 'basicInfo' | 'workInfo' | 'localtask' | 'mqttConfig'; + slotNum?: number; taskNum?: number; + }) { + const domain = typeof input === 'string' ? { target: input } : input; + if (domain.target === 'basicInfo' || domain.target === 'mqttConfig') { + return { read: domain.target }; + } + if (domain.target === 'workInfo') { + return z.object({ + read: z.literal('workInfo'), + slotNum: z.number().int().min(1).max(20).default(1) + }).parse({ read: 'workInfo', slotNum: domain.slotNum }); + } + return z.object({ + read: z.literal('localtask'), + slotNum: z.number().int().min(1).max(20).default(1), + taskNum: z.number().int().min(0).max(20) + }).parse({ read: 'localtask', slotNum: domain.slotNum, taskNum: domain.taskNum }); } switch(input: { id: string; on: boolean; slotNum?: number }) { @@ -218,29 +236,130 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter { } localTask(input: { - id: string; taskNum: number; action: 'on' | 'off'; + id: string; slotNum?: number; taskNum: number; action: 'on' | 'off'; mode: 'once' | 'daily' | 'weekly'; time: string; weekdays?: number[]; }) { - return z.object({ - action: z.literal('localtask'), id: commandId, + const domain = z.object({ + id: commandId, + slotNum: z.number().int().min(1).max(20).default(1), taskNum: z.number().int().min(1).max(20), - switch: z.enum(['on', 'off']), + action: z.enum(['on', 'off']), mode: z.enum(['once', 'daily', 'weekly']), - time: z.string().regex(/^\d{2}:\d{2}$/), - weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional() - }).parse({ - action: 'localtask', id: input.id, taskNum: input.taskNum, - switch: input.action, mode: input.mode, time: input.time, weekdays: input.weekdays - }); + time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/), + weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7) + .refine((items) => new Set(items).size === items.length, 'weekdays must be unique') + .optional() + }).superRefine((value, context) => { + if (value.mode === 'weekly' && !value.weekdays) { + context.addIssue({ code: z.ZodIssueCode.custom, message: 'weekdays are required weekly' }); + } + if (value.mode !== 'weekly' && value.weekdays) { + context.addIssue({ code: z.ZodIssueCode.custom, message: 'weekdays are only valid weekly' }); + } + }).parse(input); + const weekdaySet = new Set(domain.weekdays ?? []); + const weekdata = domain.mode === 'daily' + ? '1111111' + : domain.mode === 'weekly' + ? Array.from({ length: 7 }, (_, index) => weekdaySet.has(index + 1) ? '1' : '0').join('') + : '0000000'; + return { + action: 'localtask' as const, + slotNum: domain.slotNum, + taskNum: domain.taskNum, + tasktype: domain.action, + cyctype: domain.mode === 'daily' ? 'daycyc' : domain.mode === 'weekly' ? 'weekcyc' : 'once', + weekdata, + actiontime: domain.time.replace(':', ''), + id: domain.id + }; } - clearTask(input: { id: string; taskNum: number }) { + clearTask(input: { id: string; slotNum?: number; taskNum: number }) { return z.object({ action: z.literal('clearTask'), id: commandId, + slotNum: z.number().int().min(1).max(20).default(1), taskNum: z.number().int().min(0).max(20) }).parse({ action: 'clearTask', ...input }); } + reboot(id: string) { + return z.object({ action: z.literal('reboot'), id: commandId }) + .parse({ action: 'reboot', id }); + } + + emptyPower(id: string) { + return z.object({ action: z.literal('emptyPower'), id: commandId }) + .parse({ action: 'emptyPower', id }); + } + + returnFactory(input: { id: string; password: string }) { + return z.object({ + action: z.literal('returnFactory'), id: commandId, + password: z.string().min(1).max(32) + }).parse({ action: 'returnFactory', ...input }); + } + + setReturnKey(input: { id: string; old: string; new: string }) { + return z.object({ + action: z.literal('setReturnKey'), id: commandId, + old: z.string().min(1).max(32), new: z.string().min(1).max(32) + }).parse({ action: 'setReturnKey', ...input }); + } + + protectOff(input: { + id: string; ratedAmps: 10 | 16 | 63; + maxPower: number; maxCurrent: number; maxTemperature: number; + pullOutStop: 0 | 1; pullOutPower: number; pullOutSec: number; + chargeFullStop: 0 | 1; chargeFullPower: number; chargeFullSec: number; + }) { + const domain = z.object({ + id: commandId, ratedAmps: z.union([z.literal(10), z.literal(16), z.literal(63)]), + maxPower: z.number().positive(), maxCurrent: z.number().positive(), + maxTemperature: z.number().min(1).max(125), + pullOutStop: z.union([z.literal(0), z.literal(1)]), + pullOutPower: z.number().nonnegative(), + pullOutSec: z.number().int().min(1).max(86400), + chargeFullStop: z.union([z.literal(0), z.literal(1)]), + chargeFullPower: z.number().nonnegative(), + chargeFullSec: z.number().int().min(1).max(86400) + }).superRefine((value, context) => { + const powerCeiling = value.ratedAmps * 250; + if (value.maxCurrent > value.ratedAmps) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['maxCurrent'], + message: `maxCurrent exceeds ${value.ratedAmps}A capability` }); + } + if (value.maxPower > powerCeiling) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['maxPower'], + message: `maxPower exceeds ${powerCeiling}W capability` }); + } + if (value.pullOutPower > value.maxPower || value.chargeFullPower > value.maxPower) { + context.addIssue({ code: z.ZodIssueCode.custom, + message: 'auto-stop power cannot exceed maxPower' }); + } + }).parse(input); + const { ratedAmps: _, ...wire } = domain; + return { setting: 'protectOff' as const, ...wire }; + } + + parameter(input: { + id: string; resetHold: 0 | 1; keyLock: 0 | 1; keyOff: 0 | 1; + }) { + const domain = z.object({ + id: commandId, + resetHold: z.union([z.literal(0), z.literal(1)]), + keyLock: z.union([z.literal(0), z.literal(1)]), + keyOff: z.union([z.literal(0), z.literal(1)]) + }).parse(input); + return { + setting: 'parameter' as const, + resetHold: domain.resetHold, + keyLock: domain.keyLock, + KeyOff: domain.keyOff, + id: domain.id + }; + } + parseUplink(payload: unknown) { return normalizeVendorMessage(payload); } @@ -248,9 +367,13 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter { function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage { const parsed = vendorMessage.parse(payload); - const record = normalizeReceiveVariants(parsed as Record); + const protocolIssues: string[] = []; + const variants = normalizeReceiveVariants( + parsed as Record, protocolIssues + ); const rawEventName = parsed.event ?? parsed.action ?? parsed.read ?? parsed.setting ?? 'snapshot'; const eventName = canonicalMessageName(rawEventName); + const record = normalizeSocketStatus(variants, eventName, protocolIssues); const rawResult = parsed.result ?? null; const result = normalizeResult(eventName, rawResult); return { @@ -261,7 +384,8 @@ function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage { rawResult, deviceId: parsed.DeviceID ?? parsed.deviceID ?? null, eventAt: parseEventAt(parsed.timestamp), - payload: record + payload: record, + protocolIssues }; } @@ -278,7 +402,18 @@ function normalizeResult( addtask: ['ok', 'fail'], canceltask: ['ok'], AddDevice: ['ok', 'fail', 'timeout'], - CtrlDevice: ['ok', 'fail', 'timeout', 'full'] + CtrlDevice: ['ok', 'fail', 'timeout', 'full'], + on: ['ok'], + off: ['ok', 'update', 'paraerror'], + localtask: ['ok', 'paraerror', 'error', 'update'], + clearTask: ['ok', 'paraerror', 'error'], + reboot: ['ok', 'error'], + emptyPower: ['ok', 'paraerror', 'error'], + returnFactory: ['ok', 'paraerror', 'error'], + setReturnKey: ['ok', 'paraerror', 'error'], + protectOff: ['ok', 'paraerror', 'error', 'update'], + parameter: ['ok', 'paraerror', 'error', 'update'], + mqttConfig: ['ok', 'paraerror', 'error', 'update'] }; const allowed = allowedByCommand[eventName]; return allowed && !allowed.includes(parsed.data) ? 'UNKNOWN_VENDOR_RESULT' : parsed.data; @@ -288,16 +423,89 @@ function canonicalMessageName(value: string): string { return value.toLowerCase() === 'mqttconfig' ? 'mqttConfig' : value; } -function normalizeReceiveVariants(record: Record): Record { +function normalizeReceiveVariants( + record: Record, protocolIssues: string[] +): 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'; + if (normalized.keyOff === undefined && normalized.KeyOff !== undefined) { + normalized.keyOff = normalized.KeyOff; + } + if (normalized.keyOff !== undefined && normalized.KeyOff !== undefined + && String(normalized.keyOff) !== String(normalized.KeyOff)) { + protocolIssues.push('SOCKET_KEY_OFF_CONFLICT'); + } return normalized; } +function normalizeSocketStatus( + record: Record, eventName: string, protocolIssues: string[] +): Record { + const isWorkInfo = eventName === 'workInfo' + || ['onOff', 'vol', 'cur', 'pow', 'temp', 'temperature', 'energy'] + .some((field) => record[field] !== undefined); + if (!isWorkInfo) return record; + const normalized = { ...record }; + const temperature = normalizeDecimalField(record.temp, 'temp', protocolIssues); + const temperatureVariant = normalizeDecimalField( + record.temperature, 'temperature', protocolIssues + ); + if (temperature !== null && temperatureVariant !== null + && temperature !== temperatureVariant) { + protocolIssues.push('SOCKET_TEMPERATURE_CONFLICT'); + } + const switchOn = normalizeOnOff(record.onOff); + if (record.onOff !== undefined && switchOn === null) { + protocolIssues.push('SOCKET_ON_OFF_INVALID'); + } + return { + ...normalized, + ...(switchOn === null ? {} : { switchOn }), + ...decimalProperty('voltage', record.vol, 'vol', protocolIssues), + ...decimalProperty('current', record.cur, 'cur', protocolIssues), + ...decimalProperty('power', record.pow, 'pow', protocolIssues), + ...((temperature ?? temperatureVariant) === null + ? {} + : { temperature: temperature ?? temperatureVariant }), + ...decimalProperty('energy', record.energy, 'energy', protocolIssues) + }; +} + +function decimalProperty( + name: string, value: unknown, source: string, protocolIssues: string[] +): Record { + const normalized = normalizeDecimalField(value, source, protocolIssues); + return normalized === null ? {} : { [name]: normalized }; +} + +function normalizeDecimalField( + value: unknown, source: string, protocolIssues: string[] +): string | null { + if (value === undefined || value === null || value === '') return null; + const text = typeof value === 'number' && Number.isFinite(value) + ? String(value) + : typeof value === 'string' ? value.trim() : ''; + const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text); + if (!match) { + protocolIssues.push(`SOCKET_${source.toUpperCase()}_INVALID`); + return null; + } + const integer = match[2].replace(/^0+(?=\d)/, ''); + const fraction = (match[3] ?? '').replace(/0+$/, ''); + const zero = integer === '0' && !fraction; + return `${zero ? '' : match[1]}${integer}${fraction ? `.${fraction}` : ''}`; +} + +function normalizeOnOff(value: unknown): boolean | null { + if (value === true || value === 1 || value === '1' || value === 'on') return true; + if (value === false || value === 0 || value === '0' || value === 'off') return false; + return null; +} + 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 99d7cf3..1050f7e 100644 --- a/backend/src/routes/device-control.ts +++ b/backend/src/routes/device-control.ts @@ -68,21 +68,67 @@ const subLockSchema = contextSchema.extend({ dangerConfirmation: z.string().max(64).optional() }); const socketReadSchema = contextSchema.extend({ - target: z.enum(['basicInfo', 'workInfo']).default('workInfo') + target: z.enum(['basicInfo', 'workInfo', 'localtask', 'mqttConfig']).default('workInfo'), + slotNum: z.number().int().min(1).max(20).optional(), + taskNum: z.number().int().min(0).max(20).optional() +}).superRefine((value, context) => { + if (value.target === 'localtask' && value.taskNum === undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['taskNum'], + message: 'taskNum is required for localtask reads' }); + } }); const socketSwitchSchema = contextSchema.extend({ on: z.boolean(), slotNum: z.number().int().min(1).max(20).default(1) }); const socketTaskSchema = contextSchema.extend({ + slotNum: z.number().int().min(1).max(20).default(1), taskNum: z.number().int().min(1).max(20), action: z.enum(['on', 'off']), mode: z.enum(['once', 'daily', 'weekly']), - time: z.string().regex(/^\d{2}:\d{2}$/), - weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional() + time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/), + weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7) + .refine((items) => new Set(items).size === items.length).optional() +}).superRefine((value, context) => { + if (value.mode === 'weekly' && !value.weekdays) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['weekdays'], + message: 'weekdays are required weekly' }); + } + if (value.mode !== 'weekly' && value.weekdays) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ['weekdays'], + message: 'weekdays are only valid weekly' }); + } }); const socketClearTaskSchema = contextSchema.extend({ - taskNum: z.number().int().min(0).max(20) + slotNum: z.number().int().min(1).max(20).default(1), + taskNum: z.number().int().min(0).max(20), + dangerConfirmation: z.string().max(64).optional() +}); +const dangerSchema = contextSchema.extend({ + dangerConfirmation: z.string().max(64) +}); +const socketResetSchema = dangerSchema.extend({ + password: z.string().min(1).max(32) +}); +const socketReturnKeySchema = dangerSchema.extend({ + old: z.string().min(1).max(32), + new: z.string().min(1).max(32) +}); +const socketProtectionSchema = contextSchema.extend({ + maxPower: z.number().positive(), + maxCurrent: z.number().positive(), + maxTemperature: z.number().min(1).max(125), + pullOutStop: z.union([z.literal(0), z.literal(1)]), + pullOutPower: z.number().nonnegative(), + pullOutSec: z.number().int().min(1).max(86400), + chargeFullStop: z.union([z.literal(0), z.literal(1)]), + chargeFullPower: z.number().nonnegative(), + chargeFullSec: z.number().int().min(1).max(86400) +}); +const socketParameterSchema = contextSchema.extend({ + resetHold: z.union([z.literal(0), z.literal(1)]), + keyLock: z.union([z.literal(0), z.literal(1)]), + keyOff: z.union([z.literal(0), z.literal(1)]) }); const orderParams = z.object({ orderId: id }); const customerOpenDoorSchema = z.object({ @@ -94,7 +140,9 @@ export interface DeviceControlRouteOptions { 'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed' | 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock' | 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket' - | 'clearSmartSocketTask'>; + | 'clearSmartSocketTask' | 'rebootSmartSocket' | 'emptySmartSocketEnergy' + | 'resetSmartSocket' | 'changeSmartSocketReturnKey' + | 'configureSmartSocketProtection' | 'configureSmartSocketParameter'>; customerAccess?: Pick; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; @@ -125,13 +173,27 @@ export async function registerDeviceControlRoutes( registerManagement('/sub-lock/action', subLockSchema, (context, body) => options.service.controlSubLock(context, body)); registerManagement('/socket/read', socketReadSchema, - (context, body) => options.service.readSmartSocket(context, body.target)); + (context, body) => options.service.readSmartSocket(context, body)); registerManagement('/socket/switch', socketSwitchSchema, (context, body) => options.service.switchSmartSocket(context, body)); registerManagement('/socket/task', socketTaskSchema, (context, body) => options.service.scheduleSmartSocket(context, body)); registerManagement('/socket/task/clear', socketClearTaskSchema, - (context, body) => options.service.clearSmartSocketTask(context, body.taskNum)); + (context, body) => options.service.clearSmartSocketTask(context, body)); + registerManagement('/socket/reboot', dangerSchema, + (context, body) => options.service.rebootSmartSocket(context, body.dangerConfirmation)); + registerManagement('/socket/energy/clear', dangerSchema, + (context, body) => options.service.emptySmartSocketEnergy( + context, body.dangerConfirmation + )); + registerManagement('/socket/factory-reset', socketResetSchema, + (context, body) => options.service.resetSmartSocket(context, body)); + registerManagement('/socket/return-key', socketReturnKeySchema, + (context, body) => options.service.changeSmartSocketReturnKey(context, body)); + registerManagement('/socket/protection', socketProtectionSchema, + (context, body) => options.service.configureSmartSocketProtection(context, body)); + registerManagement('/socket/parameter', socketParameterSchema, + (context, body) => options.service.configureSmartSocketParameter(context, body)); app.post('/app-api/orders/:orderId/open-door', async (request, reply) => { if (!options.customerAccess) { diff --git a/backend/tests/device-control.test.mjs b/backend/tests/device-control.test.mjs index 70729dc..247eeb9 100644 --- a/backend/tests/device-control.test.mjs +++ b/backend/tests/device-control.test.mjs @@ -19,7 +19,8 @@ const service = new DeviceControlService({ if (sql.includes("device_type = 'SMART_SOCKET'")) { return [[{ id: 52, deviceId: 'SOCKET_001', storeId: 11, roomId: 31, - deviceType: 'SMART_SOCKET', status: 'ONLINE' + deviceType: 'SMART_SOCKET', status: 'ONLINE', model: 'JL-SOCKET-16A', + capabilities: ['POWER', 'METERING', 'RATED_16A'] }], []]; } return [[{ @@ -80,7 +81,15 @@ await service.controlSubLock(context, { 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' }); +assert.deepEqual(issued.at(-1).payload, { read: 'workInfo', slotNum: 1 }); +await service.readSmartSocket(context, { + target: 'localtask', slotNum: 2, taskNum: 0 +}); +assert.deepEqual(issued.at(-1).payload, { + read: 'localtask', slotNum: 2, taskNum: 0 +}); +await service.readSmartSocket(context, { target: 'mqttConfig' }); +assert.deepEqual(issued.at(-1).payload, { read: 'mqttConfig' }); await service.switchSmartSocket(context, { on: false, slotNum: 2 }); assert.deepEqual(issued.at(-1).payload, { action: 'off', id: '1782120000001', slotNum: 2 @@ -89,10 +98,38 @@ await service.scheduleSmartSocket(context, { taskNum: 1, action: 'on', mode: 'weekly', time: '18:30', weekdays: [5, 6] }); assert.equal(issued.at(-1).payload.action, 'localtask'); -assert.equal(issued.at(-1).payload.switch, 'on'); +assert.deepEqual(issued.at(-1).payload, { + action: 'localtask', slotNum: 1, taskNum: 1, tasktype: 'on', + cyctype: 'weekcyc', weekdata: '0000110', actiontime: '1830', + id: '1782120000001' +}); await service.clearSmartSocketTask(context, 1); -assert.equal(issued.at(-1).payload.action, 'clearTask'); -assert.equal(audits.length, 15); +assert.deepEqual(issued.at(-1).payload, { + action: 'clearTask', slotNum: 1, taskNum: 1, id: '1782120000001' +}); +await service.configureSmartSocketProtection(context, { + maxPower: 3500, maxCurrent: 15, maxTemperature: 80, + pullOutStop: 1, pullOutPower: 5, pullOutSec: 10, + chargeFullStop: 1, chargeFullPower: 8, chargeFullSec: 30 +}); +assert.equal(issued.at(-1).payload.setting, 'protectOff'); +assert.equal('ratedAmps' in issued.at(-1).payload, false); +await assert.rejects( + () => service.configureSmartSocketProtection(context, { + maxPower: 3500, maxCurrent: 17, maxTemperature: 80, + pullOutStop: 1, pullOutPower: 5, pullOutSec: 10, + chargeFullStop: 1, chargeFullPower: 8, chargeFullSec: 30 + }), + /16A capability/ +); +await service.configureSmartSocketParameter(context, { + resetHold: 1, keyLock: 0, keyOff: 1 +}); +assert.deepEqual(issued.at(-1).payload, { + setting: 'parameter', resetHold: 1, keyLock: 0, KeyOff: 1, + id: '1782120000001' +}); +assert.equal(audits.length, 20); assert.match(audits[0], /DEVICE_COMMAND_REQUESTED/); await assert.rejects( @@ -128,6 +165,50 @@ await service.controlSubLock(platformContext, { assert.equal(issued.at(-1).payload.value, 'all'); assert.equal(issued.at(-1).payload.subtype, '15'); +await assert.rejects( + () => service.emptySmartSocketEnergy(context, 'CONFIRM_EMPTY_SOCKET_ENERGY'), + (error) => error instanceof DeviceControlError + && error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN' +); +await service.emptySmartSocketEnergy(platformContext, 'CONFIRM_EMPTY_SOCKET_ENERGY'); +assert.deepEqual(issued.at(-1).payload, { + action: 'emptyPower', id: '1782120000001' +}); +await assert.rejects( + () => service.resetSmartSocket(platformContext, { + password: 'return-secret', dangerConfirmation: 'WRONG' + }), + (error) => error instanceof DeviceControlError + && error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN' +); +await service.resetSmartSocket(platformContext, { + password: 'return-secret', dangerConfirmation: 'CONFIRM_SOCKET_FACTORY_RESET' +}); +assert.equal(issued.at(-1).payload.action, 'returnFactory'); +assert.equal(issued.at(-1).payload.password, 'return-secret'); +assert.equal('dangerConfirmation' in issued.at(-1).payload, false); +await service.changeSmartSocketReturnKey(platformContext, { + old: 'old-secret', new: 'new-secret', + dangerConfirmation: 'CONFIRM_CHANGE_SOCKET_RETURN_KEY' +}); +assert.deepEqual(issued.at(-1).payload, { + action: 'setReturnKey', old: 'old-secret', new: 'new-secret', + id: '1782120000001' +}); +await assert.rejects( + () => service.clearSmartSocketTask(context, { + taskNum: 0, dangerConfirmation: 'CONFIRM_CLEAR_ALL_SOCKET_TASKS' + }), + (error) => error instanceof DeviceControlError + && error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN' +); +await service.clearSmartSocketTask(platformContext, { + slotNum: 2, taskNum: 0, dangerConfirmation: 'CONFIRM_CLEAR_ALL_SOCKET_TASKS' +}); +assert.deepEqual(issued.at(-1).payload, { + action: 'clearTask', slotNum: 2, taskNum: 0, id: '1782120000001' +}); + await assert.rejects( () => service.controlPower({ ...context, @@ -180,10 +261,16 @@ const app = await buildApp({ async cancelTask() { return {}; }, async pairSubLock() { return {}; }, async controlSubLock() { return {}; }, - async readSmartSocket() { return {}; }, + async readSmartSocket(_context, input) { routed = input; return {}; }, async switchSmartSocket(_context, input) { routed = input; return { status: 'PUBLISHED' }; }, async scheduleSmartSocket() { return {}; }, - async clearSmartSocketTask() { return {}; } + async clearSmartSocketTask() { return {}; }, + async rebootSmartSocket() { return {}; }, + async emptySmartSocketEnergy() { return {}; }, + async resetSmartSocket(_context, input) { routed = input; return {}; }, + async changeSmartSocketReturnKey() { return {}; }, + async configureSmartSocketProtection(_context, input) { routed = input; return {}; }, + async configureSmartSocketParameter() { return {}; } } } }); @@ -222,6 +309,44 @@ const socketResponse = await app.inject({ }); assert.equal(socketResponse.statusCode, 200); assert.equal(routed.on, true); +const socketTaskRead = await app.inject({ + method: 'POST', + url: '/admin-api/device-control/socket/read', + headers: { authorization: `Bearer ${token}` }, + payload: { storeId: '11', roomId: '31', target: 'localtask', slotNum: 1, taskNum: 0 } +}); +assert.equal(socketTaskRead.statusCode, 200); +assert.equal(routed.target, 'localtask'); +assert.equal(routed.taskNum, 0); +const invalidSocketTaskRead = await app.inject({ + method: 'POST', + url: '/admin-api/device-control/socket/read', + headers: { authorization: `Bearer ${token}` }, + payload: { storeId: '11', roomId: '31', target: 'localtask' } +}); +assert.equal(invalidSocketTaskRead.statusCode, 400); +const invalidSocketTask = await app.inject({ + method: 'POST', + url: '/admin-api/device-control/socket/task', + headers: { authorization: `Bearer ${token}` }, + payload: { + storeId: '11', roomId: '31', taskNum: 1, action: 'on', + mode: 'weekly', time: '24:00', weekdays: [1] + } +}); +assert.equal(invalidSocketTask.statusCode, 400); +const socketProtection = await app.inject({ + method: 'POST', + url: '/admin-api/device-control/socket/protection', + headers: { authorization: `Bearer ${token}` }, + payload: { + storeId: '11', roomId: '31', maxPower: 3500, maxCurrent: 15, + maxTemperature: 80, pullOutStop: 1, pullOutPower: 5, pullOutSec: 10, + chargeFullStop: 1, chargeFullPower: 8, chargeFullSec: 30 + } +}); +assert.equal(socketProtection.statusCode, 200); +assert.equal(routed.maxCurrent, 15); const subLockResponse = await app.inject({ method: 'POST', url: '/admin-api/device-control/sub-lock/action', @@ -274,4 +399,4 @@ assert.equal(routedContext.traceId, 'm08a-open-door'); assert.deepEqual(routedContext.access.capabilities, ['device.write']); await app.close(); -console.log('PASS: M06-E device control commands include control-box, Sub-1G and smart socket.'); +console.log('PASS: M06-E-R1 socket controls, capability limits and dangerous gates work.'); diff --git a/backend/tests/iot-protocol.test.mjs b/backend/tests/iot-protocol.test.mjs index 08b6ae1..2cccc4f 100644 --- a/backend/tests/iot-protocol.test.mjs +++ b/backend/tests/iot-protocol.test.mjs @@ -73,17 +73,69 @@ assert.throws( ); const socket = new JilianSmartSocketAdapter(); -assert.deepEqual(socket.read('workInfo'), { read: 'workInfo' }); +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.equal(socket.localTask({ +assert.deepEqual(socket.localTask({ id: '123', taskNum: 20, action: 'off', mode: 'weekly', time: '23:00', weekdays: [1, 5] -}).action, 'localtask'); -assert.deepEqual(socket.clearTask({ id: '123', taskNum: 0 }), { - action: 'clearTask', id: '123', taskNum: 0 +}), { + 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' @@ -116,6 +168,39 @@ for (const eventType of ['magstate', 'taskfinish', 'Poweron', 'connected']) { 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}$/); @@ -240,21 +325,68 @@ assert.equal(calls.some((item) => && 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.some((item) => +assert.equal(calls.filter((item) => item.sql.includes('INSERT INTO qipai_device_alerts') - && item.params.includes('SOCKET_OVERLOAD') -), true); +).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('INSERT INTO qipai_device_alerts') - && item.params.includes('SOCKET_POWER_LIMIT') -), true); -assert.equal(calls.some((item) => - item.sql.includes('INSERT INTO qipai_device_alerts') - && item.params.includes('SOCKET_TEMPERATURE_LIMIT') + item.sql.includes('qipai_iot_dead_letters') + && item.params.includes('SOCKET_TEMPERATURE_CONFLICT') ), true); await assert.rejects( @@ -280,6 +412,19 @@ const secretCommandInsert = calls.findLast((item) => 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]); }, @@ -304,4 +449,4 @@ assert.equal(commandCalls[0][0], 'create'); assert.equal(commandCalls[1][0], 'mqtt'); assert.equal(commandCalls[2][0], 'published'); -console.log('PASS: M06-C adapters, vendor spelling, command IDs, ACKs and QoS 1 dedup work.'); +console.log('PASS: M06-C/E-R1 exact wire, normalized events, ACKs and QoS 1 dedup work.'); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 826acfd..3286189 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -1934,8 +1934,8 @@ async function assertDeviceTopology(pool, context) { const socket = await repository.createAsset(actor, { storeId, roomId, deviceId: 'M06B_SOCKET_001', imei: '860000000000002', iccid: '89860000000000000002', deviceType: 'SMART_SOCKET', - model: 'JL-SOCKET', firmwareVersion: '1.0.0', signalStrength: 16, - capabilities: ['POWER', 'METERING'] + model: 'JL-SOCKET-16A', firmwareVersion: '1.0.0', signalStrength: 16, + capabilities: ['POWER', 'METERING', 'RATED_16A'] }); const lock = await repository.createAsset(actor, { storeId, roomId, deviceId: 'M06B_LOCK_001', imei: '', @@ -2281,6 +2281,150 @@ async function assertIotMessages(pool, context) { [context.tenantId, `/devicewill/${device.deviceId}`] ); assert.equal(Number(willDeadRows[0].total), 0); + + const [socketRows] = await pool.query( + `SELECT id, store_id AS storeId, room_id AS roomId, device_id AS deviceId + FROM qipai_devices + WHERE tenant_id = ? AND device_id = 'M06B_SOCKET_001'`, + [context.tenantId] + ); + const socket = socketRows[0]; + assert.ok(socket, 'M06-E-R1 requires the smart socket fixture.'); + await service.handle(`/devicesend/${socket.deviceId}`, Buffer.from(JSON.stringify({ + DeviceID: socket.deviceId, + read: 'workInfo', + slotNum: 1, + onOff: 'on', + vol: '0220.5000', + cur: '01.250', + pow: '00275.000', + temp: '45.50', + energy: '0012.3400', + timestamp: 1782120020 + }))); + const [workInfoRows] = await pool.query( + `SELECT JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.voltage')) AS voltage, + JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.current')) AS currentValue, + JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.power')) AS powerValue, + JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.temperature')) AS temperature, + JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.energy')) AS energy, + JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.switchOn')) AS switchOn + FROM qipai_iot_device_events + WHERE tenant_id = ? AND device_id = ? AND event_type = 'workInfo'`, + [context.tenantId, socket.id] + ); + assert.deepEqual(workInfoRows, [{ + voltage: '220.5', currentValue: '1.25', powerValue: '275', + temperature: '45.5', energy: '12.34', switchOn: 'true' + }]); + + const socketResultFixtures = [ + ['1782120000008', 'off', 'update', 'DEVICE_UPDATE'], + ['1782120000009', 'off', 'paraerror', 'DEVICE_PARAERROR'], + ['1782120000010', 'localtask', 'error', 'DEVICE_ERROR'] + ]; + for (const [commandId, action, result, failureCode] of socketResultFixtures) { + await service.createCommand({ + tenantId: context.tenantId, + assetId: String(socket.id), + storeId: String(socket.storeId), + roomId: String(socket.roomId), + commandId, + commandType: action, + payload: { action, id: commandId, slotNum: 1 }, + traceId: `m06e-${result}` + }); + await service.markPublished(context.tenantId, commandId); + await service.handle(`/devicesend/${socket.deviceId}`, Buffer.from(JSON.stringify({ + DeviceID: socket.deviceId, id: commandId, action, result + }))); + const [resultRows] = await pool.query( + `SELECT status, failure_code AS failureCode + FROM qipai_iot_commands WHERE tenant_id = ? AND command_id = ?`, + [context.tenantId, commandId] + ); + assert.deepEqual(resultRows, [{ status: 'FAILED', failureCode }]); + } + + for (let closeReason = 1; closeReason <= 7; closeReason += 1) { + await service.handle(`/devicesend/${socket.deviceId}`, Buffer.from(JSON.stringify({ + DeviceID: socket.deviceId, + event: 'special', + slotNum: 1, + state: 'off', + closeReason, + energy: `${closeReason}.0`, + timestamp: 1782120030 + closeReason + }))); + } + const [socketAlertRows] = await pool.query( + `SELECT alert_type AS alertType + FROM qipai_device_alerts + WHERE tenant_id = ? AND device_id = ? + AND alert_type IN ('SOCKET_PULL_OUT_STOP', 'SOCKET_CHARGE_FULL_STOP', + 'SOCKET_OVERPOWER', 'SOCKET_OVERCURRENT', 'SOCKET_OVERTEMPERATURE') + ORDER BY alert_type`, + [context.tenantId, socket.id] + ); + assert.deepEqual(socketAlertRows, [ + { alertType: 'SOCKET_CHARGE_FULL_STOP' }, + { alertType: 'SOCKET_OVERCURRENT' }, + { alertType: 'SOCKET_OVERPOWER' }, + { alertType: 'SOCKET_OVERTEMPERATURE' }, + { alertType: 'SOCKET_PULL_OUT_STOP' } + ]); + + await service.handle(`/devicesend/${socket.deviceId}`, Buffer.from(JSON.stringify({ + DeviceID: socket.deviceId, + read: 'workInfo', + temp: '45', + temperature: '46', + timestamp: 1782120040 + }))); + const [conflictRows] = await pool.query( + `SELECT processing_status AS processingStatus, + JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.temperature')) AS temperature + FROM qipai_iot_device_events + WHERE tenant_id = ? AND device_id = ? AND payload_hash = SHA2(?, 256)`, + [context.tenantId, socket.id, JSON.stringify({ + DeviceID: socket.deviceId, + read: 'workInfo', temp: '45', temperature: '46', timestamp: 1782120040 + })] + ); + assert.deepEqual(conflictRows, [{ processingStatus: 'PROTOCOL_ERROR', temperature: '45' }]); + const [conflictDeadRows] = await pool.query( + `SELECT error_code AS errorCode FROM qipai_iot_dead_letters + WHERE tenant_id = ? AND device_id = ? AND error_code = 'SOCKET_TEMPERATURE_CONFLICT'`, + [context.tenantId, socket.id] + ); + assert.deepEqual(conflictDeadRows, [{ errorCode: 'SOCKET_TEMPERATURE_CONFLICT' }]); + + await service.createCommand({ + tenantId: context.tenantId, + assetId: String(socket.id), + storeId: String(socket.storeId), + roomId: String(socket.roomId), + commandId: '1782120000011', + commandType: 'setReturnKey', + payload: { + action: 'setReturnKey', id: '1782120000011', + old: 'old-return-secret', new: 'new-return-secret' + }, + traceId: 'm06e-return-key-redaction' + }); + const [returnKeyRows] = await pool.query( + `SELECT JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.old')) AS oldValue, + JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.new')) AS newValue, + JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.oldHash')) AS oldHash, + JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.newHash')) AS newHash + FROM qipai_iot_commands + WHERE tenant_id = ? AND command_id = '1782120000011'`, + [context.tenantId] + ); + assert.equal(returnKeyRows[0].oldValue, null); + assert.equal(returnKeyRows[0].newValue, null); + assert.match(returnKeyRows[0].oldHash, /^[a-f0-9]{64}$/); + assert.match(returnKeyRows[0].newHash, /^[a-f0-9]{64}$/); } async function assertCleaningTaskTransactions(pool, context) { @@ -2913,8 +3057,12 @@ try { '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' - , + 'raw devicewill close marks the device offline without invalid-payload dead-letter', + 'smart socket workInfo exact-decimal normalization', + 'smart socket update paraerror and error command failures', + 'special closeReason 3 through 7 alerts without 1 through 2 false alarms', + 'conflicting temp and temperature protocol dead-letter', + 'smart socket return-key request hashing and masking', 'concurrent cleaning claim has one winner', 'cleaning state and event transaction rollback', 'WAITING CLAIMED and REJECTED reassignment cleanup',