- 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>
263 lines
7.6 KiB
TypeScript
263 lines
7.6 KiB
TypeScript
import { DeviceId } from './device_id.js'
|
|
import { DeviceParameter, DeviceProperty, ParameterSnapshot, PropertySnapshot } from './device_parameter.js'
|
|
import { ParameterType } from '../shared/types.js'
|
|
import { createHash } from 'node:crypto'
|
|
|
|
export type GpnStatus = 'supported' | 'unsupported' | 'unknown'
|
|
|
|
export enum OnboardingStatus {
|
|
PENDING = 'PENDING',
|
|
DISCOVERING = 'DISCOVERING',
|
|
PROVISIONING = 'PROVISIONING',
|
|
COMPLETED = 'COMPLETED',
|
|
FAILED = 'FAILED',
|
|
}
|
|
|
|
export class Device {
|
|
public tmpParamValues: any[] = []
|
|
public tmpPC: any = null
|
|
private _parameters: Map<string, DeviceParameter> = new Map()
|
|
private _properties: Map<string, DeviceProperty> = new Map()
|
|
private _session: any = null
|
|
private _lastSeen: Date = new Date()
|
|
private _onboardingStatus: OnboardingStatus = OnboardingStatus.PENDING
|
|
private _gpnStatus: GpnStatus = 'unknown'
|
|
|
|
constructor(
|
|
public readonly deviceId: DeviceId,
|
|
public readonly createdAt: Date = new Date()
|
|
) {}
|
|
|
|
public get session(): any {
|
|
return this._session
|
|
}
|
|
|
|
public get lastSeen(): Date {
|
|
return this._lastSeen
|
|
}
|
|
|
|
public get onboardingStatus(): OnboardingStatus {
|
|
return this._onboardingStatus
|
|
}
|
|
|
|
public get gpnStatus(): GpnStatus {
|
|
return this._gpnStatus
|
|
}
|
|
|
|
public setGpnStatus(status: GpnStatus): void {
|
|
this._gpnStatus = status
|
|
}
|
|
|
|
public get identifier(): string {
|
|
const raw = `${this.deviceId.manufacturer}-${this.deviceId.oui}-${this.deviceId.productClass}-${this.deviceId.serialNumber}`
|
|
return createHash('sha256').update(raw).digest('hex').substring(0, 24)
|
|
}
|
|
|
|
public attachSession(session: any): void {
|
|
this._session = session
|
|
this._lastSeen = new Date()
|
|
}
|
|
|
|
public detachSession(): void {
|
|
if (this._session) {
|
|
this._session = null
|
|
}
|
|
}
|
|
|
|
public isOnline(): boolean {
|
|
return this._session !== null
|
|
}
|
|
|
|
public updateOnboardingStatus(status: OnboardingStatus): void {
|
|
this._onboardingStatus = status
|
|
this._lastSeen = new Date()
|
|
}
|
|
|
|
public setParameter(parameter: DeviceParameter): void {
|
|
this._parameters.set(parameter.name, parameter)
|
|
}
|
|
|
|
public getParameter(name: string): DeviceParameter | null {
|
|
return this._parameters.get(name) || null
|
|
}
|
|
|
|
public getParameters(): DeviceParameter[] {
|
|
return Array.from(this._parameters.values())
|
|
}
|
|
|
|
public updateParameter(name: string, value: any): void {
|
|
const parameter = this._parameters.get(name)
|
|
if (parameter) {
|
|
parameter.updateValue(value)
|
|
}
|
|
}
|
|
|
|
public setProperty(property: DeviceProperty): void {
|
|
this._properties.set(property.name, property)
|
|
}
|
|
|
|
public getProperty(name: string): DeviceProperty | null {
|
|
return this._properties.get(name) || null
|
|
}
|
|
|
|
public getProperties(): DeviceProperty[] {
|
|
return Array.from(this._properties.values())
|
|
}
|
|
|
|
public updateProperty(name: string, value: any): void {
|
|
const property = this._properties.get(name)
|
|
if (property) {
|
|
property.updateValue(value)
|
|
}
|
|
}
|
|
|
|
public getConnectionRequestURL(): URL | null {
|
|
for (const property of this._properties.values()) {
|
|
const url = property.getConnectionRequestURL()
|
|
if (url) return url
|
|
}
|
|
return null
|
|
}
|
|
|
|
public canExecuteCommands(): boolean {
|
|
return this.isOnline() && this._onboardingStatus === OnboardingStatus.COMPLETED
|
|
}
|
|
|
|
public setTmpParamValues(values: any[]): void {
|
|
this.tmpParamValues = values
|
|
}
|
|
|
|
public getTmpParamValues(): any[] {
|
|
return this.tmpParamValues
|
|
}
|
|
|
|
public setTmpPC(value: any): void {
|
|
this.tmpPC = value
|
|
}
|
|
|
|
public getTmpPC(): any {
|
|
return this.tmpPC
|
|
}
|
|
|
|
public clearTmpValues(): void {
|
|
this.tmpParamValues = []
|
|
this.tmpPC = null
|
|
}
|
|
|
|
public organizeParameters(response: any, timestamp: Date): void {
|
|
const infoList = response?.ParameterList?.ParameterInfoStruct || []
|
|
for (const info of infoList) {
|
|
const name = info?.Name
|
|
if (!name) continue
|
|
// Skip object paths (ending with dot) - only store leaf parameters
|
|
if (name.endsWith('.')) continue
|
|
const isWritable = !!info?.Writable
|
|
|
|
if (this._parameters.has(name) || this._properties.has(name)) {
|
|
if (isWritable && this._properties.has(name)) {
|
|
const prop = this._properties.get(name)!
|
|
this._properties.delete(name)
|
|
this.setParameter(new DeviceParameter(name, prop.value, prop.type))
|
|
} else if (!isWritable && this._parameters.has(name)) {
|
|
const param = this._parameters.get(name)!
|
|
this._parameters.delete(name)
|
|
this.setProperty(new DeviceProperty(name, param.value, param.type))
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (isWritable) {
|
|
this.setParameter(new DeviceParameter(name, null, ParameterType.UNKNOWN))
|
|
} else {
|
|
this.setProperty(new DeviceProperty(name, null, ParameterType.UNKNOWN))
|
|
}
|
|
}
|
|
this._lastSeen = timestamp
|
|
}
|
|
|
|
public updateFromValues(paramValues: any[]): void {
|
|
for (const pv of paramValues || []) {
|
|
const name = pv?.Name
|
|
const valueObj = pv?.Value
|
|
const hasTextNode = valueObj && Object.prototype.hasOwnProperty.call(valueObj as any, '_')
|
|
const hasTypeAttr = valueObj && (valueObj['$xsi:type'] || valueObj?.$?.Type)
|
|
const val = hasTextNode ? valueObj._ : (hasTypeAttr ? '' : undefined)
|
|
const typeStr = valueObj?.['$xsi:type'] || valueObj?.$?.Type
|
|
const mappedType = this.mapType(typeStr)
|
|
|
|
if (!name) continue
|
|
if (val === undefined && !hasTypeAttr) continue
|
|
|
|
if (this._parameters.has(name)) {
|
|
const existing = this._parameters.get(name)!
|
|
if (mappedType && existing.type === ParameterType.UNKNOWN) {
|
|
const snap = existing.toSnapshot()
|
|
this._parameters.set(
|
|
name,
|
|
new DeviceParameter(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
|
|
)
|
|
}
|
|
this.updateParameter(name, val)
|
|
} else if (this._properties.has(name)) {
|
|
const existing = this._properties.get(name)!
|
|
if (mappedType && existing.type === ParameterType.UNKNOWN) {
|
|
const snap = existing.toSnapshot()
|
|
this._properties.set(
|
|
name,
|
|
new DeviceProperty(name, snap.value, mappedType, snap.createdAt, snap.updatedAt, snap.notification, snap.accessList)
|
|
)
|
|
}
|
|
this.updateProperty(name, val)
|
|
} else {
|
|
this.setProperty(new DeviceProperty(name, val ?? null, mappedType || ParameterType.UNKNOWN))
|
|
}
|
|
}
|
|
this._lastSeen = new Date()
|
|
}
|
|
|
|
private mapType(typeStr: string | undefined): ParameterType | undefined {
|
|
switch (typeStr) {
|
|
case 'xsd:string':
|
|
return ParameterType.STRING
|
|
case 'xsd:int':
|
|
return ParameterType.INT
|
|
case 'xsd:unsignedInt':
|
|
return ParameterType.UNSIGNED_INT
|
|
case 'xsd:boolean':
|
|
return ParameterType.BOOLEAN
|
|
default:
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
public toSnapshot(): DeviceSnapshot {
|
|
return {
|
|
deviceId: this.deviceId,
|
|
identifier: this.identifier,
|
|
createdAt: this.createdAt,
|
|
lastSeen: this._lastSeen,
|
|
onboardingStatus: this._onboardingStatus,
|
|
isOnline: this.isOnline(),
|
|
gpnStatus: this._gpnStatus,
|
|
parameters: this.getParameters().map((p) => p.toSnapshot()),
|
|
properties: this.getProperties().map((p) => p.toSnapshot()),
|
|
}
|
|
}
|
|
|
|
public updateLastSeen(): void {
|
|
this._lastSeen = new Date()
|
|
}
|
|
}
|
|
|
|
export interface DeviceSnapshot {
|
|
deviceId: DeviceId
|
|
identifier: string
|
|
createdAt: Date
|
|
lastSeen: Date
|
|
onboardingStatus: OnboardingStatus
|
|
isOnline: boolean
|
|
gpnStatus: GpnStatus
|
|
parameters: ParameterSnapshot[]
|
|
properties: PropertySnapshot[]
|
|
}
|