diff --git a/backend/src/devices/device-control-service.ts b/backend/src/devices/device-control-service.ts index 5dcd0d3..7ba0b93 100644 --- a/backend/src/devices/device-control-service.ts +++ b/backend/src/devices/device-control-service.ts @@ -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, + 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( `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( + `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; diff --git a/backend/src/devices/iot-message-service.ts b/backend/src/devices/iot-message-service.ts index 97665f7..f487858 100644 --- a/backend/src/devices/iot-message-service.ts +++ b/backend/src/devices/iot-message-service.ts @@ -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; +} diff --git a/backend/src/routes/device-control.ts b/backend/src/routes/device-control.ts index 6cde555..2395fed 100644 --- a/backend/src/routes/device-control.ts +++ b/backend/src/routes/device-control.ts @@ -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; + | 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock' + | 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket' + | 'clearSmartSocketTask'>; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; 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( url: string, diff --git a/backend/tests/device-control.test.mjs b/backend/tests/device-control.test.mjs index 911821a..abbe549 100644 --- a/backend/tests/device-control.test.mjs +++ b/backend/tests/device-control.test.mjs @@ -15,6 +15,12 @@ const issued = []; const service = new DeviceControlService({ async execute(sql) { 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 [[{ id: 51, deviceId: 'BOX_001', storeId: 11, roomId: 31, deviceType: 'CONTROL_BOX', status: 'ONLINE' @@ -56,6 +62,19 @@ await service.controlSubLock(context, { subID: 'SUB001', order: 'open', delayTime: 4 }); 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( () => service.controlSubLock(context, { @@ -114,7 +133,11 @@ const app = await buildApp({ async extendTask() { return {}; }, async cancelTask() { 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(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({ method: 'POST', url: '/admin-api/device-control/door', @@ -135,4 +166,4 @@ const invalid = await app.inject({ assert.equal(invalid.statusCode, 400); 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.'); diff --git a/backend/tests/iot-protocol.test.mjs b/backend/tests/iot-protocol.test.mjs index 4309f22..2c72ea9 100644 --- a/backend/tests/iot-protocol.test.mjs +++ b/backend/tests/iot-protocol.test.mjs @@ -91,6 +91,12 @@ const service = new IotMessageService({ async execute(sql, params) { calls.push({ sql, params }); 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 [[{ id: 51, tenantId: 7, storeId: 11, roomId: 31, deviceId: 'BOX_001', deviceType: 'CONTROL_BOX' @@ -150,6 +156,22 @@ assert.equal(calls.some((item) => item.sql.includes('INSERT INTO qipai_device_alerts') && item.params.includes('DEVICE_UNCONFIRM') ), 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( () => service.createCommand({