diff --git a/app/services/cwmp/configuration_evaluation_state.ts b/app/services/cwmp/configuration_evaluation_state.ts index 20772bb..c8a0ffd 100644 --- a/app/services/cwmp/configuration_evaluation_state.ts +++ b/app/services/cwmp/configuration_evaluation_state.ts @@ -131,7 +131,20 @@ export class ConfigurationEvaluationState { const response = await context.services.deviceAction.getParameterValues( context.device, leaves, - () => Promise.resolve('') + async (result) => { + if (result.status === 'success' && result.data) { + const list = result.data?.ParameterList?.ParameterValueStruct + if (Array.isArray(list) && list.length > 0) { + const normalized = list.map((pv: any) => ({ + ...pv, + Name: typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || pv?.Name, + })) + context.device.updateFromValues(normalized) + logger.debug({ identifier: context.device.identifier, count: normalized.length }, '[ConfigEval] Inventory GPV values saved to device') + } + } + return '' + } ) return { response } } diff --git a/app/services/cwmp/state_machine_manager.ts b/app/services/cwmp/state_machine_manager.ts index 53a1038..4e38052 100644 --- a/app/services/cwmp/state_machine_manager.ts +++ b/app/services/cwmp/state_machine_manager.ts @@ -343,8 +343,13 @@ export class StateMachineManager extends EventEmitter { return '' } - // If state machine produced a response, send it + // If state machine produced a response, send it. + // Also track the task ID that was just created (by addTask inside a state handler) + // so that handleCWMPResponse can execute its callback when the CPE replies. if (response && response !== '') { + if (!machineInstance.lastTaskId) { + machineInstance.lastTaskId = this.taskQueue.getLastCreatedTaskId(machineInstance.deviceId) + } return response } @@ -409,6 +414,12 @@ export class StateMachineManager extends EventEmitter { return '' } + // If the state machine produced a new request (e.g. next inventory GPN, next SPV), + // track the task ID so the next handleCWMPResponse can fire its callback. + if (response && response !== '' && !machineInstance.lastTaskId) { + machineInstance.lastTaskId = this.taskQueue.getLastCreatedTaskId(machineInstance.deviceId) + } + return response } catch (error) { logger.error(`Error handling CWMP response: ${error}`) diff --git a/app/services/cwmp/task_queue.ts b/app/services/cwmp/task_queue.ts index 85db8a8..6fbb8e9 100644 --- a/app/services/cwmp/task_queue.ts +++ b/app/services/cwmp/task_queue.ts @@ -86,6 +86,8 @@ export class CWMPTaskQueue extends EventEmitter { private tasks: Map = new Map() private deviceQueues: Map = new Map() private taskCallbacks: Map Promise> = new Map() + /** Tracks the most recently created task per device (set by addTask) */ + private lastCreatedTaskIdByDevice: Map = new Map() /** * Adds a task with callback (legacy-compatible API used by DeviceActionExecutor) @@ -108,6 +110,9 @@ export class CWMPTaskQueue extends EventEmitter { cwmpTask.status = TaskStatus.SENT } + // Track the most recent task created for this device so callers can retrieve its ID + this.lastCreatedTaskIdByDevice.set(JSON.stringify(deviceId), cwmpTask.id) + // Generate CWMP envelope const builder = CWMPTaskQueue.task_map[taskType] if (typeof builder !== 'function') { @@ -217,20 +222,28 @@ export class CWMPTaskQueue extends EventEmitter { return '' } + /** + * Returns the ID of the most recently created task for a device (via addTask). + * Used by the state machine manager to correlate responses with in-flight tasks + * that were created directly by state handlers (not served from the external queue). + */ + getLastCreatedTaskId(deviceId: DeviceId): string | undefined { + return this.lastCreatedTaskIdByDevice.get(JSON.stringify(deviceId)) + } + clearPendingTasksForDevice(deviceId: DeviceId): void { const deviceKey = JSON.stringify(deviceId) const queue = this.deviceQueues.get(deviceKey) || [] - const keep: string[] = [] + // Clear ALL tasks (PENDING and SENT) — after session completion, no task + // from the previous session should bleed into the next one. Tasks that were + // already SENT (created by state-machine states via addTask()) are ghost + // entries that would otherwise be served to the next session. for (const taskId of queue) { - const t = this.tasks.get(taskId) - if (t && t.status === TaskStatus.SENT) { - keep.push(taskId) - continue - } this.tasks.delete(taskId) this.taskCallbacks.delete(taskId) } - this.deviceQueues.set(deviceKey, keep) + this.deviceQueues.set(deviceKey, []) + this.lastCreatedTaskIdByDevice.delete(deviceKey) } private generateTaskId(deviceId: DeviceId, type: string): string { diff --git a/tests/unit/services/cwmp/config_eval_extra.spec.ts b/tests/unit/services/cwmp/config_eval_extra.spec.ts index a9357bb..23dad86 100644 --- a/tests/unit/services/cwmp/config_eval_extra.spec.ts +++ b/tests/unit/services/cwmp/config_eval_extra.spec.ts @@ -502,6 +502,69 @@ describe('FirmwareUpgradeState — download callback', () => { }) +describe('ConfigurationEvaluationState — inventory phase 2 GPV saves values on device', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('GPV callback in phase 2 calls updateFromValues so parameters are persisted', async () => { + const config = makeConfig({ + desiredParameters: [{ name: 'Device.X', value: '1', type: 'string' }], + inventory: [{ path: 'Device.ManagementServer.' }], + }) + vi.mocked(ConfigurationModel.find).mockResolvedValue([config]) + + const device = makeDevice() + device.setGpnStatus('supported') + const context = makeContext(device) + + const state = new ConfigurationEvaluationState() + + // Step 1: first handle → sends GPN for inventory + await state.handle(context as any) + + // Step 2: execute GPN callback with two leaf names + const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map + const gpnTaskId = Array.from(taskCallbacks.keys())[0] + await context.taskQueue.executeTaskCallback(gpnTaskId!, ResultStatus.Success, { + ParameterList: { + ParameterInfoStruct: [ + { Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }, + { Name: 'Device.ManagementServer.ConnectionRequestURL', Writable: '0' }, + ], + }, + }) + + // Step 3: second handle → sends GPV for discovered leaves (phase 2) + const result2 = await state.handle(context as any) + expect(result2.response).toContain('GetParameterValues') + + // Before GPV callback — device has no value for PeriodicInformInterval + const before = device.getParameters().find((p) => p.name === 'Device.ManagementServer.PeriodicInformInterval') + expect(before?.value ?? null).toBeNull() + + // Step 4: execute GPV callback with actual values + const gpvTaskId = Array.from(taskCallbacks.keys()).pop() + await context.taskQueue.executeTaskCallback(gpvTaskId!, ResultStatus.Success, { + ParameterList: { + ParameterValueStruct: [ + { Name: 'Device.ManagementServer.PeriodicInformInterval', Value: { _: '3600', $: { 'xsi:type': 'xsd:unsignedInt' } } }, + { Name: 'Device.ManagementServer.ConnectionRequestURL', Value: { _: 'http://cpe/cr', $: { 'xsi:type': 'xsd:string' } } }, + ], + }, + }) + + // After GPV callback — device must have the values saved + const after = device.getParameters().find((p) => p.name === 'Device.ManagementServer.PeriodicInformInterval') + expect(after).toBeDefined() + expect(after!.value).toBe('3600') + + const afterURL = device.getParameters().find((p) => p.name === 'Device.ManagementServer.ConnectionRequestURL') + expect(afterURL).toBeDefined() + expect(afterURL!.value).toBe('http://cpe/cr') + }) +}) + describe('ConfigurationEvaluationState — firmware upgrade transition (lines 154-158)', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/tests/unit/services/cwmp/task_queue.spec.ts b/tests/unit/services/cwmp/task_queue.spec.ts index d39f828..ae8eb8e 100644 --- a/tests/unit/services/cwmp/task_queue.spec.ts +++ b/tests/unit/services/cwmp/task_queue.spec.ts @@ -204,13 +204,16 @@ describe('CWMPTaskQueue — branch coverage', () => { const tq = new CWMPTaskQueue() const deviceId = new DeviceId('X', 'Y', 'Z', 'S') - tq.addCWMPTask(deviceId, 'get_parameter_values', ['a']) // PENDING + const pendingTask = tq.addCWMPTask(deviceId, 'get_parameter_values', ['a']) // PENDING const { taskId: sentId } = tq.addTask(deviceId, 'set_parameter_values', {}, async () => '') // SENT tq.clearPendingTasksForDevice(deviceId) + // clearPendingTasksForDevice clears ALL tasks (PENDING and SENT) to avoid + // ghost entries bleeding into the next session const tasks = (tq as any).tasks as Map - expect(tasks.has(sentId)).toBe(true) + expect(tasks.has(pendingTask.id)).toBe(false) + expect(tasks.has(sentId)).toBe(false) }) it('registerCallback adds callback for existing task', () => {