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;
}