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>
This commit is contained in:
1 parent
d1c65dbf1e
commit
b8ff4b68a7
12 files changed
+39449
-1
No files matched your search
@@ -0,0 +1,174 @@
|
||||
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()
|
||||
Reference in new issue
Block a user