fix(M06-C-R1): 校正控制箱与门锁协议

This commit is contained in:
Codex
2026-08-10 16:01:38 +08:00
parent b71b48b3d0
commit dabc656f63
9 changed files with 574 additions and 86 deletions
+13 -6
View File
@@ -47,7 +47,8 @@ export class DeviceControlService {
}
async playTts(context: CommandContext, input: {
content: string; volume?: number; playCount?: number; priority?: number;
content: string; volume?: number; firstPlay?: 0 | 1; loop?: number;
speaker?: number; style?: number; speed?: number; intonation?: number;
}) {
return this.issueControlBox(context, 'PlayTTS',
(id) => this.controlBox.playTts({ id, ...input }));
@@ -86,18 +87,24 @@ export class DeviceControlService {
}
async controlSubLock(context: CommandContext, input: {
subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; content?: string;
subtype: '14' | '15'; subID: string;
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
| 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; value?: string;
dangerConfirmation?: string;
}) {
if (['factoryreset'].includes(input.order)) {
const credentialOrder = ['setkey', 'delkey', 'setcard', 'delcard', 'factoryreset']
.includes(input.order);
if (credentialOrder && !this.isManager(context.access)) {
throw new DeviceControlError('DEVICE_CREDENTIAL_ACTION_FORBIDDEN');
}
if (input.order === 'factoryreset') {
if (!context.access.roles.includes('PLATFORM_ADMIN')
|| input.dangerConfirmation !== 'CONFIRM_FACTORY_RESET') {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
}
}
if (['delkey', 'delcard'].includes(input.order) && !input.content) {
if (['delkey', 'delcard'].includes(input.order) && input.value === 'all') {
if (!context.access.roles.includes('PLATFORM_ADMIN')
|| input.dangerConfirmation !== 'CONFIRM_CLEAR_CREDENTIALS') {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
+8 -2
View File
@@ -36,6 +36,7 @@ interface HardwareSmokeConfig {
controlBoxDeviceId: string;
smartSocketDeviceId: string;
subLockSubId: string;
subLockSubtype: '14' | '15' | '';
timeoutMs: number;
}
@@ -49,13 +50,17 @@ export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): H
controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '',
smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '',
subLockSubId: env.QIPAI_HARDWARE_SUB_LOCK_SUB_ID ?? '',
subLockSubtype: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '15'
? '15'
: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '14' ? '14' : '',
timeoutMs: Number(env.QIPAI_HARDWARE_SMOKE_TIMEOUT_MS ?? 8000)
};
}
export function buildHardwareSmokeCases(
config: Pick<HardwareSmokeConfig,
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId' | 'allowActions'>
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId'
| 'subLockSubtype' | 'allowActions'>
): HardwareSmokeCase[] {
const controlBox = new JilianControlBoxAdapter();
const socket = new JilianSmartSocketAdapter();
@@ -86,7 +91,7 @@ export function buildHardwareSmokeCases(
destructive: true
});
}
if (config.allowActions && config.subLockSubId) {
if (config.allowActions && config.subLockSubId && config.subLockSubtype) {
cases.push({
id: 'sub-lock-open',
deviceId: config.controlBoxDeviceId,
@@ -94,6 +99,7 @@ export function buildHardwareSmokeCases(
commandType: 'CtrlDevice',
payload: lock.control({
id: generateCommandId(Date.now(), cases.length),
subtype: config.subLockSubtype,
subID: config.subLockSubId,
order: 'open',
delayTime: 4
+90 -19
View File
@@ -32,7 +32,10 @@ export class IotMessageService {
const payloadHash = createHash('sha256').update(payload).digest('hex');
const topicMatch = /^\/(devicesend|devicewill)\/([A-Za-z0-9_-]{1,64})$/.exec(topic);
if (!topicMatch) {
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_TOPIC_INVALID');
await this.deadLetter(
null, null, topic, payloadHash, sanitizePayloadTextForStorage(payloadText),
'MQTT_TOPIC_INVALID'
);
return;
}
@@ -46,23 +49,30 @@ export class IotMessageService {
);
const device = devices[0];
if (!device) {
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_DEVICE_UNKNOWN');
await this.deadLetter(
null, null, topic, payloadHash, sanitizePayloadTextForStorage(payloadText),
'MQTT_DEVICE_UNKNOWN'
);
return;
}
let rawPayload: unknown;
let normalized: NormalizedVendorMessage;
try {
rawPayload = JSON.parse(payloadText);
normalized = topicMatch[1] === 'devicewill'
? normalizeWill(rawPayload)
: this.adapters[device.deviceType].parseUplink(rawPayload);
if (topicMatch[1] === 'devicewill') {
rawPayload = parseWillPayload(payloadText);
normalized = normalizeWill(rawPayload);
} else {
rawPayload = JSON.parse(payloadText);
normalized = this.adapters[device.deviceType].parseUplink(rawPayload);
}
if (normalized.deviceId && normalized.deviceId !== device.deviceId) {
throw new Error('Payload DeviceID does not match MQTT topic.');
}
} catch (error) {
await this.deadLetter(
device.tenantId, device.id, topic, payloadHash, payloadText,
device.tenantId, device.id, topic, payloadHash,
sanitizePayloadTextForStorage(payloadText),
'MQTT_PAYLOAD_INVALID', error instanceof Error ? error.message : 'Invalid payload'
);
return;
@@ -70,22 +80,32 @@ export class IotMessageService {
const safePayload = sanitizeSensitivePayload(rawPayload, normalized);
normalized = { ...normalized, payload: safePayload };
const processingStatus = normalized.result === 'UNKNOWN_VENDOR_RESULT'
? 'PROTOCOL_ERROR'
: 'PROCESSED';
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_iot_device_events
(tenant_id, device_id, store_id, room_id, command_id, topic, event_type,
payload_hash, raw_payload, normalized_payload, event_at, processing_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PROCESSED')
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE receive_count = receive_count + 1,
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),
normalized.eventAt]
normalized.eventAt, processingStatus]
);
if (result.affectedRows !== 1) return;
await this.updateDevice(device, normalized, safePayload);
await this.applyAlerts(device, normalized);
if (normalized.result === 'UNKNOWN_VENDOR_RESULT') {
await this.deadLetter(
device.tenantId, device.id, topic, payloadHash, JSON.stringify(safePayload),
'UNKNOWN_VENDOR_RESULT',
`Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? '<missing>'}`
);
}
if (normalized.kind === 'ACK' && normalized.commandId) {
await this.applyAcknowledgement(device, normalized);
}
@@ -106,7 +126,8 @@ export class IotMessageService {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, input.assetId, input.storeId, input.roomId ?? null,
input.orderId ?? null, input.commandId, input.commandType,
JSON.stringify(input.payload), input.traceId, input.expiresAt ?? null]
JSON.stringify(sanitizeSensitivePayload(input.payload)),
input.traceId, input.expiresAt ?? null]
);
return { recordId: String(result.insertId), commandId: input.commandId };
}
@@ -141,8 +162,17 @@ export class IotMessageService {
private async applyAcknowledgement(device: DeviceRow, message: NormalizedVendorMessage) {
const successful = message.result === 'ok';
if (successful && message.eventType === 'AddDevice'
&& (!readString(message.payload.subID ?? message.payload.subId)
|| !readString(message.payload.subtype))) {
return;
}
const status = successful ? 'ACKED' : 'FAILED';
const failureCode = successful ? '' : `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
const failureCode = successful
? ''
: message.result === 'UNKNOWN_VENDOR_RESULT'
? 'UNKNOWN_VENDOR_RESULT'
: `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
await this.pool.execute(
`UPDATE qipai_iot_commands SET status = ?, response_payload = ?,
acknowledged_at = UTC_TIMESTAMP(3), failure_code = ?
@@ -198,6 +228,10 @@ export class IotMessageService {
}
private async applyAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
if (message.result === 'UNKNOWN_VENDOR_RESULT') {
await this.upsertAlert(device, 'UNKNOWN_VENDOR_RESULT', 'MEDIUM',
`Unsupported vendor result for ${message.eventType}.`);
}
const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '')
? `DEVICE_${message.result?.toUpperCase()}`
: null;
@@ -305,18 +339,29 @@ export function generateCommandId(now = Date.now(), sequence = 0): string {
}
function normalizeWill(payload: unknown): NormalizedVendorMessage {
const record = zRecord(payload);
const record = typeof payload === 'string' ? { will: payload } : zRecord(payload);
return {
kind: 'EVENT',
commandId: null,
eventType: 'will',
result: null,
rawResult: null,
deviceId: readDeviceId(record),
eventAt: null,
payload: record
};
}
function parseWillPayload(payloadText: string): unknown {
try {
return JSON.parse(payloadText);
} catch {
const will = payloadText.trim();
if (!will || will.length > 64) throw new Error('MQTT will payload is invalid.');
return will;
}
}
function zRecord(payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('MQTT payload must be a JSON object.');
@@ -330,26 +375,52 @@ function readDeviceId(record: Record<string, unknown>): string | null {
}
function sanitizeSensitivePayload(
payload: unknown, normalized: NormalizedVendorMessage
payload: unknown, normalized?: NormalizedVendorMessage
): Record<string, unknown> {
const record = { ...zRecord(payload) };
if (normalized.eventType === 'record' && typeof record.content === 'string') {
const record: Record<string, unknown> = typeof payload === 'string'
? { will: payload }
: { ...zRecord(payload) };
const eventType = normalized?.eventType ?? readString(record.event);
if (eventType === 'record' && typeof record.content === 'string') {
const content = record.content;
record.contentHash = createHash('sha256').update(content).digest('hex');
record.contentMasked = content.length <= 4
? '*'.repeat(content.length)
: `${content.slice(0, 2)}${'*'.repeat(Math.min(8, content.length - 4))}${content.slice(-2)}`;
addMaskedSecret(record, 'content', content);
delete record.content;
}
const sensitiveLockOrder = ['setkey', 'delkey', 'setcard', 'delcard']
.includes(readString(record.order) ?? '');
if (record.action === 'CtrlDevice' && sensitiveLockOrder
&& typeof record.value === 'string') {
addMaskedSecret(record, 'value', record.value);
delete record.value;
}
if (typeof record.password === 'string') {
addMaskedSecret(record, 'password', record.password);
record.password = '<redacted>';
}
if (typeof record.card === 'string') {
addMaskedSecret(record, 'card', record.card);
record.card = '<redacted>';
}
return record;
}
function addMaskedSecret(
record: Record<string, unknown>, field: string, secret: string
): void {
record[`${field}Hash`] = createHash('sha256').update(secret).digest('hex');
record[`${field}Masked`] = secret.length <= 4
? '*'.repeat(secret.length)
: `${secret.slice(0, 2)}${'*'.repeat(Math.min(8, secret.length - 4))}${secret.slice(-2)}`;
}
function sanitizePayloadTextForStorage(payloadText: string): string {
try {
return JSON.stringify(sanitizeSensitivePayload(JSON.parse(payloadText)));
} catch {
return payloadText.slice(0, 1_000_000);
}
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
+123 -34
View File
@@ -1,26 +1,32 @@
import { z } from 'zod';
const commandId = z.string().regex(/^\d{1,13}$/);
const resultCode = z.enum([
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full', 'unknown'
]);
const vendorResult = z.string().trim().min(1).max(64);
const knownResultCodes = [
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full',
'paraerror', 'error', 'update'
] as const;
const knownResultCode = z.enum(knownResultCodes);
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(),
result: vendorResult.optional(),
event: z.string().max(64).optional(),
action: z.string().max(64).optional(),
read: z.string().max(64).optional(),
setting: z.string().max(64).optional(),
timestamp: z.union([z.string(), z.number()]).optional()
}).passthrough();
export type KnownVendorResult = z.infer<typeof knownResultCode>;
export type NormalizedVendorMessage = {
kind: 'ACK' | 'EVENT' | 'SNAPSHOT';
commandId: string | null;
eventType: string;
result: z.infer<typeof resultCode> | null;
result: KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null;
rawResult: string | null;
deviceId: string | null;
eventAt: Date | null;
payload: Record<string, unknown>;
@@ -60,20 +66,32 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
}
playTts(input: {
id: string; content: string; volume?: number; playCount?: number;
priority?: number; speaker?: number; style?: number; speed?: number; pitch?: number;
id: string; content: string; volume?: number; firstPlay?: 0 | 1; loop?: number;
speaker?: number; style?: number; speed?: number; intonation?: number;
}) {
return z.object({
action: z.literal('PlayTTS'), id: commandId,
const domain = z.object({
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 });
volume: z.number().int().min(0).max(10).default(8),
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
loop: z.number().int().positive().max(100).default(1),
speaker: z.number().int().min(0).max(5).default(0),
style: z.number().int().min(0).max(2).default(0),
speed: z.number().int().min(0).max(10).default(5),
intonation: z.number().int().min(0).max(10).default(5)
}).parse(input);
return {
action: 'PlayTTS' as const,
content: domain.content,
vol: domain.volume,
firstPlay: domain.firstPlay,
loop: domain.loop,
speaker: domain.speaker,
style: domain.style,
speed: domain.speed,
intona: domain.intonation,
id: domain.id
};
}
stopTts(id: string) {
@@ -121,31 +139,65 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
export class JilianSub1GLockAdapter implements ProtocolAdapter {
pair(input: { id: string; timeout?: number }) {
return z.object({
action: z.literal('AddDevice'), id: commandId,
const domain = z.object({
id: commandId,
timeout: z.number().int().min(10).max(300).default(60)
}).parse({ action: 'AddDevice', ...input });
}).parse(input);
return { action: 'AddDevice' as const, time: domain.timeout, id: domain.id };
}
control(input: {
id: string; subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; content?: string;
id: string; subtype: '14' | '15'; subID: string;
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
| 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; value?: string;
}) {
return z.object({
action: z.literal('CtrlDevice'), id: commandId,
const schema = z.object({
id: commandId,
subtype: z.enum(['14', '15']),
subID: z.string().min(1).max(64),
order: z.enum([
'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
'open', 'close', 'none', '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()
value: 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' });
const passwordList = /^(?:\d{6})+$/;
const cardList = /^(?:[A-Fa-f0-9]{8})+$/;
if (value.order === 'setkey' && !passwordList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 6-digit passwords' });
}
}).parse({ action: 'CtrlDevice', ...input });
if (value.order === 'delkey'
&& value.value !== 'all' && !passwordList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 6-digit passwords' });
}
if (value.order === 'setcard' && !cardList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 8-digit hex card IDs' });
}
if (value.order === 'delcard'
&& value.value !== 'all' && !cardList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 8-digit hex card IDs' });
}
if (['open', 'close', 'none', 'factoryreset'].includes(value.order) && value.value) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value is not allowed for this order' });
}
});
const domain = schema.parse(input);
return {
action: 'CtrlDevice' as const,
subtype: domain.subtype,
subID: domain.subID,
order: domain.order,
...(domain.holdopen === undefined ? {} : { holdopen: domain.holdopen }),
...(domain.delayTime === undefined ? {} : { delayTime: domain.delayTime }),
...(domain.value === undefined ? {} : {
value: domain.value !== 'all' && ['setcard', 'delcard'].includes(domain.order)
? domain.value.toUpperCase()
: domain.value
}),
id: domain.id
};
}
parseUplink(payload: unknown) {
@@ -196,19 +248,56 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter {
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';
const record = normalizeReceiveVariants(parsed as Record<string, unknown>);
const rawEventName = parsed.event ?? parsed.action ?? parsed.read ?? parsed.setting ?? 'snapshot';
const eventName = canonicalMessageName(rawEventName);
const rawResult = parsed.result ?? null;
const result = normalizeResult(eventName, rawResult);
return {
kind: parsed.result ? 'ACK' : parsed.event ? 'EVENT' : 'SNAPSHOT',
kind: parsed.event ? 'EVENT' : parsed.result ? 'ACK' : 'SNAPSHOT',
commandId: parsed.id ?? null,
eventType: eventName,
result: parsed.result ?? null,
result,
rawResult,
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
eventAt: parseEventAt(parsed.timestamp),
payload: record
};
}
function normalizeResult(
eventName: string, result: string | null
): KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null {
if (result === null) return null;
const parsed = knownResultCode.safeParse(result);
if (!parsed.success) return 'UNKNOWN_VENDOR_RESULT';
const allowedByCommand: Record<string, readonly KnownVendorResult[]> = {
PlayTTS: ['ok', 'paraerror'],
stopTTS: ['ok'],
task: ['ok', 'busy', 'fail', 'unconfirm'],
addtask: ['ok', 'fail'],
canceltask: ['ok'],
AddDevice: ['ok', 'fail', 'timeout'],
CtrlDevice: ['ok', 'fail', 'timeout', 'full']
};
const allowed = allowedByCommand[eventName];
return allowed && !allowed.includes(parsed.data) ? 'UNKNOWN_VENDOR_RESULT' : parsed.data;
}
function canonicalMessageName(value: string): string {
return value.toLowerCase() === 'mqttconfig' ? 'mqttConfig' : value;
}
function normalizeReceiveVariants(record: Record<string, unknown>): 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';
return normalized;
}
function parseEventAt(value: string | number | undefined): Date | null {
if (value === undefined) return null;
const date = typeof value === 'number'
+10 -5
View File
@@ -32,9 +32,13 @@ const doorSchema = contextSchema.extend({
});
const ttsSchema = contextSchema.extend({
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)
volume: z.number().int().min(0).max(10).default(8),
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
loop: z.number().int().positive().max(100).default(1),
speaker: z.number().int().min(0).max(5).default(0),
style: z.number().int().min(0).max(2).default(0),
speed: z.number().int().min(0).max(10).default(5),
intonation: z.number().int().min(0).max(10).default(5)
});
const minuteSchema = contextSchema.extend({
minute: z.number().int().min(0).max(10080)
@@ -53,13 +57,14 @@ const pairSchema = contextSchema.extend({
timeout: z.number().int().min(10).max(300).default(60)
});
const subLockSchema = contextSchema.extend({
subtype: z.enum(['14', '15']),
subID: z.string().min(1).max(64),
order: z.enum([
'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
'open', 'close', 'none', '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(),
value: z.string().min(1).max(128).optional(),
dangerConfirmation: z.string().max(64).optional()
});
const socketReadSchema = contextSchema.extend({