Files
adam-acs/docs/design-parameter-writability.md
T
Kevin L'HereecandCopilot b8ff4b68a7 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>
2026-06-01 11:25:15 +02:00

12 KiB

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

// 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). Un script scripts/generate-tr181-catalog.ts peut régénérer ce fichier.

[
  { "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

// 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

// 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

// 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)

// 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 :

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