feat: TR-181 parameter writability resolution
- Add GpnStatus type ('supported' | 'unsupported' | 'unknown') to Device domain
- Persist gpnStatus in DeviceRecord and all 3 persist paths
(cwmp_controller, main_state_machine, device_action_executor)
- Capture Fault 9000/9001 in getParameterNames → mark device as 'unsupported'
- Fix GPN NextLevel flag: false (non-recursive shallow scan)
- Add static TR-181 catalog (7 727 params, sourced from BBF cwmp-data-models)
- Add parameter_writability service with resolveWritability():
supported → live data; unsupported → catalog → default R/W;
unknown → catalog → null (genuinely unknown)
- Add scripts/generate-tr181-catalog.mjs to regenerate catalog from BBF XML
- Add design doc: docs/design-parameter-writability.md
- Add unit tests: 26 tests covering normalizePath and all gpnStatus branches
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
d1c65dbf1e
commit
b8ff4b68a7
12 files changed
+39449
-1
No files matched your search
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
normalizePath,
|
||||
resolveWritability,
|
||||
} from '../../../app/services/parameter_writability.js'
|
||||
import type { DeviceSnapshot } from '../../../app/domain/device/device.js'
|
||||
import { OnboardingStatus } from '../../../app/domain/device/device.js'
|
||||
import { DeviceId } from '../../../app/domain/device/device_id.js'
|
||||
import { ParameterType } from '../../../app/domain/shared/types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeSnapshot(
|
||||
overrides: Partial<DeviceSnapshot> = {}
|
||||
): DeviceSnapshot {
|
||||
return {
|
||||
deviceId: new DeviceId('Sagemcom', '10:6f:3f', 'Fast5360', 'SN123'),
|
||||
identifier: 'abc123',
|
||||
createdAt: new Date(),
|
||||
lastSeen: new Date(),
|
||||
onboardingStatus: OnboardingStatus.COMPLETED,
|
||||
isOnline: false,
|
||||
gpnStatus: 'unknown',
|
||||
parameters: [],
|
||||
properties: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function param(name: string) {
|
||||
return { name, value: null, type: ParameterType.STRING }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizePath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('leaves paths without numeric segments unchanged', () => {
|
||||
expect(normalizePath('Device.DeviceInfo.Manufacturer')).toBe(
|
||||
'Device.DeviceInfo.Manufacturer'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces a single numeric segment in the middle', () => {
|
||||
expect(normalizePath('Device.IP.Interface.1.Enable')).toBe(
|
||||
'Device.IP.Interface.{i}.Enable'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces a numeric segment at the end', () => {
|
||||
expect(normalizePath('Device.IP.Interface.1')).toBe(
|
||||
'Device.IP.Interface.{i}'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces multiple numeric segments', () => {
|
||||
expect(
|
||||
normalizePath('Device.IP.Interface.1.IPv4Address.2.IPAddress')
|
||||
).toBe('Device.IP.Interface.{i}.IPv4Address.{i}.IPAddress')
|
||||
})
|
||||
|
||||
it('handles large instance numbers', () => {
|
||||
expect(normalizePath('Device.WiFi.AccessPoint.99.Security.WEPKey.10.WEPKey')).toBe(
|
||||
'Device.WiFi.AccessPoint.{i}.Security.WEPKey.{i}.WEPKey'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveWritability — gpnStatus = 'supported'
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveWritability — gpnStatus: supported', () => {
|
||||
it('returns writable=true source=live when param is in parameters[]', () => {
|
||||
const snapshot = makeSnapshot({
|
||||
gpnStatus: 'supported',
|
||||
parameters: [param('Device.ManagementServer.URL')],
|
||||
})
|
||||
expect(resolveWritability(snapshot, 'Device.ManagementServer.URL')).toEqual(
|
||||
{ writable: true, source: 'live' }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns writable=false source=live when param is in properties[]', () => {
|
||||
const snapshot = makeSnapshot({
|
||||
gpnStatus: 'supported',
|
||||
properties: [param('Device.DeviceInfo.Manufacturer')],
|
||||
})
|
||||
expect(resolveWritability(snapshot, 'Device.DeviceInfo.Manufacturer')).toEqual(
|
||||
{ writable: false, source: 'live' }
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to catalog when param is absent from both arrays', () => {
|
||||
// Device.ManagementServer.URL is readWrite in TR-181 catalog
|
||||
const snapshot = makeSnapshot({ gpnStatus: 'supported' })
|
||||
const result = resolveWritability(snapshot, 'Device.ManagementServer.URL')
|
||||
// catalog or unknown — but NOT live since arrays are empty
|
||||
expect(result.source).not.toBe('live')
|
||||
})
|
||||
|
||||
it('parameters[] takes precedence over properties[] when both have the param', () => {
|
||||
const snapshot = makeSnapshot({
|
||||
gpnStatus: 'supported',
|
||||
parameters: [param('Device.ManagementServer.URL')],
|
||||
properties: [param('Device.ManagementServer.URL')],
|
||||
})
|
||||
expect(resolveWritability(snapshot, 'Device.ManagementServer.URL')).toEqual(
|
||||
{ writable: true, source: 'live' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveWritability — gpnStatus = 'unsupported'
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveWritability — gpnStatus: unsupported', () => {
|
||||
it('returns catalog value for a known readOnly TR-181 parameter', () => {
|
||||
// Device.DeviceInfo.Manufacturer is readOnly in TR-181
|
||||
const snapshot = makeSnapshot({ gpnStatus: 'unsupported' })
|
||||
const result = resolveWritability(snapshot, 'Device.DeviceInfo.Manufacturer')
|
||||
expect(result).toEqual({ writable: false, source: 'catalog' })
|
||||
})
|
||||
|
||||
it('returns catalog value for a known readWrite TR-181 parameter', () => {
|
||||
// Device.ManagementServer.URL is readWrite in TR-181
|
||||
const snapshot = makeSnapshot({ gpnStatus: 'unsupported' })
|
||||
const result = resolveWritability(snapshot, 'Device.ManagementServer.URL')
|
||||
expect(result).toEqual({ writable: true, source: 'catalog' })
|
||||
})
|
||||
|
||||
it('returns catalog value for an instance path (numeric → {i} normalization)', () => {
|
||||
// Device.IP.Interface.{i}.Enable is readWrite in TR-181
|
||||
const snapshot = makeSnapshot({ gpnStatus: 'unsupported' })
|
||||
const result = resolveWritability(snapshot, 'Device.IP.Interface.1.Enable')
|
||||
expect(result).toEqual({ writable: true, source: 'catalog' })
|
||||
})
|
||||
|
||||
it('returns writable=true source=default for a path absent from catalog', () => {
|
||||
const snapshot = makeSnapshot({ gpnStatus: 'unsupported' })
|
||||
const result = resolveWritability(snapshot, 'Device.Vendor.CustomParam.NotInCatalog')
|
||||
expect(result).toEqual({ writable: true, source: 'default' })
|
||||
})
|
||||
|
||||
it('ignores parameters[] and properties[] (does not use live data)', () => {
|
||||
const snapshot = makeSnapshot({
|
||||
gpnStatus: 'unsupported',
|
||||
parameters: [param('Device.ManagementServer.URL')],
|
||||
properties: [param('Device.DeviceInfo.Manufacturer')],
|
||||
})
|
||||
// Should use catalog, not live arrays
|
||||
const result = resolveWritability(snapshot, 'Device.DeviceInfo.Manufacturer')
|
||||
expect(result.source).toBe('catalog')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveWritability — gpnStatus = 'unknown'
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveWritability — gpnStatus: unknown', () => {
|
||||
it('returns catalog value for a known readOnly TR-181 parameter', () => {
|
||||
const snapshot = makeSnapshot({ gpnStatus: 'unknown' })
|
||||
const result = resolveWritability(snapshot, 'Device.DeviceInfo.Manufacturer')
|
||||
expect(result).toEqual({ writable: false, source: 'catalog' })
|
||||
})
|
||||
|
||||
it('returns catalog value for a known readWrite TR-181 parameter', () => {
|
||||
const snapshot = makeSnapshot({ gpnStatus: 'unknown' })
|
||||
const result = resolveWritability(snapshot, 'Device.ManagementServer.URL')
|
||||
expect(result).toEqual({ writable: true, source: 'catalog' })
|
||||
})
|
||||
|
||||
it('returns writable=null source=unknown for a path absent from catalog', () => {
|
||||
const snapshot = makeSnapshot({ gpnStatus: 'unknown' })
|
||||
const result = resolveWritability(snapshot, 'Device.Vendor.CustomParam.NotInCatalog')
|
||||
expect(result).toEqual({ writable: null, source: 'unknown' })
|
||||
})
|
||||
|
||||
it('distinguishes from unsupported: returns null not true for missing params', () => {
|
||||
const snapshotUnknown = makeSnapshot({ gpnStatus: 'unknown' })
|
||||
const snapshotUnsupported = makeSnapshot({ gpnStatus: 'unsupported' })
|
||||
const path = 'Device.Vendor.CustomParam.NotInCatalog'
|
||||
expect(resolveWritability(snapshotUnknown, path).writable).toBeNull()
|
||||
expect(resolveWritability(snapshotUnsupported, path).writable).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user