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 {
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');
}
+43 -52
View File
@@ -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;
}
+225 -17
View File
@@ -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'
+69 -7
View File
@@ -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) {