- tests/device.spec.ts: fix 6× organizeParameters() calls passing wrapped
response object instead of flat ParameterInfoStruct[] (runtime break)
- tests/device.spec.ts: fix setTmpParamValues([{x:1}]) with valid shape
- immediate_command_state_machine.ts: type handleResponse message param as
CWMPTaskResultData (was any, gap in typing contract)
- configuration_evaluation_state.ts: replace fragile ../../../app/ dynamic
import paths with proper static imports from ../../protocol/tr069_types.js
- main_state_machine.ts: replace all inline dynamic import() type casts with
static imports (GetParameterNamesResponse, GetParameterValuesResponse,
ParameterValueStruct, SetParameterAttribute, DownloadParameter, etc.)
675 lines
27 KiB
TypeScript
675 lines
27 KiB
TypeScript
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'
|
|
import type {
|
|
CWMPTaskResultData,
|
|
GetParameterNamesResponse,
|
|
GetParameterValuesResponse,
|
|
ParameterValueStruct,
|
|
} from '../../protocol/tr069_types.js'
|
|
import type {
|
|
SetParameterAttribute,
|
|
DownloadParameter,
|
|
UploadParameter,
|
|
RebootParameter,
|
|
} from './device_action_executor.js'
|
|
import { 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 !== '').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 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 ''
|
|
}
|
|
}
|