Files
adam-acs/app/services/device_service.ts
T
Kevin L'HereecandContinue aad93911e4 feat: TR-069 CWMP state machine hardening + exchange viewer improvements
- TR-069 CWMP state machine hardening (14 phases): shared event utility,
  multi-event routing, GPN circuit breaker, IdleState GPN guard, expanded
  GPN fault codes, CriticalReadsState, ConfigEval GPN guard, task queue
  race condition fix, explicit error handling, terminal states
  (OperationalState/PartialOperationalState), consolidated reclassify,
  crash pattern detection, removed enqueueImmediate, state flow wiring
- Fix SOAP Fault not processed by state machine (soap-env:Fault detection)
- Fix GPN circuit breaker not persisting after session drops
- Fix CriticalReadsState GPV response parsing (xsi:type normalization)
- Fix CriticalReadsState not processing GPV response values
- Skip SPV when device is already compliant (ProvisioningCode check)
- Add Device.DeviceInfo.ModelName to critical reads GPV
- Fix TypeError on DeviceDetail parameters view (null guards)
- Log empty 204 responses in exchange trace UI
- TR-181 catalog: type corrections and new entries (+6640 lines)
- SequenceViewer: replace session-nav buttons with compact dropdown select
- SequenceViewer: fix CPE/ACS lifeline headers wrapping to 2 lines
- Add 20 unit tests for state machine fixes (SOAP Fault, GPN circuit
  breaker, CriticalReads, stale session, ConfigEval/ComplianceUpdate skips)

Generated with [Continue](https://continue.dev)

Co-Authored-By: Continue <noreply@continue.dev>
2026-06-08 12:49:00 +00:00

239 lines
7.6 KiB
TypeScript

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<DeviceRecord | null> {
return DeviceModel.findByIdentifier(identifier)
}
async deleteByIdentifier(identifier: string): Promise<boolean> {
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<string, any>
mode?: string
priority?: number
}
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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
}
}