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 = new Map() private _properties: Map = 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 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: any[]): void { const WRITABLE_PATTERNS = [ /(^|\.)ProvisioningCode$/, /(^|\.)ParameterKey$/, ] for (const pv of paramValues || []) { const name = typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || 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: 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[] }