fix(M06-E-R1): 校正智慧插座协议

This commit is contained in:
Codex
2026-08-10 19:10:25 +08:00
parent 3ff68d103b
commit aed36f0f82
7 changed files with 876 additions and 113 deletions
+94 -10
View File
@@ -11,7 +11,7 @@ import {
interface DeviceRow extends RowDataPacket { interface DeviceRow extends RowDataPacket {
id: string; deviceId: string; storeId: string; roomId: string | null; id: string; deviceId: string; storeId: string; roomId: string | null;
deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET'; deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
status: string; status: string; model?: string | null; capabilities?: unknown;
} }
export class DeviceControlError extends Error { export class DeviceControlError extends Error {
@@ -115,9 +115,13 @@ export class DeviceControlService {
(id) => this.subLock.control({ id, ...vendorInput })); (id) => this.subLock.control({ id, ...vendorInput }));
} }
async readSmartSocket(context: CommandContext, target: 'basicInfo' | 'workInfo') { async readSmartSocket(context: CommandContext, input: 'basicInfo' | 'workInfo' | {
return this.issueSmartSocket(context, `socket:${target}`, target: 'basicInfo' | 'workInfo' | 'localtask' | 'mqttConfig';
() => this.smartSocket.read(target)); 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: { async switchSmartSocket(context: CommandContext, input: {
@@ -129,16 +133,77 @@ export class DeviceControlService {
} }
async scheduleSmartSocket(context: CommandContext, input: { 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[]; time: string; weekdays?: number[];
}) { }) {
return this.issueSmartSocket(context, 'localtask', return this.issueSmartSocket(context, 'localtask',
(id) => this.smartSocket.localTask({ id, ...input }), context.orderId); (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', 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( private async issueControlBox(
@@ -168,7 +233,7 @@ export class DeviceControlService {
private async issueSmartSocket( private async issueSmartSocket(
context: CommandContext, context: CommandContext,
commandType: string, commandType: string,
payloadFactory: (id: string) => Record<string, unknown>, payloadFactory: (id: string, device: DeviceRow) => Record<string, unknown>,
orderId?: string | null orderId?: string | null
) { ) {
this.assertWriteScope(context.access, context.storeId); this.assertWriteScope(context.access, context.storeId);
@@ -183,7 +248,7 @@ export class DeviceControlService {
roomId: context.roomId, roomId: context.roomId,
orderId, orderId,
commandType, commandType,
payloadFactory, payloadFactory: (id) => payloadFactory(id, device),
traceId: context.traceId, traceId: context.traceId,
expiresAt: context.expiresAt expiresAt: context.expiresAt
}); });
@@ -192,7 +257,7 @@ export class DeviceControlService {
private async resolveControlBox(context: CommandContext) { private async resolveControlBox(context: CommandContext) {
const [rows] = await this.pool.execute<DeviceRow[]>( const [rows] = await this.pool.execute<DeviceRow[]>(
`SELECT id, device_id AS deviceId, store_id AS storeId, room_id AS roomId, `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 FROM qipai_devices
WHERE tenant_id = ? AND store_id = ? AND room_id = ? WHERE tenant_id = ? AND store_id = ? AND room_id = ?
AND device_type = 'CONTROL_BOX' AND deleted_at IS NULL 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( private async auditCommand(
context: CommandContext, context: CommandContext,
device: DeviceRow, device: DeviceRow,
@@ -277,3 +352,12 @@ export interface CommandContext {
access: AccessProfile; access: AccessProfile;
expiresAt?: Date | null; 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');
}
+43 -52
View File
@@ -78,9 +78,13 @@ export class IotMessageService {
return; return;
} }
const safePayload = sanitizeSensitivePayload(rawPayload, normalized); const safeRawPayload = sanitizeSensitivePayload(rawPayload, normalized);
normalized = { ...normalized, payload: safePayload }; const safeNormalizedPayload = sanitizeSensitivePayload(normalized.payload, normalized);
const processingStatus = normalized.result === 'UNKNOWN_VENDOR_RESULT' normalized = { ...normalized, payload: safeNormalizedPayload };
const protocolCode = normalized.result === 'UNKNOWN_VENDOR_RESULT'
? 'UNKNOWN_VENDOR_RESULT'
: normalized.protocolIssues[0] ?? null;
const processingStatus = protocolCode
? 'PROTOCOL_ERROR' ? 'PROTOCOL_ERROR'
: 'PROCESSED'; : 'PROCESSED';
const [result] = await this.pool.execute<ResultSetHeader>( const [result] = await this.pool.execute<ResultSetHeader>(
@@ -92,18 +96,20 @@ export class IotMessageService {
received_at = UTC_TIMESTAMP(3)`, received_at = UTC_TIMESTAMP(3)`,
[device.tenantId, device.id, device.storeId, device.roomId, [device.tenantId, device.id, device.storeId, device.roomId,
normalized.commandId, topic, normalized.eventType, payloadHash, normalized.commandId, topic, normalized.eventType, payloadHash,
JSON.stringify(safePayload), JSON.stringify(normalized), JSON.stringify(safeRawPayload), JSON.stringify(normalized),
normalized.eventAt, processingStatus] normalized.eventAt, processingStatus]
); );
if (result.affectedRows !== 1) return; if (result.affectedRows !== 1) return;
await this.updateDevice(device, normalized, safePayload); await this.updateDevice(device, normalized, safeNormalizedPayload);
await this.applyAlerts(device, normalized); await this.applyAlerts(device, normalized);
if (normalized.result === 'UNKNOWN_VENDOR_RESULT') { if (protocolCode) {
await this.deadLetter( await this.deadLetter(
device.tenantId, device.id, topic, payloadHash, JSON.stringify(safePayload), device.tenantId, device.id, topic, payloadHash, JSON.stringify(safeRawPayload),
'UNKNOWN_VENDOR_RESULT', protocolCode,
`Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? '<missing>'}` protocolCode === 'UNKNOWN_VENDOR_RESULT'
? `Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? '<missing>'}`
: `Protocol issue for ${normalized.eventType}: ${protocolCode}`
); );
} }
if (normalized.kind === 'ACK' && normalized.commandId) { if (normalized.kind === 'ACK' && normalized.commandId) {
@@ -232,6 +238,10 @@ export class IotMessageService {
await this.upsertAlert(device, 'UNKNOWN_VENDOR_RESULT', 'MEDIUM', await this.upsertAlert(device, 'UNKNOWN_VENDOR_RESULT', 'MEDIUM',
`Unsupported vendor result for ${message.eventType}.`); `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 ?? '') const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '')
? `DEVICE_${message.result?.toUpperCase()}` ? `DEVICE_${message.result?.toUpperCase()}`
: null; : null;
@@ -251,41 +261,17 @@ export class IotMessageService {
} }
private async applySmartSocketAlerts(device: DeviceRow, message: NormalizedVendorMessage) { private async applySmartSocketAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
const payload = message.payload; if (message.eventType !== 'special') return;
const eventType = message.eventType.toLowerCase(); const closeReason = readInteger(message.payload.closeReason);
const triggered = (names: string[]) => names.some((name) => const alerts: Record<number, [string, string]> = {
eventType === name.toLowerCase() || readBoolean(payload[name]) 3: ['SOCKET_PULL_OUT_STOP', 'Smart socket stopped after appliance removal.'],
); 4: ['SOCKET_CHARGE_FULL_STOP', 'Smart socket stopped after full charge.'],
if (triggered(['overload', 'overLoad', 'overpower', 'overPower'])) { 5: ['SOCKET_OVERPOWER', 'Smart socket stopped on maximum power protection.'],
await this.upsertAlert(device, 'SOCKET_OVERLOAD', 'HIGH', 6: ['SOCKET_OVERCURRENT', 'Smart socket stopped on maximum current protection.'],
'Smart socket reported overload or overpower protection.'); 7: ['SOCKET_OVERTEMPERATURE', 'Smart socket stopped on maximum temperature protection.']
} };
if (triggered(['overheat', 'overHeat', 'overTemperature'])) { const alert = closeReason === null ? undefined : alerts[closeReason];
await this.upsertAlert(device, 'SOCKET_OVERHEAT', 'HIGH', if (alert) await this.upsertAlert(device, alert[0], 'HIGH', alert[1]);
'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.`);
}
} }
private async upsertAlert( private async upsertAlert(
@@ -348,7 +334,8 @@ function normalizeWill(payload: unknown): NormalizedVendorMessage {
rawResult: null, rawResult: null,
deviceId: readDeviceId(record), deviceId: readDeviceId(record),
eventAt: null, eventAt: null,
payload: record payload: record,
protocolIssues: []
}; };
} }
@@ -401,6 +388,14 @@ function sanitizeSensitivePayload(
addMaskedSecret(record, 'card', record.card); addMaskedSecret(record, 'card', record.card);
record.card = '<redacted>'; record.card = '<redacted>';
} }
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; return record;
} }
@@ -433,11 +428,7 @@ function readNumber(value: unknown): number | null {
return null; return null;
} }
function readBoolean(value: unknown): boolean { function readInteger(value: unknown): number | null {
if (typeof value === 'boolean') return value; const number = readNumber(value);
if (typeof value === 'number') return value !== 0; return number !== null && Number.isInteger(number) ? number : null;
if (typeof value === 'string') {
return ['1', 'true', 'yes', 'on', 'alarm'].includes(value.trim().toLowerCase());
}
return false;
} }
+225 -17
View File
@@ -30,6 +30,7 @@ export type NormalizedVendorMessage = {
deviceId: string | null; deviceId: string | null;
eventAt: Date | null; eventAt: Date | null;
payload: Record<string, unknown>; payload: Record<string, unknown>;
protocolIssues: string[];
}; };
export interface ProtocolAdapter { export interface ProtocolAdapter {
@@ -206,8 +207,25 @@ export class JilianSub1GLockAdapter implements ProtocolAdapter {
} }
export class JilianSmartSocketAdapter implements ProtocolAdapter { export class JilianSmartSocketAdapter implements ProtocolAdapter {
read(target: 'basicInfo' | 'workInfo') { read(input: 'basicInfo' | 'workInfo' | {
return z.object({ read: z.literal(target) }).parse({ read: target }); 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 }) { switch(input: { id: string; on: boolean; slotNum?: number }) {
@@ -218,29 +236,130 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter {
} }
localTask(input: { 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[]; mode: 'once' | 'daily' | 'weekly'; time: string; weekdays?: number[];
}) { }) {
return z.object({ const domain = z.object({
action: z.literal('localtask'), id: commandId, id: commandId,
slotNum: z.number().int().min(1).max(20).default(1),
taskNum: z.number().int().min(1).max(20), taskNum: z.number().int().min(1).max(20),
switch: z.enum(['on', 'off']), action: z.enum(['on', 'off']),
mode: z.enum(['once', 'daily', 'weekly']), mode: z.enum(['once', 'daily', 'weekly']),
time: z.string().regex(/^\d{2}:\d{2}$/), time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional() weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7)
}).parse({ .refine((items) => new Set(items).size === items.length, 'weekdays must be unique')
action: 'localtask', id: input.id, taskNum: input.taskNum, .optional()
switch: input.action, mode: input.mode, time: input.time, weekdays: input.weekdays }).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({ return z.object({
action: z.literal('clearTask'), id: commandId, action: z.literal('clearTask'), id: commandId,
slotNum: z.number().int().min(1).max(20).default(1),
taskNum: z.number().int().min(0).max(20) taskNum: z.number().int().min(0).max(20)
}).parse({ action: 'clearTask', ...input }); }).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) { parseUplink(payload: unknown) {
return normalizeVendorMessage(payload); return normalizeVendorMessage(payload);
} }
@@ -248,9 +367,13 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter {
function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage { function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage {
const parsed = vendorMessage.parse(payload); const parsed = vendorMessage.parse(payload);
const record = normalizeReceiveVariants(parsed as Record<string, unknown>); const protocolIssues: string[] = [];
const variants = normalizeReceiveVariants(
parsed as Record<string, unknown>, protocolIssues
);
const rawEventName = parsed.event ?? parsed.action ?? parsed.read ?? parsed.setting ?? 'snapshot'; const rawEventName = parsed.event ?? parsed.action ?? parsed.read ?? parsed.setting ?? 'snapshot';
const eventName = canonicalMessageName(rawEventName); const eventName = canonicalMessageName(rawEventName);
const record = normalizeSocketStatus(variants, eventName, protocolIssues);
const rawResult = parsed.result ?? null; const rawResult = parsed.result ?? null;
const result = normalizeResult(eventName, rawResult); const result = normalizeResult(eventName, rawResult);
return { return {
@@ -261,7 +384,8 @@ function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage {
rawResult, rawResult,
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null, deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
eventAt: parseEventAt(parsed.timestamp), eventAt: parseEventAt(parsed.timestamp),
payload: record payload: record,
protocolIssues
}; };
} }
@@ -278,7 +402,18 @@ function normalizeResult(
addtask: ['ok', 'fail'], addtask: ['ok', 'fail'],
canceltask: ['ok'], canceltask: ['ok'],
AddDevice: ['ok', 'fail', 'timeout'], 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]; const allowed = allowedByCommand[eventName];
return allowed && !allowed.includes(parsed.data) ? 'UNKNOWN_VENDOR_RESULT' : parsed.data; 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; return value.toLowerCase() === 'mqttconfig' ? 'mqttConfig' : value;
} }
function normalizeReceiveVariants(record: Record<string, unknown>): Record<string, unknown> { function normalizeReceiveVariants(
record: Record<string, unknown>, protocolIssues: string[]
): Record<string, unknown> {
const normalized = { ...record }; const normalized = { ...record };
if (normalized.welcomevoice === undefined && normalized.welvoice !== undefined) { if (normalized.welcomevoice === undefined && normalized.welvoice !== undefined) {
normalized.welcomevoice = normalized.welvoice; normalized.welcomevoice = normalized.welvoice;
} }
if (normalized.setting === 'mqttconfig') normalized.setting = 'mqttConfig'; if (normalized.setting === 'mqttconfig') normalized.setting = 'mqttConfig';
if (normalized.read === 'mqttconfig') normalized.read = '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; return normalized;
} }
function normalizeSocketStatus(
record: Record<string, unknown>, eventName: string, protocolIssues: string[]
): Record<string, unknown> {
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<string, string> {
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 { function parseEventAt(value: string | number | undefined): Date | null {
if (value === undefined) return null; if (value === undefined) return null;
const date = typeof value === 'number' const date = typeof value === 'number'
+69 -7
View File
@@ -68,21 +68,67 @@ const subLockSchema = contextSchema.extend({
dangerConfirmation: z.string().max(64).optional() dangerConfirmation: z.string().max(64).optional()
}); });
const socketReadSchema = contextSchema.extend({ 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({ const socketSwitchSchema = contextSchema.extend({
on: z.boolean(), on: z.boolean(),
slotNum: z.number().int().min(1).max(20).default(1) slotNum: z.number().int().min(1).max(20).default(1)
}); });
const socketTaskSchema = contextSchema.extend({ const socketTaskSchema = contextSchema.extend({
slotNum: z.number().int().min(1).max(20).default(1),
taskNum: z.number().int().min(1).max(20), taskNum: z.number().int().min(1).max(20),
action: z.enum(['on', 'off']), action: z.enum(['on', 'off']),
mode: z.enum(['once', 'daily', 'weekly']), mode: z.enum(['once', 'daily', 'weekly']),
time: z.string().regex(/^\d{2}:\d{2}$/), time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional() 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({ 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 orderParams = z.object({ orderId: id });
const customerOpenDoorSchema = z.object({ const customerOpenDoorSchema = z.object({
@@ -94,7 +140,9 @@ export interface DeviceControlRouteOptions {
'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed' 'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed'
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock' | 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket' | 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
| 'clearSmartSocketTask'>; | 'clearSmartSocketTask' | 'rebootSmartSocket' | 'emptySmartSocketEnergy'
| 'resetSmartSocket' | 'changeSmartSocketReturnKey'
| 'configureSmartSocketProtection' | 'configureSmartSocketParameter'>;
customerAccess?: Pick<CustomerDeviceAccessRepository, 'getDoorContext'>; customerAccess?: Pick<CustomerDeviceAccessRepository, 'getDoorContext'>;
authRepository: Pick<AuthRepository, 'validateSession'>; authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> }; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
@@ -125,13 +173,27 @@ export async function registerDeviceControlRoutes(
registerManagement('/sub-lock/action', subLockSchema, registerManagement('/sub-lock/action', subLockSchema,
(context, body) => options.service.controlSubLock(context, body)); (context, body) => options.service.controlSubLock(context, body));
registerManagement('/socket/read', socketReadSchema, registerManagement('/socket/read', socketReadSchema,
(context, body) => options.service.readSmartSocket(context, body.target)); (context, body) => options.service.readSmartSocket(context, body));
registerManagement('/socket/switch', socketSwitchSchema, registerManagement('/socket/switch', socketSwitchSchema,
(context, body) => options.service.switchSmartSocket(context, body)); (context, body) => options.service.switchSmartSocket(context, body));
registerManagement('/socket/task', socketTaskSchema, registerManagement('/socket/task', socketTaskSchema,
(context, body) => options.service.scheduleSmartSocket(context, body)); (context, body) => options.service.scheduleSmartSocket(context, body));
registerManagement('/socket/task/clear', socketClearTaskSchema, 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) => { app.post('/app-api/orders/:orderId/open-door', async (request, reply) => {
if (!options.customerAccess) { if (!options.customerAccess) {
+133 -8
View File
@@ -19,7 +19,8 @@ const service = new DeviceControlService({
if (sql.includes("device_type = 'SMART_SOCKET'")) { if (sql.includes("device_type = 'SMART_SOCKET'")) {
return [[{ return [[{
id: 52, deviceId: 'SOCKET_001', storeId: 11, roomId: 31, 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 [[{ return [[{
@@ -80,7 +81,15 @@ await service.controlSubLock(context, {
assert.equal(issued.at(-1).payload.value, '123456654321'); assert.equal(issued.at(-1).payload.value, '123456654321');
assert.equal('content' in issued.at(-1).payload, false); assert.equal('content' in issued.at(-1).payload, false);
await service.readSmartSocket(context, 'workInfo'); 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 }); await service.switchSmartSocket(context, { on: false, slotNum: 2 });
assert.deepEqual(issued.at(-1).payload, { assert.deepEqual(issued.at(-1).payload, {
action: 'off', id: '1782120000001', slotNum: 2 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] 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.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); await service.clearSmartSocketTask(context, 1);
assert.equal(issued.at(-1).payload.action, 'clearTask'); assert.deepEqual(issued.at(-1).payload, {
assert.equal(audits.length, 15); 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/); assert.match(audits[0], /DEVICE_COMMAND_REQUESTED/);
await assert.rejects( 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.value, 'all');
assert.equal(issued.at(-1).payload.subtype, '15'); 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( await assert.rejects(
() => service.controlPower({ () => service.controlPower({
...context, ...context,
@@ -180,10 +261,16 @@ const app = await buildApp({
async cancelTask() { return {}; }, async cancelTask() { return {}; },
async pairSubLock() { return {}; }, async pairSubLock() { return {}; },
async controlSubLock() { return {}; }, async controlSubLock() { return {}; },
async readSmartSocket() { return {}; }, async readSmartSocket(_context, input) { routed = input; return {}; },
async switchSmartSocket(_context, input) { routed = input; return { status: 'PUBLISHED' }; }, async switchSmartSocket(_context, input) { routed = input; return { status: 'PUBLISHED' }; },
async scheduleSmartSocket() { return {}; }, 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(socketResponse.statusCode, 200);
assert.equal(routed.on, true); 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({ const subLockResponse = await app.inject({
method: 'POST', method: 'POST',
url: '/admin-api/device-control/sub-lock/action', 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']); assert.deepEqual(routedContext.access.capabilities, ['device.write']);
await app.close(); 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.');
+160 -15
View File
@@ -73,17 +73,69 @@ assert.throws(
); );
const socket = new JilianSmartSocketAdapter(); 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 }), { assert.deepEqual(socket.switch({ id: '123', on: true }), {
action: 'on', id: '123', slotNum: 1 action: 'on', id: '123', slotNum: 1
}); });
assert.equal(socket.localTask({ assert.deepEqual(socket.localTask({
id: '123', taskNum: 20, action: 'off', mode: 'weekly', id: '123', taskNum: 20, action: 'off', mode: 'weekly',
time: '23:00', weekdays: [1, 5] time: '23:00', weekdays: [1, 5]
}).action, 'localtask'); }), {
assert.deepEqual(socket.clearTask({ id: '123', taskNum: 0 }), { action: 'localtask', slotNum: 1, taskNum: 20, tasktype: 'off',
action: 'clearTask', id: '123', taskNum: 0 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({ const ack = control.parseUplink({
DeviceID: 'BOX_001', id: '123', result: 'unconfirm', action: 'task' 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.kind, 'EVENT');
assert.equal(fixture.eventType, eventType); 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.equal(generateCommandId(1782120000000, 7), '1782120000007');
assert.match(generateCommandId(), /^\d{13}$/); assert.match(generateCommandId(), /^\d{13}$/);
@@ -240,21 +325,68 @@ assert.equal(calls.some((item) =>
&& item.params[2] === 'UNKNOWN_VENDOR_RESULT' && item.params[2] === 'UNKNOWN_VENDOR_RESULT'
), true); ), 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({ await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({
DeviceID: 'SOCKET_001', event: 'workInfo', switch: 'on', DeviceID: 'SOCKET_001', event: 'workInfo', switch: 'on',
powerW: 3601, temperature: 82, overLoad: true 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.sql.includes('INSERT INTO qipai_device_alerts')
&& item.params.includes('SOCKET_OVERLOAD') ).length, socketAlertsBeforeFakeFields, 'legacy fake fields must not generate alerts');
), true);
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) => assert.equal(calls.some((item) =>
item.sql.includes('INSERT INTO qipai_device_alerts') item.sql.includes('qipai_iot_dead_letters')
&& item.params.includes('SOCKET_POWER_LIMIT') && item.params.includes('SOCKET_TEMPERATURE_CONFLICT')
), true);
assert.equal(calls.some((item) =>
item.sql.includes('INSERT INTO qipai_device_alerts')
&& item.params.includes('SOCKET_TEMPERATURE_LIMIT')
), true); ), true);
await assert.rejects( 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('123456'), false);
assert.equal(secretCommandInsert.params[7].includes('valueHash'), true); 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 commandCalls = [];
const commandService = new DeviceCommandService({ const commandService = new DeviceCommandService({
async createCommand(input) { commandCalls.push(['create', input]); }, 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[1][0], 'mqtt');
assert.equal(commandCalls[2][0], 'published'); 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.');
@@ -1934,8 +1934,8 @@ async function assertDeviceTopology(pool, context) {
const socket = await repository.createAsset(actor, { const socket = await repository.createAsset(actor, {
storeId, roomId, deviceId: 'M06B_SOCKET_001', imei: '860000000000002', storeId, roomId, deviceId: 'M06B_SOCKET_001', imei: '860000000000002',
iccid: '89860000000000000002', deviceType: 'SMART_SOCKET', iccid: '89860000000000000002', deviceType: 'SMART_SOCKET',
model: 'JL-SOCKET', firmwareVersion: '1.0.0', signalStrength: 16, model: 'JL-SOCKET-16A', firmwareVersion: '1.0.0', signalStrength: 16,
capabilities: ['POWER', 'METERING'] capabilities: ['POWER', 'METERING', 'RATED_16A']
}); });
const lock = await repository.createAsset(actor, { const lock = await repository.createAsset(actor, {
storeId, roomId, deviceId: 'M06B_LOCK_001', imei: '', storeId, roomId, deviceId: 'M06B_LOCK_001', imei: '',
@@ -2281,6 +2281,150 @@ async function assertIotMessages(pool, context) {
[context.tenantId, `/devicewill/${device.deviceId}`] [context.tenantId, `/devicewill/${device.deviceId}`]
); );
assert.equal(Number(willDeadRows[0].total), 0); 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) { async function assertCleaningTaskTransactions(pool, context) {
@@ -2913,8 +3057,12 @@ try {
'unknown vendor result event dead-letter alert and command failure', 'unknown vendor result event dead-letter alert and command failure',
'CtrlDevice credential request hashing and masking', 'CtrlDevice credential request hashing and masking',
'magstate and taskfinish remain events rather than ACKs', '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', 'concurrent cleaning claim has one winner',
'cleaning state and event transaction rollback', 'cleaning state and event transaction rollback',
'WAITING CLAIMED and REJECTED reassignment cleanup', 'WAITING CLAIMED and REJECTED reassignment cleanup',