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: { 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', return this.issueControlBox(context, 'PlayTTS',
(id) => this.controlBox.playTts({ id, ...input })); (id) => this.controlBox.playTts({ id, ...input }));
@@ -86,18 +87,24 @@ export class DeviceControlService {
} }
async controlSubLock(context: CommandContext, input: { async controlSubLock(context: CommandContext, input: {
subID: string; subtype: '14' | '15'; subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset'; order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
holdopen?: 0 | 1; delayTime?: number; content?: string; | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; value?: string;
dangerConfirmation?: 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') if (!context.access.roles.includes('PLATFORM_ADMIN')
|| input.dangerConfirmation !== 'CONFIRM_FACTORY_RESET') { || input.dangerConfirmation !== 'CONFIRM_FACTORY_RESET') {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN'); 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') if (!context.access.roles.includes('PLATFORM_ADMIN')
|| input.dangerConfirmation !== 'CONFIRM_CLEAR_CREDENTIALS') { || input.dangerConfirmation !== 'CONFIRM_CLEAR_CREDENTIALS') {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN'); throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
+8 -2
View File
@@ -36,6 +36,7 @@ interface HardwareSmokeConfig {
controlBoxDeviceId: string; controlBoxDeviceId: string;
smartSocketDeviceId: string; smartSocketDeviceId: string;
subLockSubId: string; subLockSubId: string;
subLockSubtype: '14' | '15' | '';
timeoutMs: number; timeoutMs: number;
} }
@@ -49,13 +50,17 @@ export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): H
controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '', controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '',
smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '', smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '',
subLockSubId: env.QIPAI_HARDWARE_SUB_LOCK_SUB_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) timeoutMs: Number(env.QIPAI_HARDWARE_SMOKE_TIMEOUT_MS ?? 8000)
}; };
} }
export function buildHardwareSmokeCases( export function buildHardwareSmokeCases(
config: Pick<HardwareSmokeConfig, config: Pick<HardwareSmokeConfig,
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId' | 'allowActions'> 'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId'
| 'subLockSubtype' | 'allowActions'>
): HardwareSmokeCase[] { ): HardwareSmokeCase[] {
const controlBox = new JilianControlBoxAdapter(); const controlBox = new JilianControlBoxAdapter();
const socket = new JilianSmartSocketAdapter(); const socket = new JilianSmartSocketAdapter();
@@ -86,7 +91,7 @@ export function buildHardwareSmokeCases(
destructive: true destructive: true
}); });
} }
if (config.allowActions && config.subLockSubId) { if (config.allowActions && config.subLockSubId && config.subLockSubtype) {
cases.push({ cases.push({
id: 'sub-lock-open', id: 'sub-lock-open',
deviceId: config.controlBoxDeviceId, deviceId: config.controlBoxDeviceId,
@@ -94,6 +99,7 @@ export function buildHardwareSmokeCases(
commandType: 'CtrlDevice', commandType: 'CtrlDevice',
payload: lock.control({ payload: lock.control({
id: generateCommandId(Date.now(), cases.length), id: generateCommandId(Date.now(), cases.length),
subtype: config.subLockSubtype,
subID: config.subLockSubId, subID: config.subLockSubId,
order: 'open', order: 'open',
delayTime: 4 delayTime: 4
+90 -19
View File
@@ -32,7 +32,10 @@ export class IotMessageService {
const payloadHash = createHash('sha256').update(payload).digest('hex'); const payloadHash = createHash('sha256').update(payload).digest('hex');
const topicMatch = /^\/(devicesend|devicewill)\/([A-Za-z0-9_-]{1,64})$/.exec(topic); const topicMatch = /^\/(devicesend|devicewill)\/([A-Za-z0-9_-]{1,64})$/.exec(topic);
if (!topicMatch) { 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; return;
} }
@@ -46,23 +49,30 @@ export class IotMessageService {
); );
const device = devices[0]; const device = devices[0];
if (!device) { 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; return;
} }
let rawPayload: unknown; let rawPayload: unknown;
let normalized: NormalizedVendorMessage; let normalized: NormalizedVendorMessage;
try { try {
rawPayload = JSON.parse(payloadText); if (topicMatch[1] === 'devicewill') {
normalized = topicMatch[1] === 'devicewill' rawPayload = parseWillPayload(payloadText);
? normalizeWill(rawPayload) normalized = normalizeWill(rawPayload);
: this.adapters[device.deviceType].parseUplink(rawPayload); } else {
rawPayload = JSON.parse(payloadText);
normalized = this.adapters[device.deviceType].parseUplink(rawPayload);
}
if (normalized.deviceId && normalized.deviceId !== device.deviceId) { if (normalized.deviceId && normalized.deviceId !== device.deviceId) {
throw new Error('Payload DeviceID does not match MQTT topic.'); throw new Error('Payload DeviceID does not match MQTT topic.');
} }
} catch (error) { } catch (error) {
await this.deadLetter( 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' 'MQTT_PAYLOAD_INVALID', error instanceof Error ? error.message : 'Invalid payload'
); );
return; return;
@@ -70,22 +80,32 @@ export class IotMessageService {
const safePayload = sanitizeSensitivePayload(rawPayload, normalized); const safePayload = sanitizeSensitivePayload(rawPayload, normalized);
normalized = { ...normalized, payload: safePayload }; normalized = { ...normalized, payload: safePayload };
const processingStatus = normalized.result === 'UNKNOWN_VENDOR_RESULT'
? 'PROTOCOL_ERROR'
: 'PROCESSED';
const [result] = await this.pool.execute<ResultSetHeader>( const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_iot_device_events `INSERT INTO qipai_iot_device_events
(tenant_id, device_id, store_id, room_id, command_id, topic, event_type, (tenant_id, device_id, store_id, room_id, command_id, topic, event_type,
payload_hash, raw_payload, normalized_payload, event_at, processing_status) payload_hash, raw_payload, normalized_payload, event_at, processing_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PROCESSED') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE receive_count = receive_count + 1, ON DUPLICATE KEY UPDATE receive_count = receive_count + 1,
received_at = UTC_TIMESTAMP(3)`, received_at = UTC_TIMESTAMP(3)`,
[device.tenantId, device.id, device.storeId, device.roomId, [device.tenantId, device.id, device.storeId, device.roomId,
normalized.commandId, topic, normalized.eventType, payloadHash, normalized.commandId, topic, normalized.eventType, payloadHash,
JSON.stringify(safePayload), JSON.stringify(normalized), JSON.stringify(safePayload), JSON.stringify(normalized),
normalized.eventAt] normalized.eventAt, processingStatus]
); );
if (result.affectedRows !== 1) return; if (result.affectedRows !== 1) return;
await this.updateDevice(device, normalized, safePayload); await this.updateDevice(device, normalized, safePayload);
await this.applyAlerts(device, normalized); 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) { if (normalized.kind === 'ACK' && normalized.commandId) {
await this.applyAcknowledgement(device, normalized); await this.applyAcknowledgement(device, normalized);
} }
@@ -106,7 +126,8 @@ export class IotMessageService {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, input.assetId, input.storeId, input.roomId ?? null, [input.tenantId, input.assetId, input.storeId, input.roomId ?? null,
input.orderId ?? null, input.commandId, input.commandType, 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 }; return { recordId: String(result.insertId), commandId: input.commandId };
} }
@@ -141,8 +162,17 @@ export class IotMessageService {
private async applyAcknowledgement(device: DeviceRow, message: NormalizedVendorMessage) { private async applyAcknowledgement(device: DeviceRow, message: NormalizedVendorMessage) {
const successful = message.result === 'ok'; 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 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( await this.pool.execute(
`UPDATE qipai_iot_commands SET status = ?, response_payload = ?, `UPDATE qipai_iot_commands SET status = ?, response_payload = ?,
acknowledged_at = UTC_TIMESTAMP(3), failure_code = ? acknowledged_at = UTC_TIMESTAMP(3), failure_code = ?
@@ -198,6 +228,10 @@ export class IotMessageService {
} }
private async applyAlerts(device: DeviceRow, message: NormalizedVendorMessage) { 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 ?? '') const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '')
? `DEVICE_${message.result?.toUpperCase()}` ? `DEVICE_${message.result?.toUpperCase()}`
: null; : null;
@@ -305,18 +339,29 @@ export function generateCommandId(now = Date.now(), sequence = 0): string {
} }
function normalizeWill(payload: unknown): NormalizedVendorMessage { function normalizeWill(payload: unknown): NormalizedVendorMessage {
const record = zRecord(payload); const record = typeof payload === 'string' ? { will: payload } : zRecord(payload);
return { return {
kind: 'EVENT', kind: 'EVENT',
commandId: null, commandId: null,
eventType: 'will', eventType: 'will',
result: null, result: null,
rawResult: null,
deviceId: readDeviceId(record), deviceId: readDeviceId(record),
eventAt: null, eventAt: null,
payload: record 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> { function zRecord(payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('MQTT payload must be a JSON object.'); throw new Error('MQTT payload must be a JSON object.');
@@ -330,26 +375,52 @@ function readDeviceId(record: Record<string, unknown>): string | null {
} }
function sanitizeSensitivePayload( function sanitizeSensitivePayload(
payload: unknown, normalized: NormalizedVendorMessage payload: unknown, normalized?: NormalizedVendorMessage
): Record<string, unknown> { ): Record<string, unknown> {
const record = { ...zRecord(payload) }; const record: Record<string, unknown> = typeof payload === 'string'
if (normalized.eventType === 'record' && typeof record.content === 'string') { ? { will: payload }
: { ...zRecord(payload) };
const eventType = normalized?.eventType ?? readString(record.event);
if (eventType === 'record' && typeof record.content === 'string') {
const content = record.content; const content = record.content;
record.contentHash = createHash('sha256').update(content).digest('hex'); addMaskedSecret(record, 'content', content);
record.contentMasked = content.length <= 4
? '*'.repeat(content.length)
: `${content.slice(0, 2)}${'*'.repeat(Math.min(8, content.length - 4))}${content.slice(-2)}`;
delete record.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') { if (typeof record.password === 'string') {
addMaskedSecret(record, 'password', record.password);
record.password = '<redacted>'; record.password = '<redacted>';
} }
if (typeof record.card === 'string') { if (typeof record.card === 'string') {
addMaskedSecret(record, 'card', record.card);
record.card = '<redacted>'; record.card = '<redacted>';
} }
return record; 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 { function readString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null; return typeof value === 'string' && value.length > 0 ? value : null;
} }
+123 -34
View File
@@ -1,26 +1,32 @@
import { z } from 'zod'; import { z } from 'zod';
const commandId = z.string().regex(/^\d{1,13}$/); const commandId = z.string().regex(/^\d{1,13}$/);
const resultCode = z.enum([ const vendorResult = z.string().trim().min(1).max(64);
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full', 'unknown' const knownResultCodes = [
]); 'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full',
'paraerror', 'error', 'update'
] as const;
const knownResultCode = z.enum(knownResultCodes);
const vendorMessage = z.object({ const vendorMessage = z.object({
id: commandId.optional(), id: commandId.optional(),
DeviceID: z.string().min(1).max(64).optional(), DeviceID: z.string().min(1).max(64).optional(),
deviceID: z.string().min(1).max(64).optional(), deviceID: z.string().min(1).max(64).optional(),
IMEI: z.string().max(64).optional(), IMEI: z.string().max(64).optional(),
result: resultCode.optional(), result: vendorResult.optional(),
event: z.string().max(64).optional(), event: z.string().max(64).optional(),
action: z.string().max(64).optional(), action: z.string().max(64).optional(),
read: 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() timestamp: z.union([z.string(), z.number()]).optional()
}).passthrough(); }).passthrough();
export type KnownVendorResult = z.infer<typeof knownResultCode>;
export type NormalizedVendorMessage = { export type NormalizedVendorMessage = {
kind: 'ACK' | 'EVENT' | 'SNAPSHOT'; kind: 'ACK' | 'EVENT' | 'SNAPSHOT';
commandId: string | null; commandId: string | null;
eventType: string; eventType: string;
result: z.infer<typeof resultCode> | null; result: KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null;
rawResult: string | null;
deviceId: string | null; deviceId: string | null;
eventAt: Date | null; eventAt: Date | null;
payload: Record<string, unknown>; payload: Record<string, unknown>;
@@ -60,20 +66,32 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
} }
playTts(input: { playTts(input: {
id: string; content: string; volume?: number; playCount?: number; id: string; content: string; volume?: number; firstPlay?: 0 | 1; loop?: number;
priority?: number; speaker?: number; style?: number; speed?: number; pitch?: number; speaker?: number; style?: number; speed?: number; intonation?: number;
}) { }) {
return z.object({ const domain = z.object({
action: z.literal('PlayTTS'), id: commandId, id: commandId,
content: z.string().trim().min(1).max(500), content: z.string().trim().min(1).max(500),
volume: z.number().int().min(0).max(100).default(80), volume: z.number().int().min(0).max(10).default(8),
playCount: z.number().int().min(1).max(10).default(1), firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
priority: z.number().int().min(0).max(10).default(0), loop: z.number().int().positive().max(100).default(1),
speaker: z.number().int().min(0).max(20).default(0), speaker: z.number().int().min(0).max(5).default(0),
style: z.number().int().min(0).max(20).default(0), style: z.number().int().min(0).max(2).default(0),
speed: z.number().int().min(-500).max(500).default(0), speed: z.number().int().min(0).max(10).default(5),
pitch: z.number().int().min(-500).max(500).default(0) intonation: z.number().int().min(0).max(10).default(5)
}).parse({ action: 'PlayTTS', ...input }); }).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) { stopTts(id: string) {
@@ -121,31 +139,65 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
export class JilianSub1GLockAdapter implements ProtocolAdapter { export class JilianSub1GLockAdapter implements ProtocolAdapter {
pair(input: { id: string; timeout?: number }) { pair(input: { id: string; timeout?: number }) {
return z.object({ const domain = z.object({
action: z.literal('AddDevice'), id: commandId, id: commandId,
timeout: z.number().int().min(10).max(300).default(60) 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: { control(input: {
id: string; subID: string; id: string; subtype: '14' | '15'; subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset'; order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
holdopen?: 0 | 1; delayTime?: number; content?: string; | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; value?: string;
}) { }) {
return z.object({ const schema = z.object({
action: z.literal('CtrlDevice'), id: commandId, id: commandId,
subtype: z.enum(['14', '15']),
subID: z.string().min(1).max(64), subID: z.string().min(1).max(64),
order: z.enum([ 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(), holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
delayTime: z.number().int().min(1).max(14).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) => { }).superRefine((value, context) => {
if (['setkey', 'setcard'].includes(value.order) && !value.content) { const passwordList = /^(?:\d{6})+$/;
context.addIssue({ code: z.ZodIssueCode.custom, message: 'content is required' }); 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) { parseUplink(payload: unknown) {
@@ -196,19 +248,56 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter {
function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage { function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage {
const parsed = vendorMessage.parse(payload); const parsed = vendorMessage.parse(payload);
const record = parsed as Record<string, unknown>; const record = normalizeReceiveVariants(parsed as Record<string, unknown>);
const eventName = parsed.event ?? parsed.action ?? parsed.read ?? 'snapshot'; 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 { return {
kind: parsed.result ? 'ACK' : parsed.event ? 'EVENT' : 'SNAPSHOT', kind: parsed.event ? 'EVENT' : parsed.result ? 'ACK' : 'SNAPSHOT',
commandId: parsed.id ?? null, commandId: parsed.id ?? null,
eventType: eventName, eventType: eventName,
result: parsed.result ?? null, result,
rawResult,
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null, deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
eventAt: parseEventAt(parsed.timestamp), eventAt: parseEventAt(parsed.timestamp),
payload: record payload: record
}; };
} }
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 { function parseEventAt(value: string | number | undefined): Date | null {
if (value === undefined) return null; if (value === undefined) return null;
const date = typeof value === 'number' const date = typeof value === 'number'
+10 -5
View File
@@ -32,9 +32,13 @@ const doorSchema = contextSchema.extend({
}); });
const ttsSchema = contextSchema.extend({ const ttsSchema = contextSchema.extend({
content: z.string().trim().min(1).max(500), content: z.string().trim().min(1).max(500),
volume: z.number().int().min(0).max(100).default(80), volume: z.number().int().min(0).max(10).default(8),
playCount: z.number().int().min(1).max(10).default(1), firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
priority: z.number().int().min(0).max(10).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({ const minuteSchema = contextSchema.extend({
minute: z.number().int().min(0).max(10080) 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) timeout: z.number().int().min(10).max(300).default(60)
}); });
const subLockSchema = contextSchema.extend({ const subLockSchema = contextSchema.extend({
subtype: z.enum(['14', '15']),
subID: z.string().min(1).max(64), subID: z.string().min(1).max(64),
order: z.enum([ 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(), holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
delayTime: z.number().int().min(1).max(14).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() dangerConfirmation: z.string().max(64).optional()
}); });
const socketReadSchema = contextSchema.extend({ const socketReadSchema = contextSchema.extend({
+62 -8
View File
@@ -48,7 +48,11 @@ assert.equal(issued.at(-1).payload.action, 'ConctolPower');
await service.controlDoor(context, { order: 'open', holdopen: 1, delayTime: 4 }); await service.controlDoor(context, { order: 'open', holdopen: 1, delayTime: 4 });
assert.equal(issued.at(-1).payload.action, 'Crldoor'); assert.equal(issued.at(-1).payload.action, 'Crldoor');
await service.playTts(context, { content: '欢迎光临' }); await service.playTts(context, { content: '欢迎光临' });
assert.equal(issued.at(-1).payload.action, 'PlayTTS'); assert.deepEqual(issued.at(-1).payload, {
action: 'PlayTTS', content: '欢迎光临', vol: 8, firstPlay: 0,
loop: 1, speaker: 0, style: 0, speed: 5, intona: 5,
id: '1782120000001'
});
await service.stopTts(context); await service.stopTts(context);
assert.equal(issued.at(-1).payload.action, 'stopTTS'); assert.equal(issued.at(-1).payload.action, 'stopTTS');
await service.controlLed(context, 30); await service.controlLed(context, 30);
@@ -60,11 +64,21 @@ assert.equal(issued.at(-1).payload.action, 'addtask');
await service.cancelTask(context); await service.cancelTask(context);
assert.equal(issued.at(-1).payload.action, 'canceltask'); assert.equal(issued.at(-1).payload.action, 'canceltask');
await service.pairSubLock(context, 60); await service.pairSubLock(context, 60);
assert.equal(issued.at(-1).payload.action, 'AddDevice'); assert.deepEqual(issued.at(-1).payload, {
await service.controlSubLock(context, { action: 'AddDevice', time: 60, id: '1782120000001'
subID: 'SUB001', order: 'open', delayTime: 4
}); });
assert.equal(issued.at(-1).payload.action, 'CtrlDevice'); await service.controlSubLock(context, {
subtype: '14', subID: 'SUB001', order: 'open', delayTime: 4
});
assert.deepEqual(issued.at(-1).payload, {
action: 'CtrlDevice', subtype: '14', subID: 'SUB001',
order: 'open', delayTime: 4, id: '1782120000001'
});
await service.controlSubLock(context, {
subtype: '14', subID: 'SUB001', order: 'setkey', value: '123456654321'
});
assert.equal(issued.at(-1).payload.value, '123456654321');
assert.equal('content' in issued.at(-1).payload, false);
await service.readSmartSocket(context, 'workInfo'); await service.readSmartSocket(context, 'workInfo');
assert.deepEqual(issued.at(-1).payload, { read: 'workInfo' }); assert.deepEqual(issued.at(-1).payload, { read: 'workInfo' });
await service.switchSmartSocket(context, { on: false, slotNum: 2 }); await service.switchSmartSocket(context, { on: false, slotNum: 2 });
@@ -78,12 +92,13 @@ assert.equal(issued.at(-1).payload.action, 'localtask');
assert.equal(issued.at(-1).payload.switch, 'on'); assert.equal(issued.at(-1).payload.switch, 'on');
await service.clearSmartSocketTask(context, 1); await service.clearSmartSocketTask(context, 1);
assert.equal(issued.at(-1).payload.action, 'clearTask'); assert.equal(issued.at(-1).payload.action, 'clearTask');
assert.equal(audits.length, 14); assert.equal(audits.length, 15);
assert.match(audits[0], /DEVICE_COMMAND_REQUESTED/); assert.match(audits[0], /DEVICE_COMMAND_REQUESTED/);
await assert.rejects( await assert.rejects(
() => service.controlSubLock(context, { () => service.controlSubLock(context, {
subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET' subtype: '14', subID: 'SUB001', order: 'factoryreset',
dangerConfirmation: 'CONFIRM_FACTORY_RESET'
}), }),
(error) => error instanceof DeviceControlError (error) => error instanceof DeviceControlError
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN' && error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
@@ -93,11 +108,26 @@ const platformContext = {
access: { roles: ['PLATFORM_ADMIN'], capabilities: [], storeIds: [] } access: { roles: ['PLATFORM_ADMIN'], capabilities: [], storeIds: [] }
}; };
await service.controlSubLock(platformContext, { await service.controlSubLock(platformContext, {
subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET' subtype: '14', subID: 'SUB001', order: 'factoryreset',
dangerConfirmation: 'CONFIRM_FACTORY_RESET'
}); });
assert.equal(issued.at(-1).payload.order, 'factoryreset'); assert.equal(issued.at(-1).payload.order, 'factoryreset');
assert.equal('dangerConfirmation' in issued.at(-1).payload, false); assert.equal('dangerConfirmation' in issued.at(-1).payload, false);
await assert.rejects(
() => service.controlSubLock(context, {
subtype: '14', subID: 'SUB001', order: 'delkey', value: 'all'
}),
(error) => error instanceof DeviceControlError
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
);
await service.controlSubLock(platformContext, {
subtype: '15', subID: 'SUB002', order: 'delcard', value: 'all',
dangerConfirmation: 'CONFIRM_CLEAR_CREDENTIALS'
});
assert.equal(issued.at(-1).payload.value, 'all');
assert.equal(issued.at(-1).payload.subtype, '15');
await assert.rejects( await assert.rejects(
() => service.controlPower({ () => service.controlPower({
...context, ...context,
@@ -192,6 +222,30 @@ const socketResponse = await app.inject({
}); });
assert.equal(socketResponse.statusCode, 200); assert.equal(socketResponse.statusCode, 200);
assert.equal(routed.on, true); assert.equal(routed.on, true);
const subLockResponse = await app.inject({
method: 'POST',
url: '/admin-api/device-control/sub-lock/action',
headers: { authorization: `Bearer ${token}` },
payload: {
storeId: '11', roomId: '31', subtype: '14', subID: 'SUB001',
order: 'open', delayTime: 4
}
});
assert.equal(subLockResponse.statusCode, 200);
const invalidSubLock = await app.inject({
method: 'POST',
url: '/admin-api/device-control/sub-lock/action',
headers: { authorization: `Bearer ${token}` },
payload: { storeId: '11', roomId: '31', subID: 'SUB001', order: 'open' }
});
assert.equal(invalidSubLock.statusCode, 400);
const invalidTts = await app.inject({
method: 'POST',
url: '/admin-api/device-control/tts',
headers: { authorization: `Bearer ${token}` },
payload: { storeId: '11', roomId: '31', content: 'test', volume: 11 }
});
assert.equal(invalidTts.statusCode, 400);
const invalid = await app.inject({ const invalid = await app.inject({
method: 'POST', method: 'POST',
url: '/admin-api/device-control/door', url: '/admin-api/device-control/door',
+2 -1
View File
@@ -21,7 +21,8 @@ assert.equal(dryCases.every((item) => item.destructive === false), true);
const actionCases = buildHardwareSmokeCases({ const actionCases = buildHardwareSmokeCases({
...config, ...config,
allowActions: true, allowActions: true,
subLockSubId: 'SUB001' subLockSubId: 'SUB001',
subLockSubtype: '14'
}); });
assert.equal(actionCases.some((item) => item.id === 'control-box-power-slot1-off'), true); assert.equal(actionCases.some((item) => item.id === 'control-box-power-slot1-off'), true);
assert.equal(actionCases.some((item) => item.id === 'sub-lock-open'), true); assert.equal(actionCases.some((item) => item.id === 'sub-lock-open'), true);
+107 -9
View File
@@ -20,9 +20,14 @@ assert.deepEqual(control.controlPower({
assert.equal(control.controlDoor({ assert.equal(control.controlDoor({
id: '1234567890123', order: 'open' id: '1234567890123', order: 'open'
}).action, 'Crldoor'); }).action, 'Crldoor');
assert.equal(control.playTts({ assert.deepEqual(control.playTts({
id: '1234567890123', content: '欢迎光临' id: '1234567890123', content: '欢迎光临', volume: 7, firstPlay: 1,
}).action, 'PlayTTS'); loop: 2, speaker: 3, style: 2, speed: 6, intonation: 4
}), {
action: 'PlayTTS', content: '欢迎光临', vol: 7, firstPlay: 1,
loop: 2, speaker: 3, style: 2, speed: 6, intona: 4,
id: '1234567890123'
});
assert.deepEqual(control.stopTts('1234567890123'), { assert.deepEqual(control.stopTts('1234567890123'), {
action: 'stopTTS', id: '1234567890123' action: 'stopTTS', id: '1234567890123'
}); });
@@ -45,14 +50,26 @@ assert.throws(
const lock = new JilianSub1GLockAdapter(); const lock = new JilianSub1GLockAdapter();
assert.deepEqual(lock.pair({ id: '123', timeout: 60 }), { assert.deepEqual(lock.pair({ id: '123', timeout: 60 }), {
action: 'AddDevice', id: '123', timeout: 60 action: 'AddDevice', time: 60, id: '123'
});
assert.deepEqual(lock.control({
id: '123', subtype: '14', subID: 'SUB001', order: 'setcard',
value: 'a1b2c3d4'
}), {
action: 'CtrlDevice', subtype: '14', subID: 'SUB001', order: 'setcard',
value: 'A1B2C3D4', id: '123'
}); });
assert.equal(lock.control({
id: '123', subID: 'SUB001', order: 'open', delayTime: 4
}).action, 'CtrlDevice');
assert.throws( assert.throws(
() => lock.control({ id: '123', subID: 'SUB001', order: 'setkey' }), () => lock.control({
/content is required/ id: '123', subtype: '14', subID: 'SUB001', order: 'setkey', value: '12345'
}),
/6-digit passwords/
);
assert.throws(
() => lock.control({
id: '123', subtype: '15', subID: 'SUB001', order: 'setcard', value: 'not-card'
}),
/8-digit hex card IDs/
); );
const socket = new JilianSmartSocketAdapter(); const socket = new JilianSmartSocketAdapter();
@@ -74,6 +91,17 @@ const ack = control.parseUplink({
assert.equal(ack.kind, 'ACK'); assert.equal(ack.kind, 'ACK');
assert.equal(ack.result, 'unconfirm'); assert.equal(ack.result, 'unconfirm');
assert.equal(ack.eventType, 'task'); assert.equal(ack.eventType, 'task');
assert.equal(control.parseUplink({
DeviceID: 'BOX_001', id: '124', result: 'paraerror', action: 'PlayTTS'
}).result, 'paraerror');
assert.equal(control.parseUplink({
DeviceID: 'BOX_001', id: '125', result: 'vendor-new-code', action: 'task'
}).result, 'UNKNOWN_VENDOR_RESULT');
const configVariant = control.parseUplink({
DeviceID: 'BOX_001', setting: 'mqttconfig', welvoice: 'hello'
});
assert.equal(configVariant.eventType, 'mqttConfig');
assert.equal(configVariant.payload.welcomevoice, 'hello');
const event = lock.parseUplink({ const event = lock.parseUplink({
deviceID: 'BOX_001', event: 'record', type: 'card', state: 'open', deviceID: 'BOX_001', event: 'record', type: 'card', state: 'open',
timestamp: 1782120000 timestamp: 1782120000
@@ -81,6 +109,13 @@ const event = lock.parseUplink({
assert.equal(event.kind, 'EVENT'); assert.equal(event.kind, 'EVENT');
assert.equal(event.eventType, 'record'); assert.equal(event.eventType, 'record');
assert.equal(event.eventAt?.getUTCFullYear(), 2026); assert.equal(event.eventAt?.getUTCFullYear(), 2026);
for (const eventType of ['magstate', 'taskfinish', 'Poweron', 'connected']) {
const fixture = control.parseUplink({
DeviceID: 'BOX_001', event: eventType, state: 'open', timestamp: 1782120000
});
assert.equal(fixture.kind, 'EVENT');
assert.equal(fixture.eventType, eventType);
}
assert.equal(generateCommandId(1782120000000, 7), '1782120000007'); assert.equal(generateCommandId(1782120000000, 7), '1782120000007');
assert.match(generateCommandId(), /^\d{13}$/); assert.match(generateCommandId(), /^\d{13}$/);
@@ -156,6 +191,55 @@ assert.equal(calls.some((item) =>
item.sql.includes('INSERT INTO qipai_device_alerts') item.sql.includes('INSERT INTO qipai_device_alerts')
&& item.params.includes('DEVICE_UNCONFIRM') && item.params.includes('DEVICE_UNCONFIRM')
), true); ), true);
for (const [id, result, failureCode] of [
['125', 'busy', 'DEVICE_BUSY'],
['126', 'fail', 'DEVICE_FAIL']
]) {
await service.handle('/devicesend/BOX_001', Buffer.from(JSON.stringify({
DeviceID: 'BOX_001', id, action: 'task', result
})));
assert.equal(calls.some((item) =>
item.sql.includes('UPDATE qipai_iot_commands')
&& item.params[0] === 'FAILED'
&& item.params[2] === failureCode
), true);
}
const commandUpdatesBeforeEvents = calls.filter((item) =>
item.sql.includes('UPDATE qipai_iot_commands')
).length;
for (const eventType of ['magstate', 'taskfinish']) {
await service.handle('/devicesend/BOX_001', Buffer.from(JSON.stringify({
DeviceID: 'BOX_001', event: eventType, state: 'close'
})));
}
assert.equal(calls.filter((item) =>
item.sql.includes('UPDATE qipai_iot_commands')
).length, commandUpdatesBeforeEvents);
const deadLettersBeforeWill = calls.filter((item) =>
item.sql.includes('qipai_iot_dead_letters')
).length;
await service.handle('/devicewill/BOX_001', Buffer.from('close'));
assert.equal(calls.some((item) =>
item.sql.includes('UPDATE qipai_devices') && item.params[0] === 'OFFLINE'
), true);
assert.equal(calls.filter((item) =>
item.sql.includes('qipai_iot_dead_letters')
).length, deadLettersBeforeWill);
await service.handle('/devicesend/BOX_001', Buffer.from(JSON.stringify({
DeviceID: 'BOX_001', id: '127', action: 'task', result: 'vendor-new-code'
})));
assert.equal(calls.some((item) =>
item.sql.includes('qipai_iot_dead_letters')
&& item.params.includes('UNKNOWN_VENDOR_RESULT')
), true);
assert.equal(calls.some((item) =>
item.sql.includes('UPDATE qipai_iot_commands')
&& item.params[0] === 'FAILED'
&& item.params[2] === 'UNKNOWN_VENDOR_RESULT'
), true);
await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({ await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({
DeviceID: 'SOCKET_001', event: 'workInfo', switch: 'on', DeviceID: 'SOCKET_001', event: 'workInfo', switch: 'on',
powerW: 3601, temperature: 82, overLoad: true powerW: 3601, temperature: 82, overLoad: true
@@ -182,6 +266,20 @@ await assert.rejects(
/IOT_COMMAND_ID_INVALID/ /IOT_COMMAND_ID_INVALID/
); );
await service.createCommand({
tenantId: '7', assetId: '51', storeId: '11', commandId: '128',
commandType: 'CtrlDevice', traceId: 'secret-test',
payload: {
action: 'CtrlDevice', subtype: '14', subID: 'SUB001',
order: 'setkey', value: '123456'
}
});
const secretCommandInsert = calls.findLast((item) =>
item.sql.includes('INSERT INTO qipai_iot_commands')
);
assert.equal(secretCommandInsert.params[7].includes('123456'), false);
assert.equal(secretCommandInsert.params[7].includes('valueHash'), true);
const commandCalls = []; const commandCalls = [];
const commandService = new DeviceCommandService({ const commandService = new DeviceCommandService({
async createCommand(input) { commandCalls.push(['create', input]); }, async createCommand(input) { commandCalls.push(['create', input]); },
@@ -2108,10 +2108,23 @@ async function assertIotMessages(pool, context) {
roomId: String(device.roomId), roomId: String(device.roomId),
commandId: '1782120000002', commandId: '1782120000002',
commandType: 'AddDevice', commandType: 'AddDevice',
payload: { action: 'AddDevice', id: '1782120000002', timeout: 60 }, payload: { action: 'AddDevice', time: 60, id: '1782120000002' },
traceId: 'm06d-pair-test' traceId: 'm06d-pair-test'
}); });
await service.markPublished(context.tenantId, '1782120000002'); await service.markPublished(context.tenantId, '1782120000002');
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId,
id: '1782120000002',
action: 'AddDevice',
result: 'ok',
timestamp: 1782120002
})));
const [pairPendingRows] = await pool.query(
`SELECT status FROM qipai_iot_commands
WHERE tenant_id = ? AND command_id = '1782120000002'`,
[context.tenantId]
);
assert.deepEqual(pairPendingRows, [{ status: 'PUBLISHED' }]);
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({ await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId, DeviceID: device.deviceId,
id: '1782120000002', id: '1782120000002',
@@ -2130,6 +2143,144 @@ async function assertIotMessages(pool, context) {
[context.tenantId, device.id] [context.tenantId, device.id]
); );
assert.deepEqual(linkRows, [{ subId: 'SUB-AUTO-001', subtype: '14', model: '701C' }]); assert.deepEqual(linkRows, [{ subId: 'SUB-AUTO-001', subtype: '14', model: '701C' }]);
for (const [commandId, result, failureCode] of [
['1782120000003', 'busy', 'DEVICE_BUSY'],
['1782120000004', 'unconfirm', 'DEVICE_UNCONFIRM'],
['1782120000005', 'fail', 'DEVICE_FAIL']
]) {
await service.createCommand({
tenantId: context.tenantId,
assetId: String(device.id),
storeId: String(device.storeId),
roomId: String(device.roomId),
commandId,
commandType: 'task',
payload: { action: 'task', id: commandId, minute: 30, type: 1 },
traceId: `m06c-task-${result}`
});
await service.markPublished(context.tenantId, commandId);
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId, id: commandId, action: 'task', result
})));
const [failedRows] = await pool.query(
`SELECT status, failure_code AS failureCode
FROM qipai_iot_commands WHERE tenant_id = ? AND command_id = ?`,
[context.tenantId, commandId]
);
assert.deepEqual(failedRows, [{ status: 'FAILED', failureCode }]);
}
await service.createCommand({
tenantId: context.tenantId,
assetId: String(device.id),
storeId: String(device.storeId),
roomId: String(device.roomId),
commandId: '1782120000006',
commandType: 'task',
payload: { action: 'task', id: '1782120000006', minute: 30, type: 1 },
traceId: 'm06c-unknown-result'
});
await service.markPublished(context.tenantId, '1782120000006');
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId,
id: '1782120000006',
action: 'task',
result: 'vendor-new-code'
})));
const [unknownCommandRows] = await pool.query(
`SELECT status, failure_code AS failureCode
FROM qipai_iot_commands
WHERE tenant_id = ? AND command_id = '1782120000006'`,
[context.tenantId]
);
assert.deepEqual(unknownCommandRows, [{
status: 'FAILED', failureCode: 'UNKNOWN_VENDOR_RESULT'
}]);
const [unknownEventRows] = await pool.query(
`SELECT processing_status AS processingStatus,
JSON_UNQUOTE(JSON_EXTRACT(raw_payload, '$.result')) AS rawResult,
JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.result')) AS normalizedResult
FROM qipai_iot_device_events
WHERE tenant_id = ? AND command_id = '1782120000006'`,
[context.tenantId]
);
assert.deepEqual(unknownEventRows, [{
processingStatus: 'PROTOCOL_ERROR',
rawResult: 'vendor-new-code',
normalizedResult: 'UNKNOWN_VENDOR_RESULT'
}]);
const [unknownDeadRows] = await pool.query(
`SELECT error_code AS errorCode
FROM qipai_iot_dead_letters
WHERE tenant_id = ? AND topic = ? AND error_code = 'UNKNOWN_VENDOR_RESULT'`,
[context.tenantId, `/devicesend/${device.deviceId}`]
);
assert.deepEqual(unknownDeadRows, [{ errorCode: 'UNKNOWN_VENDOR_RESULT' }]);
await service.createCommand({
tenantId: context.tenantId,
assetId: String(device.id),
storeId: String(device.storeId),
roomId: String(device.roomId),
commandId: '1782120000007',
commandType: 'CtrlDevice',
payload: {
action: 'CtrlDevice', id: '1782120000007', subtype: '14', subID: 'SUB-AUTO-001',
order: 'setkey', value: '123456'
},
traceId: 'm06c-credential-redaction'
});
const [credentialRows] = await pool.query(
`SELECT JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.value')) AS value,
JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.valueHash')) AS valueHash
FROM qipai_iot_commands
WHERE tenant_id = ? AND command_id = '1782120000007'`,
[context.tenantId]
);
assert.equal(credentialRows[0].value, null);
assert.match(credentialRows[0].valueHash, /^[a-f0-9]{64}$/);
const [commandCountRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_iot_commands WHERE tenant_id = ?`,
[context.tenantId]
);
const commandCountBeforeEvents = Number(commandCountRows[0].total);
for (const [event, timestamp] of [['magstate', 1782120010], ['taskfinish', 1782120011]]) {
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
DeviceID: device.deviceId, event, state: 'close', timestamp
})));
}
const [commandCountAfterRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_iot_commands WHERE tenant_id = ?`,
[context.tenantId]
);
assert.equal(Number(commandCountAfterRows[0].total), commandCountBeforeEvents);
const [nonAckRows] = await pool.query(
`SELECT event_type AS eventType, command_id AS commandId
FROM qipai_iot_device_events
WHERE tenant_id = ? AND event_type IN ('magstate', 'taskfinish')
ORDER BY event_type`,
[context.tenantId]
);
assert.deepEqual(nonAckRows, [
{ eventType: 'magstate', commandId: null },
{ eventType: 'taskfinish', commandId: null }
]);
await service.handle(`/devicewill/${device.deviceId}`, Buffer.from('close'));
const [offlineRows] = await pool.query(
`SELECT status, JSON_UNQUOTE(JSON_EXTRACT(state_snapshot, '$.will')) AS willValue
FROM qipai_devices WHERE tenant_id = ? AND id = ?`,
[context.tenantId, device.id]
);
assert.deepEqual(offlineRows, [{ status: 'OFFLINE', willValue: 'close' }]);
const [willDeadRows] = await pool.query(
`SELECT COUNT(*) AS total FROM qipai_iot_dead_letters
WHERE tenant_id = ? AND topic = ? AND error_code = 'MQTT_PAYLOAD_INVALID'`,
[context.tenantId, `/devicewill/${device.deviceId}`]
);
assert.equal(Number(willDeadRows[0].total), 0);
} }
async function assertCleaningTaskTransactions(pool, context) { async function assertCleaningTaskTransactions(pool, context) {
@@ -2756,7 +2907,13 @@ try {
, ,
'door record credential hashing and masking', 'door record credential hashing and masking',
'low-battery alert upsert', 'low-battery alert upsert',
'AddDevice ACK creates 701C parent-child topology' 'AddDevice first ACK waits for final subtype and subID',
'AddDevice final ACK creates 701C parent-child topology',
'task busy unconfirm and fail remain failed command states',
'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'
, ,
'concurrent cleaning claim has one winner', 'concurrent cleaning claim has one winner',
'cleaning state and event transaction rollback', 'cleaning state and event transaction rollback',