373 lines
14 KiB
TypeScript
373 lines
14 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'
|
|
|
|
// ─── Interfaces ────────────────────────────────────────────────────────────────
|
|
|
|
export interface DeviceContext {
|
|
device: Device
|
|
session: string
|
|
message?: { success: boolean; data: any }
|
|
taskQueue: CWMPTaskQueue
|
|
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> {
|
|
// Test GPN support using the ManagementServer object path (not leaf params from Inform)
|
|
const dataModel = context.device.dataModel
|
|
if (dataModel === 'tr-181') {
|
|
return { nextState: new DiscoveringParametersState(['Device.ManagementServer.']), response: '' }
|
|
} else if (dataModel === 'tr-098') {
|
|
return { nextState: new DiscoveringParametersState(['InternetGatewayDevice.ManagementServer.']), response: '' }
|
|
}
|
|
// Unknown data model — skip GPN test
|
|
return { nextState: new ConfigurationEvaluationState(), 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 ConfigurationEvaluationState(), response: '' }
|
|
}
|
|
return { response: await this.processNext(context) }
|
|
}
|
|
|
|
// Process response and continue
|
|
if (this.parameterQueue.length > 0) {
|
|
return { response: await this.processNext(context) }
|
|
}
|
|
|
|
return { nextState: new ConfigurationEvaluationState(), 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(''))
|
|
}
|
|
}
|
|
|
|
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<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
|
|
|
|
if (this.commandsQueue.length === 0) {
|
|
if (this.includeNI) {
|
|
return { nextState: new CompletedState(), isComplete: true, response: '' }
|
|
}
|
|
return { 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 CompletedState(), isComplete: true, 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, { 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 ''
|
|
}
|
|
}
|
|
}
|
|
|
|
class CompletedState extends DeviceState {
|
|
name = 'completed'
|
|
|
|
async handle(context: DeviceContext): Promise<DeviceStateResult> {
|
|
// Persist device on completion
|
|
try {
|
|
const snapshot = context.device.toSnapshot()
|
|
await DeviceModel.createOrUpdate({
|
|
identifier: snapshot.identifier,
|
|
deviceId: snapshot.deviceId,
|
|
properties: snapshot.properties,
|
|
parameters: snapshot.parameters,
|
|
gpnStatus: snapshot.gpnStatus,
|
|
dataModel: snapshot.dataModel,
|
|
updatedAt: new Date(),
|
|
})
|
|
} catch (e) {
|
|
logger.error(`Failed to persist device on completion: ${e}`)
|
|
}
|
|
return { isComplete: true, response: '' }
|
|
}
|
|
}
|
|
|
|
// ─── Main State Machine ────────────────────────────────────────────────────────
|
|
|
|
export class MainStateMachine {
|
|
private currentState: DeviceState
|
|
private context: DeviceContext
|
|
|
|
constructor(device: Device, session: string, taskQueue: CWMPTaskQueue) {
|
|
this.currentState = new IdleState()
|
|
this.context = {
|
|
device,
|
|
session,
|
|
taskQueue,
|
|
services: {
|
|
deviceAction: new DeviceActionExecutor(taskQueue),
|
|
},
|
|
}
|
|
}
|
|
|
|
async start(): Promise<string> {
|
|
return this.executeCurrentState()
|
|
}
|
|
|
|
async handleResponse(success: boolean, message: any): Promise<string> {
|
|
this.context.message = { success, data: message }
|
|
return this.executeCurrentState()
|
|
}
|
|
|
|
isCompleted(): boolean {
|
|
return this.currentState.name === 'completed'
|
|
}
|
|
|
|
getCurrentStateName(): string {
|
|
return this.currentState.name
|
|
}
|
|
|
|
private async executeCurrentState(): Promise<string> {
|
|
let safetyGuard = 0
|
|
|
|
while (safetyGuard++ < 15) {
|
|
try {
|
|
logger.debug({ state: this.currentState.name, iteration: safetyGuard }, '[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, try to complete gracefully
|
|
this.currentState = new CompletedState()
|
|
await this.currentState.handle(this.context)
|
|
return ''
|
|
}
|
|
}
|
|
|
|
logger.warn('Main state machine safety guard reached')
|
|
return ''
|
|
}
|
|
}
|