feat(M06-E): 接入智慧插座业务控制

This commit is contained in:
Codex
2026-06-24 15:12:12 +08:00
parent 48638606fe
commit 9144fa8102
5 changed files with 198 additions and 3 deletions
@@ -4,6 +4,7 @@ import type { MySqlPool } from '../db/mysql.js';
import type { DeviceCommandService } from './device-command-service.js';
import {
JilianControlBoxAdapter,
JilianSmartSocketAdapter,
JilianSub1GLockAdapter
} from './jilian-adapters.js';
@@ -19,6 +20,7 @@ export class DeviceControlError extends Error {
export class DeviceControlService {
private readonly controlBox = new JilianControlBoxAdapter();
private readonly smartSocket = new JilianSmartSocketAdapter();
private readonly subLock = new JilianSub1GLockAdapter();
constructor(
@@ -106,6 +108,32 @@ export class DeviceControlService {
(id) => this.subLock.control({ id, ...vendorInput }));
}
async readSmartSocket(context: CommandContext, target: 'basicInfo' | 'workInfo') {
return this.issueSmartSocket(context, `socket:${target}`,
() => this.smartSocket.read(target));
}
async switchSmartSocket(context: CommandContext, input: {
on: boolean; slotNum?: number; orderId?: string | null;
}) {
return this.issueSmartSocket(context, input.on ? 'socket:on' : 'socket:off',
(id) => this.smartSocket.switch({ id, on: input.on, slotNum: input.slotNum }),
input.orderId ?? context.orderId);
}
async scheduleSmartSocket(context: CommandContext, input: {
taskNum: number; action: 'on' | 'off'; mode: 'once' | 'daily' | 'weekly';
time: string; weekdays?: number[];
}) {
return this.issueSmartSocket(context, 'localtask',
(id) => this.smartSocket.localTask({ id, ...input }), context.orderId);
}
async clearSmartSocketTask(context: CommandContext, taskNum: number) {
return this.issueSmartSocket(context, 'clearTask',
(id) => this.smartSocket.clearTask({ id, taskNum }), context.orderId);
}
private async issueControlBox(
context: CommandContext,
commandType: string,
@@ -129,6 +157,29 @@ export class DeviceControlService {
});
}
private async issueSmartSocket(
context: CommandContext,
commandType: string,
payloadFactory: (id: string) => Record<string, unknown>,
orderId?: string | null
) {
this.assertWriteScope(context.access, context.storeId);
const device = await this.resolveSmartSocket(context);
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
return this.commands.issue({
tenantId: context.tenantId,
assetId: String(device.id),
deviceId: device.deviceId,
storeId: context.storeId,
roomId: context.roomId,
orderId,
commandType,
payloadFactory,
traceId: context.traceId,
expiresAt: context.expiresAt
});
}
private async resolveControlBox(context: CommandContext) {
const [rows] = await this.pool.execute<DeviceRow[]>(
`SELECT id, device_id AS deviceId, store_id AS storeId, room_id AS roomId,
@@ -143,6 +194,20 @@ export class DeviceControlService {
return rows[0];
}
private async resolveSmartSocket(context: CommandContext) {
const [rows] = await this.pool.execute<DeviceRow[]>(
`SELECT id, device_id AS deviceId, store_id AS storeId, room_id AS roomId,
device_type AS deviceType, status
FROM qipai_devices
WHERE tenant_id = ? AND store_id = ? AND room_id = ?
AND device_type = 'SMART_SOCKET' AND deleted_at IS NULL
ORDER BY id LIMIT 1`,
[context.tenantId, context.storeId, context.roomId]
);
if (!rows[0]) throw new DeviceControlError('SMART_SOCKET_NOT_BOUND');
return rows[0];
}
private assertWriteScope(access: AccessProfile, storeId: string) {
if (access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('tenant.manage')) {
return;
@@ -211,6 +211,47 @@ export class IotMessageService {
await this.upsertAlert(device, 'LOW_BATTERY', battery <= 10 ? 'HIGH' : 'MEDIUM',
`Device battery is ${battery}%.`);
}
if (device.deviceType === 'SMART_SOCKET') {
await this.applySmartSocketAlerts(device, message);
}
}
private async applySmartSocketAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
const payload = message.payload;
const eventType = message.eventType.toLowerCase();
const triggered = (names: string[]) => names.some((name) =>
eventType === name.toLowerCase() || readBoolean(payload[name])
);
if (triggered(['overload', 'overLoad', 'overpower', 'overPower'])) {
await this.upsertAlert(device, 'SOCKET_OVERLOAD', 'HIGH',
'Smart socket reported overload or overpower protection.');
}
if (triggered(['overheat', 'overHeat', 'overTemperature'])) {
await this.upsertAlert(device, 'SOCKET_OVERHEAT', 'HIGH',
'Smart socket reported over-temperature protection.');
}
if (triggered(['overcurrent', 'overCurrent'])) {
await this.upsertAlert(device, 'SOCKET_OVERCURRENT', 'HIGH',
'Smart socket reported over-current protection.');
}
if (triggered(['overvoltage', 'overVoltage'])) {
await this.upsertAlert(device, 'SOCKET_OVERVOLTAGE', 'MEDIUM',
'Smart socket reported over-voltage protection.');
}
if (triggered(['undervoltage', 'underVoltage'])) {
await this.upsertAlert(device, 'SOCKET_UNDERVOLTAGE', 'MEDIUM',
'Smart socket reported under-voltage protection.');
}
const powerWatts = readNumber(payload.powerW ?? payload.power ?? payload.watt);
if (powerWatts !== null && powerWatts > 3500) {
await this.upsertAlert(device, 'SOCKET_POWER_LIMIT', 'HIGH',
`Smart socket power is ${powerWatts}W.`);
}
const temperature = readNumber(payload.temperature ?? payload.temp);
if (temperature !== null && temperature >= 75) {
await this.upsertAlert(device, 'SOCKET_TEMPERATURE_LIMIT', 'HIGH',
`Smart socket temperature is ${temperature}C.`);
}
}
private async upsertAlert(
@@ -320,3 +361,12 @@ function readNumber(value: unknown): number | null {
}
return null;
}
function readBoolean(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
return ['1', 'true', 'yes', 'on', 'alarm'].includes(value.trim().toLowerCase());
}
return false;
}
+28 -1
View File
@@ -58,11 +58,30 @@ const subLockSchema = contextSchema.extend({
content: z.string().min(1).max(128).optional(),
dangerConfirmation: z.string().max(64).optional()
});
const socketReadSchema = contextSchema.extend({
target: z.enum(['basicInfo', 'workInfo']).default('workInfo')
});
const socketSwitchSchema = contextSchema.extend({
on: z.boolean(),
slotNum: z.number().int().min(1).max(20).default(1)
});
const socketTaskSchema = contextSchema.extend({
taskNum: z.number().int().min(1).max(20),
action: z.enum(['on', 'off']),
mode: z.enum(['once', 'daily', 'weekly']),
time: z.string().regex(/^\d{2}:\d{2}$/),
weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional()
});
const socketClearTaskSchema = contextSchema.extend({
taskNum: z.number().int().min(0).max(20)
});
export interface DeviceControlRouteOptions {
service: Pick<DeviceControlService,
'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed'
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'>;
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
| 'clearSmartSocketTask'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -91,6 +110,14 @@ export async function registerDeviceControlRoutes(
(context, body) => options.service.pairSubLock(context, body.timeout));
register('/admin-api/device-control/sub-lock/action', subLockSchema,
(context, body) => options.service.controlSubLock(context, body));
register('/admin-api/device-control/socket/read', socketReadSchema,
(context, body) => options.service.readSmartSocket(context, body.target));
register('/admin-api/device-control/socket/switch', socketSwitchSchema,
(context, body) => options.service.switchSmartSocket(context, body));
register('/admin-api/device-control/socket/task', socketTaskSchema,
(context, body) => options.service.scheduleSmartSocket(context, body));
register('/admin-api/device-control/socket/task/clear', socketClearTaskSchema,
(context, body) => options.service.clearSmartSocketTask(context, body.taskNum));
function register<T extends z.ZodTypeAny>(
url: string,