import { Device } from '../../domain/device/device.js' import { CWMPTaskQueue } from './task_queue.js' import { DeviceActionExecutor } from './device_action_executor.js' import { getDeviceHashIdentifier } from '../../utils/device_utils.js' import { CommandStatus, CommandType } from '../../domain/commands/command.js' import DeviceModel from '../../models/device.js' import CommandModel from '../../models/command.js' import { ModeType } from '../../domain/shared/mode.js' import logger from '@adonisjs/core/services/logger' import { ConfigurationEvaluationState } from './configuration_evaluation_state.js' import { reclassifyDeviceFromCatalog } from '../parameter_writability.js' // ─── Interfaces ──────────────────────────────────────────────────────────────── export interface DeviceContext { device: Device session: string message?: { success: boolean; data: any } taskQueue: CWMPTaskQueue events?: string[] services: { deviceAction: DeviceActionExecutor } } export interface DeviceStateResult { nextState?: DeviceState response?: string isComplete?: boolean } // ─── Abstract State ──────────────────────────────────────────────────────────── abstract class DeviceState { abstract name: string abstract handle(context: DeviceContext): Promise } // ─── States ──────────────────────────────────────────────────────────────────── class IdleState extends DeviceState { name = 'idle' async handle(context: DeviceContext): Promise { const { device } = context // Determine which Inform parameters are NOT yet classified. // A param is "classified" if it's in _parameters (writable) OR in _properties (read-only) // AND the device has been through classification before (gpnStatus != 'unknown'). // If gpnStatus is 'unknown', the device has never been classified → all need GPN/catalog. const tmpParams = device.getTmpParamValues() const unclassified: string[] = [] if (device.gpnStatus === 'unknown') { // Never classified → all tmpParams are unclassified for (const pv of tmpParams) { const name = typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || null if (name) unclassified.push(name) } } else { // Already classified before → only params NOT in _parameters AND NOT in _properties are new for (const pv of tmpParams) { const name = typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || null if (!name) continue if (!device.getParameter(name) && !device.getProperty(name)) { unclassified.push(name) } } } // If all Inform params are already classified → skip GPN/catalog, go to CriticalReads if (unclassified.length === 0) { logger.info( { deviceId: device.identifier, gpnStatus: device.gpnStatus }, '[IdleState] All Inform params already classified — skipping to CriticalReads' ) return { nextState: new CriticalReadsState(), response: '' } } // Extract unique parent paths from the UNCLASSIFIED params only const parentPaths = new Set() for (const name of unclassified) { const lastDot = name.lastIndexOf('.') if (lastDot > 0) { parentPaths.add(name.substring(0, lastDot + 1)) } } // Case 1: GPN unsupported or circuit breaker open → classify unclassified via catalog if (device.gpnStatus === 'unsupported' || !device.isGpnAllowed()) { logger.info( { gpnStatus: device.gpnStatus, unclassifiedCount: unclassified.length, deviceId: device.identifier }, '[IdleState] GPN unavailable — classifying unclassified params via catalog' ) reclassifyDeviceFromCatalog(device) return { nextState: new CriticalReadsState(), response: '' } } // Case 2: GPN supported → send GPN only on parent paths of unclassified params // Case 3: GPN unknown → send GPN to test support + classify const paths = Array.from(parentPaths).sort() logger.info( { paths, unclassifiedCount: unclassified.length, gpnStatus: device.gpnStatus, deviceId: device.identifier }, '[IdleState] Classifying unclassified params via GPN' ) return { nextState: new DiscoveringParametersState(paths), response: '' } } } class DiscoveringParametersState extends DeviceState { name = 'discovering-parameters' private parameterQueue: string[] private started = false constructor(parameterQueue: string[]) { super() this.parameterQueue = parameterQueue } async handle(context: DeviceContext): Promise { if (!this.started) { this.started = true if (this.parameterQueue.length === 0) { return { nextState: new CriticalReadsState(), response: '' } } return { response: await this.processNext(context) } } // If CPE responded with a Fault, GPN is not supported — skip discovery if (context.message && !context.message.success) { logger.info( { faultData: context.message.data }, '[DiscoveringParameters] GPN Fault received — marking as unsupported' ) context.device.setGpnStatus('unsupported') context.device.recordGpnFailure() // GPN failed → classify params via catalog instead reclassifyDeviceFromCatalog(context.device) // Persist device with classification + circuit breaker state try { const hashId = getDeviceHashIdentifier({ manufacturer: context.device.deviceId.manufacturer, oui: context.device.deviceId.oui, productClass: context.device.deviceId.productClass, serialNumber: context.device.deviceId.serialNumber, }) const snapshot = context.device.toSnapshot() await DeviceModel.createOrUpdate({ identifier: hashId, deviceId: context.device.deviceId, properties: snapshot.properties, parameters: snapshot.parameters, gpnStatus: context.device.gpnStatus, dataModel: context.device.dataModel, gpnCircuitBreaker: snapshot.gpnCircuitBreaker, lastGpnSentDate: snapshot.lastGpnSentDate, updatedAt: new Date(), }) logger.info( { hashId, gpnStatus: context.device.gpnStatus, circuitBreaker: context.device.gpnCircuitBreaker }, '[DiscoveringParameters] Persisted GPN failure + catalog classification to DB' ) } catch (e) { logger.error(`Failed to persist GPN circuit breaker state: ${e}`) } return { nextState: new CriticalReadsState(), response: '' } } // GPN success — process the response if (context.message?.success && context.message.data) { try { context.device.organizeParameters(context.message.data, new Date()) context.device.setGpnStatus('supported') logger.info( { deviceId: context.device.identifier, paramCount: context.device.getParameters().length }, '[DiscoveringParameters] GPN response processed' ) } catch (e: any) { logger.error({ error: e?.message }, '[DiscoveringParameters] Error processing GPN response') } } // Continue with next path in queue if any if (this.parameterQueue.length > 0) { return { response: await this.processNext(context) } } return { nextState: new CriticalReadsState(), response: '' } } private async processNext(context: DeviceContext): Promise { if (this.parameterQueue.length === 0) return '' const current = this.parameterQueue.shift()! return context.services.deviceAction.getParameterNames(context.device, current, () => Promise.resolve('')) } } /** * CriticalReadsState (Phase 6) * * Performs GPV for HardwareVersion, SoftwareVersion, ConnectionRequestURL. * Always executed regardless of gpnStatus. * Replaces the enqueueImmediate(GPV) from the controller. */ class CriticalReadsState extends DeviceState { name = 'critical-reads' private started = false async handle(context: DeviceContext): Promise { if (!this.started) { this.started = true const isTr181 = context.device.dataModel === 'tr-181' const root = isTr181 ? 'Device' : 'InternetGatewayDevice' const criticalPaths = [ `${root}.DeviceInfo.HardwareVersion`, `${root}.DeviceInfo.SoftwareVersion`, `${root}.DeviceInfo.ModelName`, `${root}.ManagementServer.ConnectionRequestURL`, ] const response = await context.services.deviceAction.getParameterValues( context.device, criticalPaths, async (result) => { if (result.status === 'success') { const resp: any = result.data const list = resp?.ParameterList?.ParameterValueStruct if (Array.isArray(list) && list.length > 0) { context.device.setTmpParamValues(list) } } return '' } ) return { response } } // After GPV response: process values from the response, then reclassify if (context.message?.success && context.message.data) { try { const resp = context.message.data const list = resp?.ParameterList?.ParameterValueStruct if (Array.isArray(list) && list.length > 0) { // Normalize Name fields: XMLParser may return {_: "path", "$xsi:type": "..."} when Name has attributes const normalized = list.map((pv: any) => ({ ...pv, Name: typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || pv?.Name, })) context.device.setTmpParamValues(normalized) context.device.updateFromValues(normalized) logger.info({ count: normalized.length, names: normalized.map((p: any) => p.Name) }, '[CriticalReads] GPV response values processed') } } catch (e: any) { logger.error({ error: e?.message }, '[CriticalReads] Error processing GPV response values') } } // Classification is handled in IdleState — no need to reclassify here return { nextState: new ConfigurationEvaluationState(), response: '' } } } export class ExecutingCommandsState extends DeviceState { name = 'executing-commands' private commandsQueue: any[] = [] private started = false private currentCommandRecord: any | null = null private includeNI: boolean constructor(includeNI: boolean = true) { super() this.includeNI = includeNI } async handle(context: DeviceContext): Promise { if (!this.started) { const hashId = getDeviceHashIdentifier({ manufacturer: context.device.deviceId.manufacturer, oui: context.device.deviceId.oui, productClass: context.device.deviceId.productClass, serialNumber: context.device.deviceId.serialNumber, }) const collection = CommandModel.getCollection() const iWaiting = await collection.find({ deviceIdentifier: hashId, mode: ModeType.I, status: CommandStatus.WAITING }).sort({ priority: 1 }).toArray() const iPending = await collection.find({ deviceIdentifier: hashId, mode: ModeType.I, status: CommandStatus.PENDING }).sort({ priority: 1 }).toArray() const niPending = this.includeNI ? await collection.find({ deviceIdentifier: hashId, mode: ModeType.NI, status: CommandStatus.PENDING }).sort({ priority: 1 }).toArray() : [] this.commandsQueue = [...iWaiting, ...iPending, ...niPending] this.started = true logger.debug({ hashId, iWaiting: iWaiting.length, iPending: iPending.length, niPending: niPending.length, includeNI: this.includeNI }, '[ExecutingCommands] Loaded commands queue') if (this.commandsQueue.length === 0) { if (this.includeNI) { return { nextState: new OperationalState(), response: '' } } // ImmediateCommandStateMachine with no commands — mark complete return { isComplete: true, response: '' } } return { response: await this.processNextCommand(context) } } // Handle response for last command if (this.currentCommandRecord) { const now = new Date() if (context.message?.success) { this.currentCommandRecord.status = CommandStatus.COMPLETED this.currentCommandRecord.completedAt = now this.currentCommandRecord.executedAt = now // Optimistic update for SPV if (this.currentCommandRecord.action === CommandType.SET_PARAMETER_VALUES) { const params = Array.isArray(this.currentCommandRecord.parameters) ? this.currentCommandRecord.parameters : [] const pvStructs = params.map((p: any) => ({ Name: p?.name, Value: { _: p?.value, $: { Type: 'xsd:string' } }, })) if (pvStructs.length > 0) { context.device.updateFromValues(pvStructs) } } } else { this.currentCommandRecord.status = CommandStatus.FAILED this.currentCommandRecord.executedAt = now } try { await CommandModel.updateCommand(this.currentCommandRecord._id, { status: this.currentCommandRecord.status, executedAt: now, completedAt: this.currentCommandRecord.completedAt, updatedAt: now, }) } catch (err) { logger.error(`Failed to update command: ${err}`) } this.currentCommandRecord = null } if (this.commandsQueue.length > 0) { return { response: await this.processNextCommand(context) } } // All commands done if (this.includeNI) { return { nextState: new OperationalState(), response: '' } } return { response: '' } } private async processNextCommand(context: DeviceContext): Promise { if (this.commandsQueue.length === 0) return '' const commandRecord = this.commandsQueue.shift()! this.currentCommandRecord = commandRecord // Mark as SENT const now = new Date() commandRecord.status = CommandStatus.SENT commandRecord.executedAt = now try { await CommandModel.updateCommand(commandRecord._id, { status: CommandStatus.SENT, executedAt: now, updatedAt: now }) } catch (err) { logger.error(`Failed to mark command as SENT: ${err}`) } // Clear pending tasks for this device before issuing command try { context.taskQueue.clearPendingTasksForDevice(context.device.deviceId) } catch {} const action = commandRecord.action as CommandType const params = commandRecord.parameters // Map CommandType to CWMP request switch (action) { case CommandType.GET_PARAMETER_VALUES: { const names: string[] = Array.isArray(params) ? params : params?.names || params?.paths || params?.parameterNames || [] return context.services.deviceAction.getParameterValues(context.device, names, () => Promise.resolve('')) } case CommandType.GET_PARAMETER_NAMES: { const name: string = typeof params === 'string' ? params : (params?.name || params?.path || params?.parameterPath || '') return context.services.deviceAction.getParameterNames(context.device, name, () => Promise.resolve('')) } case CommandType.GET_PARAMETER_ATTRIBUTES: { const names: string[] = Array.isArray(params) ? params : params?.names || params?.paths || params?.parameterNames || [] return context.services.deviceAction.getParameterAttributes(context.device, names, () => Promise.resolve('')) } case CommandType.SET_PARAMETER_VALUES: { const rawParams = Array.isArray(params) ? params : params?.parameterValues || params?.parameters || [] const spvParams = rawParams.map((p: any) => ({ parameterName: p.parameterName ?? p.name, value: p.value, type: p.type, })) return context.services.deviceAction.applySetParameterValues(context.device, spvParams, () => Promise.resolve('')) } case CommandType.SET_PARAMETER_ATTRIBUTES: { const spaParams = Array.isArray(params) ? params : params?.attributes || params?.parameterAttributes || [] return context.services.deviceAction.applySetParameterAttributes(context.device, spaParams, () => Promise.resolve('')) } case CommandType.DOWNLOAD: { return context.services.deviceAction.applyDownload(context.device, params || {}, () => Promise.resolve('')) } case CommandType.UPLOAD: { return context.services.deviceAction.applyUpload(context.device, params || {}, () => Promise.resolve('')) } case CommandType.REBOOT: { return context.services.deviceAction.applyReboot(context.device, params || {}, () => Promise.resolve('')) } case CommandType.FACTORY_RESET: { return context.services.deviceAction.applyFactoryReset(context.device, {}, () => Promise.resolve('')) } case CommandType.SCHEDULE_INFORM: { return context.services.deviceAction.applyScheduleInform( context.device, { delayInSeconds: params?.delayInSeconds || 300, commandKey: params?.commandKey }, () => Promise.resolve('') ) } case CommandType.REFRESH: { // Refresh = GetParameterValues on all known leaf parameters that have non-empty values const allNames = [ ...context.device.getParameters().filter((p) => p.value != null && p.value !== '').map((p) => p.name), ...context.device.getProperties().filter((p) => p.value != null && p.value !== '').map((p) => p.name), ].filter((name) => !!name && !name.endsWith('.')) if (allNames.length > 0) { return context.services.deviceAction.getParameterValues(context.device, allNames, () => Promise.resolve('')) } return '' } default: logger.error(`Unknown command type: ${action}`) // Mark as failed commandRecord.status = CommandStatus.FAILED await CommandModel.updateCommand(commandRecord._id, { status: CommandStatus.FAILED, updatedAt: new Date() }) this.currentCommandRecord = null if (this.commandsQueue.length > 0) { return this.processNextCommand(context) } return '' } } } /** * OperationalState (Phase 10) * Terminal state for successful session completion. */ export class OperationalState extends DeviceState { name = 'operational' async handle(context: DeviceContext): Promise { const { device } = context // Classification is handled incrementally in IdleState — no need here // Persist device with operational status device.operationalStatus = 'operational' try { const snapshot = device.toSnapshot() await DeviceModel.createOrUpdate({ identifier: snapshot.identifier, deviceId: snapshot.deviceId, properties: snapshot.properties, parameters: snapshot.parameters, gpnStatus: snapshot.gpnStatus, dataModel: snapshot.dataModel, gpnCircuitBreaker: snapshot.gpnCircuitBreaker, lastGpnSentDate: snapshot.lastGpnSentDate, operationalStatus: 'operational', updatedAt: new Date(), }) } catch (e) { logger.error(`Failed to persist device on completion: ${e}`) } logger.info({ deviceId: device.identifier, state: 'operational' }, '[StateMachine] Session completed successfully') return { isComplete: true, response: '' } } } /** * PartialOperationalState (Phase 10) * Terminal state for degraded session completion. */ export class PartialOperationalState extends DeviceState { name = 'partial_operational' private failureContext: { reason: string; blockingState?: string; error?: any } constructor(_device: Device, failureContext: { reason: string; blockingState?: string; error?: any }) { super() this.failureContext = failureContext } async handle(context: DeviceContext): Promise { const { device } = context device.operationalStatus = 'partial_operational' device.lastFailureReason = this.failureContext.reason try { const snapshot = device.toSnapshot() await DeviceModel.createOrUpdate({ identifier: snapshot.identifier, deviceId: snapshot.deviceId, properties: snapshot.properties, parameters: snapshot.parameters, gpnStatus: snapshot.gpnStatus, dataModel: snapshot.dataModel, gpnCircuitBreaker: snapshot.gpnCircuitBreaker, lastGpnSentDate: snapshot.lastGpnSentDate, operationalStatus: 'partial_operational', lastFailureReason: this.failureContext.reason, updatedAt: new Date(), }) } catch (e) { logger.error(`Failed to persist device on partial completion: ${e}`) } logger.error({ deviceId: device.identifier, reason: this.failureContext.reason, blockingState: this.failureContext.blockingState, state: 'partial_operational', }, '[StateMachine] Session completed with degraded status') return { isComplete: true, response: '' } } } // ─── Main State Machine ──────────────────────────────────────────────────────── export class MainStateMachine { private currentState: DeviceState private context: DeviceContext constructor(device: Device, session: string, taskQueue: CWMPTaskQueue, events?: string[]) { this.currentState = new IdleState() this.context = { device, session, taskQueue, events, services: { deviceAction: new DeviceActionExecutor(taskQueue), }, } } async start(): Promise { return this.executeCurrentState() } async handleResponse(success: boolean, message: any): Promise { this.context.message = { success, data: message } return this.executeCurrentState() } isCompleted(): boolean { return this.currentState.name === 'operational' || this.currentState.name === 'partial_operational' } getCurrentStateName(): string { return this.currentState.name } private async executeCurrentState(): Promise { const MAX_STATE_TRANSITIONS = 15 let transitionCount = 0 while (transitionCount++ < MAX_STATE_TRANSITIONS) { try { logger.debug({ state: this.currentState.name, iteration: transitionCount }, '[StateMachine] Executing state') const result = await this.currentState.handle(this.context) if (result.nextState) { logger.debug({ from: this.currentState.name, to: result.nextState.name }, '[StateMachine] State transition') this.currentState = result.nextState } if (result.isComplete) { logger.info({ finalState: this.currentState.name }, '[StateMachine] Completed') return '' } if (result.response && result.response !== '') { logger.debug({ state: this.currentState.name, responseLength: result.response.length }, '[StateMachine] Sending response to CPE') return result.response } if (!result.nextState) { return '' } } catch (error) { logger.error(`Error in state ${this.currentState.name}: ${error}`) // On error, transition to PartialOperationalState this.currentState = new PartialOperationalState(this.context.device, { reason: 'unrecoverable_error', blockingState: this.currentState.name, error, }) await this.currentState.handle(this.context) return '' } } // Phase 9: Explicit error handling when max transitions exceeded logger.error({ deviceId: this.context.device.identifier, lastState: this.currentState.name, sessionId: this.context.session, }, `State machine stuck in ${this.currentState.name} after ${MAX_STATE_TRANSITIONS} transitions`) this.currentState = new PartialOperationalState(this.context.device, { reason: 'max_transitions_exceeded', blockingState: this.currentState.name, }) await this.currentState.handle(this.context) return '' } }