Files
adam-acs/app/domain/device/device.ts
T
Kevin L'HereecandContinue aad93911e4 feat: TR-069 CWMP state machine hardening + exchange viewer improvements
- 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>
2026-06-08 12:49:00 +00:00

413 lines
12 KiB
TypeScript

import { DeviceId } from './device_id.js'
import { DeviceParameter, DeviceProperty, ParameterSnapshot, PropertySnapshot } from './device_parameter.js'
import { ParameterType } from '../shared/types.js'
import { createHash } from 'node:crypto'
export type GpnStatus = 'supported' | 'unsupported' | 'unknown'
export interface GpnCircuitBreaker {
failureCount: number
lastFailureDate: Date | null
state: 'closed' | 'open' | 'half-open'
cooldownUntil: Date | null
}
/** TR-098: InternetGatewayDevice.* root — TR-181: Device.* root */
export type DataModel = 'tr-181' | 'tr-098' | 'unknown'
/** Extract a string name from a SOAP parsed node (may be a string or an object with _ text node) */
function extractName(raw: any): string | null {
if (typeof raw === 'string') return raw || null
if (raw && typeof raw === 'object' && typeof raw._ === 'string') return raw._ || null
return null
}
/** Detect which TR data model a device uses from a ParameterValueStruct list */
export function detectDataModelFromParams(paramValues: any[]): DataModel {
for (const pv of paramValues ?? []) {
const name = extractName(pv?.Name)
if (!name) continue
if (name.startsWith('Device.')) return 'tr-181'
if (name.startsWith('InternetGatewayDevice.')) return 'tr-098'
}
return 'unknown'
}
export enum OnboardingStatus {
PENDING = 'PENDING',
DISCOVERING = 'DISCOVERING',
PROVISIONING = 'PROVISIONING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
}
export class Device {
public tmpParamValues: any[] = []
public tmpPC: any = null
private _parameters: Map<string, DeviceParameter> = new Map()
private _properties: Map<string, DeviceProperty> = new Map()
private _session: any = null
private _lastSeen: Date = new Date()
private _onboardingStatus: OnboardingStatus = OnboardingStatus.PENDING
private _gpnStatus: GpnStatus = 'unknown'
private _dataModel: DataModel = 'unknown'
private _gpnCircuitBreaker: GpnCircuitBreaker = {
failureCount: 0,
lastFailureDate: null,
state: 'closed',
cooldownUntil: null,
}
private _lastGpnSentDate: Date | null = null
private _operationalStatus: string = ''
private _lastFailureReason: string = ''
constructor(
public readonly deviceId: DeviceId,
public readonly createdAt: Date = new Date()
) {}
public get session(): any {
return this._session
}
public get lastSeen(): Date {
return this._lastSeen
}
public get onboardingStatus(): OnboardingStatus {
return this._onboardingStatus
}
public get gpnStatus(): GpnStatus {
return this._gpnStatus
}
public setGpnStatus(status: GpnStatus): void {
this._gpnStatus = status
}
public get dataModel(): DataModel {
return this._dataModel
}
public setDataModel(model: DataModel): void {
this._dataModel = model
}
public get gpnCircuitBreaker(): GpnCircuitBreaker {
return this._gpnCircuitBreaker
}
public setGpnCircuitBreaker(cb: GpnCircuitBreaker): void {
this._gpnCircuitBreaker = cb
}
public get lastGpnSentDate(): Date | null {
return this._lastGpnSentDate
}
public set lastGpnSentDate(date: Date | null) {
this._lastGpnSentDate = date
}
public get operationalStatus(): string {
return this._operationalStatus
}
public set operationalStatus(status: string) {
this._operationalStatus = status
}
public get lastFailureReason(): string {
return this._lastFailureReason
}
public set lastFailureReason(reason: string) {
this._lastFailureReason = reason
}
/**
* Returns true if GPN is allowed based on circuit breaker state.
* Closed = always allowed. Half-open = allowed if cooldown expired. Open = not allowed.
*/
public isGpnAllowed(): boolean {
const { state, cooldownUntil } = this._gpnCircuitBreaker
if (state === 'closed') return true
if (state === 'half-open') {
return cooldownUntil === null || new Date() >= cooldownUntil
}
// state === 'open': check if cooldown expired → transition to half-open
if (cooldownUntil && new Date() >= cooldownUntil) {
this._gpnCircuitBreaker.state = 'half-open'
return true
}
return false
}
/**
* Record a GPN failure. >=2 failures → open with 24h cooldown.
* If already half-open → re-open with 72h cooldown.
*/
public recordGpnFailure(): void {
const cb = this._gpnCircuitBreaker
cb.failureCount++
cb.lastFailureDate = new Date()
if (cb.state === 'half-open') {
// Half-open probe failed → re-open with 72h cooldown
cb.state = 'open'
cb.cooldownUntil = new Date(Date.now() + 72 * 60 * 60 * 1000)
} else if (cb.failureCount >= 2) {
cb.state = 'open'
cb.cooldownUntil = new Date(Date.now() + 24 * 60 * 60 * 1000)
}
}
/**
* Record a successful GPN. Resets circuit breaker to closed.
*/
public recordGpnSuccess(): void {
this._gpnCircuitBreaker = {
failureCount: 0,
lastFailureDate: null,
state: 'closed',
cooldownUntil: null,
}
}
/**
* Record a crash pattern (BOOT Inform within 60s of last GPN).
* Immediately opens circuit breaker with 7-day cooldown.
*/
public recordCrashPattern(): void {
this._gpnCircuitBreaker = {
failureCount: this._gpnCircuitBreaker.failureCount + 1,
lastFailureDate: new Date(),
state: 'open',
cooldownUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
}
}
public get identifier(): string {
const raw = `${this.deviceId.manufacturer}-${this.deviceId.oui}-${this.deviceId.productClass}-${this.deviceId.serialNumber}`
return createHash('sha256').update(raw).digest('hex').substring(0, 24)
}
public attachSession(session: any): void {
this._session = session
this._lastSeen = new Date()
}
public detachSession(): void {
if (this._session) {
this._session = null
}
}
public isOnline(): boolean {
return this._session !== null
}
public updateOnboardingStatus(status: OnboardingStatus): void {
this._onboardingStatus = status
this._lastSeen = new Date()
}
public setParameter(parameter: DeviceParameter): void {
this._parameters.set(parameter.name, parameter)
}
public getParameter(name: string): DeviceParameter | null {
return this._parameters.get(name) || null
}
public getParameters(): DeviceParameter[] {
return Array.from(this._parameters.values())
}
public updateParameter(name: string, value: any): void {
const parameter = this._parameters.get(name)
if (parameter) {
parameter.updateValue(value)
}
}
public setProperty(property: DeviceProperty): void {
this._properties.set(property.name, property)
}
public getProperty(name: string): DeviceProperty | null {
return this._properties.get(name) || null
}
public getProperties(): DeviceProperty[] {
return Array.from(this._properties.values())
}
public updateProperty(name: string, value: any): void {
const property = this._properties.get(name)
if (property) {
property.updateValue(value)
}
}
public getConnectionRequestURL(): URL | null {
for (const property of this._properties.values()) {
const url = property.getConnectionRequestURL()
if (url) return url
}
return null
}
public canExecuteCommands(): boolean {
return this.isOnline() && this._onboardingStatus === OnboardingStatus.COMPLETED
}
public setTmpParamValues(values: any[]): void {
this.tmpParamValues = values
}
public getTmpParamValues(): any[] {
return this.tmpParamValues
}
public setTmpPC(value: any): void {
this.tmpPC = value
}
public getTmpPC(): any {
return this.tmpPC
}
public clearTmpValues(): void {
this.tmpParamValues = []
this.tmpPC = null
}
public organizeParameters(response: any, timestamp: Date): void {
const infoList = response?.ParameterList?.ParameterInfoStruct || []
for (const info of infoList) {
const name = extractName(info?.Name)
if (!name) continue
// Skip object paths (ending with dot) - only store leaf parameters
if (name.endsWith('.')) continue
const isWritable = !!info?.Writable
if (this._parameters.has(name) || this._properties.has(name)) {
if (isWritable && this._properties.has(name)) {
const prop = this._properties.get(name)!
this._properties.delete(name)
this.setParameter(new DeviceParameter(name, prop.value, prop.type))
} else if (!isWritable && this._parameters.has(name)) {
const param = this._parameters.get(name)!
this._parameters.delete(name)
this.setProperty(new DeviceProperty(name, param.value, param.type))
}
continue
}
if (isWritable) {
this.setParameter(new DeviceParameter(name, null, ParameterType.UNKNOWN))
} else {
this.setProperty(new DeviceProperty(name, null, ParameterType.UNKNOWN))
}
}
this._lastSeen = timestamp
}
public updateFromValues(paramValues: any[]): void {
for (const pv of paramValues || []) {
const name = extractName(pv?.Name)
const valueObj = pv?.Value
const hasTextNode = valueObj && Object.prototype.hasOwnProperty.call(valueObj as any, '_')
const hasTypeAttr = valueObj && (valueObj['$xsi:type'] || valueObj?.$?.Type)
const val = hasTextNode ? valueObj._ : (hasTypeAttr ? '' : undefined)
const typeStr = valueObj?.['$xsi:type'] || valueObj?.$?.Type
const mappedType = this.mapType(typeStr)
if (!name) continue
if (val === undefined && !hasTypeAttr) continue
if (this._parameters.has(name)) {
const existing = this._parameters.get(name)!
if (mappedType && existing.type === ParameterType.UNKNOWN) {
const snap = existing.toSnapshot()
this._parameters.set(
name,
new DeviceParameter(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
)
}
this.updateParameter(name, val)
} else if (this._properties.has(name)) {
const existing = this._properties.get(name)!
if (mappedType && existing.type === ParameterType.UNKNOWN) {
const snap = existing.toSnapshot()
this._properties.set(
name,
new DeviceProperty(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
)
}
this.updateProperty(name, val)
} else {
this.setProperty(new DeviceProperty(name, val ?? null, mappedType || ParameterType.UNKNOWN))
}
}
this._lastSeen = new Date()
}
private mapType(typeStr: string | undefined): ParameterType | undefined {
switch (typeStr) {
case 'xsd:string':
return ParameterType.STRING
case 'xsd:int':
return ParameterType.INT
case 'xsd:unsignedInt':
return ParameterType.UNSIGNED_INT
case 'xsd:boolean':
return ParameterType.BOOLEAN
default:
return undefined
}
}
public toSnapshot(): DeviceSnapshot {
return {
deviceId: this.deviceId,
identifier: this.identifier,
createdAt: this.createdAt,
lastSeen: this._lastSeen,
onboardingStatus: this._onboardingStatus,
isOnline: this.isOnline(),
gpnStatus: this._gpnStatus,
dataModel: this._dataModel,
gpnCircuitBreaker: { ...this._gpnCircuitBreaker },
lastGpnSentDate: this._lastGpnSentDate,
operationalStatus: this._operationalStatus,
lastFailureReason: this._lastFailureReason,
parameters: this.getParameters().map((p) => p.toSnapshot()),
properties: this.getProperties().map((p) => p.toSnapshot()),
}
}
public updateLastSeen(): void {
this._lastSeen = new Date()
}
}
export interface DeviceSnapshot {
deviceId: DeviceId
identifier: string
createdAt: Date
lastSeen: Date
onboardingStatus: OnboardingStatus
isOnline: boolean
gpnStatus: GpnStatus
dataModel: DataModel
gpnCircuitBreaker: GpnCircuitBreaker
lastGpnSentDate: Date | null
operationalStatus: string
lastFailureReason: string
parameters: ParameterSnapshot[]
properties: PropertySnapshot[]
}