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 type { DeviceCommandService } from './device-command-service.js';
import { import {
JilianControlBoxAdapter, JilianControlBoxAdapter,
JilianSmartSocketAdapter,
JilianSub1GLockAdapter JilianSub1GLockAdapter
} from './jilian-adapters.js'; } from './jilian-adapters.js';
@@ -19,6 +20,7 @@ export class DeviceControlError extends Error {
export class DeviceControlService { export class DeviceControlService {
private readonly controlBox = new JilianControlBoxAdapter(); private readonly controlBox = new JilianControlBoxAdapter();
private readonly smartSocket = new JilianSmartSocketAdapter();
private readonly subLock = new JilianSub1GLockAdapter(); private readonly subLock = new JilianSub1GLockAdapter();
constructor( constructor(
@@ -106,6 +108,32 @@ export class DeviceControlService {
(id) => this.subLock.control({ id, ...vendorInput })); (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( private async issueControlBox(
context: CommandContext, context: CommandContext,
commandType: string, 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) { private async resolveControlBox(context: CommandContext) {
const [rows] = await this.pool.execute<DeviceRow[]>( const [rows] = await this.pool.execute<DeviceRow[]>(
`SELECT id, device_id AS deviceId, store_id AS storeId, room_id AS roomId, `SELECT id, device_id AS deviceId, store_id AS storeId, room_id AS roomId,
@@ -143,6 +194,20 @@ export class DeviceControlService {
return rows[0]; 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) { private assertWriteScope(access: AccessProfile, storeId: string) {
if (access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('tenant.manage')) { if (access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('tenant.manage')) {
return; return;
@@ -211,6 +211,47 @@ export class IotMessageService {
await this.upsertAlert(device, 'LOW_BATTERY', battery <= 10 ? 'HIGH' : 'MEDIUM', await this.upsertAlert(device, 'LOW_BATTERY', battery <= 10 ? 'HIGH' : 'MEDIUM',
`Device battery is ${battery}%.`); `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( private async upsertAlert(
@@ -320,3 +361,12 @@ function readNumber(value: unknown): number | null {
} }
return 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(), content: z.string().min(1).max(128).optional(),
dangerConfirmation: z.string().max(64).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 { export interface DeviceControlRouteOptions {
service: Pick<DeviceControlService, service: Pick<DeviceControlService,
'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed' 'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed'
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'>; | 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
| 'clearSmartSocketTask'>;
authRepository: Pick<AuthRepository, 'validateSession'>; authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> }; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string; jwtSecret: string;
@@ -91,6 +110,14 @@ export async function registerDeviceControlRoutes(
(context, body) => options.service.pairSubLock(context, body.timeout)); (context, body) => options.service.pairSubLock(context, body.timeout));
register('/admin-api/device-control/sub-lock/action', subLockSchema, register('/admin-api/device-control/sub-lock/action', subLockSchema,
(context, body) => options.service.controlSubLock(context, body)); (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>( function register<T extends z.ZodTypeAny>(
url: string, url: string,
+33 -2
View File
@@ -15,6 +15,12 @@ const issued = [];
const service = new DeviceControlService({ const service = new DeviceControlService({
async execute(sql) { async execute(sql) {
if (sql.includes('FROM qipai_devices')) { if (sql.includes('FROM qipai_devices')) {
if (sql.includes("device_type = 'SMART_SOCKET'")) {
return [[{
id: 52, deviceId: 'SOCKET_001', storeId: 11, roomId: 31,
deviceType: 'SMART_SOCKET', status: 'ONLINE'
}], []];
}
return [[{ return [[{
id: 51, deviceId: 'BOX_001', storeId: 11, roomId: 31, id: 51, deviceId: 'BOX_001', storeId: 11, roomId: 31,
deviceType: 'CONTROL_BOX', status: 'ONLINE' deviceType: 'CONTROL_BOX', status: 'ONLINE'
@@ -56,6 +62,19 @@ await service.controlSubLock(context, {
subID: 'SUB001', order: 'open', delayTime: 4 subID: 'SUB001', order: 'open', delayTime: 4
}); });
assert.equal(issued.at(-1).payload.action, 'CtrlDevice'); assert.equal(issued.at(-1).payload.action, 'CtrlDevice');
await service.readSmartSocket(context, 'workInfo');
assert.deepEqual(issued.at(-1).payload, { read: 'workInfo' });
await service.switchSmartSocket(context, { on: false, slotNum: 2 });
assert.deepEqual(issued.at(-1).payload, {
action: 'off', id: '1782120000001', slotNum: 2
});
await service.scheduleSmartSocket(context, {
taskNum: 1, action: 'on', mode: 'weekly', time: '18:30', weekdays: [5, 6]
});
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');
await assert.rejects( await assert.rejects(
() => service.controlSubLock(context, { () => service.controlSubLock(context, {
@@ -114,7 +133,11 @@ const app = await buildApp({
async extendTask() { return {}; }, async extendTask() { return {}; },
async cancelTask() { return {}; }, async cancelTask() { return {}; },
async pairSubLock() { return {}; }, async pairSubLock() { return {}; },
async controlSubLock() { return {}; } async controlSubLock() { return {}; },
async readSmartSocket() { return {}; },
async switchSmartSocket(_context, input) { routed = input; return { status: 'PUBLISHED' }; },
async scheduleSmartSocket() { return {}; },
async clearSmartSocketTask() { return {}; }
} }
} }
}); });
@@ -126,6 +149,14 @@ const response = await app.inject({
}); });
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.equal(routed.slot1, 'on'); assert.equal(routed.slot1, 'on');
const socketResponse = await app.inject({
method: 'POST',
url: '/admin-api/device-control/socket/switch',
headers: { authorization: `Bearer ${token}` },
payload: { storeId: '11', roomId: '31', on: true, slotNum: 1 }
});
assert.equal(socketResponse.statusCode, 200);
assert.equal(routed.on, true);
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',
@@ -135,4 +166,4 @@ const invalid = await app.inject({
assert.equal(invalid.statusCode, 400); assert.equal(invalid.statusCode, 400);
await app.close(); await app.close();
console.log('PASS: M06-D control-box and Sub-1G command gates and routes work.'); console.log('PASS: M06-E device control commands include control-box, Sub-1G and smart socket.');
+22
View File
@@ -91,6 +91,12 @@ const service = new IotMessageService({
async execute(sql, params) { async execute(sql, params) {
calls.push({ sql, params }); calls.push({ sql, params });
if (sql.includes('FROM qipai_devices')) { if (sql.includes('FROM qipai_devices')) {
if (params[0] === 'SOCKET_001') {
return [[{
id: 52, tenantId: 7, storeId: 11, roomId: 31,
deviceId: 'SOCKET_001', deviceType: 'SMART_SOCKET'
}], []];
}
return [[{ return [[{
id: 51, tenantId: 7, storeId: 11, roomId: 31, id: 51, tenantId: 7, storeId: 11, roomId: 31,
deviceId: 'BOX_001', deviceType: 'CONTROL_BOX' deviceId: 'BOX_001', deviceType: 'CONTROL_BOX'
@@ -150,6 +156,22 @@ 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);
await service.handle('/devicesend/SOCKET_001', Buffer.from(JSON.stringify({
DeviceID: 'SOCKET_001', event: 'workInfo', switch: 'on',
powerW: 3601, temperature: 82, overLoad: true
})));
assert.equal(calls.some((item) =>
item.sql.includes('INSERT INTO qipai_device_alerts')
&& item.params.includes('SOCKET_OVERLOAD')
), true);
assert.equal(calls.some((item) =>
item.sql.includes('INSERT INTO qipai_device_alerts')
&& item.params.includes('SOCKET_POWER_LIMIT')
), true);
assert.equal(calls.some((item) =>
item.sql.includes('INSERT INTO qipai_device_alerts')
&& item.params.includes('SOCKET_TEMPERATURE_LIMIT')
), true);
await assert.rejects( await assert.rejects(
() => service.createCommand({ () => service.createCommand({