import DeviceModel from '#models/device' import CommandModel from '#models/command' import { type DeviceRecord } from '../domain/device/device_record.js' import { CommandStatus, CommandType, Priority } from '../domain/commands/command.js' import { ModeType } from '../domain/shared/mode.js' import logger from '@adonisjs/core/services/logger' export interface PaginationData { first?: { offset: number; limit: number } previous?: { offset: number; limit: number } next?: { offset: number; limit: number } last?: { offset: number; limit: number } } export default class DeviceService { async getDevices(offset: number = 0, limit: number = 100) { const { data, total } = await DeviceModel.paginateDevices(offset, limit) const pager = this.calculatePagination(offset, limit, total) return { data, nbDocuments: total, pager } } async getDeviceByIdentifier(identifier: string): Promise { return DeviceModel.findByIdentifier(identifier) } async deleteByIdentifier(identifier: string): Promise { return DeviceModel.deleteOne({ identifier }) } /** * Execute a command on a device. Mode I triggers a Connection Request. */ async executeCommand( identifier: string, commandPayload: { action: CommandType parameters?: Record mode?: string priority?: number } ): Promise { const device = await DeviceModel.findByIdentifier(identifier) if (!device) { throw new Error(`Device not found: ${identifier}`) } const mode = (commandPayload.mode || 'NI').toUpperCase() === 'I' ? ModeType.I : ModeType.NI const priority = commandPayload.priority ?? Priority.NORMAL // Check for duplicate pending command with same mode + priority + action const collection = CommandModel.getCollection() const existingDup = await collection.findOne({ deviceIdentifier: identifier, mode, status: CommandStatus.PENDING, action: commandPayload.action, priority, }) if (existingDup) { throw new Error(`Duplicate pending command: ${commandPayload.action} in mode ${mode}`) } // Create the command await CommandModel.createCommand({ deviceIdentifier: identifier, action: commandPayload.action, parameters: commandPayload.parameters || {}, mode, status: CommandStatus.PENDING, priority, createdAt: new Date(), updatedAt: new Date(), }) // For mode I, trigger Connection Request to wake the device if (mode === ModeType.I) { await this.applyImmediateCommandForDevice(device, identifier) } } /** * Refresh a device by sending a Connection Request for REFRESH command */ async refreshDevice(identifier: string): Promise { const device = await DeviceModel.findByIdentifier(identifier) if (!device) { throw new Error(`Device not found: ${identifier}`) } await CommandModel.createCommand({ deviceIdentifier: identifier, action: CommandType.REFRESH, parameters: {}, mode: ModeType.I, status: CommandStatus.PENDING, priority: Priority.NORMAL, createdAt: new Date(), updatedAt: new Date(), }) await this.applyImmediateCommandForDevice(device, identifier) } /** * Send Connection Request to CPE to wake it up for immediate command execution */ private async applyImmediateCommandForDevice( device: DeviceRecord, identifier: string ): Promise { const connectionRequestParam = (device.properties || []).find((p: any) => p.name?.includes('ConnectionRequestURL') ) if (!connectionRequestParam || !connectionRequestParam.value) { logger.error( { identifier, availableProps: (device.properties || []).map((p: any) => p.name) }, 'ConnectionRequestURL not found in device properties' ) return } let url: URL try { url = new URL(connectionRequestParam.value as string) } catch { logger.error(`Invalid ConnectionRequestURL: ${connectionRequestParam.value}`) return } const wakeSuccess = await this.sendWakeOnWlan(url, device) await this.updatePendingCommandsStatus(identifier, wakeSuccess) } /** * Update pending mode-I commands status based on Connection Request result */ private async updatePendingCommandsStatus(identifier: string, success: boolean): Promise { const collection = CommandModel.getCollection() const pendingCmds = await collection .find({ deviceIdentifier: identifier, mode: ModeType.I, status: CommandStatus.PENDING, }) .toArray() for (const cmd of pendingCmds) { await collection.updateOne( { _id: cmd._id }, { $set: { status: success ? CommandStatus.WAITING : CommandStatus.PENDING, updatedAt: new Date(), }, } ) } } private async sendWakeOnWlan(connectionRequestURL: URL, device: DeviceRecord): Promise { try { // Use ConnectionRequest credentials from device properties/parameters // Fallback chain: ConnectionRequestUsername > ManagementServer.Username > OUI-ProductClass-Serial const allParams = [...(device.properties || []), ...(device.parameters || [])] const connReqUsername = allParams.find((p: any) => p.name?.includes('ConnectionRequestUsername') ) const connReqPassword = allParams.find((p: any) => p.name?.includes('ConnectionRequestPassword') ) const mgmtUsername = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Username')) const mgmtPassword = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Password')) // genieacs-sim uses ManagementServer.Username/Password for CR auth (non-standard) // Real CPEs use ConnectionRequestUsername/Password const username = mgmtUsername?.value || connReqUsername?.value || `${device.deviceId.oui}-${device.deviceId.productClass}-${device.deviceId.serialNumber}` const password = mgmtPassword?.value ?? connReqPassword?.value ?? '' logger.info({ url: connectionRequestURL.toString(), username }, 'Sending Connection Request') const basicAuth = Buffer.from(`${username}:${password}`).toString('base64') const response = await fetch(connectionRequestURL.toString(), { method: 'GET', headers: { Authorization: `Basic ${basicAuth}`, }, }) if (!response.ok) { logger.error( { status: response.status, statusText: response.statusText, url: connectionRequestURL.toString(), }, 'Connection Request failed with non-OK status' ) return false } logger.info( { status: response.status, url: connectionRequestURL.toString() }, 'Connection Request successful' ) return true } catch (error: any) { logger.error( { error: error?.message, code: error?.cause?.code, url: connectionRequestURL.toString() }, 'Connection Request network error' ) return false } } calculatePagination(offset: number, limit: number, total: number): PaginationData { const pagination: PaginationData = { first: { offset: 0, limit } } if (total >= limit) { pagination.last = { offset: total - limit, limit } } else { pagination.last = { offset: 0, limit } } if (offset > 0) { pagination.previous = { offset: Math.max(0, offset - limit), limit } } if (offset + limit < total) { pagination.next = { offset: offset + limit, limit } } return pagination } }