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 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user