Files
adam-acs/scripts/generate-tr181-catalog.mjs
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

175 lines
4.6 KiB
JavaScript

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