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
@@ -119,6 +119,7 @@ export default class CwmpController {
|
||||
deviceId,
|
||||
properties: device.getProperties().map((p) => p.toSnapshot()),
|
||||
parameters: device.getParameters().map((p) => p.toSnapshot()),
|
||||
gpnStatus: device.gpnStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -194,6 +195,7 @@ export default class CwmpController {
|
||||
|
||||
record.properties?.forEach((p: any) => device.setProperty(new DeviceProperty(p.name, p.value, p.type)))
|
||||
record.parameters?.forEach((p: any) => device.setParameter(new DeviceParameter(p.name, p.value, p.type)))
|
||||
device.setGpnStatus(record.gpnStatus ?? 'unknown')
|
||||
|
||||
return device
|
||||
} catch (_error) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { DeviceParameter, DeviceProperty, ParameterSnapshot, PropertySnapshot }
|
||||
import { ParameterType } from '../shared/types.js'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export type GpnStatus = 'supported' | 'unsupported' | 'unknown'
|
||||
|
||||
export enum OnboardingStatus {
|
||||
PENDING = 'PENDING',
|
||||
DISCOVERING = 'DISCOVERING',
|
||||
@@ -19,6 +21,7 @@ export class Device {
|
||||
private _session: any = null
|
||||
private _lastSeen: Date = new Date()
|
||||
private _onboardingStatus: OnboardingStatus = OnboardingStatus.PENDING
|
||||
private _gpnStatus: GpnStatus = 'unknown'
|
||||
|
||||
constructor(
|
||||
public readonly deviceId: DeviceId,
|
||||
@@ -37,6 +40,14 @@ export class Device {
|
||||
return this._onboardingStatus
|
||||
}
|
||||
|
||||
public get gpnStatus(): GpnStatus {
|
||||
return this._gpnStatus
|
||||
}
|
||||
|
||||
public setGpnStatus(status: GpnStatus): void {
|
||||
this._gpnStatus = status
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -227,6 +238,7 @@ export class Device {
|
||||
lastSeen: this._lastSeen,
|
||||
onboardingStatus: this._onboardingStatus,
|
||||
isOnline: this.isOnline(),
|
||||
gpnStatus: this._gpnStatus,
|
||||
parameters: this.getParameters().map((p) => p.toSnapshot()),
|
||||
properties: this.getProperties().map((p) => p.toSnapshot()),
|
||||
}
|
||||
@@ -244,6 +256,7 @@ export interface DeviceSnapshot {
|
||||
lastSeen: Date
|
||||
onboardingStatus: OnboardingStatus
|
||||
isOnline: boolean
|
||||
gpnStatus: GpnStatus
|
||||
parameters: ParameterSnapshot[]
|
||||
properties: PropertySnapshot[]
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { DeviceId } from './device_id.js'
|
||||
import { PropertySnapshot, ParameterSnapshot } from './device_parameter.js'
|
||||
import { DeviceComplianceInfo } from '../configuration/compliance.js'
|
||||
import { GpnStatus } from './device.js'
|
||||
|
||||
export interface DeviceRecord {
|
||||
identifier?: string
|
||||
deviceId: DeviceId
|
||||
properties: PropertySnapshot[]
|
||||
parameters: ParameterSnapshot[]
|
||||
gpnStatus?: GpnStatus
|
||||
compliance?: DeviceComplianceInfo
|
||||
provisioningCode?: string
|
||||
updatedAt?: Date
|
||||
|
||||
@@ -73,6 +73,7 @@ export class DeviceActionExecutor {
|
||||
deviceId: snapshot.deviceId,
|
||||
properties: snapshot.properties,
|
||||
parameters: snapshot.parameters,
|
||||
gpnStatus: snapshot.gpnStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -152,10 +153,24 @@ export class DeviceActionExecutor {
|
||||
if (result.status === ResultStatus.Success) {
|
||||
try {
|
||||
device.organizeParameters(result.data as any, new Date())
|
||||
device.setGpnStatus('supported')
|
||||
await this.persistDevice(device)
|
||||
} catch (e) {
|
||||
logger.error(`Error organizing parameters: ${e}`)
|
||||
}
|
||||
} else {
|
||||
// Inspecter le fault code pour distinguer "non supporté" de "erreur transitoire"
|
||||
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) {
|
||||
device.setGpnStatus('unsupported')
|
||||
await this.persistDevice(device)
|
||||
}
|
||||
// Autres erreurs (réseau, timeout, 9002+) → gpnStatus inchangé
|
||||
}
|
||||
return await callback(result)
|
||||
}
|
||||
|
||||
@@ -281,6 +281,7 @@ class CompletedState extends DeviceState {
|
||||
deviceId: snapshot.deviceId,
|
||||
properties: snapshot.properties,
|
||||
parameters: snapshot.parameters,
|
||||
gpnStatus: snapshot.gpnStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
} catch (e) {
|
||||
|
||||
@@ -40,7 +40,7 @@ export enum TaskStatus {
|
||||
export class CWMPTaskQueue extends EventEmitter {
|
||||
static task_map: Record<string, (props: any) => string> = {
|
||||
get_parameter_names: (parameterPath: string) => {
|
||||
return CWMPMessageBuilder.getParameterNames(undefined, parameterPath, true)
|
||||
return CWMPMessageBuilder.getParameterNames(undefined, parameterPath, false)
|
||||
},
|
||||
get_parameter_attributes: (parameterNames: string[]) => {
|
||||
return CWMPMessageBuilder.getParameterAttributes(undefined, parameterNames)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import type { DeviceSnapshot } from '../domain/device/device.js'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const tr181Catalog: Array<{ path: string; writable: boolean; deviceClass: string }> =
|
||||
require('../../config/tr181-catalog.json')
|
||||
|
||||
// Index chargé une fois au démarrage — O(1) lookup
|
||||
const catalogIndex = new Map(tr181Catalog.map((e) => [e.path, e.writable]))
|
||||
|
||||
/** Remplace les segments numériques par {i} pour matcher les entrées du catalogue */
|
||||
export function normalizePath(path: string): string {
|
||||
return path.replace(/\.\d+\./g, '.{i}.').replace(/\.\d+$/, '.{i}')
|
||||
}
|
||||
|
||||
export type WritabilitySource = 'live' | 'catalog' | 'default' | 'unknown'
|
||||
|
||||
export interface WritabilityResult {
|
||||
writable: boolean | null
|
||||
source: WritabilitySource
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout la writabilité d'un paramètre à partir de l'état persisté du device.
|
||||
*
|
||||
* Logique :
|
||||
* 1. gpnStatus = 'supported' → parameters[] = writable, properties[] = RO (source fiable)
|
||||
* 2. gpnStatus = 'unsupported' → catalogue TR-181 → absent = writable=true (défaut R/W)
|
||||
* 3. gpnStatus = 'unknown' → catalogue TR-181 → absent = writable=null (genuinement inconnu)
|
||||
*/
|
||||
export function resolveWritability(snapshot: DeviceSnapshot, paramPath: string): WritabilityResult {
|
||||
const { gpnStatus, parameters, properties } = snapshot
|
||||
|
||||
if (gpnStatus === 'supported') {
|
||||
if (parameters.some((p) => p.name === paramPath)) return { writable: true, source: 'live' }
|
||||
if (properties.some((p) => p.name === paramPath)) return { writable: false, source: 'live' }
|
||||
}
|
||||
|
||||
// gpnStatus = 'unsupported' ou 'unknown' → consulter le catalogue
|
||||
const normalized = normalizePath(paramPath)
|
||||
if (catalogIndex.has(normalized)) {
|
||||
return { writable: catalogIndex.get(normalized)!, source: 'catalog' }
|
||||
}
|
||||
|
||||
// Absent du catalogue
|
||||
if (gpnStatus === 'unsupported') return { writable: true, source: 'default' }
|
||||
return { writable: null, source: 'unknown' }
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,289 @@
|
||||
# Design — Writabilité des paramètres TR-069
|
||||
|
||||
## Problème
|
||||
|
||||
La méthode TR-069 `GetParameterNames` permet de découvrir les paramètres d'un équipement et de savoir lesquels sont accessibles en écriture (`writable=true`) ou en lecture seule (`writable=false`).
|
||||
|
||||
Certains équipements — notamment les **Livebox Sagemcom** — ne répondent pas à cet appel ou retournent `Fault 9000 / Method not supported`. Sans cette information, l'ACS ne peut pas :
|
||||
|
||||
- Afficher un indicateur R/W vs Read-only dans la fiche device
|
||||
- Protéger l'utilisateur contre une tentative de `SetParameterValues` sur un paramètre en lecture seule
|
||||
|
||||
---
|
||||
|
||||
## Contraintes architecturales (état actuel du codebase)
|
||||
|
||||
1. **TR-069 est session-bound** : l'ACS ne peut pas appeler `GetParameterNames` à la demande depuis une requête HTTP. Le CPE initie les sessions. Les RPC CWMP sont exécutés dans la state machine (`DiscoveringParametersState` → `DeviceActionExecutor.getParameterNames`).
|
||||
|
||||
2. **La writabilité est déjà persistée** : `device.parameters[]` = writable (issu de `organizeParameters()` après GPN réussi), `device.properties[]` = RO si GPN a réussi, mais par défaut tout param inconnu va aussi dans `properties[]` via `updateFromValues()` (ligne 201 de `device.ts`).
|
||||
|
||||
3. **Le fault code GPN n'est pas tracé** : quand GPN retourne `Fault 9000`, `success = false` dans la state machine, mais rien n'indique que le device ne supporte pas GPN. On ne distingue pas "device hors-ligne" de "GPN non supporté".
|
||||
|
||||
---
|
||||
|
||||
## Approches envisagées
|
||||
|
||||
### Option A — Writabilité depuis session + catalogue statique (retenue)
|
||||
|
||||
Exploiter la writabilité déjà apprise pendant les sessions CWMP, complétée par un fichier catalogue statique TR-181 pour les devices qui ne supportent pas GPN.
|
||||
|
||||
**Principe :**
|
||||
- Ajouter `gpnStatus: 'supported' | 'unsupported' | 'unknown'` au device
|
||||
- Pendant la session : si GPN réussit → `gpnStatus = 'supported'` + `organizeParameters()` ; si `Fault 9000/9001` → `gpnStatus = 'unsupported'` ; si autre erreur → `gpnStatus` inchangé (erreur transitoire)
|
||||
- L'API lit l'état persisté et applique la logique de résolution
|
||||
- Pas de GPN live depuis l'API HTTP
|
||||
|
||||
**Avantages :** cohérent avec l'architecture session-bound, sans état supplémentaire en mémoire, distingue les cas correctement.
|
||||
|
||||
---
|
||||
|
||||
### Option B — GPN live synchrone depuis l'API HTTP
|
||||
|
||||
Appeler GPN à la demande lors d'une requête HTTP avec un timeout court.
|
||||
|
||||
*Non retenu — TR-069 est session-bound : impossible d'envoyer un RPC sans session active. Un timeout n'est pas équivalent à "non supporté".*
|
||||
|
||||
---
|
||||
|
||||
### Option C — Cache long-terme par device (parameterMeta)
|
||||
|
||||
Persister le résultat de GPN dans un champ dédié par session.
|
||||
|
||||
*Non retenu — `parameters[]`/`properties[]` jouent déjà ce rôle. Ajouter un champ `gpnStatus` suffit.*
|
||||
|
||||
---
|
||||
|
||||
## Solution retenue
|
||||
|
||||
### Logique de résolution (côté API — lecture de l'état persisté)
|
||||
|
||||
```
|
||||
gpnStatus = 'supported'
|
||||
→ parameters[path] trouvé → writable = true (source: 'live')
|
||||
→ properties[path] trouvé → writable = false (source: 'live')
|
||||
|
||||
gpnStatus = 'unsupported'
|
||||
→ Tous les params sont dans properties[] par défaut (updateFromValues)
|
||||
→ Consulter tr181-catalog.json[path]
|
||||
→ Trouvé → writable = catalog value (source: 'catalog')
|
||||
→ Absent → writable = true (source: 'default', badge "non vérifié TR-181")
|
||||
|
||||
gpnStatus = 'unknown' (device jamais connecté ou erreur transitoire)
|
||||
→ Consulter tr181-catalog.json[path]
|
||||
→ Trouvé → writable = catalog value (source: 'catalog')
|
||||
→ Absent → writable = null (source: 'unknown' — afficher "?")
|
||||
```
|
||||
|
||||
### Conditions de fallback `gpnStatus = 'unsupported'`
|
||||
|
||||
Uniquement si le device retourne explicitement :
|
||||
- `Fault 9000` — Method not supported
|
||||
- `Fault 9001` — Request denied
|
||||
|
||||
**Tout le reste** (timeout réseau, Fault 9002/9003/9004, device hors-ligne) = erreur transitoire → `gpnStatus` inchangé.
|
||||
|
||||
---
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### Champ `gpnStatus` sur `DeviceRecord`
|
||||
|
||||
```typescript
|
||||
// app/domain/device/device_record.ts
|
||||
export interface DeviceRecord {
|
||||
identifier?: string
|
||||
deviceId: DeviceId
|
||||
properties: PropertySnapshot[]
|
||||
parameters: ParameterSnapshot[]
|
||||
gpnStatus?: 'supported' | 'unsupported' | 'unknown' // nouveau
|
||||
compliance?: DeviceComplianceInfo
|
||||
provisioningCode?: string
|
||||
updatedAt?: Date
|
||||
createdAt?: Date
|
||||
}
|
||||
```
|
||||
|
||||
### Fichier `config/tr181-catalog.json`
|
||||
|
||||
Fichier statique embarqué dans l'application, versionné dans Git. Chargé en mémoire au démarrage, indexé par path pour un lookup O(1).
|
||||
|
||||
**Source :** XMLs officiels Broadband Forum ([github.com/BroadbandForum/data-model-template](https://github.com/BroadbandForum/data-model-template)). Un script `scripts/generate-tr181-catalog.ts` peut régénérer ce fichier.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "path": "Device.DeviceInfo.SoftwareVersion", "writable": false, "deviceClass": "Device" },
|
||||
{ "path": "Device.DeviceInfo.HardwareVersion", "writable": false, "deviceClass": "Device" },
|
||||
{ "path": "Device.DeviceInfo.SerialNumber", "writable": false, "deviceClass": "Device" },
|
||||
{ "path": "Device.DeviceInfo.Manufacturer", "writable": false, "deviceClass": "Device" },
|
||||
{ "path": "Device.DeviceInfo.ProvisioningCode", "writable": true, "deviceClass": "Device" },
|
||||
{ "path": "Device.ManagementServer.URL", "writable": true, "deviceClass": "Device" },
|
||||
{ "path": "Device.ManagementServer.Username", "writable": true, "deviceClass": "Device" },
|
||||
{ "path": "Device.ManagementServer.Password", "writable": true, "deviceClass": "Device" },
|
||||
{ "path": "Device.ManagementServer.PeriodicInformInterval", "writable": true, "deviceClass": "Device" },
|
||||
{ "path": "Device.IP.Interface.{i}.IPv4Address.{i}.IPAddress", "writable": false, "deviceClass": "Device" },
|
||||
{ "path": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", "writable": false, "deviceClass": "IGD" },
|
||||
{ "path": "InternetGatewayDevice.DeviceInfo.HardwareVersion", "writable": false, "deviceClass": "IGD" },
|
||||
{ "path": "InternetGatewayDevice.ManagementServer.URL", "writable": true, "deviceClass": "IGD" },
|
||||
{ "path": "InternetGatewayDevice.ManagementServer.PeriodicInformInterval","writable": true, "deviceClass": "IGD" }
|
||||
]
|
||||
```
|
||||
|
||||
> Les paramètres avec index générique (`{i}`) sont matchés par normalisation du path (remplacement des segments numériques par `{i}`).
|
||||
|
||||
---
|
||||
|
||||
## Implémentation
|
||||
|
||||
### 1. Domaine — ajout de `gpnStatus` sur `Device`
|
||||
|
||||
```typescript
|
||||
// app/domain/device/device.ts
|
||||
export type GpnStatus = 'supported' | 'unsupported' | 'unknown'
|
||||
|
||||
export class Device {
|
||||
private _gpnStatus: GpnStatus = 'unknown'
|
||||
|
||||
public get gpnStatus(): GpnStatus { return this._gpnStatus }
|
||||
public setGpnStatus(status: GpnStatus): void { this._gpnStatus = status }
|
||||
|
||||
public toSnapshot(): DeviceSnapshot {
|
||||
return {
|
||||
// ...existant...
|
||||
gpnStatus: this._gpnStatus,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Session CWMP — capture du statut GPN dans `DeviceActionExecutor`
|
||||
|
||||
```typescript
|
||||
// app/services/cwmp/device_action_executor.ts
|
||||
async getParameterNames(device, parameterName, callback): Promise<string> {
|
||||
const wrappedCallback = async (result: TaskResult) => {
|
||||
if (result.status === ResultStatus.Success) {
|
||||
device.organizeParameters(result.data, new Date())
|
||||
device.setGpnStatus('supported')
|
||||
await this.persistDevice(device)
|
||||
} else {
|
||||
// Inspecter le fault code pour distinguer "non supporté" de "erreur transitoire"
|
||||
const faultCode = result.data?.FaultCode ?? result.data?.faultCode
|
||||
const isUnsupported = faultCode === 9000 || faultCode === 9001
|
||||
if (isUnsupported) {
|
||||
device.setGpnStatus('unsupported')
|
||||
await this.persistDevice(device)
|
||||
}
|
||||
// Autres erreurs (réseau, timeout, 9002...) → gpnStatus inchangé
|
||||
}
|
||||
return callback(result)
|
||||
}
|
||||
return this.taskQueue.addTask(device.deviceId, 'get_parameter_names', parameterName, wrappedCallback)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Persistance — `DeviceRecord` et `CwmpController.loadDevice`
|
||||
|
||||
```typescript
|
||||
// Lors du chargement : restituer gpnStatus
|
||||
device.setGpnStatus(record.gpnStatus ?? 'unknown')
|
||||
|
||||
// Lors de la persistance : inclure gpnStatus dans createOrUpdate
|
||||
await DeviceModel.createOrUpdate({
|
||||
identifier: snapshot.identifier,
|
||||
deviceId: snapshot.deviceId,
|
||||
properties: snapshot.properties,
|
||||
parameters: snapshot.parameters,
|
||||
gpnStatus: snapshot.gpnStatus, // nouveau
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Service de résolution de writabilité (lecture seule, côté API)
|
||||
|
||||
```typescript
|
||||
// app/services/parameter_writability.ts
|
||||
import tr181Catalog from '#config/tr181-catalog.json' assert { type: 'json' }
|
||||
|
||||
const catalogIndex = new Map(tr181Catalog.map((e) => [e.path, e.writable]))
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\.\d+\./g, '.{i}.').replace(/\.\d+$/, '.{i}')
|
||||
}
|
||||
|
||||
export type WritabilityResult = {
|
||||
writable: boolean | null
|
||||
source: 'live' | 'catalog' | 'default' | 'unknown'
|
||||
}
|
||||
|
||||
export function resolveWritability(
|
||||
deviceSnapshot: DeviceSnapshot,
|
||||
paramPath: string
|
||||
): WritabilityResult {
|
||||
const { gpnStatus, parameters, properties } = deviceSnapshot
|
||||
|
||||
if (gpnStatus === 'supported') {
|
||||
if (parameters.some((p) => p.name === paramPath)) return { writable: true, source: 'live' }
|
||||
if (properties.some((p) => p.name === paramPath)) return { writable: false, source: 'live' }
|
||||
}
|
||||
|
||||
// gpnStatus = 'unsupported' ou 'unknown' → consulter le catalogue
|
||||
const normalized = normalizePath(paramPath)
|
||||
if (catalogIndex.has(normalized)) {
|
||||
return { writable: catalogIndex.get(normalized)!, source: 'catalog' }
|
||||
}
|
||||
|
||||
// Pas dans le catalogue
|
||||
if (gpnStatus === 'unsupported') return { writable: true, source: 'default' }
|
||||
return { writable: null, source: 'unknown' }
|
||||
}
|
||||
```
|
||||
|
||||
### 5. API — enrichissement de la liste de paramètres
|
||||
|
||||
`GET /api/v1/devices/:id/parameters` retourne chaque paramètre avec `writable` et `writabilitySource` :
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Device.ManagementServer.URL",
|
||||
"value": "http://acs.example.com/",
|
||||
"writable": true,
|
||||
"writabilitySource": "live"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Frontend
|
||||
|
||||
| Paramètre | Valeur | Writabilité |
|
||||
|-----------|--------|-------------|
|
||||
| Device.DeviceInfo.SoftwareVersion | 1.4.19 | 🔒 Read-only |
|
||||
| Device.ManagementServer.URL | http://… | ✏️ R/W |
|
||||
| Device.X_VENDOR_Feature | xyz | ✏️ R/W *(non vérifié TR-181)* |
|
||||
| Device.Something (device hors-ligne, absent catalogue) | — | ❓ Inconnu |
|
||||
|
||||
- **🔒 Read-only** (`writable=false`) : bouton "Modifier" désactivé
|
||||
- **✏️ R/W** (`writable=true, source=live|catalog`) : bouton "Modifier" actif
|
||||
- **✏️ R/W non vérifié** (`writable=true, source=default`) : bouton actif + badge orange "non référencé TR-181"
|
||||
- **❓ Inconnu** (`writable=null`) : bouton désactivé — device jamais connecté ou en erreur transitoire
|
||||
|
||||
---
|
||||
|
||||
## Priorités d'implémentation
|
||||
|
||||
1. **`gpnStatus` dans `Device` + `DeviceRecord`** — champ additionnel minimal
|
||||
2. **`DeviceActionExecutor.getParameterNames`** — capturer `Fault 9000/9001` et mettre à jour `gpnStatus`
|
||||
3. **`CwmpController.loadDevice`** — restituer `gpnStatus` depuis la DB
|
||||
4. **Fichier `config/tr181-catalog.json`** — constituer depuis les sources BBF
|
||||
5. **Service `resolveWritability`** — lecture seule, O(1)
|
||||
6. **API enrichie** — `writable` + `writabilitySource` dans la liste de paramètres
|
||||
7. **UI** — indicateurs et désactivation du bouton "Modifier" sur les params RO
|
||||
|
||||
---
|
||||
|
||||
## Risques
|
||||
|
||||
| Risque | Mitigation |
|
||||
|--------|------------|
|
||||
| Fault code absent ou mal structuré dans `result.data` | Normaliser l'accès : `result.data?.FaultCode ?? result.data?.Detail?.Fault?.FaultCode` |
|
||||
| `gpnStatus = 'unsupported'` permanent même si firmware mis à jour | Réinitialiser à `'unknown'` lors d'un Bootstrap/Factory Reset event |
|
||||
| Paramètres `{i}` non normalisés correctement | Tests unitaires sur `normalizePath` |
|
||||
| Nouvelle révision TR-181 non couverte | Script de régénération + versionner le fichier dans Git |
|
||||
@@ -0,0 +1,174 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
|
||||
const OUTPUT_PATH = new URL('../config/tr181-catalog.json', import.meta.url)
|
||||
const INDEX_URL = 'https://cwmp-data-models.broadband-forum.org/'
|
||||
const SOURCE_CANDIDATES = [
|
||||
'https://raw.githubusercontent.com/BroadbandForum/usp-data-models/master/tr-181-2-usp.xml',
|
||||
'https://raw.githubusercontent.com/BroadbandForum/usp-data-models/master/tr-181-2-common.xml',
|
||||
'https://raw.githubusercontent.com/BroadbandForum/cwmp-data-models/master/tr-181-2-18-0.xml',
|
||||
]
|
||||
const XML_PARSER = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
})
|
||||
|
||||
async function fetchText(url) {
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.text()
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
function getDocumentRoot(parsedXml) {
|
||||
return parsedXml['dm:document'] ?? parsedXml.document ?? parsedXml
|
||||
}
|
||||
|
||||
function normalizeObjectPath(objectName) {
|
||||
return objectName.endsWith('.') ? objectName.slice(0, -1) : objectName
|
||||
}
|
||||
|
||||
function extractImportUrl(baseUrl, importNode) {
|
||||
const fileReference = importNode?.['@_file'] ?? importNode?.['@_href'] ?? null
|
||||
|
||||
if (!fileReference) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new URL(fileReference, baseUrl).href
|
||||
}
|
||||
|
||||
function discoverLatestCwmpFullUrl(indexHtml) {
|
||||
const matches = [...indexHtml.matchAll(/href=["'](tr-181-[^"']+-cwmp-full\.xml)["']/gi)]
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new Error('Could not discover a TR-181 CWMP full XML URL from the Broadband Forum index')
|
||||
}
|
||||
|
||||
return new URL(matches[0][1], INDEX_URL).href
|
||||
}
|
||||
|
||||
async function resolveRootSourceUrl() {
|
||||
for (const candidate of SOURCE_CANDIDATES) {
|
||||
try {
|
||||
await fetchText(candidate)
|
||||
return candidate
|
||||
} catch {
|
||||
// Fall through to the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
const indexHtml = await fetchText(INDEX_URL)
|
||||
return discoverLatestCwmpFullUrl(indexHtml)
|
||||
}
|
||||
|
||||
async function loadDocuments(rootUrl) {
|
||||
const queuedUrls = [rootUrl]
|
||||
const visitedUrls = new Set()
|
||||
const documents = []
|
||||
|
||||
while (queuedUrls.length > 0) {
|
||||
const currentUrl = queuedUrls.shift()
|
||||
|
||||
if (!currentUrl || visitedUrls.has(currentUrl)) {
|
||||
continue
|
||||
}
|
||||
|
||||
visitedUrls.add(currentUrl)
|
||||
|
||||
const xmlText = await fetchText(currentUrl)
|
||||
const parsedXml = XML_PARSER.parse(xmlText)
|
||||
const documentRoot = getDocumentRoot(parsedXml)
|
||||
|
||||
documents.push({ url: currentUrl, documentRoot })
|
||||
|
||||
for (const importNode of toArray(documentRoot.import)) {
|
||||
const importUrl = extractImportUrl(currentUrl, importNode)
|
||||
|
||||
if (importUrl && !visitedUrls.has(importUrl)) {
|
||||
queuedUrls.push(importUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return documents
|
||||
}
|
||||
|
||||
function extractCatalogEntries(documentRoot) {
|
||||
const catalogEntries = []
|
||||
|
||||
for (const model of toArray(documentRoot.model)) {
|
||||
for (const objectNode of toArray(model.object)) {
|
||||
const objectName = objectNode?.['@_name']
|
||||
|
||||
if (!objectName) {
|
||||
continue
|
||||
}
|
||||
|
||||
const objectPath = normalizeObjectPath(objectName)
|
||||
const objectAccess = objectNode['@_access'] ?? 'readOnly'
|
||||
|
||||
for (const parameterNode of toArray(objectNode.parameter)) {
|
||||
const parameterName = parameterNode?.['@_name']
|
||||
|
||||
if (!parameterName) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parameterAccess = parameterNode['@_access'] ?? objectAccess
|
||||
|
||||
catalogEntries.push({
|
||||
path: `${objectPath}.${parameterName}`,
|
||||
writable: parameterAccess === 'readWrite',
|
||||
deviceClass: 'tr181',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return catalogEntries
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rootSourceUrl = await resolveRootSourceUrl()
|
||||
const documents = await loadDocuments(rootSourceUrl)
|
||||
const catalogByPath = new Map()
|
||||
|
||||
for (const { documentRoot } of documents) {
|
||||
for (const entry of extractCatalogEntries(documentRoot)) {
|
||||
const existingEntry = catalogByPath.get(entry.path)
|
||||
|
||||
if (!existingEntry) {
|
||||
catalogByPath.set(entry.path, entry)
|
||||
continue
|
||||
}
|
||||
|
||||
if (existingEntry.writable !== entry.writable) {
|
||||
throw new Error(`Conflicting writability detected for ${entry.path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const catalogEntries = [...catalogByPath.values()].sort((left, right) =>
|
||||
left.path.localeCompare(right.path)
|
||||
)
|
||||
|
||||
await writeFile(OUTPUT_PATH, `${JSON.stringify(catalogEntries, null, 2)}\n`)
|
||||
|
||||
console.log(
|
||||
`Generated ${catalogEntries.length} TR-181 parameters from ${documents.length} XML document(s); source=${rootSourceUrl}`
|
||||
)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Device, OnboardingStatus } from '../../../../app/domain/device/device.js'
|
||||
import { DeviceId } from '../../../../app/domain/device/device_id.js'
|
||||
import { DeviceParameter, DeviceProperty } from '../../../../app/domain/device/device_parameter.js'
|
||||
import { ParameterType } from '../../../../app/domain/shared/types.js'
|
||||
|
||||
function makeDevice(): Device {
|
||||
return new Device(new DeviceId('Sagemcom', '10:6f:3f', 'Fast5360', 'SN123'))
|
||||
}
|
||||
|
||||
describe('Device.gpnStatus', () => {
|
||||
it('defaults to "unknown"', () => {
|
||||
expect(makeDevice().gpnStatus).toBe('unknown')
|
||||
})
|
||||
|
||||
it('can be set to "supported"', () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('supported')
|
||||
expect(device.gpnStatus).toBe('supported')
|
||||
})
|
||||
|
||||
it('can be set to "unsupported"', () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unsupported')
|
||||
expect(device.gpnStatus).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('can be changed back to "unknown"', () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('supported')
|
||||
device.setGpnStatus('unknown')
|
||||
expect(device.gpnStatus).toBe('unknown')
|
||||
})
|
||||
|
||||
it('is included in the snapshot', () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unsupported')
|
||||
expect(device.toSnapshot().gpnStatus).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('snapshot reflects the latest status', () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('supported')
|
||||
device.setGpnStatus('unsupported')
|
||||
expect(device.toSnapshot().gpnStatus).toBe('unsupported')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Device.toSnapshot', () => {
|
||||
it('includes gpnStatus alongside other fields', () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('supported')
|
||||
const snapshot = device.toSnapshot()
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
gpnStatus: 'supported',
|
||||
onboardingStatus: OnboardingStatus.PENDING,
|
||||
isOnline: false,
|
||||
parameters: [],
|
||||
properties: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('snapshot parameters/properties are consistent with gpnStatus semantics', () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('supported')
|
||||
device.setParameter(new DeviceParameter('Device.ManagementServer.URL', 'http://acs', ParameterType.STRING))
|
||||
device.setProperty(new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom', ParameterType.STRING))
|
||||
|
||||
const snapshot = device.toSnapshot()
|
||||
|
||||
expect(snapshot.gpnStatus).toBe('supported')
|
||||
expect(snapshot.parameters.map((p) => p.name)).toContain('Device.ManagementServer.URL')
|
||||
expect(snapshot.properties.map((p) => p.name)).toContain('Device.DeviceInfo.Manufacturer')
|
||||
})
|
||||
})
|
||||
@@ -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