feat(M06-E): 接入智慧插座业务控制
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user