- Copy cwmp-1-0..4.ts source classes from cwmp-connector (papaye) into app/protocol/ as local files (ts-nocheck, external dependency) - Add soap_envelope.ts: SoapEnvelope, SoapEnvelopeHeader, CwmpID, NAMESPACES - Add tr069_types.ts: re-exports all CWMP classes + CwmpMessage discriminated union + typed task payload interfaces (CWMPTaskType, CWMPTaskPayload, etc.) - Rewrite cwmp_message_builder.ts to use class-based builders instead of raw XML template strings; add transferCompleteResponse() and soapFault() - soap.ts: extractCwmpMessage returns CwmpMessage union, adds runtime allowlist guard against unknown CWMP method names from CPE - cwmp_middleware.ts: envelope typed SoapEnvelope|null, message CwmpMessage|null - cwmp_controller.ts: handleInform(informData: Inform), remove any, migrate TransferComplete and fault to new builder path, drop buildSoapEnvelope import - task_queue.ts: CWMPTask.type → CWMPTaskType literal union, payload → CWMPTaskPayload, result → CWMPTaskResultData - device_action_executor.ts: GPV/GPN/GPA responses typed, no more any - main_state_machine.ts: DeviceContext.message.data → CWMPTaskResultData, organizeParameters receives ParameterInfoStruct[] directly - device.ts: tmpParamValues typed ParameterValueStruct[], organizeParameters signature updated, updateFromValues typed - device_id.ts: fromInform typed with DeviceIdStruct - soap_envelope.ts: remove unconditional <cwmp:noMoreRequests> header (CPE→ACS only per TR-069 §3.7.1.4)
449 lines
14 KiB
TypeScript
449 lines
14 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'
|
|
import type { ParameterValueStruct, ParameterInfoStruct } from '../../protocol/tr069_types.js'
|
|
|
|
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: string | { _: string } | unknown): string | null {
|
|
if (typeof raw === 'string') return raw || null
|
|
if (raw && typeof raw === 'object' && typeof (raw as { _?: unknown })._ === 'string')
|
|
return (raw as { _: string })._ || null
|
|
return null
|
|
}
|
|
|
|
/** Detect which TR data model a device uses from a ParameterValueStruct list */
|
|
export function detectDataModelFromParams(paramValues: ParameterValueStruct[]): 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: ParameterValueStruct[] = []
|
|
public tmpPC: string | null = 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 (cooldown expired probe).
|
|
* Open → transitions to half-open when cooldown expires (clears cooldownUntil).
|
|
*
|
|
* NOTE: the open→half-open transition mutates state but does NOT persist to DB.
|
|
* The caller (IdleState) is responsible for persisting if needed.
|
|
*/
|
|
public isGpnAllowed(): boolean {
|
|
const cb = this._gpnCircuitBreaker
|
|
if (cb.state === 'closed') return true
|
|
if (cb.state === 'half-open') return true
|
|
// state === 'open': check if cooldown expired → transition to half-open
|
|
if (cb.cooldownUntil && new Date() >= cb.cooldownUntil) {
|
|
cb.state = 'half-open'
|
|
cb.cooldownUntil = null // clear stale cooldown so snapshot is clean
|
|
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 removeProperty(name: string): void {
|
|
this._properties.delete(name)
|
|
}
|
|
|
|
/**
|
|
* Parameters that arrive in the Inform payload are writable by definition
|
|
* (the CPE reports them because the ACS is supposed to manage them).
|
|
* updateFromValues() defaults unknowns to properties (read-only), so we
|
|
* promote the known-writable Inform params back to parameters here.
|
|
*
|
|
* Known writable Inform parameters (TR-098 and TR-181):
|
|
* - *.ProvisioningCode
|
|
* - *.ParameterKey
|
|
*/
|
|
public reclassifyInformWritables(paramValues: ParameterValueStruct[]): void {
|
|
const WRITABLE_PATTERNS = [
|
|
/(^|\.)ProvisioningCode$/,
|
|
/(^|\.)ParameterKey$/,
|
|
]
|
|
for (const pv of paramValues || []) {
|
|
const name = typeof pv?.Name === 'string' ? pv.Name : (pv?.Name as { _?: string })?._ || null
|
|
if (!name) continue
|
|
if (!WRITABLE_PATTERNS.some((re) => re.test(name))) continue
|
|
// Only act if it ended up as a property (wrong classification)
|
|
if (!this._properties.has(name)) continue
|
|
const prop = this._properties.get(name)!
|
|
this._properties.delete(name)
|
|
this._parameters.set(name, new DeviceParameter(name, prop.value, prop.type))
|
|
}
|
|
}
|
|
|
|
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: ParameterValueStruct[]): void {
|
|
this.tmpParamValues = values
|
|
}
|
|
|
|
public getTmpParamValues(): ParameterValueStruct[] {
|
|
return this.tmpParamValues
|
|
}
|
|
|
|
public setTmpPC(value: string | null): void {
|
|
this.tmpPC = value
|
|
}
|
|
|
|
public getTmpPC(): string | null {
|
|
return this.tmpPC
|
|
}
|
|
|
|
public clearTmpValues(): void {
|
|
this.tmpParamValues = []
|
|
this.tmpPC = null
|
|
}
|
|
|
|
public organizeParameters(infoList: ParameterInfoStruct[], timestamp: Date): void {
|
|
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: ParameterValueStruct[]): void {
|
|
for (const pv of paramValues || []) {
|
|
const name = extractName(pv?.Name)
|
|
const valueObj = pv?.Value
|
|
const hasTextNode = valueObj && Object.prototype.hasOwnProperty.call(valueObj, '_')
|
|
const valueObjRaw = valueObj as unknown as Record<string, unknown> | undefined
|
|
const hasTypeAttr = valueObjRaw && (valueObjRaw['$xsi:type'] || valueObj?.$?.Type)
|
|
const val = hasTextNode ? valueObj?._ : (hasTypeAttr ? '' : undefined)
|
|
const typeStr = (valueObjRaw?.['$xsi:type'] as string | undefined) || 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[]
|
|
}
|