fix(M06-C-R1): 校正控制箱与门锁协议
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
if (topicMatch[1] === 'devicewill') {
|
||||
rawPayload = parseWillPayload(payloadText);
|
||||
normalized = normalizeWill(rawPayload);
|
||||
} else {
|
||||
rawPayload = JSON.parse(payloadText);
|
||||
normalized = topicMatch[1] === 'devicewill'
|
||||
? normalizeWill(rawPayload)
|
||||
: this.adapters[device.deviceType].parseUplink(rawPayload);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -48,7 +48,11 @@ assert.equal(issued.at(-1).payload.action, 'ConctolPower');
|
||||
await service.controlDoor(context, { order: 'open', holdopen: 1, delayTime: 4 });
|
||||
assert.equal(issued.at(-1).payload.action, 'Crldoor');
|
||||
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);
|
||||
assert.equal(issued.at(-1).payload.action, 'stopTTS');
|
||||
await service.controlLed(context, 30);
|
||||
@@ -60,11 +64,21 @@ assert.equal(issued.at(-1).payload.action, 'addtask');
|
||||
await service.cancelTask(context);
|
||||
assert.equal(issued.at(-1).payload.action, 'canceltask');
|
||||
await service.pairSubLock(context, 60);
|
||||
assert.equal(issued.at(-1).payload.action, 'AddDevice');
|
||||
await service.controlSubLock(context, {
|
||||
subID: 'SUB001', order: 'open', delayTime: 4
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'AddDevice', time: 60, id: '1782120000001'
|
||||
});
|
||||
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');
|
||||
assert.deepEqual(issued.at(-1).payload, { read: 'workInfo' });
|
||||
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');
|
||||
await service.clearSmartSocketTask(context, 1);
|
||||
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/);
|
||||
|
||||
await assert.rejects(
|
||||
() => 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.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
|
||||
@@ -93,11 +108,26 @@ const platformContext = {
|
||||
access: { roles: ['PLATFORM_ADMIN'], capabilities: [], storeIds: [] }
|
||||
};
|
||||
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('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(
|
||||
() => service.controlPower({
|
||||
...context,
|
||||
@@ -192,6 +222,30 @@ const socketResponse = await app.inject({
|
||||
});
|
||||
assert.equal(socketResponse.statusCode, 200);
|
||||
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({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/door',
|
||||
|
||||
@@ -21,7 +21,8 @@ assert.equal(dryCases.every((item) => item.destructive === false), true);
|
||||
const actionCases = buildHardwareSmokeCases({
|
||||
...config,
|
||||
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 === 'sub-lock-open'), true);
|
||||
|
||||
@@ -20,9 +20,14 @@ assert.deepEqual(control.controlPower({
|
||||
assert.equal(control.controlDoor({
|
||||
id: '1234567890123', order: 'open'
|
||||
}).action, 'Crldoor');
|
||||
assert.equal(control.playTts({
|
||||
id: '1234567890123', content: '欢迎光临'
|
||||
}).action, 'PlayTTS');
|
||||
assert.deepEqual(control.playTts({
|
||||
id: '1234567890123', content: '欢迎光临', volume: 7, firstPlay: 1,
|
||||
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'), {
|
||||
action: 'stopTTS', id: '1234567890123'
|
||||
});
|
||||
@@ -45,14 +50,26 @@ assert.throws(
|
||||
|
||||
const lock = new JilianSub1GLockAdapter();
|
||||
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(
|
||||
() => lock.control({ id: '123', subID: 'SUB001', order: 'setkey' }),
|
||||
/content is required/
|
||||
() => lock.control({
|
||||
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();
|
||||
@@ -74,6 +91,17 @@ const ack = control.parseUplink({
|
||||
assert.equal(ack.kind, 'ACK');
|
||||
assert.equal(ack.result, 'unconfirm');
|
||||
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({
|
||||
deviceID: 'BOX_001', event: 'record', type: 'card', state: 'open',
|
||||
timestamp: 1782120000
|
||||
@@ -81,6 +109,13 @@ const event = lock.parseUplink({
|
||||
assert.equal(event.kind, 'EVENT');
|
||||
assert.equal(event.eventType, 'record');
|
||||
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.match(generateCommandId(), /^\d{13}$/);
|
||||
@@ -156,6 +191,55 @@ assert.equal(calls.some((item) =>
|
||||
item.sql.includes('INSERT INTO qipai_device_alerts')
|
||||
&& item.params.includes('DEVICE_UNCONFIRM')
|
||||
), 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({
|
||||
DeviceID: 'SOCKET_001', event: 'workInfo', switch: 'on',
|
||||
powerW: 3601, temperature: 82, overLoad: true
|
||||
@@ -182,6 +266,20 @@ await assert.rejects(
|
||||
/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 commandService = new DeviceCommandService({
|
||||
async createCommand(input) { commandCalls.push(['create', input]); },
|
||||
|
||||
@@ -2108,10 +2108,23 @@ async function assertIotMessages(pool, context) {
|
||||
roomId: String(device.roomId),
|
||||
commandId: '1782120000002',
|
||||
commandType: 'AddDevice',
|
||||
payload: { action: 'AddDevice', id: '1782120000002', timeout: 60 },
|
||||
payload: { action: 'AddDevice', time: 60, id: '1782120000002' },
|
||||
traceId: 'm06d-pair-test'
|
||||
});
|
||||
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({
|
||||
DeviceID: device.deviceId,
|
||||
id: '1782120000002',
|
||||
@@ -2130,6 +2143,144 @@ async function assertIotMessages(pool, context) {
|
||||
[context.tenantId, device.id]
|
||||
);
|
||||
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) {
|
||||
@@ -2756,7 +2907,13 @@ try {
|
||||
,
|
||||
'door record credential hashing and masking',
|
||||
'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',
|
||||
'cleaning state and event transaction rollback',
|
||||
|
||||
Reference in New Issue
Block a user