import { ZWaveError , ZWaveErrorCodes } from "@zwave-js/core" ; import { enumFilesRecursive , formatId , JSONObject , ObjectKeyMap , pick , ReadonlyObjectKeyMap , stringify , } from "@zwave-js/shared" ; import { entries } from "alcalzone-shared/objects" ; import { isArray , isObject } from "alcalzone-shared/typeguards" ; import * as fs from "fs-extra" ; import { pathExists , readFile , writeFile } from "fs-extra" ; import JSON5 from "json5" ; import path from "path" ; import { CompatConfig } from "./CompatConfig" ; import { clearTemplateCache , readJsonWithTemplate } from "./JsonTemplate" ; import type { ConfigLogger } from "./Logger" ; import { evaluate } from "./Logic" ; import { configDir , externalConfigDir , hexKeyRegex4Digits , throwInvalidConfig , } from "./utils" ; export interface FirmwareVersionRange { min : string ; max : string ; } export interface DeviceID { manufacturerId : number ; productType : number ; productId : number ; firmwareVersion ? : string ; } export interface DeviceConfigIndexEntry { manufacturerId : string ; productType : string ; productId : string ; firmwareVersion : FirmwareVersionRange ; rootDir ? : string ; filename : string ; } export interface FulltextDeviceConfigIndexEntry { manufacturerId : string ; manufacturer : string ; label : string ; description : string ; productType : string ; productId : string ; firmwareVersion : FirmwareVersionRange ; rootDir ? : string ; filename : string ; } export type ConditionalParamInfoMap = ReadonlyObjectKeyMap < { parameter : number ; valueBitMask ? : number ; } , ConditionalParamInformation [ ] > ; export type ParamInfoMap = ReadonlyObjectKeyMap < { parameter : number ; valueBitMask ? : number ; } , ParamInformation > ; export const embeddedDevicesDir = path . join ( configDir , "devices" ) ; const fulltextIndexPath = path . join ( embeddedDevicesDir , "fulltext_index.json" ) ; export function getDevicesPaths ( configDir ) { const devicesDir = path . join ( configDir , "devices" ) ; const indexPath = path . join ( devicesDir , "index.json" ) ; return { devicesDir , indexPath } ; } export type DeviceConfigIndex = DeviceConfigIndexEntry [ ] ; export type FulltextDeviceConfigIndex = FulltextDeviceConfigIndexEntry [ ] ; async function hasChangedDeviceFiles ( devicesRoot , dir , lastChange ) { const filesAndDirs = await fs . readdir ( dir ) ; for ( const f of filesAndDirs ) { const fullPath = path . join ( dir , f ) ; const stat = await fs . stat ( fullPath ) ; if ( ( dir !== devicesRoot || f !== "index.json" ) && ( stat . isFile ( ) || stat . isDirectory ( ) ) && stat . mtime > lastChange ) { return true ; } else if ( stat . isDirectory ( ) ) { if ( await hasChangedDeviceFiles ( devicesRoot , fullPath , lastChange ) ) return true ; } } return false ; } /**
 * Read all device config files from a given directory and return them as index entries.
 * Does not update the index itself.
 */ async function generateIndex < T extends Record < string , unknown > > ( devicesDir , isEmbedded , extractIndexEntries , logger ? ) { const index = [ ] ; clearTemplateCache ( ) ; const configFiles = await enumFilesRecursive ( devicesDir , ( file ) => file . endsWith ( ".json" ) && ! file . endsWith ( "index.json" ) && ! file . includes ( "/templates/" ) && ! file . includes ( "\\templates\\" ) ) ; for ( const file of configFiles ) { const relativePath = path . relative ( devicesDir , file ) . replace ( /\\/g , "/" ) ; try { const config = await DeviceConfig . from ( file , isEmbedded , { rootDir : devicesDir , relative : true } ) ; index . push ( ... extractIndexEntries ( config ) . map ( ( entry ) => { const ret = { ... entry , filename : relativePath } ; if ( devicesDir !== embeddedDevicesDir ) { ret . rootDir = devicesDir ; } return ret ; } ) ) ; } catch ( e ) { const message = `Error parsing config file ${ relativePath }: ${ ( e as Error ) . message }` ; if ( process . env . NODE_ENV === "test" || ! ! process . env . CI ) { throw new ZWaveError ( message , ZWaveErrorCodes . Config_Invalid ) ; } else { logger ?. print ( message , "error" ) ; } } } return index ; } async function loadDeviceIndexShared < T extends Record < string , unknown > > ( devicesDir , indexPath , extractIndexEntries , logger ? ) { let needsUpdate = ! ( await pathExists ( indexPath ) ) ; let index ; let mtimeIndex ; if ( ! needsUpdate ) { try { const fileContents = await readFile ( indexPath , "utf8" ) ; index = JSON5 . parse ( fileContents ) ; mtimeIndex = ( await fs . stat ( indexPath ) ) . mtime ; } catch { logger ?. print ( "Error while parsing index file - regenerating..." , "warn" ) ; needsUpdate = true ; } finally { if ( ! index ) { logger ?. print ( "Index file was malformed - regenerating..." , "warn" ) ; needsUpdate = true ; } } } if ( ! needsUpdate ) { needsUpdate = await hasChangedDeviceFiles ( devicesDir , devicesDir , mtimeIndex ! ) ; if ( needsUpdate ) { logger ?. print ( "Device configuration files on disk changed - regenerating index..." , "verbose" ) ; } } if ( needsUpdate ) { index = await generateIndex ( devicesDir , true , extractIndexEntries , logger ) ; try { await writeFile ( path . join ( indexPath ) , `// This file is auto-generated. DO NOT edit it by hand if you don't know what you're doing!"
${ stringify ( index , "\t" ) }
` , "utf8" ) ; logger ?. print ( "Device index regenerated" , "verbose" ) ; } catch ( e ) { logger ?. print ( `Writing the device index to disk failed: ${ ( e as Error ) . message }` , "error" ) ; } } return index ! ; } internal export async function generatePriorityDeviceIndex ( deviceConfigPriorityDir , logger ? ) { return ( await generateIndex ( deviceConfigPriorityDir , false , ( config ) => config . devices . map ( ( dev ) => ( { manufacturerId : formatId ( config . manufacturerId . toString ( 16 ) ) , manufacturer : config . manufacturer , label : config . label , productType : formatId ( dev . productType ) , productId : formatId ( dev . productId ) , firmwareVersion : config . firmwareVersion , rootDir : deviceConfigPriorityDir } ) ) , logger ) ) . map ( ( { filename , ... entry } ) => ( { ... entry , filename : path . join ( deviceConfigPriorityDir , filename ) } ) ) ; } internal export async function loadDeviceIndexInternal ( logger ? , externalConfig ? ) { const { devicesDir , indexPath } = getDevicesPaths ( ( externalConfig && externalConfigDir ( ) ) || configDir ) ; return loadDeviceIndexShared ( devicesDir , indexPath , ( config ) => config . devices . map ( ( dev ) => ( { manufacturerId : formatId ( config . manufacturerId . toString ( 16 ) ) , manufacturer : config . manufacturer , label : config . label , productType : formatId ( dev . productType ) , productId : formatId ( dev . productId ) , firmwareVersion : config . firmwareVersion } ) ) , logger ) ; } internal export async function loadFulltextDeviceIndexInternal ( logger ? ) { return loadDeviceIndexShared ( embeddedDevicesDir , fulltextIndexPath , ( config ) => config . devices . map ( ( dev ) => ( { manufacturerId : formatId ( config . manufacturerId . toString ( 16 ) ) , manufacturer : config . manufacturer , label : config . label , description : config . description , productType : formatId ( dev . productType ) , productId : formatId ( dev . productId ) , firmwareVersion : config . firmwareVersion , rootDir : embeddedDevicesDir } ) ) , logger ) ; } function isHexKeyWith4Digits ( val ) { return typeof val === "string" && hexKeyRegex4Digits . test ( val ) ; } const firmwareVersionRegex = /^\d{1,3}\.\d{1,3}$/ ; function isFirmwareVersion ( val ) { return ( typeof val === "string" && firmwareVersionRegex . test ( val ) && val . split ( "." ) . map ( ( str ) => parseInt ( str , 10 ) ) . every ( ( num ) => num >= 0 && num <= 255 ) ) ; } function conditionApplies ( condition , context ) { try { return ! ! evaluate ( condition , context ) ; } catch ( e ) { throw new ZWaveError ( `Invalid condition "${ condition }"!` , ZWaveErrorCodes . Config_Invalid ) ; } } /** This class represents a device config entry whose conditional settings have not been evaluated yet */ export class ConditionalDeviceConfig { public static async from ( filename , isEmbedded , options ) { const { relative , rootDir } = options ; const relativePath = relative ? path . relative ( rootDir , filename ) . replace ( /\\/g , "/" ) : filename ; const json = await readJsonWithTemplate ( filename , options . rootDir ) ; return new ConditionalDeviceConfig ( relativePath , isEmbedded , json ) ; } public constructor ( filename , isEmbedded , definition ) { this . filename = filename ; this . isEmbedded = isEmbedded ; if ( ! isHexKeyWith4Digits ( definition . manufacturerId ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
manufacturer id must be a lowercase hexadecimal number with 4 digits` ) ; } this . manufacturerId = parseInt ( definition . manufacturerId , 16 ) ; for ( const prop of [ "manufacturer" , "label" , "description" ] as const ) { if ( typeof definition [ prop ] !== "string" ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
${ prop } is not a string` ) ; } this [ prop ] = definition [ prop ] ; } if ( ! isArray ( definition . devices ) || ! ( definition . devices as any [ ] ) . every ( ( dev ) => isObject ( dev ) && isHexKeyWith4Digits ( dev . productType ) && isHexKeyWith4Digits ( dev . productId ) ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
devices is malformed (not an object or type/id that is not a lowercase 4-digit hex key)` ) ; } this . devices = ( definition . devices as any [ ] ) . map ( ( { productType , productId } ) => ( { productType : parseInt ( productType , 16 ) , productId : parseInt ( productId , 16 ) } ) ) ; if ( ! isObject ( definition . firmwareVersion ) || ! isFirmwareVersion ( definition . firmwareVersion . min ) || ! isFirmwareVersion ( definition . firmwareVersion . max ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
firmwareVersion is malformed or invalid` ) ; } else { const { min , max } = definition . firmwareVersion ; this . firmwareVersion = { min , max } ; } if ( definition . endpoints != undefined ) { const endpoints = new Map < number , ConditionalEndpointConfig > ( ) ; if ( ! isObject ( definition . endpoints ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
endpoints is not an object` ) ; } for ( const [ key , ep ] of entries ( definition . endpoints ) ) { if ( ! /^\d+$/ . test ( key ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
found non-numeric endpoint index "${ key }" in endpoints` ) ; } const epIndex = parseInt ( key , 10 ) ; endpoints . set ( epIndex , new ConditionalEndpointConfig ( filename , epIndex , ep ) ) ; } this . endpoints = endpoints ; } if ( definition . associations != undefined ) { const associations = new Map < number , ConditionalAssociationConfig > ( ) ; if ( ! isObject ( definition . associations ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
associations is not an object` ) ; } for ( const [ key , assocDefinition ] of entries ( definition . associations ) ) { if ( ! /^[1-9][0-9]*$/ . test ( key ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
found non-numeric group id "${ key }" in associations` ) ; } const keyNum = parseInt ( key , 10 ) ; associations . set ( keyNum , new ConditionalAssociationConfig ( filename , keyNum , assocDefinition ) ) ; } this . associations = associations ; } if ( definition . paramInformation != undefined ) { const paramInformation = new ObjectKeyMap < { parameter : number ; valueBitMask ? : number ; } , ConditionalParamInformation [ ] > ( ) ; if ( isArray ( definition . paramInformation ) ) { if ( ! definition . paramInformation . every ( ( entry ) => "#" in entry ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }: 
required property "#" missing in at least one entry of paramInformation` ) ; } for ( const paramDefinition of definition . paramInformation ) { const { [ "#" ] : paramNo , ... defn } = paramDefinition ; const match = /^(\d+)(?:\[0x([0-9a-fA-F]+)\])?$/ . exec ( paramNo ) ; if ( ! match ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }: 
found invalid param number "${ paramNo }" in paramInformation` ) ; } const keyNum = parseInt ( match [ 1 ] , 10 ) ; const bitMask = match [ 2 ] != undefined ? parseInt ( match [ 2 ] , 16 ) : undefined ; const key = { parameter : keyNum , valueBitMask : bitMask } ; if ( ! paramInformation . has ( key ) ) paramInformation . set ( key , [ ] ) ; paramInformation . get ( key ) ! . push ( new ConditionalParamInformation ( this , keyNum , bitMask , defn ) ) ; } } else if ( ( process . env . NODE_ENV !== "test" || ! ! process . env . CI ) && isObject ( definition . paramInformation ) ) { for ( const [ key , paramDefinition ] of entries ( definition . paramInformation ) ) { const match = /^(\d+)(?:\[0x([0-9a-fA-F]+)\])?$/ . exec ( key ) ; if ( ! match ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
found invalid param number "${ key }" in paramInformation` ) ; } if ( ! isObject ( paramDefinition ) && ! ( isArray ( paramDefinition ) && ( paramDefinition as any [ ] ) . every ( ( p ) => isObject ( p ) ) ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
paramInformation "${ key }" is invalid: Every entry must either be an object or an array of objects!` ) ; } const defns = isArray ( paramDefinition ) ? paramDefinition : [ paramDefinition ] ; const keyNum = parseInt ( match [ 1 ] , 10 ) ; const bitMask = match [ 2 ] != undefined ? parseInt ( match [ 2 ] , 16 ) : undefined ; paramInformation . set ( { parameter : keyNum , valueBitMask : bitMask } , defns . map ( ( def ) => new ConditionalParamInformation ( this , keyNum , bitMask , def ) ) ) ; } } else { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
paramInformation must be an array!` ) ; } this . paramInformation = paramInformation ; } if ( definition . proprietary != undefined ) { if ( ! isObject ( definition . proprietary ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
proprietary is not an object` ) ; } this . proprietary = definition . proprietary ; } if ( definition . compat != undefined ) { if ( ! isObject ( definition . compat ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
compat is not an object` ) ; } this . compat = new CompatConfig ( filename , definition . compat ) ; } if ( definition . metadata != undefined ) { if ( ! isObject ( definition . metadata ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
metadata is not an object` ) ; } this . metadata = new DeviceMetadata ( filename , definition . metadata ) ; } } public readonly filename : string ; public readonly manufacturer ! : string ; public readonly manufacturerId : number ; public readonly label ! : string ; public readonly description ! : string ; public readonly devices : readonly { productType : number ; productId : number ; } [ ] ; public readonly firmwareVersion : FirmwareVersionRange ; public readonly endpoints ? : ReadonlyMap < number , ConditionalEndpointConfig > ; public readonly associations ? : ReadonlyMap < number , ConditionalAssociationConfig > ; public readonly paramInformation ? : ConditionalParamInfoMap ; /**
     * Contains manufacturer-specific support information for the
     * ManufacturerProprietary CC
     */ public readonly proprietary ? : Record < string , unknown > ; /** Contains compatibility options */ public readonly compat ? : CompatConfig ; /** Contains instructions and other metadata for the device */ public readonly metadata ? : DeviceMetadata ; /** Whether this is an embedded configuration or not */ public readonly isEmbedded : boolean ; public evaluate ( deviceId ? ) { let associations ; if ( this . associations ) { associations = new Map ( ) ; for ( const [ group , assoc ] of this . associations ) { const evaluated = assoc . evaluateCondition ( deviceId ) ; if ( evaluated ) associations . set ( group , evaluated ) ; } } let endpoints ; if ( this . endpoints ) { endpoints = new Map ( ) ; for ( const [ group , assoc ] of this . endpoints ) { const evaluated = assoc . evaluateCondition ( deviceId ) ; if ( evaluated ) endpoints . set ( group , evaluated ) ; } } let paramInformation ; if ( this . paramInformation ) { paramInformation = new ObjectKeyMap ( ) ; for ( const [ key , params ] of this . paramInformation . entries ( ) ) { for ( const param of params ) { const evaluated = param . evaluateCondition ( deviceId ) ; if ( evaluated ) paramInformation . set ( key , evaluated ) ; } } } return new DeviceConfig ( this . filename , this . isEmbedded , this . manufacturer , this . manufacturerId , this . label , this . description , this . devices , this . firmwareVersion , endpoints , associations , paramInformation , this . proprietary , this . compat , this . metadata ) ; } } export class DeviceConfig { public static async from ( filename , isEmbedded , options ) { const ret = await ConditionalDeviceConfig . from ( filename , isEmbedded , options ) ; return ret . evaluate ( options . deviceId ) ; } public constructor ( public readonly filename , /** Whether this is an embedded configuration or not */ public readonly isEmbedded , public readonly manufacturer , public readonly manufacturerId , public readonly label , public readonly description , public readonly devices , public readonly firmwareVersion , public readonly endpoints ? , public readonly associations ? , public readonly paramInformation ? , /**
     * Contains manufacturer-specific support information for the
     * ManufacturerProprietary CC
     */ public readonly proprietary ? , /** Contains compatibility options */ public readonly compat ? , /** Contains instructions and other metadata for the device */ public readonly metadata ? ) { } /** Returns the association config for a given endpoint */ public getAssociationConfigForEndpoint ( endpointIndex , group ) { if ( endpointIndex === 0 ) { return ( this . associations ?. get ( group ) ?? this . endpoints ?. get ( 0 ) ?. associations ?. get ( group ) ) ; } else { return this . endpoints ?. get ( endpointIndex ) ?. associations ?. get ( group ) ; } } } export class ConditionalEndpointConfig { public constructor ( filename , index , definition ) { this . index = index ; if ( definition . $if != undefined && typeof definition . $if !== "string" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ filename }:
Endpoint ${ index } has a non-string $if condition` ) ; } this . condition = definition . $if ; if ( definition . associations != undefined ) { const associations = new Map < number , ConditionalAssociationConfig > ( ) ; if ( ! isObject ( definition . associations ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
Endpoint ${ index }: associations is not an object` ) ; } for ( const [ key , assocDefinition ] of entries ( definition . associations ) ) { if ( ! /^[1-9][0-9]*$/ . test ( key ) ) { throwInvalidConfig ( `device` , `packages/config/config/devices/${ filename }:
Endpoint ${ index }: found non-numeric group id "${ key }" in associations` ) ; } const keyNum = parseInt ( key , 10 ) ; associations . set ( keyNum , new ConditionalAssociationConfig ( filename , keyNum , assocDefinition ) ) ; } this . associations = associations ; } } public readonly index : number ; public readonly condition ? : string ; public readonly associations ? : ReadonlyMap < number , ConditionalAssociationConfig > ; public evaluateCondition ( deviceId ? ) { if ( deviceId && this . condition && ! conditionApplies ( this . condition , deviceId ) ) { return ; } let associations ; if ( this . associations ) { associations = new Map ( ) ; for ( const [ group , assoc ] of this . associations ) { const evaluated = assoc . evaluateCondition ( deviceId ) ; if ( evaluated ) associations . set ( group , evaluated ) ; } } return { ... pick ( this , [ "index" ] ) , associations } ; } } export type EndpointConfig = Omit < ConditionalEndpointConfig , "condition" | "evaluateCondition" | "associations" > & { associations : Map < number , AssociationConfig > | undefined ; } ; export class ConditionalAssociationConfig { public constructor ( filename , groupId , definition ) { this . groupId = groupId ; if ( definition . $if != undefined && typeof definition . $if !== "string" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ filename }:
Association ${ groupId } has a non-string $if condition` ) ; } this . condition = definition . $if ; if ( typeof definition . label !== "string" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ filename }:
Association ${ groupId } has a non-string label` ) ; } this . label = definition . label ; if ( definition . description != undefined && typeof definition . description !== "string" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ filename }:
Association ${ groupId } has a non-string description` ) ; } this . description = definition . description ; if ( typeof definition . maxNodes !== "number" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ filename }:
maxNodes for association ${ groupId } is not a number` ) ; } this . maxNodes = definition . maxNodes ; if ( definition . isLifeline != undefined && typeof definition . isLifeline !== "boolean" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ filename }:
isLifeline in association ${ groupId } must be a boolean` ) ; } this . isLifeline = ! ! definition . isLifeline ; if ( definition . multiChannel != undefined && typeof definition . multiChannel !== "boolean" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ filename }:
multiChannel in association ${ groupId } must be a boolean` ) ; } this . multiChannel = definition . multiChannel ?? "auto" ; } public readonly condition ? : string ; public readonly groupId : number ; public readonly label : string ; public readonly description ? : string ; public readonly maxNodes : number ; /**
     * Whether this association group is used to report updates to the controller.
     * While Z-Wave+ defines a single lifeline, older devices may have multiple lifeline associations.
     */ public readonly isLifeline : boolean ; /**
     * Controls the strategy of setting up lifeline associations:
     *
     * * `true` - Use a multi channel association (if possible)
     * * `false` - Use a node association (if possible)
     * * `"auto"` - Prefer node associations, fall back to multi channel associations
     */ public readonly multiChannel : boolean | "auto" ; public evaluateCondition ( deviceId ? ) { if ( deviceId && this . condition && ! conditionApplies ( this . condition , deviceId ) ) { return ; } return pick ( this , [ "groupId" , "label" , "description" , "maxNodes" , "isLifeline" , "multiChannel" , ] ) ; } } export type AssociationConfig = Omit < ConditionalAssociationConfig , "condition" | "evaluateCondition" > ; export class ConditionalParamInformation { public constructor ( parent , parameterNumber , valueBitMask , definition ) { this . parameterNumber = parameterNumber ; this . valueBitMask = valueBitMask ; this . condition = definition . $if ; if ( typeof definition . label !== "string" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber } has a non-string label` ) ; } this . label = definition . label ; if ( definition . description != undefined && typeof definition . description !== "string" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber } has a non-string description` ) ; } this . description = definition . description ; if ( typeof definition . valueSize !== "number" || definition . valueSize <= 0 ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber } has an invalid value size` ) ; } this . valueSize = definition . valueSize ; if ( typeof definition . minValue !== "number" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber } has a non-numeric property minValue` ) ; } this . minValue = definition . minValue ; if ( typeof definition . maxValue !== "number" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber } has a non-numeric property maxValue` ) ; } this . maxValue = definition . maxValue ; if ( definition . unsigned != undefined && typeof definition . unsigned !== "boolean" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber } has a non-boolean property unsigned` ) ; } this . unsigned = definition . unsigned === true ; if ( definition . unit != undefined && typeof definition . unit !== "string" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber } has a non-string unit` ) ; } this . unit = definition . unit ; if ( definition . readOnly != undefined && definition . readOnly !== true ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
		Parameter #${ parameterNumber }: readOnly must true or omitted!` ) ; } this . readOnly = definition . readOnly ; if ( definition . writeOnly != undefined && definition . writeOnly !== true ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
		Parameter #${ parameterNumber }: writeOnly must be true or omitted!` ) ; } this . writeOnly = definition . writeOnly ; if ( definition . defaultValue == undefined ) { if ( ! this . readOnly ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber } is missing defaultValue, which is required unless the parameter is readOnly` ) ; } } else if ( typeof definition . defaultValue !== "number" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber } has a non-numeric property defaultValue` ) ; } this . defaultValue = definition . defaultValue ; if ( definition . allowManualEntry != undefined && definition . allowManualEntry !== false ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber }: allowManualEntry must be false or omitted!` ) ; } this . allowManualEntry = definition . allowManualEntry ?? ( this . readOnly ? false : true ) ; if ( isArray ( definition . options ) && ! definition . options . every ( ( opt ) => isObject ( opt ) && typeof opt . label === "string" && typeof opt . value === "number" ) ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ parent . filename }:
Parameter #${ parameterNumber }: options is malformed!` ) ; } this . options = definition . options ?. map ( ( opt ) => new ConditionalConfigOption ( opt . value , opt . label , opt . $if ) ) ?? [ ] ; } public readonly parameterNumber : number ; public readonly valueBitMask ? : number ; public readonly label : string ; public readonly description ? : string ; public readonly valueSize : number ; public readonly minValue : number ; public readonly maxValue : number ; public readonly unsigned ? : boolean ; public readonly defaultValue : number ; public readonly unit ? : string ; public readonly readOnly ? : true ; public readonly writeOnly ? : true ; public readonly allowManualEntry : boolean ; public readonly options : readonly ConditionalConfigOption [ ] ; public readonly condition ? : string ; public evaluateCondition ( deviceId ? ) { if ( deviceId && this . condition && ! conditionApplies ( this . condition , deviceId ) ) { return ; } return { ... pick ( this , [ "parameterNumber" , "valueBitMask" , "label" , "description" , "valueSize" , "minValue" , "maxValue" , "unsigned" , "defaultValue" , "unit" , "readOnly" , "writeOnly" , "allowManualEntry" , ] ) , options : this . options . map ( ( o ) => o . evaluateCondition ( deviceId ) ) . filter ( ( o ) : o is ConfigOption => ! ! o ) } ; } } export type ParamInformation = Omit < ConditionalParamInformation , "condition" | "evaluateCondition" | "options" > & { options : readonly ConfigOption [ ] ; } ; export class ConditionalConfigOption { public constructor ( public readonly value , public readonly label , public readonly condition ? ) { } public evaluateCondition ( deviceId ? ) { if ( deviceId && this . condition && ! conditionApplies ( this . condition , deviceId ) ) { return ; } return pick ( this , [ "value" , "label" ] ) ; } } export interface ConfigOption { value : number ; label : string ; } export class DeviceMetadata { public constructor ( filename , definition ) { for ( const prop of [ "wakeup" , "inclusion" , "exclusion" , "reset" , "manual" , "comments" , ] as const ) { if ( prop in definition ) { const value = definition [ prop ] ; if ( prop === "comments" ) { const isComment = ( opt ) => isObject ( opt ) && typeof opt . level === "string" && typeof opt . text === "string" ; const isValid = ( isArray ( value ) && value . every ( isComment ) ) || isComment ( value ) ; if ( ! isValid ) throwInvalidConfig ( "devices" , `packages/config/config/devices/${ filename }:
The metadata entry comments is invalid!` ) ; } else if ( typeof value !== "string" ) { throwInvalidConfig ( "devices" , `packages/config/config/devices/${ filename }:
The metadata entry ${ prop } must be a string!` ) ; } this [ prop ] = value ; } } } /** How to wake up the device manually */ public readonly wakeup ? : string ; /** Inclusion instructions */ public readonly inclusion ? : string ; /** Exclusion instructions */ public readonly exclusion ? : string ; /** Instructions for resetting the device to factory defaults */ public readonly reset ? : string ; /** A link to the device manual */ public readonly manual ? : string ; /** Comments for this device */ public readonly comments ? : DeviceComment | DeviceComment [ ] ; } export interface DeviceComment { level : "info" | "warning" | "error" ; text : string ; }