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>
This commit is contained in:
Kevin L'HereecandContinue committed 2026-06-08 12:49:00 +00:00
1 parent dabe15839d
commit aad93911e4
35 files changed
+8593 -4497

No files matched your search

+1
View File
@@ -4,5 +4,6 @@
*/
/// <reference path="../../adonisrc.ts" />
/// <reference path="../../config/exchange_store.ts" />
/// <reference path="../../config/hash.ts" />
/// <reference path="../../config/logger.ts" />
+48
View File
@@ -24,6 +24,12 @@ const routes = {
tokens: [{"old":"/api/v1/checkservice","type":0,"val":"api","end":""},{"old":"/api/v1/checkservice","type":0,"val":"v1","end":""},{"old":"/api/v1/checkservice","type":0,"val":"checkservice","end":""}],
types: placeholder as Registry['health.checkservice']['types'],
},
'health.check_tr_069_exchanges': {
methods: ["GET","HEAD"],
pattern: '/api/v1/health/tr069-exchanges',
tokens: [{"old":"/api/v1/health/tr069-exchanges","type":0,"val":"api","end":""},{"old":"/api/v1/health/tr069-exchanges","type":0,"val":"v1","end":""},{"old":"/api/v1/health/tr069-exchanges","type":0,"val":"health","end":""},{"old":"/api/v1/health/tr069-exchanges","type":0,"val":"tr069-exchanges","end":""}],
types: placeholder as Registry['health.check_tr_069_exchanges']['types'],
},
'devices.index': {
methods: ["GET","HEAD"],
pattern: '/api/v1/devices',
@@ -60,6 +66,30 @@ const routes = {
tokens: [{"old":"/api/v1/devices/:identifier/refresh","type":0,"val":"api","end":""},{"old":"/api/v1/devices/:identifier/refresh","type":0,"val":"v1","end":""},{"old":"/api/v1/devices/:identifier/refresh","type":0,"val":"devices","end":""},{"old":"/api/v1/devices/:identifier/refresh","type":1,"val":"identifier","end":""},{"old":"/api/v1/devices/:identifier/refresh","type":0,"val":"refresh","end":""}],
types: placeholder as Registry['devices.refresh']['types'],
},
'references.index': {
methods: ["GET","HEAD"],
pattern: '/api/v1/devices/:identifier/references',
tokens: [{"old":"/api/v1/devices/:identifier/references","type":0,"val":"api","end":""},{"old":"/api/v1/devices/:identifier/references","type":0,"val":"v1","end":""},{"old":"/api/v1/devices/:identifier/references","type":0,"val":"devices","end":""},{"old":"/api/v1/devices/:identifier/references","type":1,"val":"identifier","end":""},{"old":"/api/v1/devices/:identifier/references","type":0,"val":"references","end":""}],
types: placeholder as Registry['references.index']['types'],
},
'references.show': {
methods: ["GET","HEAD"],
pattern: '/api/v1/devices/:identifier/references/:provider',
tokens: [{"old":"/api/v1/devices/:identifier/references/:provider","type":0,"val":"api","end":""},{"old":"/api/v1/devices/:identifier/references/:provider","type":0,"val":"v1","end":""},{"old":"/api/v1/devices/:identifier/references/:provider","type":0,"val":"devices","end":""},{"old":"/api/v1/devices/:identifier/references/:provider","type":1,"val":"identifier","end":""},{"old":"/api/v1/devices/:identifier/references/:provider","type":0,"val":"references","end":""},{"old":"/api/v1/devices/:identifier/references/:provider","type":1,"val":"provider","end":""}],
types: placeholder as Registry['references.show']['types'],
},
'references.resolve_all': {
methods: ["POST"],
pattern: '/api/v1/devices/:identifier/references/resolve',
tokens: [{"old":"/api/v1/devices/:identifier/references/resolve","type":0,"val":"api","end":""},{"old":"/api/v1/devices/:identifier/references/resolve","type":0,"val":"v1","end":""},{"old":"/api/v1/devices/:identifier/references/resolve","type":0,"val":"devices","end":""},{"old":"/api/v1/devices/:identifier/references/resolve","type":1,"val":"identifier","end":""},{"old":"/api/v1/devices/:identifier/references/resolve","type":0,"val":"references","end":""},{"old":"/api/v1/devices/:identifier/references/resolve","type":0,"val":"resolve","end":""}],
types: placeholder as Registry['references.resolve_all']['types'],
},
'references.resolve_one': {
methods: ["POST"],
pattern: '/api/v1/devices/:identifier/references/resolve/:provider',
tokens: [{"old":"/api/v1/devices/:identifier/references/resolve/:provider","type":0,"val":"api","end":""},{"old":"/api/v1/devices/:identifier/references/resolve/:provider","type":0,"val":"v1","end":""},{"old":"/api/v1/devices/:identifier/references/resolve/:provider","type":0,"val":"devices","end":""},{"old":"/api/v1/devices/:identifier/references/resolve/:provider","type":1,"val":"identifier","end":""},{"old":"/api/v1/devices/:identifier/references/resolve/:provider","type":0,"val":"references","end":""},{"old":"/api/v1/devices/:identifier/references/resolve/:provider","type":0,"val":"resolve","end":""},{"old":"/api/v1/devices/:identifier/references/resolve/:provider","type":1,"val":"provider","end":""}],
types: placeholder as Registry['references.resolve_one']['types'],
},
'commands.index': {
methods: ["GET","HEAD"],
pattern: '/api/v1/commands',
@@ -114,6 +144,24 @@ const routes = {
tokens: [{"old":"/api/v1/tr069-trace/:id/log","type":0,"val":"api","end":""},{"old":"/api/v1/tr069-trace/:id/log","type":0,"val":"v1","end":""},{"old":"/api/v1/tr069-trace/:id/log","type":0,"val":"tr069-trace","end":""},{"old":"/api/v1/tr069-trace/:id/log","type":1,"val":"id","end":""},{"old":"/api/v1/tr069-trace/:id/log","type":0,"val":"log","end":""}],
types: placeholder as Registry['tr_069_trace.show_log']['types'],
},
'exchange.index': {
methods: ["GET","HEAD"],
pattern: '/api/v1/tr069-trace/:deviceId/exchanges',
tokens: [{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":0,"val":"api","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":0,"val":"v1","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":0,"val":"tr069-trace","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":1,"val":"deviceId","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":0,"val":"exchanges","end":""}],
types: placeholder as Registry['exchange.index']['types'],
},
'exchange.destroy': {
methods: ["DELETE"],
pattern: '/api/v1/tr069-trace/:deviceId/exchanges',
tokens: [{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":0,"val":"api","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":0,"val":"v1","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":0,"val":"tr069-trace","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":1,"val":"deviceId","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges","type":0,"val":"exchanges","end":""}],
types: placeholder as Registry['exchange.destroy']['types'],
},
'exchange.stream': {
methods: ["GET","HEAD"],
pattern: '/api/v1/tr069-trace/:deviceId/exchanges/stream',
tokens: [{"old":"/api/v1/tr069-trace/:deviceId/exchanges/stream","type":0,"val":"api","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges/stream","type":0,"val":"v1","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges/stream","type":0,"val":"tr069-trace","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges/stream","type":1,"val":"deviceId","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges/stream","type":0,"val":"exchanges","end":""},{"old":"/api/v1/tr069-trace/:deviceId/exchanges/stream","type":0,"val":"stream","end":""}],
types: placeholder as Registry['exchange.stream']['types'],
},
'configurations_v_1.index': {
methods: ["GET","HEAD"],
pattern: '/api/v1/configurations',
+111 -15
View File
@@ -43,6 +43,18 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/health_controller').default['checkservice']>>>
}
}
'health.check_tr_069_exchanges': {
methods: ["GET","HEAD"]
pattern: '/api/v1/health/tr069-exchanges'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/health_controller').default['checkTr069Exchanges']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/health_controller').default['checkTr069Exchanges']>>>
}
}
'devices.index': {
methods: ["GET","HEAD"]
pattern: '/api/v1/devices'
@@ -115,6 +127,54 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/devices_controller').default['refresh']>>>
}
}
'references.index': {
methods: ["GET","HEAD"]
pattern: '/api/v1/devices/:identifier/references'
types: {
body: {}
paramsTuple: [ParamValue]
params: { identifier: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/references_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/references_controller').default['index']>>>
}
}
'references.show': {
methods: ["GET","HEAD"]
pattern: '/api/v1/devices/:identifier/references/:provider'
types: {
body: {}
paramsTuple: [ParamValue, ParamValue]
params: { identifier: ParamValue; provider: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/references_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/references_controller').default['show']>>>
}
}
'references.resolve_all': {
methods: ["POST"]
pattern: '/api/v1/devices/:identifier/references/resolve'
types: {
body: {}
paramsTuple: [ParamValue]
params: { identifier: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/references_controller').default['resolveAll']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/references_controller').default['resolveAll']>>>
}
}
'references.resolve_one': {
methods: ["POST"]
pattern: '/api/v1/devices/:identifier/references/resolve/:provider'
types: {
body: {}
paramsTuple: [ParamValue, ParamValue]
params: { identifier: ParamValue; provider: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/references_controller').default['resolveOne']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/references_controller').default['resolveOne']>>>
}
}
'commands.index': {
methods: ["GET","HEAD"]
pattern: '/api/v1/commands'
@@ -223,6 +283,42 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/tr069_trace_controller').default['showLog']>>>
}
}
'exchange.index': {
methods: ["GET","HEAD"]
pattern: '/api/v1/tr069-trace/:deviceId/exchanges'
types: {
body: {}
paramsTuple: [ParamValue]
params: { deviceId: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/exchange_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/exchange_controller').default['index']>>>
}
}
'exchange.destroy': {
methods: ["DELETE"]
pattern: '/api/v1/tr069-trace/:deviceId/exchanges'
types: {
body: {}
paramsTuple: [ParamValue]
params: { deviceId: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/exchange_controller').default['destroy']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/exchange_controller').default['destroy']>>>
}
}
'exchange.stream': {
methods: ["GET","HEAD"]
pattern: '/api/v1/tr069-trace/:deviceId/exchanges/stream'
types: {
body: {}
paramsTuple: [ParamValue]
params: { deviceId: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/exchange_controller').default['stream']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/exchange_controller').default['stream']>>>
}
}
'configurations_v_1.index': {
methods: ["GET","HEAD"]
pattern: '/api/v1/configurations'
@@ -251,12 +347,12 @@ export interface Registry {
methods: ["POST"]
pattern: '/api/v1/configurations'
types: {
body: {}
body: ExtractBody<InferInput<(typeof import('#validators/configuration').createConfigurationValidator)>>
paramsTuple: []
params: {}
query: {}
query: ExtractQuery<InferInput<(typeof import('#validators/configuration').createConfigurationValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/configurations_v1_controller').default['store']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/configurations_v1_controller').default['store']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/configurations_v1_controller').default['store']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'configurations_v_1.show': {
@@ -275,12 +371,12 @@ export interface Registry {
methods: ["PUT"]
pattern: '/api/v1/configurations/:id'
types: {
body: {}
body: ExtractBody<InferInput<(typeof import('#validators/configuration').updateConfigurationValidator)>>
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
query: ExtractQuery<InferInput<(typeof import('#validators/configuration').updateConfigurationValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/configurations_v1_controller').default['update']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/configurations_v1_controller').default['update']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/configurations_v1_controller').default['update']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'configurations_v_1.destroy': {
@@ -407,24 +503,24 @@ export interface Registry {
methods: ["POST"]
pattern: '/api/v1/firmware-catalog/entries'
types: {
body: {}
body: ExtractBody<InferInput<(typeof import('#validators/firmware_catalog').createFirmwareEntryValidator)>>
paramsTuple: []
params: {}
query: {}
query: ExtractQuery<InferInput<(typeof import('#validators/firmware_catalog').createFirmwareEntryValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['createEntry']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['createEntry']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['createEntry']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'firmware_catalog.update_entry': {
methods: ["PUT"]
pattern: '/api/v1/firmware-catalog/entries/:id'
types: {
body: {}
body: ExtractBody<InferInput<(typeof import('#validators/firmware_catalog').updateFirmwareEntryValidator)>>
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
query: ExtractQuery<InferInput<(typeof import('#validators/firmware_catalog').updateFirmwareEntryValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['updateEntry']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['updateEntry']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['updateEntry']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'firmware_catalog.delete_entry': {
@@ -443,12 +539,12 @@ export interface Registry {
methods: ["POST"]
pattern: '/api/v1/firmware-catalog/routes'
types: {
body: {}
body: ExtractBody<InferInput<(typeof import('#validators/firmware_catalog').createMigrationRouteValidator)>>
paramsTuple: []
params: {}
query: {}
query: ExtractQuery<InferInput<(typeof import('#validators/firmware_catalog').createMigrationRouteValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['createRoute']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['createRoute']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/firmware_catalog_controller').default['createRoute']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'firmware_catalog.update_route': {
+12
View File
@@ -6,6 +6,7 @@ export interface ApiDefinition {
health: {
checklife: typeof routes['health.checklife']
checkservice: typeof routes['health.checkservice']
checkTr069Exchanges: typeof routes['health.check_tr_069_exchanges']
}
devices: {
index: typeof routes['devices.index']
@@ -15,6 +16,12 @@ export interface ApiDefinition {
executeCommand: typeof routes['devices.execute_command']
refresh: typeof routes['devices.refresh']
}
references: {
index: typeof routes['references.index']
show: typeof routes['references.show']
resolveAll: typeof routes['references.resolve_all']
resolveOne: typeof routes['references.resolve_one']
}
commands: {
index: typeof routes['commands.index']
show: typeof routes['commands.show']
@@ -29,6 +36,11 @@ export interface ApiDefinition {
destroy: typeof routes['tr_069_trace.destroy']
showLog: typeof routes['tr_069_trace.show_log']
}
exchange: {
index: typeof routes['exchange.index']
destroy: typeof routes['exchange.destroy']
stream: typeof routes['exchange.stream']
}
configurationsV1: {
index: typeof routes['configurations_v_1.index']
listTags: typeof routes['configurations_v_1.list_tags']
+2
View File
@@ -8,8 +8,10 @@ export const controllers = {
ConfigurationsV1: () => import('#controllers/configurations_v1_controller'),
Cwmp: () => import('#controllers/cwmp_controller'),
Devices: () => import('#controllers/devices_controller'),
Exchange: () => import('#controllers/exchange_controller'),
FirmwareCatalog: () => import('#controllers/firmware_catalog_controller'),
Health: () => import('#controllers/health_controller'),
Pages: () => import('#controllers/pages_controller'),
References: () => import('#controllers/references_controller'),
Tr069Trace: () => import('#controllers/tr069_trace_controller'),
}
+21
View File
@@ -7,12 +7,17 @@ export type ScannedRoutes = {
'cwmp': { paramsTuple?: []; params?: {} }
'health.checklife': { paramsTuple?: []; params?: {} }
'health.checkservice': { paramsTuple?: []; params?: {} }
'health.check_tr_069_exchanges': { paramsTuple?: []; params?: {} }
'devices.index': { paramsTuple?: []; params?: {} }
'devices.criteria_values': { paramsTuple?: []; params?: {} }
'devices.show': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'devices.destroy': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'devices.execute_command': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'devices.refresh': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'references.index': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'references.show': { paramsTuple: [ParamValue,ParamValue]; params: {'identifier': ParamValue,'provider': ParamValue} }
'references.resolve_all': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'references.resolve_one': { paramsTuple: [ParamValue,ParamValue]; params: {'identifier': ParamValue,'provider': ParamValue} }
'commands.index': { paramsTuple?: []; params?: {} }
'commands.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'commands.store': { paramsTuple?: []; params?: {} }
@@ -22,6 +27,9 @@ export type ScannedRoutes = {
'tr_069_trace.save_raw_config': { paramsTuple?: []; params?: {} }
'tr_069_trace.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'tr_069_trace.show_log': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'exchange.index': { paramsTuple: [ParamValue]; params: {'deviceId': ParamValue} }
'exchange.destroy': { paramsTuple: [ParamValue]; params: {'deviceId': ParamValue} }
'exchange.stream': { paramsTuple: [ParamValue]; params: {'deviceId': ParamValue} }
'configurations_v_1.index': { paramsTuple?: []; params?: {} }
'configurations_v_1.list_tags': { paramsTuple?: []; params?: {} }
'configurations_v_1.store': { paramsTuple?: []; params?: {} }
@@ -64,6 +72,8 @@ export type ScannedRoutes = {
'cwmp': { paramsTuple?: []; params?: {} }
'devices.execute_command': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'devices.refresh': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'references.resolve_all': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'references.resolve_one': { paramsTuple: [ParamValue,ParamValue]; params: {'identifier': ParamValue,'provider': ParamValue} }
'commands.store': { paramsTuple?: []; params?: {} }
'tr_069_trace.store': { paramsTuple?: []; params?: {} }
'configurations_v_1.store': { paramsTuple?: []; params?: {} }
@@ -78,14 +88,19 @@ export type ScannedRoutes = {
GET: {
'health.checklife': { paramsTuple?: []; params?: {} }
'health.checkservice': { paramsTuple?: []; params?: {} }
'health.check_tr_069_exchanges': { paramsTuple?: []; params?: {} }
'devices.index': { paramsTuple?: []; params?: {} }
'devices.criteria_values': { paramsTuple?: []; params?: {} }
'devices.show': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'references.index': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'references.show': { paramsTuple: [ParamValue,ParamValue]; params: {'identifier': ParamValue,'provider': ParamValue} }
'commands.index': { paramsTuple?: []; params?: {} }
'commands.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'tr_069_trace.index': { paramsTuple?: []; params?: {} }
'tr_069_trace.raw_config': { paramsTuple?: []; params?: {} }
'tr_069_trace.show_log': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'exchange.index': { paramsTuple: [ParamValue]; params: {'deviceId': ParamValue} }
'exchange.stream': { paramsTuple: [ParamValue]; params: {'deviceId': ParamValue} }
'configurations_v_1.index': { paramsTuple?: []; params?: {} }
'configurations_v_1.list_tags': { paramsTuple?: []; params?: {} }
'configurations_v_1.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
@@ -111,14 +126,19 @@ export type ScannedRoutes = {
HEAD: {
'health.checklife': { paramsTuple?: []; params?: {} }
'health.checkservice': { paramsTuple?: []; params?: {} }
'health.check_tr_069_exchanges': { paramsTuple?: []; params?: {} }
'devices.index': { paramsTuple?: []; params?: {} }
'devices.criteria_values': { paramsTuple?: []; params?: {} }
'devices.show': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'references.index': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'references.show': { paramsTuple: [ParamValue,ParamValue]; params: {'identifier': ParamValue,'provider': ParamValue} }
'commands.index': { paramsTuple?: []; params?: {} }
'commands.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'tr_069_trace.index': { paramsTuple?: []; params?: {} }
'tr_069_trace.raw_config': { paramsTuple?: []; params?: {} }
'tr_069_trace.show_log': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'exchange.index': { paramsTuple: [ParamValue]; params: {'deviceId': ParamValue} }
'exchange.stream': { paramsTuple: [ParamValue]; params: {'deviceId': ParamValue} }
'configurations_v_1.index': { paramsTuple?: []; params?: {} }
'configurations_v_1.list_tags': { paramsTuple?: []; params?: {} }
'configurations_v_1.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
@@ -144,6 +164,7 @@ export type ScannedRoutes = {
DELETE: {
'devices.destroy': { paramsTuple: [ParamValue]; params: {'identifier': ParamValue} }
'tr_069_trace.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'exchange.destroy': { paramsTuple: [ParamValue]; params: {'deviceId': ParamValue} }
'configurations_v_1.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'firmware_catalog.delete_entry': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'firmware_catalog.delete_route': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
@@ -0,0 +1,78 @@
# Tâche : Refactoring State Machine — Session unique
## Objectif
Refactorer la `MainStateMachine` pour que tout le workflow d'onboarding se fasse en **une seule session CWMP**, sans déclencher de `ScheduleInform`.
## Flow attendu (session unique)
```
CPE → ACS : Inform (BOOTSTRAP)
ACS → CPE : InformResponse
CPE → ACS : POST vide
ACS → CPE : GetParameterNames
CPE → ACS : Fault (9003 si non supporté) OU GetParameterNamesResponse (si supporté)
ACS → CPE : GetParameterValues (lectures critiques)
CPE → ACS : GetParameterValuesResponse
ACS → CPE : SetParameterValues (ProvisioningCode + PeriodicInformInterval)
CPE → ACS : SetParameterValuesResponse
ACS → CPE : 204 (fin de session)
```
## Flow actuel (problème)
```
Session 1 (BOOTSTRAP):
Inform → InformResponse → Empty → SPV → SPVResponse → 204
Session 2 (SCHEDULED - déclenchée par ScheduleInform):
Inform → InformResponse → Empty → GPV → GPVResponse → GPN → Fault → 204
Session 3 (PERIODIC):
Inform → InformResponse → Empty → SPV → SPVResponse → 204
```
## Problèmes identifiés
1. **Le GPV critique est servi par la task queue AVANT la state machine** — le `continue()` dans `state_machine_manager.ts` servait les queued tasks en priorité sur la state machine (corrigé partiellement)
2. **L'ordre des états n'est pas optimal** — actuellement `IdleState → DiscoveringParams → ConfigEval → ComplianceUpdate → ExecutingCommands`, mais le GPV critique est hors de la state machine (dans la task queue)
3. **Le SCHEDULED est déclenché par l'équipement** suite au changement du PeriodicInformInterval — l'ACS ne l'envoie pas explicitement mais le CPE refait un Inform avec event SCHEDULED après avoir accepté le SPV
## Changements requis
### 1. Intégrer le GPV critique dans la state machine
Au lieu d'enqueuer le GPV via `manager.enqueueImmediate()` dans le controller, l'ajouter comme état dans la state machine :
```
IdleState → DiscoveringParametersState (GPN) → CriticalReadsState (GPV) → ConfigurationEvaluationState → ComplianceUpdateState (SPV) → ExecutingCommandsState → CompletedState
```
### 2. Ne pas retenter GPN si gpnStatus est déjà "unsupported"
Dans `IdleState`, vérifier `device.gpnStatus` :
- Si `'unsupported'` → skip directement à `CriticalReadsState`
- Si `'unknown'` ou `'supported'` → tenter GPN
### 3. Supprimer les enqueue critiques du controller
Retirer le bloc `enqueueImmediate(device, 'get_parameter_values', criticalPaths)` du `cwmp_controller.ts` — c'est la state machine qui gère.
## Fichiers impactés
| Fichier | Changement |
|---------|-----------|
| `app/services/cwmp/main_state_machine.ts` | Ajouter `CriticalReadsState`, réordonner les états |
| `app/services/cwmp/state_machine_manager.ts` | Simplifier `continue()` (plus de task queue priority) |
| `app/controllers/cwmp_controller.ts` | Retirer `enqueueImmediate` pour GPV |
| `tests/unit/services/main_state_machine.spec.ts` | Adapter les tests |
| `tests/unit/services/cwmp/main_state_machine_extended.spec.ts` | Adapter |
## Critères de succès
- [ ] Un BOOTSTRAP frais produit une seule session : `Inform → GPN → Fault/Response → GPV → SPV → 204`
- [ ] Un PERIODIC sur un device connu (gpnStatus=unsupported) : `Inform → SPV → 204` (pas de GPN)
- [ ] Pas de session SCHEDULED superflue (le GPV/GPN est fait dans la session BOOTSTRAP)
- [ ] Tous les tests unitaires passent
- [ ] Le genieacs-sim (Huawei, GPN supporté) continue de fonctionner
- [ ] Le simulateur Livebox (GPN non supporté) provisionne correctement en 1 session
+35 -27
View File
@@ -3,12 +3,12 @@ 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 { getReferenceEngine } from '../services/references/reference_engine.js'
import { normalizeEventCode } from '../utils/cwmp-events.js'
import logger from '@adonisjs/core/services/logger'
export default class CwmpController {
@@ -118,6 +118,12 @@ export default class CwmpController {
let device = await this.loadDevice(deviceId)
if (!device) {
device = new Device(deviceId)
logger.info({ deviceId: deviceId.toIdentifier() }, '[handleInform] Device not found in DB — creating new')
} else {
logger.info(
{ deviceId: deviceId.toIdentifier(), gpnStatus: device.gpnStatus, circuitBreaker: device.gpnCircuitBreaker },
'[handleInform] Device loaded from DB'
)
}
device.updateLastSeen()
@@ -127,11 +133,6 @@ export default class CwmpController {
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)
@@ -157,6 +158,8 @@ export default class CwmpController {
parameters: device.getParameters().map((p) => p.toSnapshot()),
gpnStatus: device.gpnStatus,
dataModel: device.dataModel,
gpnCircuitBreaker: device.gpnCircuitBreaker,
lastGpnSentDate: device.lastGpnSentDate,
updatedAt: new Date(),
})
logger.info({ hashId }, 'Inform received')
@@ -174,12 +177,23 @@ export default class CwmpController {
logger.info({ events }, 'CWMP Inform events extracted')
// Track last periodic inform timestamp
const isPeriodic = events.some((e) => String(e).toUpperCase().includes('PERIODIC') || String(e) === '2')
const isBootstrap = events.some((e) => String(e).toUpperCase().includes('BOOTSTRAP') || String(e) === '0')
const normalizedEvents = events.map((e) => normalizeEventCode(e))
const isPeriodic = normalizedEvents.some((e) => e === 'PERIODIC')
const isBootstrap = normalizedEvents.some((e) => e === 'BOOTSTRAP')
if (isPeriodic || isBootstrap) {
await DeviceModel.updateOne({ identifier: hashId }, { $set: { lastPeriodicInformAt: new Date() } }).catch(() => {})
}
// Phase 12: Crash pattern detection
const hasBoot = normalizedEvents.some((e) => e === 'BOOT')
if (hasBoot && device.lastGpnSentDate) {
const timeSinceGpn = Date.now() - device.lastGpnSentDate.getTime()
if (timeSinceGpn < 60000) {
device.recordCrashPattern()
logger.warn({ hashId, timeSinceGpn }, 'Possible GPN-induced crash detected for device')
}
}
// Start state machine
await manager.startStateMachine(device, session, { events })
manager.bindRemoteToDevice(remoteKey, device.deviceId)
@@ -204,25 +218,6 @@ export default class CwmpController {
logger.error({ error: _e?.message, stack: _e?.stack }, '[References] Error during resolution')
}
// 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 }
}
@@ -280,6 +275,19 @@ export default class CwmpController {
device.setGpnStatus(record.gpnStatus ?? 'unknown')
device.setDataModel(record.dataModel ?? 'unknown')
// Restore circuit breaker state
if (record.gpnCircuitBreaker) {
device.setGpnCircuitBreaker({
failureCount: record.gpnCircuitBreaker.failureCount ?? 0,
lastFailureDate: record.gpnCircuitBreaker.lastFailureDate ? new Date(record.gpnCircuitBreaker.lastFailureDate) : null,
state: record.gpnCircuitBreaker.state ?? 'closed',
cooldownUntil: record.gpnCircuitBreaker.cooldownUntil ? new Date(record.gpnCircuitBreaker.cooldownUntil) : null,
})
}
if (record.lastGpnSentDate) {
device.lastGpnSentDate = new Date(record.lastGpnSentDate)
}
return device
} catch (_error) {
return null
+129 -4
View File
@@ -5,14 +5,28 @@ 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: unknown = pv?.Name
if (typeof name !== 'string') continue
const name = extractName(pv?.Name)
if (!name) continue
if (name.startsWith('Device.')) return 'tr-181'
if (name.startsWith('InternetGatewayDevice.')) return 'tr-098'
}
@@ -37,6 +51,15 @@ export class Device {
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,
@@ -71,6 +94,100 @@ export class Device {
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)
@@ -170,7 +287,7 @@ export class Device {
public organizeParameters(response: any, timestamp: Date): void {
const infoList = response?.ParameterList?.ParameterInfoStruct || []
for (const info of infoList) {
const name = info?.Name
const name = extractName(info?.Name)
if (!name) continue
// Skip object paths (ending with dot) - only store leaf parameters
if (name.endsWith('.')) continue
@@ -200,7 +317,7 @@ export class Device {
public updateFromValues(paramValues: any[]): void {
for (const pv of paramValues || []) {
const name = pv?.Name
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)
@@ -263,6 +380,10 @@ export class Device {
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()),
}
@@ -282,6 +403,10 @@ export interface DeviceSnapshot {
isOnline: boolean
gpnStatus: GpnStatus
dataModel: DataModel
gpnCircuitBreaker: GpnCircuitBreaker
lastGpnSentDate: Date | null
operationalStatus: string
lastFailureReason: string
parameters: ParameterSnapshot[]
properties: PropertySnapshot[]
}
+5 -1
View File
@@ -1,7 +1,7 @@
import { DeviceId } from './device_id.js'
import { PropertySnapshot, ParameterSnapshot } from './device_parameter.js'
import { DeviceComplianceInfo } from '../configuration/compliance.js'
import { DataModel, GpnStatus } from './device.js'
import { DataModel, GpnStatus, GpnCircuitBreaker } from './device.js'
import type { DeviceReference } from '../../services/references/types.js'
export interface DeviceRecord {
@@ -11,6 +11,10 @@ export interface DeviceRecord {
parameters: ParameterSnapshot[]
gpnStatus?: GpnStatus
dataModel?: DataModel
gpnCircuitBreaker?: GpnCircuitBreaker
lastGpnSentDate?: Date | null
operationalStatus?: string
lastFailureReason?: string
compliance?: DeviceComplianceInfo
provisioningCode?: string
references?: DeviceReference[]
+10
View File
@@ -42,10 +42,13 @@ export default class CwmpLoggerMiddleware {
const sessionId = `${remoteIp}:${remotePort}->${localAddress}:${localPort}`
let capturedResponseXml: string | null = null
let isEmptyResponse = false
const originalSend = response.send.bind(response)
response.send = (body: any) => {
if (typeof body === 'string' && body.includes('soap-env:Envelope')) {
capturedResponseXml = body
} else if (!body || (typeof body === 'string' && body.trim() === '')) {
isEmptyResponse = true
}
return originalSend(body)
}
@@ -77,6 +80,13 @@ export default class CwmpLoggerMiddleware {
sessionId,
rawXml: capturedResponseXml,
}).catch(() => {})
} else if (isEmptyResponse) {
exchangeStore.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId,
rawXml: '',
}).catch(() => {})
}
// Old system: file-based writes (kept for Phase 1 dual-write rollback safety)
+9
View File
@@ -67,6 +67,15 @@ export function extractCwmpMessage(body: any): CwmpMessage | null {
return { type, data: body[key] }
}
}
// Detect SOAP Fault (CPE sends soap-env:Fault / SOAP-ENV:Fault when it cannot process a request)
const faultKey = Object.keys(body).find(
(k) => k.toLowerCase().endsWith(':fault') || k === 'Fault'
)
if (faultKey) {
return { type: 'Fault', data: body[faultKey] }
}
return null
}
@@ -1,6 +1,7 @@
import ConfigurationEngine from '../configuration_engine.js'
import type { EngineResult } from '../configuration_engine.js'
import type { DeviceStateResult, DeviceContext } from './main_state_machine.js'
import { PartialOperationalState } from './main_state_machine.js'
import DeviceModel from '#models/device'
import logger from '@adonisjs/core/services/logger'
import { ComplianceStatus } from '../../domain/configuration/compliance.js'
@@ -35,10 +36,10 @@ export class ConfigurationEvaluationState {
this.initialized = true
this.engineResult = await this.engine.evaluate(context.device)
logger.debug(
logger.info(
{
identifier: context.device.identifier,
configId: this.engineResult.configuration?.identifier,
configId: this.engineResult.configuration?.identifier || null,
inventoryPaths: this.engineResult.inventoryPaths.length,
paramDiffs: this.engineResult.parameterDiffs.length,
firmwareSteps: this.engineResult.firmwareMigrationPath.length,
@@ -56,10 +57,9 @@ export class ConfigurationEvaluationState {
}
)
// Always populate inventory queue (discovery happens regardless of compliance)
this.inventoryQueue = [...this.engineResult.inventoryPaths]
// Skip inventory when already compliant — no need to re-read parameters
if (!this.engineResult.isAlreadyCompliant) {
this.inventoryQueue = [...this.engineResult.inventoryPaths]
this.paramDiffs = this.engineResult.parameterDiffs.map((d) => ({
name: d.name,
value: d.targetValue,
@@ -81,24 +81,35 @@ export class ConfigurationEvaluationState {
}
).catch(() => {})
return { isComplete: true }
return { nextState: new PartialOperationalState(context.device, {
reason: 'configuration_evaluation_failed',
blockingState: 'configuration-evaluation',
error: error.message,
}) }
}
}
private async processNextAction(context: DeviceContext): Promise<DeviceStateResult> {
// 1. Inventory phase 1: GetParameterNames per subtree path
// Skip if device does not support GPN (avoids useless round-trips that will fail)
if (this.inventoryQueue.length > 0 && context.device.gpnStatus !== 'unsupported') {
// Skip if device does not support GPN or circuit breaker is active
if (this.inventoryQueue.length > 0 && context.device.gpnStatus !== 'unsupported' && context.device.isGpnAllowed()) {
const path = this.inventoryQueue.shift()!
logger.debug({ identifier: context.device.identifier, path }, '[ConfigEval] Inventory GetParameterNames')
const response = await context.services.deviceAction.getParameterNames(
context.device,
path,
async (result) => {
if (result.status === 'error') {
// GPN failed during inventory — record failure
context.device.recordGpnFailure()
}
// Collect leaf names into pendingValuesQueue for phase 2
const infoList = result.data?.ParameterList?.ParameterInfoStruct || []
const leafNames: string[] = infoList
.map((info: any) => info?.Name)
.map((info: any) => {
const n = info?.Name
return typeof n === 'string' ? n : (n && typeof n._ === 'string' ? n._ : null)
})
.filter((name: any): name is string => !!name && !name.endsWith('.'))
if (leafNames.length > 0) {
this.pendingValuesQueue.push(...leafNames)
@@ -108,9 +119,8 @@ export class ConfigurationEvaluationState {
)
return { response }
} else if (this.inventoryQueue.length > 0) {
// GPN unsupported — skip inventory (GPV on object paths also fails on most simulators)
// Parameters will be discovered via Inform values and SPV responses
logger.debug({ identifier: context.device.identifier, skipped: this.inventoryQueue.length }, '[ConfigEval] Skipping inventory (GPN unsupported)')
// GPN unsupported or circuit breaker active — skip inventory
logger.debug({ identifier: context.device.identifier, skipped: this.inventoryQueue.length }, '[ConfigEval] Skipping inventory (GPN unsupported or circuit breaker active)')
this.inventoryQueue = []
}
@@ -250,6 +260,19 @@ export class ComplianceUpdateState {
// Always build and write provisioning code to device (functional for "once", informative for "maintain")
if (this.engineResult.configuration) {
const provCode = buildProvisioningCode(this.engineResult.configuration)
// Check if device already has the correct provisioning code — skip SPV if so
const allParams = [...context.device.getParameters(), ...context.device.getProperties()]
const currentPC = allParams.find((p: any) => /(^|\.)ProvisioningCode$/.test(p?.name))
const currentPCValue = currentPC?.value ?? ''
if (currentPCValue === provCode) {
logger.debug({ identifier: context.device.identifier, provCode }, '[ComplianceUpdate] Provisioning code already matches — skipping SPV')
updateData.provisioningCode = provCode
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {})
return { nextState: new ExecutingCommandsState(true) }
}
updateData.provisioningCode = provCode
await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {})
+23 -26
View File
@@ -1,7 +1,6 @@
import { Device } from '../../domain/device/device.js'
import { ParameterType } from '../../domain/shared/types.js'
import { CWMPTaskQueue, ResultStatus, TaskResult } from './task_queue.js'
import { reclassifyDeviceFromCatalog } from '../parameter_writability.js'
import DeviceModel from '../../models/device.js'
import logger from '@adonisjs/core/services/logger'
@@ -98,9 +97,6 @@ export class DeviceActionExecutor {
const list = resp?.ParameterList?.ParameterValueStruct
if (Array.isArray(list) && list.length > 0) {
device.updateFromValues(list)
if (device.gpnStatus === 'unsupported') {
reclassifyDeviceFromCatalog(device)
}
await this.persistDevice(device)
}
} catch (e) {
@@ -110,7 +106,7 @@ export class DeviceActionExecutor {
return await callback(result)
}
return this.taskQueue.addTask(device.deviceId, 'get_parameter_values', parameterPaths, wrappedCallback)
return this.taskQueue.addTask(device.deviceId, 'get_parameter_values', parameterPaths, wrappedCallback).xml
}
async getParameterAttributes(
@@ -149,7 +145,7 @@ export class DeviceActionExecutor {
return await callback(result)
}
return this.taskQueue.addTask(device.deviceId, 'get_parameter_attributes', parameterList, wrappedCallback)
return this.taskQueue.addTask(device.deviceId, 'get_parameter_attributes', parameterList, wrappedCallback).xml
}
async getParameterNames(
@@ -162,30 +158,37 @@ export class DeviceActionExecutor {
try {
device.organizeParameters(result.data as any, new Date())
device.setGpnStatus('supported')
device.recordGpnSuccess()
device.lastGpnSentDate = new Date()
await this.persistDevice(device)
} catch (e) {
logger.error(`Error organizing parameters: ${e}`)
}
} else {
// Inspecter le fault code pour distinguer "non supporté" de "erreur transitoire"
// Inspect fault code to distinguish "unsupported" from "transient error"
const faultCode =
result.data?.FaultCode ??
result.data?.faultCode ??
result.data?.Detail?.Fault?.FaultCode ??
result.data?.detail?.Fault?.FaultCode
const code = typeof faultCode === 'string' ? parseInt(faultCode, 10) : faultCode
if (code === 9000 || code === 9001) {
const GPN_UNSUPPORTED_CODES = [9000, 9001, 9003, 9004, 9005]
const GPN_TRANSIENT_CODES = [9002]
if (GPN_UNSUPPORTED_CODES.includes(code)) {
device.setGpnStatus('unsupported')
// Reclassify parameters using TR-181 catalog writability
reclassifyDeviceFromCatalog(device)
device.recordGpnFailure()
await this.persistDevice(device)
} else if (GPN_TRANSIENT_CODES.includes(code)) {
// Transient error: log warning, do not mark unsupported, allow retry this session
logger.warn({ code, deviceId: device.deviceId?.serialNumber }, 'GPN transient error (9002) — will not mark unsupported')
}
// Autres erreurs (réseau, timeout, 9002+) → gpnStatus inchangé
// Network timeout or other codes: do not mark unsupported, skip GPN for this session only
}
return await callback(result)
}
return this.taskQueue.addTask(device.deviceId, 'get_parameter_names', parameterName, wrappedCallback)
return this.taskQueue.addTask(device.deviceId, 'get_parameter_names', parameterName, wrappedCallback).xml
}
async applySetParameterValues(
@@ -238,15 +241,12 @@ export class DeviceActionExecutor {
async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
device.updateFromValues(pvStructs as any)
if (device.gpnStatus === 'unsupported') {
reclassifyDeviceFromCatalog(device)
}
await this.persistDevice(device)
return await callback(true)
}
return await callback(false)
}
)
).xml
}
async applySetParameterAttributes(
@@ -272,7 +272,7 @@ export class DeviceActionExecutor {
}
return await callback(false)
}
)
).xml
}
async applyScheduleInform(
@@ -288,7 +288,7 @@ export class DeviceActionExecutor {
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
).xml
}
async applyFactoryReset(
@@ -303,7 +303,7 @@ export class DeviceActionExecutor {
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
).xml
}
async applyReboot(
@@ -318,7 +318,7 @@ export class DeviceActionExecutor {
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
).xml
}
async applyDownload(
@@ -344,7 +344,7 @@ export class DeviceActionExecutor {
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
).xml
}
async applyUpload(
@@ -366,7 +366,7 @@ export class DeviceActionExecutor {
async (result: TaskResult) => {
return await callback(result.status === ResultStatus.Success)
}
)
).xml
}
async insertProvisioningCode(
@@ -402,15 +402,12 @@ export class DeviceActionExecutor {
async (result: TaskResult) => {
if (result.status === ResultStatus.Success) {
device.updateFromValues(pvStructs as any)
if (device.gpnStatus === 'unsupported') {
reclassifyDeviceFromCatalog(device)
}
await this.persistDevice(device)
device.tmpPC = ''
return await callback(true)
}
return await callback(false)
}
)
).xml
}
}
@@ -40,7 +40,7 @@ export class ImmediateCommandStateMachine {
}
isCompleted(): boolean {
return this.currentState.name === 'completed'
return this.currentState.name === 'completed' || this.currentState.name === 'operational' || this.currentState.name === 'partial_operational'
}
private async execute(): Promise<string> {
+219 -25
View File
@@ -17,6 +17,7 @@ export interface DeviceContext {
session: string
message?: { success: boolean; data: any }
taskQueue: CWMPTaskQueue
events?: string[]
services: {
deviceAction: DeviceActionExecutor
}
@@ -41,15 +42,32 @@ class IdleState extends DeviceState {
name = 'idle'
async handle(context: DeviceContext): Promise<DeviceStateResult> {
// Test GPN support using the ManagementServer object path (not leaf params from Inform)
const dataModel = context.device.dataModel
const { device } = context
// Phase 4: Check GPN circuit breaker before attempting GPN
if (device.gpnStatus === 'unsupported' || !device.isGpnAllowed()) {
logger.info(
{ gpnStatus: device.gpnStatus, circuitBreaker: device.gpnCircuitBreaker, deviceId: device.identifier },
'[IdleState] Skipping GPN — device unsupported or circuit breaker open'
)
// Skip GPN, go directly to CriticalReadsState
return { nextState: new CriticalReadsState(), response: '' }
}
logger.info(
{ gpnStatus: device.gpnStatus, circuitBreaker: device.gpnCircuitBreaker, deviceId: device.identifier },
'[IdleState] GPN allowed — proceeding to discovery'
)
// Test GPN support using the ManagementServer object path
const dataModel = device.dataModel
if (dataModel === 'tr-181') {
return { nextState: new DiscoveringParametersState(['Device.ManagementServer.']), response: '' }
} else if (dataModel === 'tr-098') {
return { nextState: new DiscoveringParametersState(['InternetGatewayDevice.ManagementServer.']), response: '' }
}
// Unknown data model — skip GPN test
return { nextState: new ConfigurationEvaluationState(), response: '' }
// Unknown data model — skip GPN test, go to CriticalReadsState
return { nextState: new CriticalReadsState(), response: '' }
}
}
@@ -67,7 +85,7 @@ class DiscoveringParametersState extends DeviceState {
if (!this.started) {
this.started = true
if (this.parameterQueue.length === 0) {
return { nextState: new ConfigurationEvaluationState(), response: '' }
return { nextState: new CriticalReadsState(), response: '' }
}
return { response: await this.processNext(context) }
}
@@ -79,7 +97,37 @@ class DiscoveringParametersState extends DeviceState {
'[DiscoveringParameters] GPN Fault received — marking as unsupported'
)
context.device.setGpnStatus('unsupported')
return { nextState: new ConfigurationEvaluationState(), response: '' }
context.device.recordGpnFailure()
// Persist circuit breaker state immediately so it survives session drops
try {
const hashId = getDeviceHashIdentifier({
manufacturer: context.device.deviceId.manufacturer,
oui: context.device.deviceId.oui,
productClass: context.device.deviceId.productClass,
serialNumber: context.device.deviceId.serialNumber,
})
const snapshot = context.device.toSnapshot()
await DeviceModel.createOrUpdate({
identifier: hashId,
deviceId: context.device.deviceId,
properties: snapshot.properties,
parameters: snapshot.parameters,
gpnStatus: context.device.gpnStatus,
dataModel: context.device.dataModel,
gpnCircuitBreaker: snapshot.gpnCircuitBreaker,
lastGpnSentDate: snapshot.lastGpnSentDate,
updatedAt: new Date(),
})
logger.info(
{ hashId, gpnStatus: context.device.gpnStatus, circuitBreaker: context.device.gpnCircuitBreaker },
'[DiscoveringParameters] Persisted GPN failure state to DB'
)
} catch (e) {
logger.error(`Failed to persist GPN circuit breaker state: ${e}`)
}
return { nextState: new CriticalReadsState(), response: '' }
}
// Process response and continue
@@ -87,7 +135,7 @@ class DiscoveringParametersState extends DeviceState {
return { response: await this.processNext(context) }
}
return { nextState: new ConfigurationEvaluationState(), response: '' }
return { nextState: new CriticalReadsState(), response: '' }
}
private async processNext(context: DeviceContext): Promise<string> {
@@ -97,6 +145,75 @@ class DiscoveringParametersState extends DeviceState {
}
}
/**
* CriticalReadsState (Phase 6)
*
* Performs GPV for HardwareVersion, SoftwareVersion, ConnectionRequestURL.
* Always executed regardless of gpnStatus.
* Replaces the enqueueImmediate(GPV) from the controller.
*/
class CriticalReadsState extends DeviceState {
name = 'critical-reads'
private started = false
async handle(context: DeviceContext): Promise<DeviceStateResult> {
if (!this.started) {
this.started = true
const isTr181 = context.device.dataModel === 'tr-181'
const root = isTr181 ? 'Device' : 'InternetGatewayDevice'
const criticalPaths = [
`${root}.DeviceInfo.HardwareVersion`,
`${root}.DeviceInfo.SoftwareVersion`,
`${root}.DeviceInfo.ModelName`,
`${root}.ManagementServer.ConnectionRequestURL`,
]
const response = await context.services.deviceAction.getParameterValues(
context.device,
criticalPaths,
async (result) => {
if (result.status === 'success') {
const resp: any = result.data
const list = resp?.ParameterList?.ParameterValueStruct
if (Array.isArray(list) && list.length > 0) {
context.device.setTmpParamValues(list)
}
}
return ''
}
)
return { response }
}
// After GPV response: process values from the response, then reclassify
if (context.message?.success && context.message.data) {
try {
const resp = context.message.data
const list = resp?.ParameterList?.ParameterValueStruct
if (Array.isArray(list) && list.length > 0) {
// Normalize Name fields: XMLParser may return {_: "path", "$xsi:type": "..."} when Name has attributes
const normalized = list.map((pv: any) => ({
...pv,
Name: typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || pv?.Name,
}))
context.device.setTmpParamValues(normalized)
context.device.updateFromValues(normalized)
logger.info({ count: normalized.length, names: normalized.map((p: any) => p.Name) }, '[CriticalReads] GPV response values processed')
}
} catch (e: any) {
logger.error({ error: e?.message }, '[CriticalReads] Error processing GPV response values')
}
}
if (context.device.gpnStatus !== 'supported') {
reclassifyDeviceFromCatalog(context.device)
}
return { nextState: new ConfigurationEvaluationState(), response: '' }
}
}
export class ExecutingCommandsState extends DeviceState {
name = 'executing-commands'
private commandsQueue: any[] = []
@@ -131,7 +248,7 @@ export class ExecutingCommandsState extends DeviceState {
if (this.commandsQueue.length === 0) {
if (this.includeNI) {
return { nextState: new CompletedState(), isComplete: true, response: '' }
return { nextState: new OperationalState(), response: '' }
}
// ImmediateCommandStateMachine with no commands — mark complete
return { isComplete: true, response: '' }
@@ -184,7 +301,7 @@ export class ExecutingCommandsState extends DeviceState {
// All commands done
if (this.includeNI) {
return { nextState: new CompletedState(), isComplete: true, response: '' }
return { nextState: new OperationalState(), response: '' }
}
return { response: '' }
}
@@ -286,18 +403,23 @@ export class ExecutingCommandsState extends DeviceState {
}
}
class CompletedState extends DeviceState {
name = 'completed'
/**
* OperationalState (Phase 10)
* Terminal state for successful session completion.
*/
export class OperationalState extends DeviceState {
name = 'operational'
async handle(context: DeviceContext): Promise<DeviceStateResult> {
// Reclassify properties → parameters if GPN is unsupported
if (context.device.gpnStatus === 'unsupported') {
reclassifyDeviceFromCatalog(context.device)
}
const { device } = context
// Persist device on completion
// Consolidated reclassification (Phase 11)
reclassifyDeviceFromCatalog(device)
// Persist device with operational status
device.operationalStatus = 'operational'
try {
const snapshot = context.device.toSnapshot()
const snapshot = device.toSnapshot()
await DeviceModel.createOrUpdate({
identifier: snapshot.identifier,
deviceId: snapshot.deviceId,
@@ -305,11 +427,66 @@ class CompletedState extends DeviceState {
parameters: snapshot.parameters,
gpnStatus: snapshot.gpnStatus,
dataModel: snapshot.dataModel,
gpnCircuitBreaker: snapshot.gpnCircuitBreaker,
lastGpnSentDate: snapshot.lastGpnSentDate,
operationalStatus: 'operational',
updatedAt: new Date(),
})
} catch (e) {
logger.error(`Failed to persist device on completion: ${e}`)
}
logger.info({ deviceId: device.identifier, state: 'operational' }, '[StateMachine] Session completed successfully')
return { isComplete: true, response: '' }
}
}
/**
* PartialOperationalState (Phase 10)
* Terminal state for degraded session completion.
*/
export class PartialOperationalState extends DeviceState {
name = 'partial_operational'
private failureContext: { reason: string; blockingState?: string; error?: any }
constructor(_device: Device, failureContext: { reason: string; blockingState?: string; error?: any }) {
super()
this.failureContext = failureContext
}
async handle(context: DeviceContext): Promise<DeviceStateResult> {
const { device } = context
device.operationalStatus = 'partial_operational'
device.lastFailureReason = this.failureContext.reason
try {
const snapshot = device.toSnapshot()
await DeviceModel.createOrUpdate({
identifier: snapshot.identifier,
deviceId: snapshot.deviceId,
properties: snapshot.properties,
parameters: snapshot.parameters,
gpnStatus: snapshot.gpnStatus,
dataModel: snapshot.dataModel,
gpnCircuitBreaker: snapshot.gpnCircuitBreaker,
lastGpnSentDate: snapshot.lastGpnSentDate,
operationalStatus: 'partial_operational',
lastFailureReason: this.failureContext.reason,
updatedAt: new Date(),
})
} catch (e) {
logger.error(`Failed to persist device on partial completion: ${e}`)
}
logger.error({
deviceId: device.identifier,
reason: this.failureContext.reason,
blockingState: this.failureContext.blockingState,
state: 'partial_operational',
}, '[StateMachine] Session completed with degraded status')
return { isComplete: true, response: '' }
}
}
@@ -320,12 +497,13 @@ export class MainStateMachine {
private currentState: DeviceState
private context: DeviceContext
constructor(device: Device, session: string, taskQueue: CWMPTaskQueue) {
constructor(device: Device, session: string, taskQueue: CWMPTaskQueue, events?: string[]) {
this.currentState = new IdleState()
this.context = {
device,
session,
taskQueue,
events,
services: {
deviceAction: new DeviceActionExecutor(taskQueue),
},
@@ -342,7 +520,7 @@ export class MainStateMachine {
}
isCompleted(): boolean {
return this.currentState.name === 'completed'
return this.currentState.name === 'operational' || this.currentState.name === 'partial_operational'
}
getCurrentStateName(): string {
@@ -350,11 +528,12 @@ export class MainStateMachine {
}
private async executeCurrentState(): Promise<string> {
let safetyGuard = 0
const MAX_STATE_TRANSITIONS = 15
let transitionCount = 0
while (safetyGuard++ < 15) {
while (transitionCount++ < MAX_STATE_TRANSITIONS) {
try {
logger.debug({ state: this.currentState.name, iteration: safetyGuard }, '[StateMachine] Executing state')
logger.debug({ state: this.currentState.name, iteration: transitionCount }, '[StateMachine] Executing state')
const result = await this.currentState.handle(this.context)
if (result.nextState) {
@@ -377,14 +556,29 @@ export class MainStateMachine {
}
} catch (error) {
logger.error(`Error in state ${this.currentState.name}: ${error}`)
// On error, try to complete gracefully
this.currentState = new CompletedState()
// On error, transition to PartialOperationalState
this.currentState = new PartialOperationalState(this.context.device, {
reason: 'unrecoverable_error',
blockingState: this.currentState.name,
error,
})
await this.currentState.handle(this.context)
return ''
}
}
logger.warn('Main state machine safety guard reached')
// Phase 9: Explicit error handling when max transitions exceeded
logger.error({
deviceId: this.context.device.identifier,
lastState: this.currentState.name,
sessionId: this.context.session,
}, `State machine stuck in ${this.currentState.name} after ${MAX_STATE_TRANSITIONS} transitions`)
this.currentState = new PartialOperationalState(this.context.device, {
reason: 'max_transitions_exceeded',
blockingState: this.currentState.name,
})
await this.currentState.handle(this.context)
return ''
}
}
+52 -51
View File
@@ -5,6 +5,7 @@ import { CWMPTaskQueue, ResultStatus } from './task_queue.js'
import { MainStateMachine } from './main_state_machine.js'
import { ImmediateCommandStateMachine } from './immediate_command_state_machine.js'
import { getDeviceHashIdentifier } from '../../utils/device_utils.js'
import { resolveHighestPriorityEvent, normalizeEventCode } from '../../utils/cwmp-events.js'
import { CommandStatus } from '../../domain/commands/command.js'
import { ModeType } from '../../domain/shared/mode.js'
import CommandModel from '../../models/command.js'
@@ -47,7 +48,6 @@ export class StateMachineManager extends EventEmitter {
constructor() {
super()
this.taskQueue = new CWMPTaskQueue()
this.setupTaskQueueListeners()
}
static getInstance(): StateMachineManager {
@@ -83,7 +83,8 @@ export class StateMachineManager extends EventEmitter {
): Promise<StateMachineInstance> {
const deviceKey = JSON.stringify(device.deviceId)
const evts = opts?.events || []
const lastEvent = this.normalizeEvent(evts.length > 0 ? evts[evts.length - 1] : '')
const primaryEvent = resolveHighestPriorityEvent(evts)
const normalizedEvents = evts.map((e) => normalizeEventCode(e))
// Check for NI commands that force MainStateMachine
let forceMainForNI = false
@@ -113,8 +114,8 @@ export class StateMachineManager extends EventEmitter {
const existingMachine = this.machines.get(existingMachineId)
if (existingMachine && existingMachine.status === MachineStatus.RUNNING) {
const isImmediate = existingMachine.stateMachine instanceof ImmediateCommandStateMachine
const onboardingEvent = ['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(
lastEvent
const onboardingEvent = ['BOOTSTRAP', 'BOOT', 'PERIODIC', 'SCHEDULED'].includes(
primaryEvent
)
const shouldPreempt = isImmediate && (forceMainForNI || onboardingEvent)
@@ -127,7 +128,7 @@ export class StateMachineManager extends EventEmitter {
existingMachine.session = session
}
// Replace with MainStateMachine
const newMachine = new MainStateMachine(device, session, this.taskQueue)
const newMachine = new MainStateMachine(device, session, this.taskQueue, normalizedEvents)
;(existingMachine as any).stateMachine = newMachine
existingMachine.events = evts
await newMachine.start()
@@ -138,7 +139,17 @@ export class StateMachineManager extends EventEmitter {
// For a new CONNECTION REQUEST arriving while a previous immediate session
// is still technically "running", complete the old one and create a fresh machine
if (isImmediate && lastEvent === '6 CONNECTION REQUEST') {
if (isImmediate && primaryEvent === 'CONNECTION REQUEST') {
this.completeMachine(existingMachine)
// Fall through to create a new machine below
} else if (!isImmediate && existingSessionId !== session) {
// Stale MainStateMachine from a dropped session — the CPE started a new
// Inform cycle which means the previous session is dead. Complete the old
// machine and create a fresh one for the new session.
logger.warn(
{ oldSession: existingSessionId, newSession: session, state: existingMachine.currentState },
'[StateMachineManager] Replacing stale MainStateMachine from dropped session'
)
this.completeMachine(existingMachine)
// Fall through to create a new machine below
} else {
@@ -160,17 +171,21 @@ export class StateMachineManager extends EventEmitter {
let stateMachine: MainStateMachine | ImmediateCommandStateMachine
if (forceMainForNI) {
stateMachine = new MainStateMachine(device, session, this.taskQueue)
} else if (lastEvent === '6 CONNECTION REQUEST') {
stateMachine = new MainStateMachine(device, session, this.taskQueue, normalizedEvents)
} else if (primaryEvent === 'BOOTSTRAP' || primaryEvent === 'BOOT') {
// BOOTSTRAP/BOOT always triggers full MainStateMachine onboarding
stateMachine = new MainStateMachine(device, session, this.taskQueue, normalizedEvents)
} else if (primaryEvent === 'CONNECTION REQUEST') {
// CONNECTION REQUEST only if no BOOTSTRAP/BOOT present
stateMachine = new ImmediateCommandStateMachine(device, session, this.taskQueue)
} else if (primaryEvent === 'PERIODIC' || primaryEvent === 'VALUE CHANGE' || primaryEvent === 'SCHEDULED') {
// Lightweight MainStateMachine
stateMachine = new MainStateMachine(device, session, this.taskQueue, normalizedEvents)
} else if (primaryEvent === 'DIAGNOSTICS COMPLETE' || primaryEvent === 'TRANSFER COMPLETE') {
stateMachine = new ImmediateCommandStateMachine(device, session, this.taskQueue)
} else if (
['0 BOOTSTRAP', '1 BOOT', '2 PERIODIC', '3 SCHEDULED'].includes(lastEvent) ||
!lastEvent
) {
stateMachine = new MainStateMachine(device, session, this.taskQueue)
} else {
// Unknown event - use MainStateMachine as default
stateMachine = new MainStateMachine(device, session, this.taskQueue)
stateMachine = new MainStateMachine(device, session, this.taskQueue, normalizedEvents)
}
const machineInstance: StateMachineInstance = {
@@ -311,7 +326,29 @@ export class StateMachineManager extends EventEmitter {
return response
}
// Serve queued tasks
// Let the state machine continue first (GPN discovery, config eval, etc.)
const response = await machineInstance.stateMachine.handleResponse(true, { isFirst: false })
logger.debug(
{
session,
stateName: machineInstance.stateMachine.getCurrentStateName(),
responseLength: response?.length || 0,
},
'[Continue] State machine handleResponse result'
)
machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName()
if (machineInstance.stateMachine.isCompleted()) {
this.completeMachine(machineInstance)
return ''
}
// If state machine produced a response, send it
if (response && response !== '') {
return response
}
// Otherwise, serve queued tasks (GPV, etc.)
const pendingTask = this.taskQueue.getTaskForDevice(machineInstance.deviceId)
if (pendingTask) {
const builder = CWMPTaskQueue.task_map[pendingTask.task]
@@ -322,16 +359,6 @@ export class StateMachineManager extends EventEmitter {
}
}
// Let the state machine continue
const response = await machineInstance.stateMachine.handleResponse(true, { isFirst: false })
logger.debug({ session, stateName: machineInstance.stateMachine.getCurrentStateName(), responseLength: response?.length || 0 }, '[Continue] State machine handleResponse result')
machineInstance.currentState = machineInstance.stateMachine.getCurrentStateName()
if (machineInstance.stateMachine.isCompleted()) {
this.completeMachine(machineInstance)
return ''
}
return response
} catch (error) {
logger.error(`Error continuing session ${session}: ${error}`)
@@ -418,17 +445,6 @@ export class StateMachineManager extends EventEmitter {
logger.error(`State machine failed: ${error}`)
}
private normalizeEvent(evt: string): string {
const normalized = (evt || '').toString().trim().replace(/\s+/g, ' ').toUpperCase()
// Handle simulator variants that send only the event code number
if (normalized === '6') return '6 CONNECTION REQUEST'
if (normalized === '0') return '0 BOOTSTRAP'
if (normalized === '1') return '1 BOOT'
if (normalized === '2') return '2 PERIODIC'
if (normalized === '3') return '3 SCHEDULED'
return normalized
}
cleanupCompletedMachines(olderThanHours: number = 1): void {
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000)
for (const [id, machine] of this.machines.entries()) {
@@ -446,19 +462,4 @@ export class StateMachineManager extends EventEmitter {
this.stopCleanupTimer()
this.cleanupCompletedMachines(0)
}
private setupTaskQueueListeners(): void {
this.taskQueue.on('taskSent', (task: any) => {
// Find the machine for this device and set lastTaskId
const deviceKey = JSON.stringify(task.deviceId)
const sessionId = this.deviceSessions.get(deviceKey)
if (sessionId) {
const machineId = this.sessionMachines.get(sessionId)
if (machineId) {
const machine = this.machines.get(machineId)
if (machine) machine.lastTaskId = task.id
}
}
})
}
}
+5 -7
View File
@@ -89,33 +89,32 @@ export class CWMPTaskQueue extends EventEmitter {
/**
* Adds a task with callback (legacy-compatible API used by DeviceActionExecutor)
* Returns the CWMP XML envelope to send to the device
* Returns the CWMP XML envelope and task ID
*/
addTask(
deviceId: DeviceId,
taskType: string,
props: any,
callback: (result: TaskResult) => Promise<string>
): string {
): { xml: string; taskId: string } {
const cwmpTask = this.addCWMPTask(deviceId, taskType, props)
if (callback) {
this.taskCallbacks.set(cwmpTask.id, callback)
}
// Mark as SENT immediately so the manager correlates the response
// Mark as SENT immediately
if (cwmpTask.status === TaskStatus.PENDING) {
cwmpTask.status = TaskStatus.SENT
this.emit('taskSent', cwmpTask)
}
// Generate CWMP envelope
const builder = CWMPTaskQueue.task_map[taskType]
if (typeof builder !== 'function') {
logger.warn(`No CWMP builder found for task '${taskType}'`)
return ''
return { xml: '', taskId: cwmpTask.id }
}
return builder(props)
return { xml: builder(props), taskId: cwmpTask.id }
}
/**
@@ -168,7 +167,6 @@ export class CWMPTaskQueue extends EventEmitter {
if (cwmpTask.status === TaskStatus.PENDING) {
cwmpTask.status = TaskStatus.SENT
this.emit('taskSent', cwmpTask)
}
return { id: taskId, task: cwmpTask.type, props: cwmpTask.payload }
+57 -21
View File
@@ -1,6 +1,6 @@
import DeviceModel from '#models/device'
import CommandModel from '#models/command'
import { DeviceRecord } from '../domain/device/device_record.js'
import { type DeviceRecord } from '../domain/device/device_record.js'
import { CommandStatus, CommandType, Priority } from '../domain/commands/command.js'
import { ModeType } from '../domain/shared/mode.js'
import logger from '@adonisjs/core/services/logger'
@@ -30,12 +30,15 @@ export default class DeviceService {
/**
* Execute a command on a device. Mode I triggers a Connection Request.
*/
async executeCommand(identifier: string, commandPayload: {
action: CommandType
parameters?: Record<string, any>
mode?: string
priority?: number
}): Promise<void> {
async executeCommand(
identifier: string,
commandPayload: {
action: CommandType
parameters?: Record<string, any>
mode?: string
priority?: number
}
): Promise<void> {
const device = await DeviceModel.findByIdentifier(identifier)
if (!device) {
throw new Error(`Device not found: ${identifier}`)
@@ -101,13 +104,19 @@ export default class DeviceService {
/**
* Send Connection Request to CPE to wake it up for immediate command execution
*/
private async applyImmediateCommandForDevice(device: DeviceRecord, identifier: string): Promise<void> {
private async applyImmediateCommandForDevice(
device: DeviceRecord,
identifier: string
): Promise<void> {
const connectionRequestParam = (device.properties || []).find((p: any) =>
p.name?.includes('ConnectionRequestURL')
)
if (!connectionRequestParam || !connectionRequestParam.value) {
logger.error({ identifier, availableProps: (device.properties || []).map((p: any) => p.name) }, 'ConnectionRequestURL not found in device properties')
logger.error(
{ identifier, availableProps: (device.properties || []).map((p: any) => p.name) },
'ConnectionRequestURL not found in device properties'
)
return
}
@@ -128,16 +137,23 @@ export default class DeviceService {
*/
private async updatePendingCommandsStatus(identifier: string, success: boolean): Promise<void> {
const collection = CommandModel.getCollection()
const pendingCmds = await collection.find({
deviceIdentifier: identifier,
mode: ModeType.I,
status: CommandStatus.PENDING,
}).toArray()
const pendingCmds = await collection
.find({
deviceIdentifier: identifier,
mode: ModeType.I,
status: CommandStatus.PENDING,
})
.toArray()
for (const cmd of pendingCmds) {
await collection.updateOne(
{ _id: cmd._id },
{ $set: { status: success ? CommandStatus.WAITING : CommandStatus.PENDING, updatedAt: new Date() } }
{
$set: {
status: success ? CommandStatus.WAITING : CommandStatus.PENDING,
updatedAt: new Date(),
},
}
)
}
}
@@ -148,14 +164,21 @@ export default class DeviceService {
// Fallback chain: ConnectionRequestUsername > ManagementServer.Username > OUI-ProductClass-Serial
const allParams = [...(device.properties || []), ...(device.parameters || [])]
const connReqUsername = allParams.find((p: any) => p.name?.includes('ConnectionRequestUsername'))
const connReqPassword = allParams.find((p: any) => p.name?.includes('ConnectionRequestPassword'))
const connReqUsername = allParams.find((p: any) =>
p.name?.includes('ConnectionRequestUsername')
)
const connReqPassword = allParams.find((p: any) =>
p.name?.includes('ConnectionRequestPassword')
)
const mgmtUsername = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Username'))
const mgmtPassword = allParams.find((p: any) => p.name?.endsWith('ManagementServer.Password'))
// genieacs-sim uses ManagementServer.Username/Password for CR auth (non-standard)
// Real CPEs use ConnectionRequestUsername/Password
const username = mgmtUsername?.value || connReqUsername?.value || `${device.deviceId.oui}-${device.deviceId.productClass}-${device.deviceId.serialNumber}`
const username =
mgmtUsername?.value ||
connReqUsername?.value ||
`${device.deviceId.oui}-${device.deviceId.productClass}-${device.deviceId.serialNumber}`
const password = mgmtPassword?.value ?? connReqPassword?.value ?? ''
logger.info({ url: connectionRequestURL.toString(), username }, 'Sending Connection Request')
@@ -169,13 +192,26 @@ export default class DeviceService {
})
if (!response.ok) {
logger.error({ status: response.status, statusText: response.statusText, url: connectionRequestURL.toString() }, 'Connection Request failed with non-OK status')
logger.error(
{
status: response.status,
statusText: response.statusText,
url: connectionRequestURL.toString(),
},
'Connection Request failed with non-OK status'
)
return false
}
logger.info({ status: response.status, url: connectionRequestURL.toString() }, 'Connection Request successful')
logger.info(
{ status: response.status, url: connectionRequestURL.toString() },
'Connection Request successful'
)
return true
} catch (error: any) {
logger.error({ error: error?.message, code: error?.cause?.code, url: connectionRequestURL.toString() }, 'Connection Request network error')
logger.error(
{ error: error?.message, code: error?.cause?.code, url: connectionRequestURL.toString() },
'Connection Request network error'
)
return false
}
}
+22 -4
View File
@@ -109,12 +109,30 @@ export class ExchangeStoreService {
const events: string[] = []
let match: RegExpExecArray | null
// Map numeric-only codes to their TR-069 labels
const numericLabels: Record<string, string> = {
'0': 'BOOTSTRAP',
'1': 'BOOT',
'2': 'PERIODIC',
'3': 'SCHEDULED',
'4': 'VALUE CHANGE',
'5': 'KICKED',
'6': 'CONNECTION REQUEST',
'7': 'TRANSFER COMPLETE',
'8': 'DIAGNOSTICS COMPLETE',
}
while ((match = eventRegex.exec(xml)) !== null) {
const raw = match[1].trim()
// Extract just the label part (e.g., "0 BOOTSTRAP" → "BOOTSTRAP", "2 PERIODIC" → "PERIODIC")
const label = raw.replace(/^\d+\s+/, '')
if (label) {
events.push(label)
// If numeric only (e.g., "0"), map to label
if (numericLabels[raw]) {
events.push(numericLabels[raw])
} else {
// Extract just the label part (e.g., "0 BOOTSTRAP" → "BOOTSTRAP")
const label = raw.replace(/^\d+\s+/, '')
if (label) {
events.push(label)
}
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Shared CWMP event normalization and priority resolution utilities.
* Single source of truth for event code handling (fixes MINEUR-9).
*/
export const EVENT_PRIORITY: Record<string, number> = {
'BOOTSTRAP': 100,
'BOOT': 90,
'CONNECTION REQUEST': 80,
'PERIODIC': 70,
'VALUE CHANGE': 60,
'DIAGNOSTICS COMPLETE': 50,
'TRANSFER COMPLETE': 40,
'SCHEDULE': 30,
}
/**
* Normalize an EventCode from an Inform message.
* Strips numeric prefix and normalizes the label.
* Examples: '0 BOOTSTRAP' → 'BOOTSTRAP', '1 BOOT' → 'BOOT', '6' → 'CONNECTION REQUEST'
*/
export function normalizeEventCode(raw: string): string {
const trimmed = raw.trim().toUpperCase()
// Already a known label without numeric prefix
if (EVENT_PRIORITY[trimmed] !== undefined) return trimmed
// Full format like "0 BOOTSTRAP" → strip numeric prefix
const match = trimmed.match(/^\d+\s+(.+)$/)
if (match) return match[1]
// Numeric-only → expand to standard label
switch (trimmed) {
case '0': return 'BOOTSTRAP'
case '1': return 'BOOT'
case '2': return 'PERIODIC'
case '3': return 'SCHEDULED'
case '4': return 'VALUE CHANGE'
case '5': return 'KICKED'
case '6': return 'CONNECTION REQUEST'
case '7': return 'TRANSFER COMPLETE'
case '8': return 'DIAGNOSTICS COMPLETE'
default: return trimmed
}
}
/**
* Resolve the highest priority event from a list of raw event codes.
* Returns the normalized name of the highest-priority event.
*/
export function resolveHighestPriorityEvent(events: string[]): string {
if (events.length === 0) return ''
let highest = ''
let highestPriority = -1
for (const raw of events) {
const normalized = normalizeEventCode(raw)
const priority = EVENT_PRIORITY[normalized] ?? 0
if (priority > highestPriority) {
highestPriority = priority
highest = normalized
}
}
return highest
}
+6650 -4034
View File
File diff suppressed because it is too large. Load diff
+156 -54
View File
@@ -297,6 +297,31 @@ async function confirmClear() {
// --- Computed ---
const hasMessages = computed(() => messages.value.length > 0)
// Group messages by sessionId for visual session boundaries
const sessionGroups = computed(() => {
const groups: Array<{ sessionId: string; messages: CwmpExchange[]; firstMethod: string }> = []
let currentGroup: { sessionId: string; messages: CwmpExchange[]; firstMethod: string } | null = null
for (const msg of messages.value) {
if (!currentGroup || currentGroup.sessionId !== msg.sessionId) {
currentGroup = { sessionId: msg.sessionId, messages: [], firstMethod: msg.cwmpMethod }
groups.push(currentGroup)
}
currentGroup.messages.push(msg)
}
return groups
})
// Scroll to a specific session group
function scrollToSession(groupIndex: number) {
const el = document.getElementById(`session-group-${groupIndex}`)
if (el && scrollContainer.value) {
const containerTop = scrollContainer.value.getBoundingClientRect().top
const elTop = el.getBoundingClientRect().top
scrollContainer.value.scrollTop += (elTop - containerTop)
}
}
const connectionBadgeClass = computed(() => {
switch (connectionState.value) {
case 'connected':
@@ -444,11 +469,31 @@ watch(
</button>
</div>
<!-- Session dropdown (clickable to jump) -->
<div v-if="sessionGroups.length > 1" class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
<label class="text-muted small fw-semibold text-nowrap mb-0" style="font-size: 0.7rem">Session :</label>
<select
class="form-select form-select-sm"
style="font-size: 0.72rem; max-width: 320px"
@change="scrollToSession(Number(($event.target as HTMLSelectElement).value))"
>
<option value="" disabled selected>Aller à…</option>
<option
v-for="(group, idx) in sessionGroups"
:key="'nav-' + idx"
:value="idx"
>
S{{ idx + 1 }} — {{ group.firstMethod }} ({{ group.messages.length }} msg)
</option>
</select>
<span class="text-muted small text-nowrap" style="font-size: 0.7rem">{{ sessionGroups.length }} sessions</span>
</div>
<!-- Lifeline headers -->
<div class="d-flex align-items-center border-bottom px-3 py-2 bg-light">
<div class="text-center fw-bold small" style="width: 24px">CPE</div>
<div class="text-center fw-bold small text-nowrap" style="width: 36px">CPE</div>
<div class="flex-grow-1"></div>
<div class="text-center fw-bold small" style="width: 24px">ACS</div>
<div class="text-center fw-bold small text-nowrap" style="width: 36px">ACS</div>
</div>
<!-- Messages container with scroll -->
@@ -472,60 +517,73 @@ watch(
</div>
<div
v-for="message in messages"
:key="message.sequenceNumber"
class="sequence-row d-flex align-items-center px-3 py-2 border-bottom"
:class="{
'sequence-row--selected': selectedMessage?.sequenceNumber === message.sequenceNumber,
'sequence-row--cpe-to-acs': message.direction === 'CPE→ACS',
'sequence-row--acs-to-cpe': message.direction === 'ACS→CPE',
}"
role="button"
tabindex="0"
@click="selectMessage(message)"
@keydown.enter="selectMessage(message)"
@keydown.space.prevent="selectMessage(message)"
v-for="(group, groupIndex) in sessionGroups"
:key="group.sessionId + '-' + groupIndex"
:id="'session-group-' + groupIndex"
class="session-group"
>
<!-- CPE lifeline -->
<div class="lifeline-column text-center" style="width: 24px">
<div class="lifeline-bar"></div>
<!-- Session label -->
<div class="session-label d-flex align-items-center px-3 py-1">
<span class="session-badge font-monospace">Session {{ groupIndex + 1 }}</span>
<span class="session-id text-muted font-monospace ms-2">{{ group.sessionId.split('->')[0]?.split(':').pop() || '' }}</span>
</div>
<!-- Arrow area -->
<div class="flex-grow-1 d-flex flex-column align-items-center position-relative">
<!-- Method label -->
<span
v-if="message.cwmpMethod === 'Empty'"
class="small text-muted fst-italic mb-1"
>
POST vide
</span>
<span
v-else
class="small fw-medium mb-1"
:title="needsTruncation(message.cwmpMethod) ? message.cwmpMethod : undefined"
>
{{ truncateMethod(message.cwmpMethod) }}
</span>
<!-- Arrow -->
<div class="sequence-arrow w-100 position-relative" :class="[
message.direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left',
message.cwmpMethod === 'Empty' ? 'arrow-empty' : ''
]">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
<div
v-for="message in group.messages"
:key="message.sequenceNumber"
class="sequence-row d-flex align-items-center px-3 py-2"
:class="{
'sequence-row--selected': selectedMessage?.sequenceNumber === message.sequenceNumber,
'sequence-row--cpe-to-acs': message.direction === 'CPE→ACS',
'sequence-row--acs-to-cpe': message.direction === 'ACS→CPE',
}"
role="button"
tabindex="0"
@click="selectMessage(message)"
@keydown.enter="selectMessage(message)"
@keydown.space.prevent="selectMessage(message)"
>
<!-- CPE lifeline -->
<div class="lifeline-column text-center" style="width: 36px">
<div class="lifeline-bar"></div>
</div>
<!-- Timestamp -->
<span class="font-monospace text-muted mt-1" style="font-size: 0.7rem">
{{ formatTimestamp(message.timestamp) }}
</span>
</div>
<!-- Arrow area -->
<div class="flex-grow-1 d-flex flex-column align-items-center position-relative">
<!-- Method label -->
<span
v-if="message.cwmpMethod === 'Empty'"
class="small text-muted fst-italic mb-1"
>
POST vide
</span>
<span
v-else
class="small fw-medium mb-1"
:title="needsTruncation(message.cwmpMethod) ? message.cwmpMethod : undefined"
>
{{ truncateMethod(message.cwmpMethod) }}
</span>
<!-- ACS lifeline -->
<div class="lifeline-column text-center" style="width: 24px">
<div class="lifeline-bar"></div>
<!-- Arrow -->
<div class="sequence-arrow w-100 position-relative" :class="[
message.direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left',
message.cwmpMethod === 'Empty' ? 'arrow-empty' : ''
]">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<!-- Timestamp -->
<span class="font-monospace text-muted mt-1" style="font-size: 0.7rem">
{{ formatTimestamp(message.timestamp) }}
</span>
</div>
<!-- ACS lifeline -->
<div class="lifeline-column text-center" style="width: 36px">
<div class="lifeline-bar"></div>
</div>
</div>
</div>
</div>
@@ -586,16 +644,25 @@ watch(
}
.sequence-row--selected {
background-color: var(--bs-primary-bg-subtle, #e7f1ff) !important;
border-left: 3px solid var(--bs-primary, #ff7900) !important;
background-color: #e7f1ff !important;
outline: 2px solid #0d6efd;
outline-offset: -2px;
}
.sequence-row--cpe-to-acs .arrow-line {
background-color: var(--bs-primary, #ff7900);
background-color: #ff7900;
}
.sequence-row--cpe-to-acs .arrow-head {
border-left-color: var(--bs-primary, #ff7900);
border-left-color: #ff7900;
}
.sequence-row--selected.sequence-row--cpe-to-acs .arrow-line {
background-color: #ff7900;
}
.sequence-row--selected.sequence-row--cpe-to-acs .arrow-head {
border-left-color: #ff7900;
}
.sequence-row--acs-to-cpe .arrow-line {
@@ -676,4 +743,39 @@ watch(
.top-sentinel {
min-height: 1px;
}
.session-group {
border-left: 3px solid #e9ecef;
margin: 8px 4px;
border-radius: 6px;
background: #fcfcfd;
box-shadow: 0 0 0 1px #e9ecef;
overflow: hidden;
}
.session-group:nth-child(odd) {
border-left-color: #ff7900;
}
.session-group:nth-child(even) {
border-left-color: #527edb;
}
.session-label {
background: #f1f3f5;
border-bottom: 1px solid #e9ecef;
}
.session-badge {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #495057;
}
.session-id {
font-size: 0.6rem;
}
</style>
+30 -109
View File
@@ -3,9 +3,6 @@ import { ref } from 'vue'
import { router, Link } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import TitleBar from '../components/TitleBar.vue'
import SequenceViewer from '../components/SequenceViewer.vue'
import InspectorPanel from '../components/InspectorPanel.vue'
import type { CwmpExchange } from '../composables/useExchangeStream'
const { t } = useI18n()
@@ -19,68 +16,40 @@ const isLoading = ref(false)
const showDeleteConfirm = ref(false)
const isTraced = ref(false)
const tracingAction = ref(false)
const traceStartedAt = ref<string | null>(null)
const selectedMessage = ref<CwmpExchange | null>(null)
// Check if device is already traced
async function checkTraceStatus() {
try {
const response = await fetch('/api/v1/tr069-trace')
if (response.ok) {
const data = await response.json()
const entries = data.entries || []
const entry = entries.find((e: any) => e.deviceId === device.identifier)
isTraced.value = !!entry
if (entry) {
traceStartedAt.value = entry.startedAt || new Date().toISOString()
}
isTraced.value = (data.entries || []).some((e: any) => e.deviceId === device.identifier)
}
} catch {
// Silently ignore
}
} catch { /* ignore */ }
}
async function toggleTrace() {
tracingAction.value = true
try {
if (isTraced.value) {
const response = await fetch(`/api/v1/tr069-trace/${encodeURIComponent(device.identifier)}`, {
method: 'DELETE',
})
if (response.ok || response.status === 204) {
isTraced.value = false
traceStartedAt.value = null
selectedMessage.value = null
}
await fetch(`/api/v1/tr069-trace/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' })
isTraced.value = false
} else {
const response = await fetch('/api/v1/tr069-trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: device.identifier }),
})
if (response.ok) {
const data = await response.json()
isTraced.value = true
traceStartedAt.value = data.entry?.startedAt || new Date().toISOString()
}
if (response.ok) isTraced.value = true
}
} catch {
// Silently ignore
} finally {
tracingAction.value = false
}
} catch { /* ignore */ }
finally { tracingAction.value = false }
}
checkTraceStatus()
function onMessageSelect(message: CwmpExchange | null) {
selectedMessage.value = message
}
function deleteDevice() {
router.delete(`/devices/${encodeURIComponent(device.identifier)}`, {
onSuccess: () => router.visit('/devices'),
})
async function deleteDevice() {
await fetch(`/api/v1/devices/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' })
router.visit('/')
}
const showGetModelPopup = ref(false)
const getModelInput = ref('')
@@ -147,8 +116,8 @@ function submitGetModel() {
const estimatedNextConnection = (() => {
const lastPeriodic = device.lastPeriodicInformAt
if (!lastPeriodic) return null
const allParams = [...(device.properties || []), ...(device.parameters || [])]
const intervalParam = allParams.find((p: any) => p.name?.includes('PeriodicInformInterval'))
const allParams = [...(device.properties || []), ...(device.parameters || [])].filter((p: any) => p && typeof p.name === 'string')
const intervalParam = allParams.find((p: any) => p.name.includes('PeriodicInformInterval'))
const intervalSeconds = intervalParam?.value ? parseInt(intervalParam.value, 10) : 0
if (!intervalSeconds || intervalSeconds <= 0) return null
const next = new Date(new Date(lastPeriodic).getTime() + intervalSeconds * 1000)
@@ -157,12 +126,11 @@ const estimatedNextConnection = (() => {
// Check if the device has a ConnectionRequestURL (needed for immediate mode / Refresh)
const hasConnectionRequestUrl = (() => {
const allParams = [...(device.properties || []), ...(device.parameters || [])]
const allParams = [...(device.properties || []), ...(device.parameters || [])].filter((p: any) => p && typeof p.name === 'string')
const crUrl = allParams.find(
(p: any) =>
p.name &&
(p.name.endsWith('ManagementServer.ConnectionRequestURL') ||
p.name.endsWith('.ConnectionRequestURL'))
p.name.endsWith('ManagementServer.ConnectionRequestURL') ||
p.name.endsWith('.ConnectionRequestURL')
)
return crUrl && crUrl.value && crUrl.value.trim() !== ''
})()
@@ -232,11 +200,6 @@ async function resolveAllProviders() {
Actions
</a>
</li>
<li v-if="isTraced" class="nav-item">
<a class="nav-link" :class="{ active: activeSection === 'exchanges' }" href="javascript:void(0);" @click="scrollTo('exchanges')">
Échanges TR-069
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeSection === 'commands' }" href="javascript:void(0);" @click="scrollTo('commands')">
{{ t('titles.commands') }}
@@ -426,7 +389,7 @@ async function resolveAllProviders() {
<!-- Section: Properties (collapsible) -->
<div class="accordion-section mb-3" id="properties">
<div class="section-header" @click="toggleSection('properties')">
<h2 class="h6 mb-0">{{ t('labels.properties') }} ({{ device.properties?.filter((p: any) => p.value != null).length || 0 }})</h2>
<h2 class="h6 mb-0">{{ t('labels.properties') }} ({{ device.properties?.filter((p: any) => typeof p.name === 'string' && p.value != null).length || 0 }})</h2>
<span class="chevron" :class="{ open: isSectionOpen('properties') }">▾</span>
</div>
<div v-if="isSectionOpen('properties')" class="section-body">
@@ -440,7 +403,7 @@ async function resolveAllProviders() {
</tr>
</thead>
<tbody>
<tr v-for="prop in device.properties?.filter((p: any) => p.name != null && p.value != null)" :key="prop.name">
<tr v-for="prop in device.properties?.filter((p: any) => p && typeof p.name === 'string' && p.value != null)" :key="prop.name">
<td class="multi-line small" v-html="formatName(prop.name)"></td>
<td class="multi-line small font-monospace" v-html="formatName(prop.value)"></td>
<td class="small">{{ prop.type }}</td>
@@ -454,7 +417,7 @@ async function resolveAllProviders() {
<!-- Section: Parameters (collapsible) -->
<div class="accordion-section mb-3" id="parameters">
<div class="section-header" @click="toggleSection('parameters')">
<h2 class="h6 mb-0">{{ t('labels.parameters') }} ({{ device.parameters?.filter((p: any) => p.value != null).length || 0 }})</h2>
<h2 class="h6 mb-0">{{ t('labels.parameters') }} ({{ device.parameters?.filter((p: any) => p && typeof p.name === 'string' && p.value != null).length || 0 }})</h2>
<span class="chevron" :class="{ open: isSectionOpen('parameters') }">▾</span>
</div>
<div v-if="isSectionOpen('parameters')" class="section-body">
@@ -468,7 +431,7 @@ async function resolveAllProviders() {
</tr>
</thead>
<tbody>
<tr v-for="param in device.parameters?.filter((p: any) => p.name != null && p.value != null)" :key="param.name">
<tr v-for="param in device.parameters?.filter((p: any) => p && typeof p.name === 'string' && p.value != null)" :key="param.name">
<td class="multi-line small" v-html="formatName(param.name)"></td>
<td class="multi-line small font-monospace" v-html="formatName(param.value)"></td>
<td class="small">{{ param.type }}</td>
@@ -506,44 +469,28 @@ async function resolveAllProviders() {
<button class="btn btn-outline-dark btn-sm" @click="openGetModelPopup" :disabled="isLoading">
{{ t('button.getModel') }}
</button>
<span
v-if="isTraced"
class="d-inline-block"
tabindex="0"
data-bs-toggle="tooltip"
title="Tracing already active. Click 'Disable Tracing' to stop."
>
<button class="btn btn-outline-primary btn-sm" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
</svg>
Add to TR-069 monitoring
</button>
</span>
<button
v-if="isTraced"
class="btn btn-warning btn-sm"
class="btn btn-sm"
:class="isTraced ? 'btn-warning' : 'btn-outline-secondary'"
:disabled="tracingAction"
@click="toggleTrace"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293z"/>
<path d="M13.5 1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3M11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.499 2.499 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5"/>
</svg>
{{ tracingAction ? '…' : 'Disable Tracing' }}
{{ tracingAction ? '…' : isTraced ? 'Tracing actif' : 'Activer le tracing' }}
</button>
<button
v-if="!isTraced"
<Link
:href="`/tr069-trace?device=${encodeURIComponent(device.identifier)}`"
class="btn btn-outline-primary btn-sm"
:disabled="tracingAction"
@click="toggleTrace"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0"/>
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8m8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7"/>
</svg>
{{ tracingAction ? '…' : 'Add to TR-069 monitoring' }}
</button>
Échanges TR-069
</Link>
<button class="btn btn-outline-danger btn-sm ms-auto" @click="showDeleteConfirm = true">
Supprimer l'équipement
</button>
@@ -558,32 +505,6 @@ async function resolveAllProviders() {
</div>
</div>
<!-- Section: Échanges TR-069 (collapsible, only when traced) -->
<div v-if="isTraced" class="accordion-section mb-3" id="exchanges">
<div class="section-header" @click="toggleSection('exchanges')">
<h2 class="h6 mb-0">Échanges TR-069</h2>
<span class="chevron" :class="{ open: isSectionOpen('exchanges') }">▾</span>
</div>
<div v-if="isSectionOpen('exchanges')" class="section-body">
<div class="d-flex gap-3" style="min-height: 400px">
<div class="flex-grow-1" style="overflow: hidden">
<SequenceViewer
:device-id="device.identifier"
:device="{
identifier: device.identifier,
manufacturer: device.deviceId?.manufacturer || '',
productClass: device.deviceId?.productClass || '',
serialNumber: device.deviceId?.serialNumber || '',
}"
:trace-started-at="traceStartedAt || ''"
@select="onMessageSelect"
/>
</div>
<InspectorPanel :selected-message="selectedMessage" />
</div>
</div>
</div>
<!-- Section: Commands for this device -->
<div class="accordion-section mb-3" id="commands">
<div class="section-header" @click="toggleSection('commands')">
+100 -73
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
import TitleBar from '~/components/TitleBar.vue'
import SequenceViewer from '~/components/SequenceViewer.vue'
import InspectorPanel from '~/components/InspectorPanel.vue'
@@ -20,21 +20,28 @@ const removingDeviceId = ref<string | null>(null)
const entriesError = ref('')
const actionError = ref('')
// Log viewer state
// Trace modal state
const showTraceModal = ref(false)
const selectedDeviceId = ref<string | null>(null)
const logContent = ref('')
const logLines = ref(0)
const loadingLog = ref(false)
const logError = ref('')
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
// Inspector panel state (new exchange viewer)
const inspectorMessage = ref<CwmpExchange | null>(null)
const pendingDeviceId = ref<string | null>(null)
function onMessageSelect(message: CwmpExchange | null) {
inspectorMessage.value = message
}
function openTraceModal(deviceId: string) {
selectedDeviceId.value = deviceId
inspectorMessage.value = null
showTraceModal.value = true
}
function closeTraceModal() {
showTraceModal.value = false
selectedDeviceId.value = null
inspectorMessage.value = null
}
// JSON editor modal state
const showJsonModal = ref(false)
const jsonContent = ref('')
@@ -49,26 +56,19 @@ const selectedEntry = computed(() =>
onMounted(async () => {
await loadEntries()
})
watch(selectedDeviceId, async (id) => {
stopAutoRefresh()
logContent.value = ''
logLines.value = 0
logError.value = ''
inspectorMessage.value = null
})
function startAutoRefresh(deviceId: string) {
autoRefreshTimer = setInterval(() => { void loadLog(deviceId) }, 3000)
}
function stopAutoRefresh() {
if (autoRefreshTimer !== null) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
// Ouvrir directement la modale si ?device= est présent dans l'URL
const params = new URLSearchParams(window.location.search)
const deviceParam = params.get('device')
if (deviceParam) {
// Si le device est déjà tracé, ouvrir la modale
if (tracedDeviceIds.value.has(deviceParam)) {
openTraceModal(deviceParam)
} else {
// Sinon proposer de l'activer (on stocke juste l'id pour highlight)
pendingDeviceId.value = deviceParam
}
}
}
})
async function loadEntries() {
loadingEntries.value = true
@@ -79,7 +79,7 @@ async function loadEntries() {
const data = await response.json()
tracedDevices.value = data.entries || []
if (selectedDeviceId.value && !tracedDeviceIds.value.has(selectedDeviceId.value)) {
selectedDeviceId.value = null
closeTraceModal()
}
} catch (error) {
entriesError.value = error instanceof Error ? error.message : 'Failed to load trace entries'
@@ -99,7 +99,7 @@ async function removeDevice(deviceId: string) {
const payload = await response.json().catch(() => ({}))
throw new Error(payload.error || 'Failed to disable tracing')
}
if (selectedDeviceId.value === deviceId) selectedDeviceId.value = null
if (selectedDeviceId.value === deviceId) closeTraceModal()
await loadEntries()
} catch (error) {
actionError.value = error instanceof Error ? error.message : 'Failed to disable tracing'
@@ -108,29 +108,28 @@ async function removeDevice(deviceId: string) {
}
}
async function loadLog(deviceId: string) {
loadingLog.value = true
logError.value = ''
async function enableTrace(deviceId: string) {
actionError.value = ''
pendingDeviceId.value = deviceId
try {
const response = await fetch(`/api/v1/tr069-trace/${encodeURIComponent(deviceId)}/log?lines=500`)
const response = await fetch('/api/v1/tr069-trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
})
if (!response.ok) {
const payload = await response.json().catch(() => ({}))
throw new Error(payload.error || 'Failed to load log')
throw new Error(payload.error || 'Failed to enable tracing')
}
const data = await response.json()
logContent.value = data.content ?? ''
logLines.value = data.lines ?? 0
await loadEntries()
openTraceModal(deviceId)
} catch (error) {
logError.value = error instanceof Error ? error.message : 'Failed to load log'
actionError.value = error instanceof Error ? error.message : 'Unknown error'
} finally {
loadingLog.value = false
pendingDeviceId.value = null
}
}
function clearLog() {
logContent.value = ''
}
// JSON editor modal functions
async function openJsonEditor() {
showJsonModal.value = true
@@ -206,7 +205,7 @@ function formatJson() {
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="h4 mb-1">Traced devices</h2>
<p class="text-muted small mb-0">Click a row to view its live SOAP log.</p>
<p class="text-muted small mb-0">Activez le tracing sur un équipement puis cliquez sur "Voir" pour visualiser les échanges en direct.</p>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-dark">{{ tracedDevices.length }}</span>
@@ -221,6 +220,16 @@ function formatJson() {
<div v-if="entriesError" class="alert alert-danger" role="alert">{{ entriesError }}</div>
<div v-if="actionError" class="alert alert-warning" role="alert">{{ actionError }}</div>
<div v-if="pendingDeviceId && !tracedDeviceIds.has(pendingDeviceId)" class="alert alert-info d-flex align-items-center gap-3 mb-3">
<span>L'équipement <strong class="font-monospace">{{ pendingDeviceId }}</strong> n'est pas encore tracé.</span>
<button
class="btn btn-sm btn-primary ms-auto"
@click="enableTrace(pendingDeviceId)"
>
Activer le tracing
</button>
<button class="btn btn-sm btn-outline-secondary" @click="pendingDeviceId = null">✕</button>
</div>
<p v-if="loadingEntries" class="text-muted small mb-0">Loading traced devices&hellip;</p>
@@ -241,9 +250,9 @@ function formatJson() {
<tr
v-for="entry in tracedDevices"
:key="entry.deviceId"
:class="{ 'table-active': selectedDeviceId === entry.deviceId }"
:class="{ 'table-warning': pendingDeviceId === entry.deviceId }"
style="cursor: pointer"
@click="selectedDeviceId = entry.deviceId"
@click="openTraceModal(entry.deviceId)"
>
<td class="font-monospace small">{{ entry.deviceId }}</td>
<td>{{ entry.manufacturer || '—' }}</td>
@@ -257,13 +266,13 @@ function formatJson() {
:disabled="removingDeviceId === entry.deviceId"
@click.stop="removeDevice(entry.deviceId)"
>
{{ removingDeviceId === entry.deviceId ? 'Removing…' : 'Remove' }}
{{ removingDeviceId === entry.deviceId ? '…' : 'Supprimer' }}
</button>
</td>
</tr>
<tr v-if="tracedDevices.length === 0">
<td colspan="7" class="text-center text-muted py-4">
No traced devices configured. Click "Edit JSON" to add devices.
Aucun équipement tracé. Utilisez "Edit JSON" pour en ajouter ou activez le tracing depuis la fiche d'équipement.
</td>
</tr>
</tbody>
@@ -272,33 +281,51 @@ function formatJson() {
</div>
</div>
<!-- Exchange Viewer panel (full width) — new sequence diagram viewer -->
<div v-if="selectedDeviceId && selectedEntry" class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h5 mb-0">Échanges TR-069</h3>
<button class="btn btn-sm btn-outline-secondary" @click="selectedDeviceId = null">&#x2715; Fermer</button>
</div>
<div class="row g-0" style="min-height: 550px">
<div class="col-6 border-end overflow-hidden">
<SequenceViewer
:device-id="selectedEntry.deviceId"
:device="{
identifier: selectedEntry.deviceId,
manufacturer: selectedEntry.manufacturer || '',
productClass: selectedEntry.productClass || '',
serialNumber: selectedEntry.serialNumber || '',
}"
:trace-started-at="selectedEntry.startedAt || ''"
:compact="true"
@select="onMessageSelect"
/>
</div>
<div class="col-6">
<InspectorPanel :selected-message="inspectorMessage" />
</div>
<!-- Trace Modal -->
<Teleport to="body">
<div v-if="showTraceModal" class="modal-backdrop fade show" @click="closeTraceModal"></div>
<div
v-if="showTraceModal && selectedEntry"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
aria-modal="true"
style="z-index: 1055"
>
<div class="modal-dialog" style="width: 90vw; max-width: 90vw; height: 95vh; margin: 2.5vh auto;">
<div class="modal-content" style="height: 95vh; overflow: hidden;">
<div class="modal-header border-bottom py-2 px-3" style="flex-shrink: 0;">
<div class="d-flex align-items-center gap-3">
<h5 class="modal-title mb-0">Échanges TR-069</h5>
<span class="text-muted small font-monospace">{{ selectedEntry.manufacturer || '' }} — {{ selectedEntry.serialNumber }}</span>
</div>
<button type="button" class="btn-close" aria-label="Close" @click="closeTraceModal"></button>
</div>
<div class="modal-body p-0 d-flex overflow-hidden" style="height: calc(95vh - 57px);">
<div class="overflow-hidden border-end" style="width: calc(100% * 5 / 12)">
<SequenceViewer
:device-id="selectedEntry.deviceId"
:device="{
identifier: selectedEntry.deviceId,
manufacturer: selectedEntry.manufacturer || '',
productClass: selectedEntry.productClass || '',
serialNumber: selectedEntry.serialNumber || '',
}"
:trace-started-at="selectedEntry.startedAt || ''"
:compact="true"
@select="onMessageSelect"
/>
</div>
<div style="width: calc(100% * 7 / 12); overflow-y: auto">
<InspectorPanel :selected-message="inspectorMessage" />
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
<!-- JSON Editor Modal -->
<Teleport to="body">
+7 -1
View File
@@ -15,7 +15,13 @@
"test": "node ace test",
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"docker:build": "docker compose -f docker-compose.yml -f docker-compose.simulator.yml build",
"docker:up": "docker compose -f docker-compose.yml -f docker-compose.simulator.yml up -d",
"docker:down": "docker compose -f docker-compose.yml -f docker-compose.simulator.yml down",
"docker:logs": "docker compose -f docker-compose.yml -f docker-compose.simulator.yml logs -f app",
"docker:test": "docker compose -f docker-compose.yml up -d mongodb && sleep 2 && NODE_ENV=test MONGODB_URI=mongodb://adam:adam@localhost:27017/adam-acs?authSource=admin npx vitest run ; STATUS=$? ; docker compose -f docker-compose.yml stop mongodb ; exit $STATUS",
"docker:build-and-test": "npm run docker:build && npm run docker:test"
},
"imports": {
"#controllers/*": "./app/controllers/*.js",
@@ -0,0 +1,69 @@
# TR-069 CWMP State Machine — Problem Catalog
## CRITIQUE-1: Race Condition task queue / state machine
- File: `statemachinemanager.ts` (continue() + setupTaskQueueListeners())
- Detail: `addTask()` in DeviceActionExecutor emits 'taskSent' synchronously, setting `lastTaskId` BEFORE the XML is returned to the CPE. When both the state machine and task queue serve tasks in the same session, `lastTaskId` can point to the wrong task, corrupting CPE response routing.
- Impact: Responses routed to wrong callback, silent data corruption, session hangs.
## CRITIQUE-2: Multiple superfluous sessions on BOOTSTRAP
- File: `cwmpcontroller.ts` (handleInform()) + `statemachinemanager.ts` (startStateMachine())
- Detail: `enqueueImmediate(GPV)` in the controller + implicit ScheduleInform via SPV systematically generate 3 sessions instead of 1.
- Impact: 3x ACS load per bootstrap, unnecessary CPE wake-ups, slower onboarding.
## CRITIQUE-3: IdleState does not check gpnStatus before sending GPN
- File: `mainstatemachine.ts` (IdleState.handle())
- Detail: A device with gpnStatus='unsupported' (Livebox, buggy CPEs) still receives a GPN every session, causing systematic Fault or CPE crash/reboot.
- Impact: CPE crash loops, Fault flooding, device never reaches operational state.
## CRITIQUE-4: Silent safetyGuard in executeCurrentState()
- File: `mainstatemachine.ts` (executeCurrentState())
- Detail: The `while (safetyGuard++ < 15)` loop stops silently if the machine loops. No log of blocking state, no transition to an explicit error state.
- Impact: Sessions terminate with empty response, device not marked in error, monitoring blind spot.
## MAJEUR-5: Multi-event routing based on LAST event only
- File: `statemachinemanager.ts` (startStateMachine())
- Code: `const lastEvent = this.normalizeEvent(evts[evts.length - 1])`
- Detail: An Inform with ['0 BOOTSTRAP', '1 BOOT'] processes only '1 BOOT'. An Inform with ['6 CONNECTION REQUEST', '2 PERIODIC'] forces ImmediateCommandStateMachine when onboarding is required.
- Impact: Wrong state machine selected, missed onboarding, incorrect provisioning flow.
## MAJEUR-6: Incomplete GPN fault codes in DeviceActionExecutor
- File: `deviceactionexecutor.ts` (getParameterNames() wrappedCallback)
- Code: `if (code === 9000 || code === 9001) { device.setGpnStatus('unsupported') }`
- Detail: Codes 9003 (Invalid Arguments), 9004 (Resources Exceeded), 9005 (Invalid Parameter Name) should also trigger fallback. Network/timeout codes should NOT mark unsupported.
- Impact: Missing fallback on legitimate GPN failures, false unsupported marking on transient errors.
## MAJEUR-7: No GPN circuit breaker across sessions
- Files: `mainstatemachine.ts`, `configurationevaluationstate.ts`, `deviceactionexecutor.ts`
- Detail: No failure counter persisted between sessions. A CPE that crashes on GPN will be retried every Inform. No cooldown, no crash pattern detection.
- Impact: Infinite crash loops for vulnerable CPEs, ACS resource waste.
## MAJEUR-8: Critical GPV outside state machine (collision with ConfigEvalState)
- File: `cwmpcontroller.ts` (handleInform() enqueueImmediate block)
- Detail: GPV (HardwareVersion, SoftwareVersion, ConnectionRequestURL) is served by the task queue, not the state machine. It can interleave with GPN/GPV from ConfigurationEvaluationState, corrupting CWMP exchange order.
- Impact: Out-of-order CWMP messages, session corruption, device misidentification.
## MINEUR-9: Duplicated EventCode normalization
- Files: `cwmpcontroller.ts` (normalizeEventCode()), `statemachinemanager.ts` (normalizeEvent())
- Detail: Two identical functions, two maintenance points.
- Impact: Drift risk, inconsistent normalization.
## MINEUR-10: reclassifyDeviceFromCatalog() called at 4 different places
- Files: `cwmpcontroller.ts`, `deviceactionexecutor.ts` (x2), `mainstatemachine.ts` (CompletedState)
- Detail: Scattered fallback logic, hard to unit test.
- Impact: Inconsistent reclassification timing, test complexity.
## MINEUR-11: No explicit PartialOperational state
- File: `configurationevaluationstate.ts` (catch block -> { isComplete: true })
- Detail: On ConfigEval failure, the machine terminates without persisting a degraded status usable for monitoring/alerting.
- Impact: Monitoring blind to degraded devices, no alerting on partial provisioning.
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import { extractCwmpMessage } from '../../../app/protocol/soap.js'
describe('extractCwmpMessage — SOAP Fault detection', () => {
it('detects soap-env:Fault as Fault message', () => {
const body = {
'soap-env:Fault': {
faultcode: 'Client',
faultstring: 'CWMP fault',
detail: { 'cwmp:Fault': { FaultCode: '9001', FaultString: 'Request denied' } },
},
}
const msg = extractCwmpMessage(body)
expect(msg).not.toBeNull()
expect(msg!.type).toBe('Fault')
expect(msg!.data.faultcode).toBe('Client')
})
it('detects SOAP-ENV:Fault as Fault message', () => {
const body = {
'SOAP-ENV:Fault': {
faultcode: 'Server',
faultstring: 'Internal error',
},
}
const msg = extractCwmpMessage(body)
expect(msg).not.toBeNull()
expect(msg!.type).toBe('Fault')
expect(msg!.data.faultcode).toBe('Server')
})
it('detects bare Fault as Fault message', () => {
const body = {
Fault: { FaultCode: '9002', FaultString: 'Internal error' },
}
const msg = extractCwmpMessage(body)
expect(msg).not.toBeNull()
expect(msg!.type).toBe('Fault')
})
it('detects soapenv:Fault as Fault message', () => {
const body = {
'soapenv:Fault': { faultcode: 'Client', faultstring: 'Fault' },
}
const msg = extractCwmpMessage(body)
expect(msg).not.toBeNull()
expect(msg!.type).toBe('Fault')
})
it('prefers cwmp: prefixed keys over Fault detection', () => {
const body = {
'cwmp:GetParameterValuesResponse': { ParameterList: [] },
'soap-env:Fault': { faultcode: 'Client' },
}
const msg = extractCwmpMessage(body)
expect(msg).not.toBeNull()
expect(msg!.type).toBe('GetParameterValuesResponse')
})
it('returns null when body has no cwmp: keys and no Fault', () => {
const body = { 'other:Something': {} }
expect(extractCwmpMessage(body)).toBeNull()
})
})
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock logger
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))
// Mock DeviceModel
vi.mock('../../../../app/models/device.js', () => ({
default: {
createOrUpdate: vi.fn().mockResolvedValue({}),
findByDeviceId: vi.fn().mockResolvedValue(null),
updateOne: vi.fn().mockResolvedValue({}),
},
}))
// Mock CommandModel
vi.mock('../../../../app/models/command.js', () => ({
default: {
getCollection: vi.fn().mockReturnValue({
find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }),
countDocuments: vi.fn().mockResolvedValue(0),
}),
updateCommand: vi.fn().mockResolvedValue({}),
},
}))
// Mock ConfigurationModel
vi.mock('#models/configuration', () => ({
default: {
find: vi.fn().mockResolvedValue([]),
findByIdentifier: vi.fn().mockResolvedValue(null),
},
}))
// Mock firmware catalog service
vi.mock('../../../../app/services/firmware_catalog_service.js', () => ({
default: class {
async resolveUpgradePath() { return [] }
},
FirmwareCatalogService: class {
async resolveUpgradePath() { return [] }
},
}))
// Mock parameter_writability
vi.mock('../../../../app/services/parameter_writability.js', () => ({
reclassifyDeviceFromCatalog: vi.fn(),
}))
import { ConfigurationEvaluationState, ComplianceUpdateState } from '../../../../app/services/cwmp/configuration_evaluation_state.js'
import { Device } from '../../../../app/domain/device/device.js'
import { DeviceId } from '../../../../app/domain/device/device_id.js'
import { DeviceProperty } from '../../../../app/domain/device/device_parameter.js'
import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js'
import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js'
import { ParameterType } from '../../../../app/domain/shared/types.js'
import { buildProvisioningCode } from '../../../../app/utils/provisioning_code.js'
function makeDevice(): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setDataModel('tr-181')
device.setGpnStatus('unsupported')
return device
}
function makeContext(device: Device) {
const taskQueue = new CWMPTaskQueue()
return {
device,
session: 'test-session',
taskQueue,
services: {
deviceAction: new DeviceActionExecutor(taskQueue),
},
}
}
describe('ConfigurationEvaluationState — skip inventory when already compliant', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('skips inventory when isAlreadyCompliant is true (no matching config)', async () => {
const device = makeDevice()
const context = makeContext(device)
const state = new ConfigurationEvaluationState()
const result = await state.handle(context as any)
// With no configs in DB, evaluate returns isAlreadyCompliant: true
// State should transition forward without sending any GPN/GPV
// (no inventory, no diffs)
expect(result.response).toBeFalsy()
expect(result.nextState || result.isComplete).toBeTruthy()
})
})
describe('ComplianceUpdateState — skip provCode SPV when matching', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('skips SPV when device already has matching provisioning code', async () => {
const device = makeDevice()
const mockConfig = { identifier: 'TestConfig', version: '1.0', desiredParameters: [], firmwareTarget: null }
const expectedProvCode = buildProvisioningCode(mockConfig as any)
// Set the device's ProvisioningCode to match what buildProvisioningCode produces
device.setProperty(new DeviceProperty(
'Device.DeviceInfo.ProvisioningCode',
expectedProvCode,
ParameterType.STRING
))
const context = makeContext(device)
const mockEngineResult = {
configuration: mockConfig,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
}
const state = new ComplianceUpdateState(mockEngineResult as any)
const result = await state.handle(context as any)
// Should NOT produce a response (no SPV sent)
// Should transition to ExecutingCommandsState
expect(result.response).toBeFalsy()
expect(result.nextState).toBeDefined()
})
})
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock logger
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))
// Mock DeviceModel
vi.mock('../../../../app/models/device.js', () => ({
default: {
createOrUpdate: vi.fn().mockResolvedValue({}),
findByDeviceId: vi.fn().mockResolvedValue(null),
updateOne: vi.fn().mockResolvedValue({}),
},
}))
// Mock CommandModel
vi.mock('../../../../app/models/command.js', () => ({
default: {
getCollection: vi.fn().mockReturnValue({
find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }),
countDocuments: vi.fn().mockResolvedValue(0),
}),
updateCommand: vi.fn().mockResolvedValue({}),
},
}))
// Mock configuration evaluation (skip config eval for these tests)
vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({
ConfigurationEvaluationState: class {
name = 'configuration-evaluation'
async handle() { return { isComplete: true, response: '' } }
},
}))
// Mock reclassify
vi.mock('../../../../app/services/parameter_writability.js', () => ({
reclassifyDeviceFromCatalog: vi.fn(),
}))
import { MainStateMachine } from '../../../../app/services/cwmp/main_state_machine.js'
import { Device } from '../../../../app/domain/device/device.js'
import { DeviceId } from '../../../../app/domain/device/device_id.js'
import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js'
function makeDevice(): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setDataModel('tr-181')
device.setGpnStatus('unsupported') // Skip GPN, go to CriticalReads
return device
}
describe('CriticalReadsState — GPV response processing', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('processes GPV response and updates device values', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
// Start: IdleState → CriticalReadsState → sends GPV
const gpvRequest = await sm.start()
expect(gpvRequest).toContain('GetParameterValues')
// Simulate GPVResponse with ParameterValueStruct
await sm.handleResponse(true, {
ParameterList: {
ParameterValueStruct: [
{ Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v3.0', '$xsi:type': 'xsd:string' } },
{ Name: 'Device.DeviceInfo.SoftwareVersion', Value: { _: 'SR50-5.0.0', '$xsi:type': 'xsd:string' } },
{ Name: 'Device.DeviceInfo.ModelName', Value: { _: 'Livebox 4', '$xsi:type': 'xsd:string' } },
],
},
})
// Device should have the values stored
const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion')
expect(hwProp).not.toBeNull()
expect(hwProp!.value).toBe('v3.0')
const swProp = device.getProperty('Device.DeviceInfo.SoftwareVersion')
expect(swProp).not.toBeNull()
expect(swProp!.value).toBe('SR50-5.0.0')
})
it('normalizes Name fields from object to string when xsi:type attribute is present', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Simulate GPVResponse where Name has xsi:type attribute (parsed as object by XMLParser)
await sm.handleResponse(true, {
ParameterList: {
ParameterValueStruct: [
{
Name: { _: 'Device.DeviceInfo.HardwareVersion', '$xsi:type': 'xsd:string' },
Value: { _: 'v2.1', '$xsi:type': 'xsd:string' },
},
],
},
})
// Should normalize Name._ to string and store correctly
const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion')
expect(hwProp).not.toBeNull()
expect(hwProp!.value).toBe('v2.1')
})
it('handles empty ParameterValueStruct gracefully', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Simulate empty GPV response
await sm.handleResponse(true, { ParameterList: { ParameterValueStruct: [] } })
// Should not throw, machine should continue
expect(sm.getCurrentStateName()).not.toBe('critical-reads')
})
it('handles missing ParameterList gracefully', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Simulate response with no ParameterList
await sm.handleResponse(true, {})
// Should not throw, machine should continue
expect(sm.getCurrentStateName()).not.toBe('critical-reads')
})
it('transitions to configuration-evaluation after processing', async () => {
const device = makeDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
await sm.handleResponse(true, {
ParameterList: {
ParameterValueStruct: [
{ Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v1', '$xsi:type': 'xsd:string' } },
],
},
})
// Should have transitioned past critical-reads to configuration-evaluation
expect(sm.getCurrentStateName()).not.toBe('critical-reads')
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
})
})
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock logger
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))
// Mock DeviceModel
vi.mock('../../../../app/models/device.js', () => ({
default: {
createOrUpdate: vi.fn().mockResolvedValue({}),
findByDeviceId: vi.fn().mockResolvedValue(null),
updateOne: vi.fn().mockResolvedValue({}),
},
}))
// Mock CommandModel
vi.mock('../../../../app/models/command.js', () => ({
default: {
getCollection: vi.fn().mockReturnValue({
find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }),
countDocuments: vi.fn().mockResolvedValue(0),
}),
updateCommand: vi.fn().mockResolvedValue({}),
},
}))
// Mock configuration engine
vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({
ConfigurationEvaluationState: class {
name = 'configuration-evaluation'
async handle() { return { isComplete: true, response: '' } }
},
}))
// Mock reclassify
vi.mock('../../../../app/services/parameter_writability.js', () => ({
reclassifyDeviceFromCatalog: vi.fn(),
}))
import { MainStateMachine } from '../../../../app/services/cwmp/main_state_machine.js'
import { Device } from '../../../../app/domain/device/device.js'
import { DeviceId } from '../../../../app/domain/device/device_id.js'
import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js'
import DeviceModel from '../../../../app/models/device.js'
function makeDevice(): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setDataModel('tr-181')
return device
}
describe('GPN Circuit Breaker — DiscoveringParametersState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('IdleState skips GPN when gpnStatus is unsupported', async () => {
const device = makeDevice()
device.setGpnStatus('unsupported')
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
// Start triggers IdleState → should skip GPN
const response = await sm.start()
// Should produce a GPV (critical reads) not a GPN
expect(response).toContain('GetParameterValues')
expect(response).not.toContain('GetParameterNames')
})
it('IdleState skips GPN when circuit breaker is open', async () => {
const device = makeDevice()
device.setGpnStatus('unknown')
device.setGpnCircuitBreaker({
failureCount: 2,
lastFailureDate: new Date(),
state: 'open',
cooldownUntil: new Date(Date.now() + 86400000),
})
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
const response = await sm.start()
// Should produce a GPV (critical reads) not a GPN
expect(response).toContain('GetParameterValues')
expect(response).not.toContain('GetParameterNames')
})
it('IdleState proceeds to GPN when gpnStatus is unknown and circuit breaker is closed', async () => {
const device = makeDevice()
device.setGpnStatus('unknown')
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
const response = await sm.start()
// Should produce a GPN request
expect(response).toContain('GetParameterNames')
})
it('DiscoveringParametersState calls recordGpnFailure and persists on fault', async () => {
const device = makeDevice()
device.setGpnStatus('unknown')
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
// Start: IdleState → DiscoveringParametersState → sends GPN
await sm.start()
// Simulate CPE fault response
const response = await sm.handleResponse(false, { FaultCode: '9001', FaultString: 'Method not supported' })
// Device should now be marked as unsupported
expect(device.gpnStatus).toBe('unsupported')
// Circuit breaker should have recorded the failure
expect(device.gpnCircuitBreaker.failureCount).toBe(1)
// Should have persisted via createOrUpdate
expect(DeviceModel.createOrUpdate).toHaveBeenCalled()
// Should continue to critical reads (GPV)
expect(response).toContain('GetParameterValues')
})
})
@@ -13,8 +13,8 @@ vi.mock('../../../../app/models/command.js', () => ({
default: {
getCollection: () => ({
find: vi.fn().mockReturnValue({
sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }),
}),
sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }),
}),
}),
updateCommand: vi.fn().mockResolvedValue({}),
},
@@ -54,17 +54,19 @@ describe('MainStateMachine', () => {
await sm.start()
// Simulate GPN success response
const response = await sm.handleResponse(true, {
ParameterList: { ParameterInfoStruct: [{ Name: 'Device.ManagementServer.URL', Writable: '1' }] },
ParameterList: {
ParameterInfoStruct: [{ Name: 'Device.ManagementServer.URL', Writable: '1' }],
},
})
// After GPN success, should transition to configuration-evaluation
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
// After GPN success, should transition to critical-reads
expect(sm.getCurrentStateName()).toBe('critical-reads')
})
it('with unknown data model, skips to configuration-evaluation', async () => {
it('with unknown data model, skips to critical-reads', async () => {
const sm = new MainStateMachine(makeDevice('unknown'), 'sess-1', new CWMPTaskQueue())
await sm.start()
// idle → directly to configuration-evaluation (no GPN for unknown model)
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
// idle → directly to critical-reads (no GPN for unknown model)
expect(sm.getCurrentStateName()).toBe('critical-reads')
})
it('with tr-098, sends GPN for InternetGatewayDevice.ManagementServer.', async () => {
@@ -82,23 +84,24 @@ describe('MainStateMachine', () => {
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
})
it('full lifecycle: idle → discovering → config-eval → compliance → executing → completed', async () => {
it('full lifecycle: idle → discovering → critical-reads → config-eval → compliance → executing → operational', async () => {
const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue())
// Step 1: start → GPN
await sm.start()
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
// Step 2: GPN response → config-eval
// Step 2: GPN response → critical-reads
await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
expect(sm.getCurrentStateName()).toBe('critical-reads')
// Step 3+: config eval will process its queue and eventually reach compliance-update
// Since our mock engine returns inventoryPaths, it will send GPN requests
// After all config eval actions complete, it reaches compliance-update → executing-commands → completed
// Simulate multiple responses to drain the config eval state
// Step 3+: critical reads will send GPV, after response moves to config eval
// Simulate multiple responses to drain the state machine
let guard = 0
while (!sm.isCompleted() && guard++ < 20) {
await sm.handleResponse(true, { Status: 0, ParameterList: { ParameterInfoStruct: [], ParameterValueStruct: [] } })
await sm.handleResponse(true, {
Status: 0,
ParameterList: { ParameterInfoStruct: [], ParameterValueStruct: [] },
})
}
expect(sm.isCompleted()).toBe(true)
})
@@ -127,7 +130,9 @@ describe('ExecutingCommandsState', () => {
services: { deviceAction: { getParameterValues: vi.fn() } as any },
}
const result = await state.handle(context)
expect(result.isComplete).toBe(true)
// With includeNI=true and no commands, transitions to OperationalState
expect(result.nextState).toBeDefined()
expect(result.nextState!.name).toBe('operational')
})
it('returns isComplete when no commands found (includeNI=false)', async () => {
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock logger
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))
// Mock DeviceModel
vi.mock('../../../../app/models/device.js', () => ({
default: {
createOrUpdate: vi.fn().mockResolvedValue({}),
findByDeviceId: vi.fn().mockResolvedValue(null),
updateOne: vi.fn().mockResolvedValue({}),
},
}))
// Mock CommandModel
vi.mock('../../../../app/models/command.js', () => ({
default: {
getCollection: vi.fn().mockReturnValue({
find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }),
countDocuments: vi.fn().mockResolvedValue(0),
}),
updateCommand: vi.fn().mockResolvedValue({}),
},
}))
import { StateMachineManager, MachineStatus } from '../../../../app/services/cwmp/state_machine_manager.js'
import { Device } from '../../../../app/domain/device/device.js'
import { DeviceId } from '../../../../app/domain/device/device_id.js'
function makeDevice(): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setDataModel('tr-181')
device.setGpnStatus('unsupported')
return device
}
describe('StateMachineManager — stale session replacement', () => {
let manager: StateMachineManager
beforeEach(() => {
vi.clearAllMocks()
// Reset singleton
;(StateMachineManager as any).instance = null
manager = StateMachineManager.getInstance()
})
it('replaces stale MainStateMachine when session differs', async () => {
const device = makeDevice()
// Start first machine on session-A
const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] })
expect(machine1.status).toBe(MachineStatus.RUNNING)
expect(machine1.session).toBe('session-A')
// Start second machine on session-B (different session, same device)
const machine2 = await manager.startStateMachine(device, 'session-B', { events: ['2 PERIODIC'] })
// Should be a NEW machine (not the old one rebinding)
expect(machine2.session).toBe('session-B')
// Old machine should be completed
expect(machine1.status).toBe(MachineStatus.COMPLETED)
})
it('does NOT replace machine when session is same', async () => {
const device = makeDevice()
// Start machine on session-A
const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] })
// Start again on SAME session
const machine2 = await manager.startStateMachine(device, 'session-A', { events: ['2 PERIODIC'] })
// Should return the same machine (rebinding)
expect(machine2.id).toBe(machine1.id)
expect(machine1.status).toBe(MachineStatus.RUNNING)
})
it('creates fresh machine after replacing stale one', async () => {
const device = makeDevice()
// Start first machine
await manager.startStateMachine(device, 'session-old', { events: ['0 BOOTSTRAP'] })
// Start new machine with different session
const newMachine = await manager.startStateMachine(device, 'session-new', { events: ['2 PERIODIC'] })
// Fresh machine should be running
expect(newMachine.status).toBe(MachineStatus.RUNNING)
expect(newMachine.session).toBe('session-new')
// Should be a MainStateMachine (PERIODIC event triggers MainStateMachine)
expect(newMachine.currentState).toBeDefined()
})
})
+8 -7
View File
@@ -36,21 +36,22 @@ describe('CWMPTaskQueue', () => {
describe('addTask', () => {
it('adds task, marks as SENT, and returns SOAP XML', () => {
const xml = queue.addTask(makeDeviceId(), 'reboot', { commandKey: 'test' }, async () => '')
expect(xml).toContain('Reboot')
expect(xml).toContain('test')
const result = queue.addTask(makeDeviceId(), 'reboot', { commandKey: 'test' }, async () => '')
expect(result.xml).toContain('Reboot')
expect(result.xml).toContain('test')
expect(result.taskId).toBeDefined()
})
it('returns empty string for unknown task type', () => {
const xml = queue.addTask(makeDeviceId(), 'unknown_type', {}, async () => '')
expect(xml).toBe('')
const result = queue.addTask(makeDeviceId(), 'unknown_type', {}, async () => '')
expect(result.xml).toBe('')
})
it('emits taskSent event', () => {
it('does not emit taskSent event (race condition fix)', () => {
let sent = false
queue.on('taskSent', () => { sent = true })
queue.addTask(makeDeviceId(), 'factory_reset', {}, async () => '')
expect(sent).toBe(true)
expect(sent).toBe(false)
})
})
@@ -102,7 +102,7 @@ describe('MainStateMachine — TR-181 device', () => {
// Simulate any response (success or failure) — state machine advances
await sm.handleResponse(true, {})
// Should have moved past discovering-parameters (no more paths in queue)
// Should have moved past discovering-parameters (now in critical-reads)
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
expect(sm.getCurrentStateName()).not.toBe('idle')
})
@@ -198,16 +198,17 @@ describe('MainStateMachine — TR-098 device', () => {
// ---------------------------------------------------------------------------
describe('MainStateMachine — unknown data model', () => {
it('skips GPN and goes directly to configuration-evaluation', async () => {
it('skips GPN and goes directly to critical-reads', async () => {
const device = makeUnknownDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Should skip discovering-parameters entirely
// Should skip discovering-parameters and go to critical-reads
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
expect(sm.getCurrentStateName()).not.toBe('idle')
expect(sm.getCurrentStateName()).toBe('critical-reads')
})
})
@@ -254,7 +255,7 @@ describe('MainStateMachine — CompletedState reclassification', () => {
// ---------------------------------------------------------------------------
describe('DeviceActionExecutor — reclassification after updateFromValues', () => {
it('reclassifies properties added via SPV callback when GPN unsupported', async () => {
it('SPV callback updates device values but does not reclassify (consolidated to terminal state)', async () => {
const device = makeTr181Device()
device.setGpnStatus('unsupported')
// Device starts with only read-only properties (like after first Inform)
@@ -282,10 +283,9 @@ describe('DeviceActionExecutor — reclassification after updateFromValues', ()
await taskQueue.executeTaskCallback(spvTask!.id, 'success' as any, { Status: 0 })
// ProvisioningCode should now be a parameter (reclassified after updateFromValues)
expect(device.getParameter('Device.DeviceInfo.ProvisioningCode')).not.toBeNull()
expect(device.getParameter('Device.DeviceInfo.ProvisioningCode')!.value).toBe('test-code')
expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')).toBeNull()
// ProvisioningCode is stored as a property (reclassification happens later in OperationalState)
expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')).not.toBeNull()
expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')!.value).toBe('test-code')
})
it('does NOT reclassify when GPN is supported (live data is authoritative)', async () => {