# 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 { 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 |