Files
adam-acs/app/services/cwmp/main_state_machine.ts
Kevin L'Hereec f9e56c78b1 refactor: migrate all internal imports to package.json subpath aliases
- Add #domain, #protocol, #utils aliases to package.json imports map
- Replace all relative imports (./, ../, ../..) in app/ and
  database/mongo_migrations/ with the corresponding #alias imports,
  eliminating relative path traversal across the codebase
- Remove dead code: ConfigurationVersionModel.findByConfigurationIdentifier
  (unused, superseded by paginateVersions) and unused exception classes
  (NotFoundException, UnprocessableEntityException, DatabaseException)
  from app/exceptions/index.ts, keeping only BadRequestException
- Fix pre-existing syntax error in configuration_version.ts (mismatched
  parenthesis) that broke tsc
- Fix eqeqeq violation in configuration_service.ts and isNaN ->
  Number.isNaN in configuration_engine.ts / cwmp-1-0.ts / exchange_controller.ts
- Fix no-shadow violation in cwmp-1-0.ts EventStruct constructor
- Prettier formatting pass on touched files

Validated: tsc --noEmit clean, eslint app/ clean, full test suite green.
2026-07-22 11:45:14 +02:00

830 lines
29 KiB
TypeScript

import { type Device } from '#domain/device/device'
import { type CWMPTaskQueue } from '#services/cwmp/task_queue'
import { DeviceActionExecutor } from '#services/cwmp/device_action_executor'
import { getDeviceHashIdentifier } from '#utils/device_utils'
import { CommandStatus, CommandType } from '#domain/commands/command'
import DeviceModel from '#models/device'
import CommandModel from '#models/command'
import { ModeType } from '#domain/shared/mode'
import logger from '@adonisjs/core/services/logger'
import { ConfigurationEvaluationState } from '#services/cwmp/configuration_evaluation_state'
import { reclassifyDeviceFromCatalog } from '#services/parameter_writability'
import type {
CWMPTaskResultData,
GetParameterNamesResponse,
GetParameterValuesResponse,
ParameterValueStruct,
} from '#protocol/tr069_types'
import type {
SetParameterAttribute,
DownloadParameter,
UploadParameter,
RebootParameter,
} from '#services/cwmp/device_action_executor'
import { type ObjectId } from 'mongodb'
// ─── Interfaces ────────────────────────────────────────────────────────────────
export interface DeviceContext {
device: Device
session: string
message?: { success: boolean; data: CWMPTaskResultData }
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<DeviceStateResult>
}
// ─── States ────────────────────────────────────────────────────────────────────
class IdleState extends DeviceState {
name = 'idle'
async handle(context: DeviceContext): Promise<DeviceStateResult> {
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 as { _?: string })?._ || 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 as { _?: string })?._ || 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.
// Skip root-level paths (e.g. "Device." or "InternetGatewayDevice.") that would
// produce a huge GPN response — root-level params like Device.RootDataModelVersion
// will be discovered when deeper subtrees are explored or classified via catalog.
const parentPaths = new Set<string>()
for (const name of unclassified) {
const lastDot = name.lastIndexOf('.')
if (lastDot <= 0) continue
const parentPath = name.substring(0, lastDot + 1)
// Only add paths with at least 2 segments (e.g. "Device.DeviceInfo." not "Device.")
const segments = parentPath.replace(/\.$/, '').split('.')
if (segments.length >= 2) {
parentPaths.add(parentPath)
}
}
// 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<DeviceStateResult> {
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 {
const gpnResp = context.message.data as GetParameterNamesResponse
const infoList = gpnResp?.ParameterList?.ParameterInfoStruct ?? []
context.device.organizeParameters(infoList, new Date())
context.device.setGpnStatus('supported')
logger.info(
{
deviceId: context.device.identifier,
paramCount: context.device.getParameters().length,
},
'[DiscoveringParameters] GPN response processed'
)
} catch (e: unknown) {
logger.error(
{ error: (e as Error)?.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<string> {
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<DeviceStateResult> {
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}.DeviceInfo.ProvisioningCode`,
`${root}.DeviceInfo.UpTime`,
`${root}.ManagementServer.URL`,
`${root}.ManagementServer.ConnectionRequestURL`,
`${root}.ManagementServer.ConnectionRequestUsername`,
`${root}.ManagementServer.ConnectionRequestPassword`,
`${root}.ManagementServer.PeriodicInformInterval`,
`${root}.ManagementServer.PeriodicInformEnable`,
]
const response = await context.services.deviceAction.getParameterValues(
context.device,
criticalPaths,
async (result) => {
if (result.status === 'success') {
const resp = result.data as GetParameterValuesResponse
const list = resp?.ParameterList?.ParameterValueStruct ?? []
if (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 as GetParameterValuesResponse
const list = resp?.ParameterList?.ParameterValueStruct ?? []
if (list.length > 0) {
// Normalize Name fields: XMLParser may return {_: "path", "$xsi:type": "..."} when Name has attributes
const normalized = list.map((pv) => ({
...pv,
Name:
typeof pv?.Name === 'string' ? pv.Name : (pv?.Name as { _?: string })?._ || pv?.Name,
}))
context.device.setTmpParamValues(normalized)
context.device.updateFromValues(normalized)
logger.info(
{ count: normalized.length, names: normalized.map((p) => p.Name) },
'[CriticalReads] GPV response values processed'
)
}
} catch (e: unknown) {
logger.error(
{ error: (e as Error)?.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: Record<string, unknown>[] = []
private started = false
private currentCommandRecord: Record<string, unknown> | null = null
private includeNI: boolean
constructor(includeNI: boolean = true) {
super()
this.includeNI = includeNI
}
async handle(context: DeviceContext): Promise<DeviceStateResult> {
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 rawList = Array.isArray(this.currentCommandRecord.parameters)
? (this.currentCommandRecord.parameters as Array<{ name?: string; value?: unknown }>)
: []
const pvStructs: ParameterValueStruct[] = rawList.map((p) => ({
'@class': '.ParameterValueStruct',
'Name': p?.name ?? '',
'Value': {
'@class': '.ValueObject',
'_': String(p?.value ?? ''),
'$': { '@class': '.ValueObjectAttribute', '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 as ObjectId, {
status: this.currentCommandRecord.status as CommandStatus | undefined,
executedAt: this.currentCommandRecord.executedAt as Date | undefined,
completedAt: this.currentCommandRecord.completedAt as Date | undefined,
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<string> {
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 as ObjectId, {
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
// params is typed as unknown from MongoDB — we cast to a flexible record for access
const params = commandRecord.parameters as
| Record<string, unknown>
| string
| unknown[]
| undefined
// Map CommandType to CWMP request
switch (action) {
case CommandType.GET_PARAMETER_VALUES: {
const p = params as Record<string, unknown> | string[] | undefined
const names: string[] = Array.isArray(p)
? (p as string[])
: ((p as Record<string, unknown>)?.names as string[]) ||
((p as Record<string, unknown>)?.paths as string[]) ||
((p as Record<string, unknown>)?.parameterNames as string[]) ||
[]
return context.services.deviceAction.getParameterValues(context.device, names, () =>
Promise.resolve('')
)
}
case CommandType.GET_PARAMETER_NAMES: {
const p = params as Record<string, unknown> | string | undefined
const name: string =
typeof p === 'string'
? p
: ((p as Record<string, unknown>)?.name as string) ||
((p as Record<string, unknown>)?.path as string) ||
((p as Record<string, unknown>)?.parameterPath as string) ||
''
return context.services.deviceAction.getParameterNames(context.device, name, () =>
Promise.resolve('')
)
}
case CommandType.GET_PARAMETER_ATTRIBUTES: {
const p = params as Record<string, unknown> | string[] | undefined
const names: string[] = Array.isArray(p)
? (p as string[])
: ((p as Record<string, unknown>)?.names as string[]) ||
((p as Record<string, unknown>)?.paths as string[]) ||
((p as Record<string, unknown>)?.parameterNames as string[]) ||
[]
return context.services.deviceAction.getParameterAttributes(context.device, names, () =>
Promise.resolve('')
)
}
case CommandType.SET_PARAMETER_VALUES: {
const p = params as
| Record<string, unknown>
| Array<{ parameterName?: string; name?: string; value: unknown; type?: string }>
| undefined
const rawParams: Array<{
parameterName?: string
name?: string
value: unknown
type?: string
}> = Array.isArray(p)
? p
: ((p as Record<string, unknown>)?.parameterValues as typeof rawParams) ||
((p as Record<string, unknown>)?.parameters as typeof rawParams) ||
[]
const spvParams = rawParams.map((pv) => ({
parameterName: pv.parameterName ?? pv.name ?? '',
value: pv.value as string | number | boolean,
type: pv.type,
}))
return context.services.deviceAction.applySetParameterValues(
context.device,
spvParams,
() => Promise.resolve('')
)
}
case CommandType.SET_PARAMETER_ATTRIBUTES: {
const p = params as Record<string, unknown> | unknown[] | undefined
const spaParams = Array.isArray(p)
? p
: ((p as Record<string, unknown>)?.attributes as unknown[]) ||
((p as Record<string, unknown>)?.parameterAttributes as unknown[]) ||
[]
return context.services.deviceAction.applySetParameterAttributes(
context.device,
spaParams as SetParameterAttribute[],
() => Promise.resolve('')
)
}
case CommandType.DOWNLOAD: {
const p = (params as Record<string, unknown>) || {}
return context.services.deviceAction.applyDownload(
context.device,
p as unknown as DownloadParameter,
() => Promise.resolve('')
)
}
case CommandType.UPLOAD: {
const p = (params as Record<string, unknown>) || {}
return context.services.deviceAction.applyUpload(
context.device,
p as unknown as UploadParameter,
() => Promise.resolve('')
)
}
case CommandType.REBOOT: {
const p = (params as Record<string, unknown>) || {}
return context.services.deviceAction.applyReboot(context.device, p as RebootParameter, () =>
Promise.resolve('')
)
}
case CommandType.FACTORY_RESET: {
return context.services.deviceAction.applyFactoryReset(context.device, {}, () =>
Promise.resolve('')
)
}
case CommandType.SCHEDULE_INFORM: {
const p = (params as Record<string, unknown>) || {}
return context.services.deviceAction.applyScheduleInform(
context.device,
{
delayInSeconds: (p?.delayInSeconds as number) || 300,
commandKey: p?.commandKey as string | undefined,
},
() => 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 !== undefined && p.value !== '')
.map((p) => p.name),
...context.device
.getProperties()
.filter((p) => p.value !== null && p.value !== undefined && 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 as ObjectId, {
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<DeviceStateResult> {
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<DeviceStateResult> {
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<string> {
return this.executeCurrentState()
}
async handleResponse(success: boolean, message: CWMPTaskResultData): Promise<string> {
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<string> {
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 ''
}
}