- 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>
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
SetParameterValuessur un paramètre en lecture seule
Contraintes architecturales (état actuel du codebase)
-
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). -
La writabilité est déjà persistée :
device.parameters[]= writable (issu deorganizeParameters()après GPN réussi),device.properties[]= RO si GPN a réussi, mais par défaut tout param inconnu va aussi dansproperties[]viaupdateFromValues()(ligne 201 dedevice.ts). -
Le fault code GPN n'est pas tracé : quand GPN retourne
Fault 9000,success = falsedans 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(); siFault 9000/9001→gpnStatus = 'unsupported'; si autre erreur →gpnStatusinchangé (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 supportedFault 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
gpnStatusdansDevice+DeviceRecord— champ additionnel minimalDeviceActionExecutor.getParameterNames— capturerFault 9000/9001et mettre à jourgpnStatusCwmpController.loadDevice— restituergpnStatusdepuis la DB- Fichier
config/tr181-catalog.json— constituer depuis les sources BBF - Service
resolveWritability— lecture seule, O(1) - API enrichie —
writable+writabilitySourcedans la liste de paramètres - 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 |