- 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>
414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import { Device } from '../../domain/device/device.js'
|
|
import { ParameterType } from '../../domain/shared/types.js'
|
|
import { CWMPTaskQueue, ResultStatus, TaskResult } from './task_queue.js'
|
|
import DeviceModel from '../../models/device.js'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
|
|
// Parameter types (simplified from legacy provisioning domain)
|
|
interface SetParameterValue {
|
|
parameterName: string
|
|
value: string | number | boolean
|
|
type?: string
|
|
}
|
|
|
|
interface SetParameterAttribute {
|
|
name: string
|
|
notificationChange: boolean
|
|
notification: number
|
|
accessListChange: boolean
|
|
accessList: string[]
|
|
}
|
|
|
|
interface SetScheduleInform {
|
|
delayInSeconds: number
|
|
commandKey?: string
|
|
}
|
|
|
|
interface DownloadParameter {
|
|
commandKey: string
|
|
fileType: string
|
|
url: string
|
|
username?: string
|
|
password?: string
|
|
fileSize?: number
|
|
targetFileName?: string
|
|
delaySeconds?: number
|
|
successUrl?: string
|
|
failureUrl?: string
|
|
}
|
|
|
|
interface UploadParameter {
|
|
commandKey: string
|
|
fileType: string
|
|
url: string
|
|
username?: string
|
|
password?: string
|
|
delayInSeconds?: number
|
|
}
|
|
|
|
interface FactoryResetParameter {
|
|
commandKey?: string
|
|
}
|
|
|
|
interface RebootParameter {
|
|
commandKey?: string
|
|
}
|
|
|
|
/**
|
|
* Executes CWMP actions on devices via the TaskQueue
|
|
* Each action wraps a callback that persists state on success
|
|
*/
|
|
export class DeviceActionExecutor {
|
|
private taskQueue: CWMPTaskQueue
|
|
|
|
constructor(taskQueue: CWMPTaskQueue) {
|
|
this.taskQueue = taskQueue
|
|
}
|
|
|
|
private async persistDevice(device: Device): Promise<void> {
|
|
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,
|
|
updatedAt: new Date(),
|
|
})
|
|
} catch (e) {
|
|
logger.error(`Failed to persist device: ${e}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get parameter values from device.
|
|
*/
|
|
async getParameterValues(
|
|
device: Device,
|
|
parameterPaths: string[],
|
|
callback: (result: TaskResult) => Promise<string>
|
|
): Promise<string> {
|
|
const wrappedCallback = async (result: TaskResult) => {
|
|
if (result.status === ResultStatus.Success) {
|
|
try {
|
|
const resp: any = result.data
|
|
const list = resp?.ParameterList?.ParameterValueStruct
|
|
if (Array.isArray(list) && list.length > 0) {
|
|
device.updateFromValues(list)
|
|
await this.persistDevice(device)
|
|
}
|
|
} catch (e) {
|
|
logger.error(`Error updating device from parameter values: ${e}`)
|
|
}
|
|
}
|
|
return await callback(result)
|
|
}
|
|
|
|
return this.taskQueue.addTask(device.deviceId, 'get_parameter_values', parameterPaths, wrappedCallback).xml
|
|
}
|
|
|
|
async getParameterAttributes(
|
|
device: Device,
|
|
parameterList: string[],
|
|
callback: (result: TaskResult) => Promise<string>
|
|
): Promise<string> {
|
|
const wrappedCallback = async (result: TaskResult) => {
|
|
if (result.status === ResultStatus.Success) {
|
|
try {
|
|
const resp: any = result.data
|
|
const list = resp?.ParameterList?.ParameterAttributeStruct
|
|
if (Array.isArray(list) && list.length > 0) {
|
|
for (const attr of list) {
|
|
const name: string | undefined = attr?.Name
|
|
if (!name) continue
|
|
const notification: number | undefined = typeof attr?.Notification === 'number' ? attr.Notification : undefined
|
|
const accessList: string[] | undefined = attr?.AccessList?.string
|
|
|
|
const param = device.getParameter(name)
|
|
if (param) {
|
|
param.updateAttributes?.(notification, accessList)
|
|
continue
|
|
}
|
|
const prop = device.getProperty(name)
|
|
if (prop) {
|
|
prop.updateAttributes?.(notification, accessList)
|
|
}
|
|
}
|
|
await this.persistDevice(device)
|
|
}
|
|
} catch (e) {
|
|
logger.error(`Error updating device from parameter attributes: ${e}`)
|
|
}
|
|
}
|
|
return await callback(result)
|
|
}
|
|
|
|
return this.taskQueue.addTask(device.deviceId, 'get_parameter_attributes', parameterList, wrappedCallback).xml
|
|
}
|
|
|
|
async getParameterNames(
|
|
device: Device,
|
|
parameterName: string,
|
|
callback: (result: TaskResult) => Promise<string>
|
|
): Promise<string> {
|
|
const wrappedCallback = async (result: TaskResult) => {
|
|
if (result.status === ResultStatus.Success) {
|
|
try {
|
|
device.organizeParameters(result.data as any, new Date())
|
|
device.setGpnStatus('supported')
|
|
device.recordGpnSuccess()
|
|
device.lastGpnSentDate = new Date()
|
|
await this.persistDevice(device)
|
|
} catch (e) {
|
|
logger.error(`Error organizing parameters: ${e}`)
|
|
}
|
|
} else {
|
|
// Inspect fault code to distinguish "unsupported" from "transient error"
|
|
const faultCode =
|
|
result.data?.FaultCode ??
|
|
result.data?.faultCode ??
|
|
result.data?.Detail?.Fault?.FaultCode ??
|
|
result.data?.detail?.Fault?.FaultCode
|
|
const code = typeof faultCode === 'string' ? parseInt(faultCode, 10) : faultCode
|
|
const GPN_UNSUPPORTED_CODES = [9000, 9001, 9003, 9004, 9005]
|
|
const GPN_TRANSIENT_CODES = [9002]
|
|
|
|
if (GPN_UNSUPPORTED_CODES.includes(code)) {
|
|
device.setGpnStatus('unsupported')
|
|
device.recordGpnFailure()
|
|
await this.persistDevice(device)
|
|
} else if (GPN_TRANSIENT_CODES.includes(code)) {
|
|
// Transient error: log warning, do not mark unsupported, allow retry this session
|
|
logger.warn({ code, deviceId: device.deviceId?.serialNumber }, 'GPN transient error (9002) — will not mark unsupported')
|
|
}
|
|
// Network timeout or other codes: do not mark unsupported, skip GPN for this session only
|
|
}
|
|
return await callback(result)
|
|
}
|
|
|
|
return this.taskQueue.addTask(device.deviceId, 'get_parameter_names', parameterName, wrappedCallback).xml
|
|
}
|
|
|
|
async applySetParameterValues(
|
|
device: Device,
|
|
parameters: SetParameterValue[],
|
|
callback: (result: boolean) => Promise<string>
|
|
): Promise<string> {
|
|
const existingParameters = device.getParameters()
|
|
|
|
const inferType = (val: any): string => {
|
|
const t = typeof val
|
|
switch (t) {
|
|
case 'boolean': return 'xsd:boolean'
|
|
case 'number': return Number.isInteger(val) ? 'xsd:int' : 'xsd:string'
|
|
default: return 'xsd:string'
|
|
}
|
|
}
|
|
|
|
const cwmpTypeMap: Record<string, string> = {
|
|
string: 'xsd:string',
|
|
unsignedInt: 'xsd:unsignedInt',
|
|
int: 'xsd:int',
|
|
boolean: 'xsd:boolean',
|
|
dateTime: 'xsd:dateTime',
|
|
}
|
|
|
|
const pvStructs = parameters.map((p) => {
|
|
const existing = existingParameters.find((ep) => ep.name === p.parameterName)
|
|
// Priority: 1) explicit type from config, 2) known device type, 3) inferred from JS value
|
|
let xsdType: string
|
|
if (p.type && cwmpTypeMap[p.type]) {
|
|
xsdType = cwmpTypeMap[p.type]
|
|
} else if (existing?.type && existing.type !== ParameterType.UNKNOWN) {
|
|
xsdType = existing.type // already in xsd: format (e.g. 'xsd:string')
|
|
} else {
|
|
xsdType = inferType(p.value)
|
|
}
|
|
return {
|
|
Name: p.parameterName,
|
|
Value: { _: p.value, $: { Type: xsdType } },
|
|
}
|
|
})
|
|
|
|
const parameterKey = `SetPValues_${device.deviceId.serialNumber}_${Date.now()}`
|
|
|
|
return this.taskQueue.addTask(
|
|
device.deviceId,
|
|
'set_parameter_values',
|
|
{ parameterValueList: pvStructs, parameterKey },
|
|
async (result: TaskResult) => {
|
|
if (result.status === ResultStatus.Success) {
|
|
device.updateFromValues(pvStructs as any)
|
|
await this.persistDevice(device)
|
|
return await callback(true)
|
|
}
|
|
return await callback(false)
|
|
}
|
|
).xml
|
|
}
|
|
|
|
async applySetParameterAttributes(
|
|
device: Device,
|
|
parameters: SetParameterAttribute[],
|
|
callback: (result: boolean) => Promise<string>
|
|
): Promise<string> {
|
|
const attrs = parameters.map((p) => ({
|
|
name: p.name,
|
|
notificationChange: p.notificationChange ?? false,
|
|
notification: p.notification ?? 0,
|
|
accessListChange: p.accessListChange ?? false,
|
|
accessList: (p.accessList as any)?.string || [],
|
|
}))
|
|
|
|
return this.taskQueue.addTask(
|
|
device.deviceId,
|
|
'set_parameter_attributes',
|
|
attrs,
|
|
async (result: TaskResult) => {
|
|
if (result.status === ResultStatus.Success) {
|
|
return await callback(true)
|
|
}
|
|
return await callback(false)
|
|
}
|
|
).xml
|
|
}
|
|
|
|
async applyScheduleInform(
|
|
device: Device,
|
|
parameter: SetScheduleInform,
|
|
callback: (result: boolean) => Promise<string>
|
|
): Promise<string> {
|
|
const commandKey = parameter.commandKey || `ScheduleInform_${device.deviceId.serialNumber}_${Date.now()}`
|
|
return this.taskQueue.addTask(
|
|
device.deviceId,
|
|
'schedule_inform',
|
|
{ delayInSeconds: parameter.delayInSeconds, commandKey },
|
|
async (result: TaskResult) => {
|
|
return await callback(result.status === ResultStatus.Success)
|
|
}
|
|
).xml
|
|
}
|
|
|
|
async applyFactoryReset(
|
|
_device: Device,
|
|
_parameter: FactoryResetParameter,
|
|
callback: (result: boolean) => Promise<string>
|
|
): Promise<string> {
|
|
return this.taskQueue.addTask(
|
|
_device.deviceId,
|
|
'factory_reset',
|
|
{},
|
|
async (result: TaskResult) => {
|
|
return await callback(result.status === ResultStatus.Success)
|
|
}
|
|
).xml
|
|
}
|
|
|
|
async applyReboot(
|
|
device: Device,
|
|
parameter: RebootParameter,
|
|
callback: (result: boolean) => Promise<string>
|
|
): Promise<string> {
|
|
return this.taskQueue.addTask(
|
|
device.deviceId,
|
|
'reboot',
|
|
{ commandKey: parameter.commandKey || '' },
|
|
async (result: TaskResult) => {
|
|
return await callback(result.status === ResultStatus.Success)
|
|
}
|
|
).xml
|
|
}
|
|
|
|
async applyDownload(
|
|
device: Device,
|
|
parameter: DownloadParameter,
|
|
callback: (result: boolean) => Promise<string>
|
|
): Promise<string> {
|
|
return this.taskQueue.addTask(
|
|
device.deviceId,
|
|
'download',
|
|
{
|
|
commandKey: parameter.commandKey,
|
|
fileType: parameter.fileType,
|
|
url: parameter.url,
|
|
username: parameter.username,
|
|
password: parameter.password,
|
|
fileSize: parameter.fileSize,
|
|
targetFileName: parameter.targetFileName,
|
|
delaySeconds: parameter.delaySeconds,
|
|
successUrl: parameter.successUrl,
|
|
failureUrl: parameter.failureUrl,
|
|
},
|
|
async (result: TaskResult) => {
|
|
return await callback(result.status === ResultStatus.Success)
|
|
}
|
|
).xml
|
|
}
|
|
|
|
async applyUpload(
|
|
device: Device,
|
|
parameter: UploadParameter,
|
|
callback: (result: boolean) => Promise<string>
|
|
): Promise<string> {
|
|
return this.taskQueue.addTask(
|
|
device.deviceId,
|
|
'upload',
|
|
{
|
|
commandKey: parameter.commandKey,
|
|
fileType: parameter.fileType,
|
|
url: parameter.url,
|
|
username: parameter.username,
|
|
password: parameter.password,
|
|
delaySeconds: parameter.delayInSeconds,
|
|
},
|
|
async (result: TaskResult) => {
|
|
return await callback(result.status === ResultStatus.Success)
|
|
}
|
|
).xml
|
|
}
|
|
|
|
async insertProvisioningCode(
|
|
device: Device,
|
|
callback: (result: boolean) => Promise<string>
|
|
): Promise<string> {
|
|
const allParams = device.getParameters()
|
|
const allProps = device.getProperties()
|
|
|
|
const knownPC = [...allParams, ...allProps].find((p: any) => /(^|\.)ProvisioningCode$/.test(p?.name))
|
|
|
|
// Use the detected data model stored on the device; fall back to scanning known params
|
|
let provisioningPath: string
|
|
if (knownPC) {
|
|
provisioningPath = knownPC.name
|
|
} else if (device.dataModel === 'tr-181') {
|
|
provisioningPath = 'Device.DeviceInfo.ProvisioningCode'
|
|
} else if (device.dataModel === 'tr-098') {
|
|
provisioningPath = 'InternetGatewayDevice.DeviceInfo.ProvisioningCode'
|
|
} else {
|
|
// Legacy fallback: scan stored params
|
|
const hasDeviceTree = [...allParams, ...allProps].some((p: any) => typeof p?.name === 'string' && p.name.startsWith('Device.'))
|
|
provisioningPath = hasDeviceTree ? 'Device.DeviceInfo.ProvisioningCode' : 'InternetGatewayDevice.DeviceInfo.ProvisioningCode'
|
|
}
|
|
|
|
const parameterKey = `SetPValues_${device.deviceId.serialNumber}_${Date.now()}`
|
|
const pvStructs = [{ Name: provisioningPath, Value: { _: device.tmpPC, $: { Type: 'xsd:string' } } }]
|
|
|
|
return this.taskQueue.addTask(
|
|
device.deviceId,
|
|
'set_parameter_values',
|
|
{ parameterValueList: pvStructs, parameterKey },
|
|
async (result: TaskResult) => {
|
|
if (result.status === ResultStatus.Success) {
|
|
device.updateFromValues(pvStructs as any)
|
|
await this.persistDevice(device)
|
|
device.tmpPC = ''
|
|
return await callback(true)
|
|
}
|
|
return await callback(false)
|
|
}
|
|
).xml
|
|
}
|
|
}
|