- 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>
175 lines
4.6 KiB
JavaScript
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()
|