/** * Exports KNX datapoint type factories and utilities. * * Written in Italy with love, sun and passion, by Massimo Saccani. * * Released under the MIT License. * Use at your own risk; the author assumes no liability for damages. */ /** * KNXEngine - a KNX protocol stack in Javascript * (C) 2020-2022 Supergiovane */ import * as util from 'util' import { module } from '../KnxLog' import { hasProp } from '../utils' import DPT1 from './dpt1' import DPT2 from './dpt2' import DPT3 from './dpt3' import DPT4 from './dpt4' import DPT5 from './dpt5' import DPT6 from './dpt6' import DPT7 from './dpt7' import DPT8 from './dpt8' import DPT9 from './dpt9' import DPT10 from './dpt10' import DPT11 from './dpt11' import DPT12 from './dpt12' import DPT13 from './dpt13' import DPT14 from './dpt14' import DPT15 from './dpt15' import DPT16 from './dpt16' import DPT17 from './dpt17' import DPT18 from './dpt18' import DPT19 from './dpt19' import DPT20 from './dpt20' import DPT21 from './dpt21' import DPT22 from './dpt22' import DPT23 from './dpt23' import DPT24 from './dpt24' import DPT25 from './dpt25' import DPT26 from './dpt26' import DPT27 from './dpt27' import DPT28 from './dpt28' import DPT29 from './dpt29' import DPT213 from './dpt213' import DPT222 from './dpt222' import DPT232 from './dpt232' import DPT235 from './dpt235' import DPT237 from './dpt237' import DPT238 from './dpt238' import DPT242 from './dpt242' import DPT249 from './dpt249' import DPT251 from './dpt251' import DPT275 from './dpt275' import DPT999 from './dpt999' import DPT60001 from './dpt60001' import DPT60002 from './dpt60002' type Range = [number, number] | [undefined] const logger = module('DPTLib') export interface APDUTelegramContext { groupAddress?: string sourceAddress?: string } export interface FormatAPDUContext extends APDUTelegramContext { dptid?: number | string logSuffix?: string } export interface FromBufferContext extends APDUTelegramContext { dptid?: number | string logSuffix?: string } interface DatapointSubtype { scalar_range?: Range name: string use?: string desc?: string force_encoding?: string unit?: string enc?: Record range?: Range } export interface DatapointConfig { id: string subtypeid?: string basetype: { bitlength: number signedness?: string range?: Range valuetype: string desc?: string help?: string helplink?: string } subtype?: DatapointSubtype subtypes?: Record formatAPDU?: ( value: any, context?: FormatAPDUContext, ) => Buffer | void | null fromBuffer?: (buf: Buffer, context?: FromBufferContext) => any } export const dpts: Record = { [DPT1.id]: DPT1, [DPT2.id]: DPT2, [DPT3.id]: DPT3, [DPT4.id]: DPT4, [DPT5.id]: DPT5, [DPT6.id]: DPT6, [DPT7.id]: DPT7, [DPT8.id]: DPT8, [DPT9.id]: DPT9, [DPT10.id]: DPT10, [DPT11.id]: DPT11, [DPT12.id]: DPT12, [DPT13.id]: DPT13, [DPT14.id]: DPT14, [DPT15.id]: DPT15, [DPT16.id]: DPT16, [DPT17.id]: DPT17, [DPT18.id]: DPT18, [DPT19.id]: DPT19, [DPT20.id]: DPT20, [DPT21.id]: DPT21, [DPT22.id]: DPT22, [DPT23.id]: DPT23, [DPT24.id]: DPT24, [DPT25.id]: DPT25, [DPT26.id]: DPT26, [DPT27.id]: DPT27, [DPT28.id]: DPT28, [DPT29.id]: DPT29, [DPT213.id]: DPT213, [DPT222.id]: DPT222, [DPT232.id]: DPT232, [DPT235.id]: DPT235, [DPT237.id]: DPT237, [DPT238.id]: DPT238, [DPT242.id]: DPT242, [DPT249.id]: DPT249, [DPT251.id]: DPT251, [DPT275.id]: DPT275, [DPT999.id]: DPT999, [DPT60001.id]: DPT60001, [DPT60002.id]: DPT60002, } // a generic DPT resolution function // DPTs might come in as 9/"9"/"9.001"/"DPT9.001" export function resolve(dptid: string | number): DatapointConfig { const m = dptid .toString() .toUpperCase() .match(/^(?:DPT)?(\d+)(\.(\d+))?$/) if (m === null) throw Error(`Invalid DPT format: ${dptid}`) const dptkey = util.format('DPT%s', m[1]) const dpt = dpts[dptkey] if (!dpt) throw Error(`Unsupported DPT: ${dptid}`) const cloned_dpt = cloneDpt(dpt) const subtypeId = m[3] const subtype = subtypeId ? cloned_dpt.subtypes?.[subtypeId] : undefined if (subtype) { cloned_dpt.subtypeid = subtypeId cloned_dpt.subtype = subtype } return cloned_dpt } /* POPULATE an APDU object from a given Javascript value for the given DPT * - either by a custom DPT formatAPDU function * - or by this generic version, which: * -- 1) checks if the value adheres to the range set from the DPT's bitlength * */ export type APDU = { bitlength: number data: Buffer } function buildAPDULogSuffix( dptid?: number | string, context?: APDUTelegramContext, ): string { const chunks: string[] = [] if (context?.groupAddress) chunks.push(`ga=${context.groupAddress}`) if (context?.sourceAddress) chunks.push(`src=${context.sourceAddress}`) if (dptid !== undefined) chunks.push(`dpt=${dptid}`) return chunks.length > 0 ? ` [${chunks.join(' ')}]` : '' } function logValue(value: any): string { try { const serialized = util.inspect(value, { depth: 4, maxArrayLength: 20, maxStringLength: 300, breakLength: 120, }) if (serialized.length > 600) { return `${serialized.slice(0, 597)}...` } return serialized } catch (error) { const message = error instanceof Error ? error.message : String(error) return `[unserializable: ${message}]` } } function getDptLabel(dpt: DatapointConfig, dptid?: number | string): string { if (dptid !== undefined) return dptid.toString() return `${dpt.id}${dpt.subtypeid ? `.${dpt.subtypeid}` : ''}` } function getDptLabelSafe( dpt: DatapointConfig | null | undefined, dptid?: number | string, ): string { if (dptid !== undefined) return dptid.toString() if (!dpt) return 'unknown DPT' return getDptLabel(dpt, dptid) } function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message return String(error) } const DPT_ERROR_ALREADY_LOGGED = Symbol('dpt-error-already-logged') const DPT_ERROR_STATS_LOG_EVERY = 25 export interface DptErrorStat { encodeErrors: number decodeErrors: number totalErrors: number lastError: string lastUpdate: string } const dptErrorStats = new Map() function isDebugEnabled(): boolean { if (typeof logger.isLevelEnabled === 'function') { return logger.isLevelEnabled('debug') } return logger.level === 'debug' } function logIfDebug( level: 'debug' | 'warn' | 'error', message: string, ...args: unknown[] ): void { if (!isDebugEnabled()) return if (level === 'debug') logger.debug(message, ...args) if (level === 'warn') logger.warn(message, ...args) if (level === 'error') logger.error(message, ...args) } function recordDptError( dptLabel: string, phase: 'encode' | 'decode', errorMessage: string, logSuffix = '', ): void { const now = new Date().toISOString() const stats = dptErrorStats.get(dptLabel) || { encodeErrors: 0, decodeErrors: 0, totalErrors: 0, lastError: '', lastUpdate: now, } if (phase === 'encode') stats.encodeErrors += 1 if (phase === 'decode') stats.decodeErrors += 1 stats.totalErrors += 1 stats.lastError = errorMessage stats.lastUpdate = now dptErrorStats.set(dptLabel, stats) if ( isDebugEnabled() && (stats.totalErrors <= 3 || stats.totalErrors % DPT_ERROR_STATS_LOG_EVERY === 0) ) { logger.debug( 'dpt error stats: dpt=%s total=%d encode=%d decode=%d lastError=%s%s', dptLabel, stats.totalErrors, stats.encodeErrors, stats.decodeErrors, errorMessage, logSuffix, ) } } export function getDptErrorStats(): Record { const out: Record = {} for (const [dpt, stat] of dptErrorStats.entries()) { out[dpt] = { ...stat } } return out } export function resetDptErrorStats(): void { dptErrorStats.clear() } function markErrorLogged(error: unknown): void { if (error && typeof error === 'object') { try { ;(error as { [DPT_ERROR_ALREADY_LOGGED]?: boolean })[ DPT_ERROR_ALREADY_LOGGED ] = true } catch { // Best-effort marker only; ignore non-extensible/frozen error objects. } } } function isErrorLogged(error: unknown): boolean { return Boolean( error && typeof error === 'object' && (error as { [DPT_ERROR_ALREADY_LOGGED]?: boolean })[ DPT_ERROR_ALREADY_LOGGED ], ) } export function populateAPDU( value: any, apdu: APDU, dptid?: number | string, context?: APDUTelegramContext, ) { const dpt = resolve(dptid || 'DPT1') const dptLabel = getDptLabel(dpt, dptid) const logSuffix = buildAPDULogSuffix(dptLabel, context) const nbytes = Math.ceil(dpt.basetype.bitlength / 8) // apdu.data = new Buffer(nbytes); // 14/09/2020 Supregiovane: Deprecated. Replaced with below. apdu.data = Buffer.alloc(nbytes) apdu.bitlength = (dpt.basetype && dpt.basetype.bitlength) || 1 let tgtvalue = value logger.debug( 'populateAPDU start: dpt=%s inputType=%s input=%s expectedBytes=%d%s', dptLabel, typeof value, logValue(value), nbytes, logSuffix, ) // get the raw APDU data for the given JS value if (typeof dpt.formatAPDU === 'function') { // nothing to do here, DPT-specific formatAPDU implementation will handle everything // knxLog.get().debug('>>> custom formatAPDU(%s): %j', dptid, value); // TODO: this could return void, what to do in that case? try { apdu.data = dpt.formatAPDU(value, { dptid, groupAddress: context?.groupAddress, sourceAddress: context?.sourceAddress, logSuffix, }) as Buffer } catch (error) { const message = getErrorMessage(error) if (!isErrorLogged(error)) { logIfDebug( 'error', 'populateAPDU custom encoder failed: dpt=%s input=%s error=%s%s', dptLabel, logValue(value), message, logSuffix, ) recordDptError(dptLabel, 'encode', message, logSuffix) markErrorLogged(error) } throw new Error( `${dptLabel}: encode failed for value ${logValue(value)}: ${message}`, ) } if (Buffer.isBuffer(apdu.data)) { logger.debug( 'populateAPDU custom encoded: dpt=%s apdu=%s len=%d%s', dptLabel, apdu.data.toString('hex'), apdu.data.length, logSuffix, ) } else { logIfDebug( 'error', 'populateAPDU custom returned non-buffer: dpt=%s outputType=%s output=%s%s', dptLabel, typeof apdu.data, logValue(apdu.data), logSuffix, ) } // knxLog.get().debug('<<< custom formatAPDU(%s): %j', dptid, apdu.data); } else { if (!isFinite(value)) { logIfDebug( 'error', 'populateAPDU generic invalid value: dpt=%s inputType=%s input=%s%s', dptLabel, typeof value, logValue(value), logSuffix, ) throw new Error( util.format('Invalid value, expected a %s', dpt.basetype?.desc), ) } // check if value is in range, be it explicitly defined or implied from bitlength const range = hasProp(dpt.basetype, 'range') ? dpt.basetype.range : [0, 2 ** dpt.basetype.bitlength - 1] // is there a scalar range? eg. DPT5.003 angle degrees (0=0, ff=360) if (hasProp(dpt, 'subtype') && hasProp(dpt.subtype, 'scalar_range')) { const scalar = dpt.subtype.scalar_range if (value < scalar[0] || value > scalar[1]) { logger.debug( 'Value %j(%s) out of scalar range(%j) for %s', value, typeof value, scalar, dpt.id, ) } else { // convert value from its scalar representation // e.g. in DPT5.001, 50(%) => 0x7F , 100(%) => 0xFF const a = (scalar[1] - scalar[0]) / (range[1] - range[0]) const b = scalar[0] - range[0] tgtvalue = Math.round((value - b) / a) } } else { // just a plain numeric value, only check if within bounds // eslint-disable-next-line no-lonely-if if (value < range[0] || value > range[1]) { logger.debug( 'Value %j(%s) out of bounds(%j) for %s.%s', value, typeof value, range, dpt.id, dpt.subtypeid, ) } } // generic APDU is assumed to convey an unsigned integer of arbitrary bitlength try { if ( hasProp(dpt.basetype, 'signedness') && dpt.basetype.signedness === 'signed' ) { apdu.data.writeIntBE(tgtvalue, 0, nbytes) } else { apdu.data.writeUIntBE(tgtvalue, 0, nbytes) } } catch (error) { const message = getErrorMessage(error) logIfDebug( 'error', 'populateAPDU generic encode failed: dpt=%s targetValue=%s expectedBytes=%d error=%s%s', dptLabel, logValue(tgtvalue), nbytes, message, logSuffix, ) recordDptError(dptLabel, 'encode', message, logSuffix) markErrorLogged(error) throw error } logger.debug( 'populateAPDU generic encoded: dpt=%s targetValue=%s apdu=%s len=%d%s', dptLabel, logValue(tgtvalue), apdu.data.toString('hex'), apdu.data.length, logSuffix, ) } // knxLog.get().debug('generic populateAPDU tgtvalue=%j(%s) nbytes=%d => apdu=%j', tgtvalue, typeof tgtvalue, nbytes, apdu); return apdu } /* get the correct Javascript value from a APDU buffer for the given DPT * - either by a custom DPT formatAPDU function * - or by this generic version, which: * -- 1) checks if the value adheres to the range set from the DPT's bitlength */ export function fromBuffer( buf: Buffer, dpt: DatapointConfig, context?: FromBufferContext, ) { try { // sanity check if (!dpt) throw Error(util.format('DPT %s not found', dpt)) const dptLabel = getDptLabel(dpt, context?.dptid) const logSuffix = context?.logSuffix || buildAPDULogSuffix(dptLabel, context) const bufferHex = Buffer.isBuffer(buf) ? buf.toString('hex') : logValue(buf) logger.debug( 'fromBuffer start: dpt=%s bufLen=%d buf=%s%s', dptLabel, Buffer.isBuffer(buf) ? buf.length : -1, bufferHex, logSuffix, ) let value = 0 // get the raw APDU data for the given JS value if (typeof dpt.fromBuffer === 'function') { // nothing to do here, DPT-specific fromBuffer implementation will handle everything value = dpt.fromBuffer(buf, { dptid: context?.dptid || dptLabel, groupAddress: context?.groupAddress, sourceAddress: context?.sourceAddress, logSuffix, }) logger.debug( 'fromBuffer custom decoded: dpt=%s value=%s%s', dptLabel, logValue(value), logSuffix, ) } else { const expectedLength = Math.ceil(dpt.basetype.bitlength / 8) if (expectedLength > 6) { throw Error( 'cannot handle unsigned integers more then 6 bytes in length', ) } if (buf.length !== expectedLength) { logIfDebug( 'warn', 'fromBuffer: %s expects %d bytes (got %d bytes)', dpt.id, expectedLength, buf.length, ) return null } // knxLog.get().debug('%s buflength == %d => %j', typeof buf, buf.length, JSON.stringify(buf) ); // get a raw unsigned integer from the buffer if ( hasProp(dpt.basetype, 'signedness') && dpt.basetype.signedness === 'signed' ) { value = buf.readIntBE(0, buf.length) } else { value = buf.readUIntBE(0, buf.length) } // knxLog.get().debug(' ../knx/src/index.js : DPT : ' + JSON.stringify(dpt)); // for exploring dpt and implementing description if ( hasProp(dpt, 'subtype') && hasProp(dpt.subtype, 'scalar_range') ) { const range = hasProp(dpt.basetype, 'range') ? dpt.basetype.range : [0, 2 ** dpt.basetype.bitlength - 1] const scalar = dpt.subtype.scalar_range // convert value from its scalar representation // e.g. in DPT5.001, 50(%) => 0x7F , 100(%) => 0xFF const a = (scalar[1] - scalar[0]) / (range[1] - range[0]) const b = scalar[0] - range[0] value = Math.round(a * value + b) // knxLog.get().debug('fromBuffer scalar a=%j b=%j %j', a,b, value); } logger.debug( 'fromBuffer generic decoded: dpt=%s value=%s%s', dptLabel, logValue(value), logSuffix, ) } // knxLog.get().debug('generic fromBuffer buf=%j, value=%j', buf, value); return value } catch (error) { const message = getErrorMessage(error) const dptLabel = getDptLabelSafe(dpt, context?.dptid) const logSuffix = context?.logSuffix || buildAPDULogSuffix(dptLabel, context) if (!isErrorLogged(error)) { logIfDebug( 'error', 'fromBuffer failed: dpt=%s buf=%s error=%s%s', dptLabel, buf?.toString('hex') || '', message, logSuffix, ) recordDptError(dptLabel, 'decode', message, logSuffix) markErrorLogged(error) } throw new Error( `${dpt?.id || 'unknown DPT'}: decode failed for buffer [${buf?.toString('hex') || ''}]: ${message}`, ) } } const cloneDpt = (d: DatapointConfig): DatapointConfig => { const { fromBuffer: fb, formatAPDU: fa } = d const clonedDpt = { ...JSON.parse(JSON.stringify(d)), fromBuffer: fb, formatAPDU: fa, } as DatapointConfig if (typeof fa === 'function') { clonedDpt.formatAPDU = (value: any, context?: FormatAPDUContext) => { try { return fa.call(clonedDpt, value, context) } catch (error) { if (!isErrorLogged(error)) { const dptLabel = getDptLabel(clonedDpt, context?.dptid) const message = getErrorMessage(error) logIfDebug( 'error', 'datapoint formatAPDU failed: dpt=%s inputType=%s input=%s error=%s%s', dptLabel, typeof value, logValue(value), message, context?.logSuffix || '', ) recordDptError( dptLabel, 'encode', message, context?.logSuffix || '', ) markErrorLogged(error) } throw error } } } if (typeof fb === 'function') { clonedDpt.fromBuffer = (buf: Buffer, context?: FromBufferContext) => { try { return fb.call(clonedDpt, buf, context) } catch (error) { if (!isErrorLogged(error)) { const dptLabel = getDptLabel(clonedDpt, context?.dptid) const message = getErrorMessage(error) logIfDebug( 'error', 'datapoint fromBuffer failed: dpt=%s buf=%s error=%s%s', dptLabel, Buffer.isBuffer(buf) ? buf.toString('hex') : logValue(buf), message, context?.logSuffix || '', ) recordDptError( dptLabel, 'decode', message, context?.logSuffix || '', ) markErrorLogged(error) } throw error } } } return clonedDpt }