- 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>
290 lines
12 KiB
Markdown
290 lines
12 KiB
Markdown
# 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 |
|