feat(M06-C): 完成设备协议适配与消息幂等
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const commandId = z.string().regex(/^\d{1,13}$/);
|
||||
const resultCode = z.enum([
|
||||
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full', 'unknown'
|
||||
]);
|
||||
const vendorMessage = z.object({
|
||||
id: commandId.optional(),
|
||||
DeviceID: z.string().min(1).max(64).optional(),
|
||||
deviceID: z.string().min(1).max(64).optional(),
|
||||
IMEI: z.string().max(64).optional(),
|
||||
result: resultCode.optional(),
|
||||
event: z.string().max(64).optional(),
|
||||
action: z.string().max(64).optional(),
|
||||
read: z.string().max(64).optional(),
|
||||
timestamp: z.union([z.string(), z.number()]).optional()
|
||||
}).passthrough();
|
||||
|
||||
export type NormalizedVendorMessage = {
|
||||
kind: 'ACK' | 'EVENT' | 'SNAPSHOT';
|
||||
commandId: string | null;
|
||||
eventType: string;
|
||||
result: z.infer<typeof resultCode> | null;
|
||||
deviceId: string | null;
|
||||
eventAt: Date | null;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface ProtocolAdapter {
|
||||
parseUplink(payload: unknown): NormalizedVendorMessage;
|
||||
}
|
||||
|
||||
export class JilianControlBoxAdapter implements ProtocolAdapter {
|
||||
read(target: 'basicInfo' | 'mqttConfig' | 'startVoice' | 'task' | 'taskconfig') {
|
||||
return z.object({ read: z.literal(target) }).parse({ read: target });
|
||||
}
|
||||
|
||||
controlPower(input: {
|
||||
id: string; slot1?: 'on' | 'off'; slot2?: 'on' | 'off';
|
||||
slot3?: 'on' | 'off'; slotall?: 'on' | 'off';
|
||||
}) {
|
||||
return z.object({
|
||||
action: z.literal('ConctolPower'), id: commandId,
|
||||
slot1: z.enum(['on', 'off']).optional(),
|
||||
slot2: z.enum(['on', 'off']).optional(),
|
||||
slot3: z.enum(['on', 'off']).optional(),
|
||||
slotall: z.enum(['on', 'off']).optional()
|
||||
}).refine((value) => value.slot1 || value.slot2 || value.slot3 || value.slotall)
|
||||
.parse({ action: 'ConctolPower', ...input });
|
||||
}
|
||||
|
||||
controlDoor(input: {
|
||||
id: string; order: 'open' | 'close'; holdopen?: 0 | 1; delayTime?: number;
|
||||
}) {
|
||||
return z.object({
|
||||
action: z.literal('Crldoor'), id: commandId,
|
||||
order: z.enum(['open', 'close']), holdopen: z.union([z.literal(0), z.literal(1)]).default(0),
|
||||
delayTime: z.number().int().min(1).max(14).default(4)
|
||||
}).parse({ action: 'Crldoor', ...input });
|
||||
}
|
||||
|
||||
playTts(input: {
|
||||
id: string; content: string; volume?: number; playCount?: number;
|
||||
priority?: number; speaker?: number; style?: number; speed?: number; pitch?: number;
|
||||
}) {
|
||||
return z.object({
|
||||
action: z.literal('PlayTTS'), id: commandId,
|
||||
content: z.string().trim().min(1).max(500),
|
||||
volume: z.number().int().min(0).max(100).default(80),
|
||||
playCount: z.number().int().min(1).max(10).default(1),
|
||||
priority: z.number().int().min(0).max(10).default(0),
|
||||
speaker: z.number().int().min(0).max(20).default(0),
|
||||
style: z.number().int().min(0).max(20).default(0),
|
||||
speed: z.number().int().min(-500).max(500).default(0),
|
||||
pitch: z.number().int().min(-500).max(500).default(0)
|
||||
}).parse({ action: 'PlayTTS', ...input });
|
||||
}
|
||||
|
||||
stopTts(id: string) {
|
||||
return z.object({ action: z.literal('stopTTS'), id: commandId })
|
||||
.parse({ action: 'stopTTS', id });
|
||||
}
|
||||
|
||||
controlLed(input: { id: string; minute: number }) {
|
||||
return z.object({
|
||||
action: z.literal('CrlLED'), id: commandId,
|
||||
minute: z.number().int().min(0).max(10080)
|
||||
}).parse({ action: 'CrlLED', ...input });
|
||||
}
|
||||
|
||||
startTask(input: {
|
||||
id: string; minute: number; type: 1 | 2 | 3;
|
||||
subID?: string; holdopen?: 0 | 1; delayTime?: number;
|
||||
}) {
|
||||
return z.object({
|
||||
action: z.literal('task'), id: commandId,
|
||||
minute: z.number().int().min(1).max(10080),
|
||||
type: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||
subID: z.string().min(1).max(64).optional(),
|
||||
holdopen: z.union([z.literal(0), z.literal(1)]).default(0),
|
||||
delayTime: z.number().int().min(1).max(14).default(4)
|
||||
}).parse({ action: 'task', ...input });
|
||||
}
|
||||
|
||||
extendTask(input: { id: string; addminute: number }) {
|
||||
return z.object({
|
||||
action: z.literal('addtask'), id: commandId,
|
||||
addminute: z.number().int().min(1).max(10080)
|
||||
}).parse({ action: 'addtask', ...input });
|
||||
}
|
||||
|
||||
cancelTask(id: string) {
|
||||
return z.object({ action: z.literal('canceltask'), id: commandId })
|
||||
.parse({ action: 'canceltask', id });
|
||||
}
|
||||
|
||||
parseUplink(payload: unknown) {
|
||||
return normalizeVendorMessage(payload);
|
||||
}
|
||||
}
|
||||
|
||||
export class JilianSub1GLockAdapter implements ProtocolAdapter {
|
||||
pair(input: { id: string; timeout?: number }) {
|
||||
return z.object({
|
||||
action: z.literal('AddDevice'), id: commandId,
|
||||
timeout: z.number().int().min(10).max(300).default(60)
|
||||
}).parse({ action: 'AddDevice', ...input });
|
||||
}
|
||||
|
||||
control(input: {
|
||||
id: string; subID: string;
|
||||
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
|
||||
holdopen?: 0 | 1; delayTime?: number; content?: string;
|
||||
}) {
|
||||
return z.object({
|
||||
action: z.literal('CtrlDevice'), id: commandId,
|
||||
subID: z.string().min(1).max(64),
|
||||
order: z.enum([
|
||||
'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
|
||||
]),
|
||||
holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
|
||||
delayTime: z.number().int().min(1).max(14).optional(),
|
||||
content: z.string().min(1).max(128).optional()
|
||||
}).superRefine((value, context) => {
|
||||
if (['setkey', 'setcard'].includes(value.order) && !value.content) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: 'content is required' });
|
||||
}
|
||||
}).parse({ action: 'CtrlDevice', ...input });
|
||||
}
|
||||
|
||||
parseUplink(payload: unknown) {
|
||||
return normalizeVendorMessage(payload);
|
||||
}
|
||||
}
|
||||
|
||||
export class JilianSmartSocketAdapter implements ProtocolAdapter {
|
||||
read(target: 'basicInfo' | 'workInfo') {
|
||||
return z.object({ read: z.literal(target) }).parse({ read: target });
|
||||
}
|
||||
|
||||
switch(input: { id: string; on: boolean; slotNum?: number }) {
|
||||
return z.object({
|
||||
action: z.enum(['on', 'off']), id: commandId,
|
||||
slotNum: z.number().int().min(1).max(20).default(1)
|
||||
}).parse({ action: input.on ? 'on' : 'off', id: input.id, slotNum: input.slotNum });
|
||||
}
|
||||
|
||||
localTask(input: {
|
||||
id: string; taskNum: number; action: 'on' | 'off';
|
||||
mode: 'once' | 'daily' | 'weekly'; time: string; weekdays?: number[];
|
||||
}) {
|
||||
return z.object({
|
||||
action: z.literal('localtask'), id: commandId,
|
||||
taskNum: z.number().int().min(1).max(20),
|
||||
switch: 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
|
||||
});
|
||||
}
|
||||
|
||||
clearTask(input: { id: string; taskNum: number }) {
|
||||
return z.object({
|
||||
action: z.literal('clearTask'), id: commandId,
|
||||
taskNum: z.number().int().min(0).max(20)
|
||||
}).parse({ action: 'clearTask', ...input });
|
||||
}
|
||||
|
||||
parseUplink(payload: unknown) {
|
||||
return normalizeVendorMessage(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage {
|
||||
const parsed = vendorMessage.parse(payload);
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const eventName = parsed.event ?? parsed.action ?? parsed.read ?? 'snapshot';
|
||||
return {
|
||||
kind: parsed.result ? 'ACK' : parsed.event ? 'EVENT' : 'SNAPSHOT',
|
||||
commandId: parsed.id ?? null,
|
||||
eventType: eventName,
|
||||
result: parsed.result ?? null,
|
||||
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
|
||||
eventAt: parseEventAt(parsed.timestamp),
|
||||
payload: record
|
||||
};
|
||||
}
|
||||
|
||||
function parseEventAt(value: string | number | undefined): Date | null {
|
||||
if (value === undefined) return null;
|
||||
const date = typeof value === 'number'
|
||||
? new Date(value < 10_000_000_000 ? value * 1000 : value)
|
||||
: new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
Reference in New Issue
Block a user