import type { HttpContext } from '@adonisjs/core/http' import { buildSoapEnvelope } from '../protocol/soap.js' import { CWMPMessageBuilder } from '../protocol/common/cwmp_message_builder.js' import { StateMachineManager } from '../services/cwmp/state_machine_manager.js' import { Device, detectDataModelFromParams } from '../domain/device/device.js' import { reclassifyDeviceFromCatalog } from '../services/parameter_writability.js' import { DeviceId } from '../domain/device/device_id.js' import { DeviceParameter, DeviceProperty } from '../domain/device/device_parameter.js' import DeviceModel from '../models/device.js' import { getDeviceHashIdentifier } from '../utils/device_utils.js' import logger from '@adonisjs/core/services/logger' export default class CwmpController { private static managerInitialized = false private getManager(): StateMachineManager { const manager = StateMachineManager.getInstance() if (!CwmpController.managerInitialized) { manager.startCleanupTimer(60000) CwmpController.managerInitialized = true } return manager } /** * POST /cwmp10 * * Main CWMP/TR-069 endpoint. The cwmp_middleware has already: * - Parsed the raw XML into a SOAP envelope * - Extracted the CWMP message type and data * - Validated content-type * - Attached everything to request.cwmp */ async handle(ctx: HttpContext) { const { request, response } = ctx const manager = this.getManager() const { sessionId, remoteKey, envelope, message, isEmpty } = request.cwmp try { if (isEmpty) { manager.ensureSessionForRemote(remoteKey, sessionId) const hashId = manager.getHashIdForRemote(remoteKey) if (hashId) ctx.cwmpDeviceId = hashId if (manager.hasSession(sessionId)) { const result = await manager.continue(sessionId) if (result && result !== '') { return this.sendSoap(response, result) } } return response.status(204).send('') } if (message?.type === 'Inform') { const { xml, hashId } = await this.handleInform( message.data, sessionId, envelope?.header?.id, remoteKey, manager ) ctx.cwmpDeviceId = hashId return this.sendSoap(response, xml) } if (message?.type === 'TransferComplete') { const hashId = manager.getHashIdForRemote(remoteKey) if (hashId) ctx.cwmpDeviceId = hashId const resp = buildSoapEnvelope( undefined, '' ) return this.sendSoap(response, resp) } manager.ensureSessionForRemote(remoteKey, sessionId) const hashId = manager.getHashIdForRemote(remoteKey) if (hashId) ctx.cwmpDeviceId = hashId if (message && manager.hasSession(sessionId)) { const { success, payload } = this.getMessageInfo(message) const result = await manager.handleCWMPResponse(sessionId, success, payload) if (result && result !== '') { return this.sendSoap(response, result) } } return response.status(204).send('') } catch (error: any) { logger.error({ error: error?.message, stack: error?.stack }, 'CWMP error handling request') const faultXml = this.buildSoapFault(error?.message || 'Internal error') return this.sendSoap(response, faultXml) } } // ─── Inform Handling ─────────────────────────────────────────────────────── private async handleInform( informData: any, session: string, cwmpId: string | undefined, remoteKey: string, manager: StateMachineManager ): Promise<{ xml: string; hashId: string }> { logger.info( { informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId }, 'CWMP handleInform called' ) const deviceIdStruct = informData?.DeviceId || {} const deviceId = DeviceId.fromInform(deviceIdStruct) // Load or create device let device = await this.loadDevice(deviceId) if (!device) { device = new Device(deviceId) } device.updateLastSeen() // Process inform parameter values const paramValues = informData?.ParameterList?.ParameterValueStruct || [] if (paramValues.length > 0) { device.updateFromValues(paramValues) } // Reclassify properties → parameters using catalog when GPN is unsupported if (device.gpnStatus === 'unsupported') { reclassifyDeviceFromCatalog(device) } device.setTmpParamValues(paramValues) // Detect TR data model from Inform parameter names (Device.* = TR-181, InternetGatewayDevice.* = TR-098) // Only update if currently unknown — once detected, keep it stable across sessions if (device.dataModel === 'unknown') { const detectedModel = detectDataModelFromParams(paramValues) if (detectedModel !== 'unknown') { device.setDataModel(detectedModel) } } // Persist device const hashId = getDeviceHashIdentifier({ manufacturer: deviceId.manufacturer, oui: deviceId.oui, productClass: deviceId.productClass, serialNumber: deviceId.serialNumber, }) await DeviceModel.createOrUpdate({ identifier: hashId, deviceId, properties: device.getProperties().map((p) => p.toSnapshot()), parameters: device.getParameters().map((p) => p.toSnapshot()), gpnStatus: device.gpnStatus, dataModel: device.dataModel, updatedAt: new Date(), }) logger.info({ hashId }, 'Inform received') // Extract events const events: string[] = Array.isArray(informData?.Event?.EventStruct) ? informData.Event.EventStruct.map((e: any) => e?.EventCode).filter( (c: any): c is string => !!c ) : [] logger.info({ events }, 'CWMP Inform events extracted') // Start state machine await manager.startStateMachine(device, session, { events }) manager.bindRemoteToDevice(remoteKey, device.deviceId) // After Inform for onboarding events, enqueue critical reads using the detected data model const onlyConnReq = events.length > 0 && events.every((e) => e === '6 CONNECTION REQUEST') if (!onlyConnReq) { try { const isTr181 = device.dataModel === 'tr-181' const root = isTr181 ? 'Device' : 'InternetGatewayDevice' const criticalPaths = [ `${root}.DeviceInfo.HardwareVersion`, `${root}.DeviceInfo.SoftwareVersion`, `${root}.ManagementServer.ConnectionRequestURL`, ...(isTr181 ? [] : [`${root}.DeviceInfo.SpecVersion`]), ] logger.debug({ dataModel: device.dataModel, criticalPaths }, 'Enqueueing critical reads') await manager.enqueueImmediate(device, 'get_parameter_values', criticalPaths) } catch (_e) { // Non-critical } } return { xml: CWMPMessageBuilder.informResponse(cwmpId), hashId } } // ─── Helpers ─────────────────────────────────────────────────────────────── private sendSoap(response: any, xml: string) { response.header('Content-Type', 'text/xml; charset=utf-8') response.header('SOAPAction', '""') return response.send(xml) } private getMessageInfo(message: { type: string; data: any }): { success: boolean; payload: any } { const failTypes = ['Fault'] return { success: !failTypes.includes(message.type), payload: message.data, } } private buildSoapFault(reason: string): string { const body = ` Server ${reason} 8002 ${reason} ` return buildSoapEnvelope(undefined, body) } private async loadDevice(deviceId: DeviceId): Promise { try { const record = await DeviceModel.findByDeviceId(deviceId) if (!record) return null const device = new Device( new DeviceId( record.deviceId.manufacturer, record.deviceId.oui, record.deviceId.productClass, record.deviceId.serialNumber ), record.createdAt ) record.properties?.forEach((p: any) => device.setProperty(new DeviceProperty(p.name, p.value, p.type)) ) record.parameters?.forEach((p: any) => device.setParameter(new DeviceParameter(p.name, p.value, p.type)) ) device.setGpnStatus(record.gpnStatus ?? 'unknown') device.setDataModel(record.dataModel ?? 'unknown') return device } catch (_error) { return null } } }