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:
1 parent
dabe15839d
commit
aad93911e4
35 files changed
+8593
-4497
No files matched your search
Vendored
+1
@@ -4,5 +4,6 @@
|
||||
*/
|
||||
|
||||
/// <reference path="../../adonisrc.ts" />
|
||||
/// <reference path="../../config/exchange_store.ts" />
|
||||
/// <reference path="../../config/hash.ts" />
|
||||
/// <reference path="../../config/logger.ts" />
|
||||
@@ -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
@@ -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': {
|
||||
|
||||
Vendored
+12
@@ -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']
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
Vendored
+21
@@ -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
|
||||
@@ -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
@@ -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[]
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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…</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">✕ 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
@@ -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.
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user