fix(M06-E-R1): 校正智慧插座协议
This commit is contained in:
@@ -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<string, unknown>,
|
||||
payloadFactory: (id: string, device: DeviceRow) => Record<string, unknown>,
|
||||
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<DeviceRow[]>(
|
||||
`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');
|
||||
}
|
||||
|
||||
@@ -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<ResultSetHeader>(
|
||||
@@ -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 ?? '<missing>'}`
|
||||
device.tenantId, device.id, topic, payloadHash, JSON.stringify(safeRawPayload),
|
||||
protocolCode,
|
||||
protocolCode === 'UNKNOWN_VENDOR_RESULT'
|
||||
? `Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? '<missing>'}`
|
||||
: `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<number, [string, string]> = {
|
||||
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 = '<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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export type NormalizedVendorMessage = {
|
||||
deviceId: string | null;
|
||||
eventAt: Date | null;
|
||||
payload: Record<string, unknown>;
|
||||
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<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 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<string, unknown>): Record<string, unknown> {
|
||||
function normalizeReceiveVariants(
|
||||
record: Record<string, unknown>, protocolIssues: string[]
|
||||
): Record<string, unknown> {
|
||||
const normalized = { ...record };
|
||||
if (normalized.welcomevoice === undefined && normalized.welvoice !== undefined) {
|
||||
normalized.welcomevoice = normalized.welvoice;
|
||||
}
|
||||
if (normalized.setting === 'mqttconfig') normalized.setting = 'mqttConfig';
|
||||
if (normalized.read === 'mqttconfig') normalized.read = 'mqttConfig';
|
||||
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<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 {
|
||||
if (value === undefined) return null;
|
||||
const date = typeof value === 'number'
|
||||
|
||||
@@ -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<CustomerDeviceAccessRepository, 'getDoorContext'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
assert.equal(calls.some((item) =>
|
||||
).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')
|
||||
&& item.params.includes('SOCKET_POWER_LIMIT')
|
||||
), true);
|
||||
assert.equal(calls.some((item) =>
|
||||
).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')
|
||||
&& item.params.includes('SOCKET_TEMPERATURE_LIMIT')
|
||||
).length, alertCountBefore, `closeReason ${closeReason} is only a local operation`);
|
||||
}
|
||||
}
|
||||
|
||||
await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({
|
||||
DeviceID: 'SOCKET_001', read: 'workInfo', temp: '45', temperature: '46'
|
||||
})));
|
||||
assert.equal(calls.some((item) =>
|
||||
item.sql.includes('qipai_iot_dead_letters')
|
||||
&& item.params.includes('SOCKET_TEMPERATURE_CONFLICT')
|
||||
), true);
|
||||
|
||||
await assert.rejects(
|
||||
@@ -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.');
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user