UNPKG

@w2a-iiot/parsers

Version:
1,411 lines (1,396 loc) 527 kB
import * as v from "valibot"; import { safeParse } from "valibot"; //#region ../parsers/src/codecs/utils.ts /** * Checks the validity of multiple channels. * @param channels The channels to check. * @param codecName Optional codec name for more detailed error messages. * @throws Will throw an error if start is equal to or greater than end. * @throws Will throw an error if duplicate channel names are found. */ function checkChannelsValidity(channels, codecName) { const channelNameSet = /* @__PURE__ */ new Set(); channels.forEach((channel) => { if (channel.start >= channel.end) { if (codecName) throw new Error(`Invalid channel range in codec ${codecName}: ${channel.start} >= ${channel.end} in channel ${channel.name}`); throw new Error(`Invalid channel range: ${channel.start} >= ${channel.end} in channel ${channel.name}`); } if (channelNameSet.has(channel.name)) { if (codecName) throw new Error(`Duplicate channel name found in codec ${codecName}: ${channel.name}`); throw new Error(`Duplicate channel name found: ${channel.name}`); } channelNameSet.add(channel.name); }); } /** * Checks the validity of multiple codecs and ensures they are compatible. * Validates that all codecs have unique names, matching channel names, consistent channel ranges, * and consistent adjustMeasurementRangeDisallowed settings. * * @param codecs The array of codecs to validate. * @returns A record mapping channel names to their adjustment permission status. * `true` means adjustment is disallowed, `false` means adjustment is allowed. * @throws Will throw an error if no codecs are provided. * @throws Will throw an error if codec names are not unique. * @throws Will throw an error if codecs have different channel names. * @throws Will throw an error if channels with the same name have different ranges across codecs. * @throws Will throw an error if channels with the same name have inconsistent adjustMeasurementRangeDisallowed settings. */ function checkCodecsValidity(codecs) { if (codecs.length === 0) throw new Error("At least one codec must be provided"); codecs.forEach((codec) => { checkChannelsValidity(codec.getChannels(), codec.name); }); codecs.forEach((codec, index) => { if (codecs.filter((_, i) => i !== index).some((c) => c.name === codec.name)) throw new Error(`Codec names must be unique. Duplicate found: ${codec.name}`); }); const firstCodecChannelNames = codecs[0].getChannels().map((c) => c.name); codecs.forEach((codec, index) => { if (index === 0) return; const currentCodecChannelNames = codec.getChannels().map((c) => c.name); const check = { missing: [], extra: [] }; currentCodecChannelNames.forEach((name) => { if (!firstCodecChannelNames.includes(name)) check.extra.push(name); }); firstCodecChannelNames.forEach((name) => { if (!currentCodecChannelNames.includes(name)) check.missing.push(name); }); if (check.extra.length > 0) throw new Error(`Codec ${codec.name} has extra channels not present in other codecs: ${check.extra.join(", ")}`); if (check.missing.length > 0) throw new Error(`Codec ${codec.name} is missing channels present in other codecs: ${check.missing.join(", ")}`); }); const channelRanges = /* @__PURE__ */ new Map(); const channelAdjustmentPermissions = {}; codecs.forEach((codec) => { codec.getChannels().forEach((channel) => { const range = [channel.start, channel.end]; const isDisallowed = channel.adjustMeasurementRangeDisallowed ?? false; if (channelRanges.has(channel.name)) { const existingRange = channelRanges.get(channel.name); if (existingRange[0] !== range[0] || existingRange[1] !== range[1]) throw new Error(`Channel ${channel.name} has inconsistent ranges across codecs: ${JSON.stringify(existingRange)} vs ${JSON.stringify(range)}`); const existingDisallowed = channelAdjustmentPermissions[channel.name]; if (existingDisallowed !== isDisallowed) throw new Error(`Channel ${channel.name} has inconsistent adjustMeasurementRangeDisallowed settings across codecs: ${existingDisallowed} vs ${isDisallowed}`); } else { channelRanges.set(channel.name, range); channelAdjustmentPermissions[channel.name] = isDisallowed; } }); }); return channelAdjustmentPermissions; } //#endregion //#region ../parsers/src/schemas/index.ts function createFPortSchema() { return v.pipe(v.number(), v.minValue(1), v.maxValue(255), v.integer()); } function createRecvTimeSchema() { return v.optional(v.string()); } function createUplinkInputSchema() { return v.object({ bytes: v.array(v.pipe(v.number(), v.minValue(0), v.maxValue(255), v.integer())), fPort: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(224), v.integer())), recvTime: v.optional(v.string()) }, "Uplink input should be an object with `bytes` and optional `fPort` and `recvTime` properties."); } function createHexUplinkInputSchema() { return v.pipe(v.union([v.object({ bytes: v.pipe(v.string()), fPort: v.optional(createFPortSchema()), recvTime: createRecvTimeSchema() }), v.string()]), v.transform((input) => { if (typeof input === "string") return { bytes: input }; return input; })); } //#endregion //#region ../../node_modules/.pnpm/nstr@0.1.3/node_modules/nstr/dist/index.mjs /** * Automatically determines the appropriate precision for a number by detecting * floating point artifacts (consecutive 0s or 9s) and truncating accordingly. */ function nstr(value, options = {}) { const { threshold = 4, maxDecimals = 10 } = options; if (Number.isNaN(value)) return "NaN"; if (!isFinite(value)) return value.toString(); if (Number.isInteger(value)) return value.toString(); const str = value.toFixed(maxDecimals); let patternStart = -1; let patternChar = ""; const decimalIndex = str.indexOf("."); const startIndex = decimalIndex >= 0 ? decimalIndex + 1 : str.length; for (let i = startIndex; i < str.length; i++) { const char = str[i]; if (char === "0" || char === "9") { let consecutiveCount = 1; let j = i + 1; while (j < str.length && str[j] === char) { consecutiveCount++; j++; } if (consecutiveCount >= threshold) { patternStart = i; patternChar = char; break; } i = j - 1; } } if (patternStart !== -1) { let truncated = str.substring(0, patternStart); if (patternChar === "9") { const beforeNines = truncated; const rounded = Number.parseFloat(beforeNines); const decimalPos = beforeNines.indexOf("."); const decimalPlaces = decimalPos >= 0 ? beforeNines.length - decimalPos - 1 : 0; const increment = Math.pow(10, -decimalPlaces); const roundedUp = rounded < 0 ? rounded - increment : rounded + increment; if (decimalPlaces > 0) truncated = roundedUp.toFixed(decimalPlaces); else truncated = roundedUp.toString(); } let result = truncated.replace(/\.?0+$/, ""); if (result.endsWith(".")) result = result.slice(0, -1); if (result === "-0." || result === "0." || result === "-0" || result === "0") result = "0"; return result; } let result = str.replace(/\.?0+$/, ""); if (result.endsWith(".")) result = result.slice(0, -1); if (result === "-0." || result === "0." || result === "-0" || result === "0") result = "0"; return result; } //#endregion //#region ../parsers/src/utils.ts const DEFAULT_ROUNDING_DECIMALS = 4; /** * Returns the number of decimals to use for rounding, with robust handling for edge cases. * * @param roundingDecimals The requested number of decimals (may be undefined, NaN, Infinity, etc.) * @param currentDecimals The fallback number of decimals if roundingDecimals is not valid * @returns The number of decimals to use for rounding * * - If roundingDecimals is undefined, NaN, or -Infinity, returns fallback (currentDecimals or DEFAULT_ROUNDING_DECIMALS) * - If roundingDecimals is Infinity, returns Infinity * - If Math.floor(roundingDecimals) >= 0, returns Math.floor(roundingDecimals) * - Otherwise, returns fallback */ function getRoundingDecimals(roundingDecimals, currentDecimals) { const fallbackDecimals = currentDecimals ?? DEFAULT_ROUNDING_DECIMALS; if (roundingDecimals === void 0 || Number.isNaN(roundingDecimals) || roundingDecimals === -Infinity) return fallbackDecimals; if (roundingDecimals === Infinity) return Infinity; return Math.floor(roundingDecimals) >= 0 ? Math.floor(roundingDecimals) : fallbackDecimals; } /** * Converts a hexadecimal string to an array of integer bytes. * The function handles various hex string formats including prefixes and spaces. * Returns null if the input string contains invalid hexadecimal characters. * * @param hexString The hexadecimal string to convert (supports '0x' prefix and spaces) * @returns Array of integer bytes or null if invalid hex string * @example * hexStringToIntArray('1234') // Returns: [0x12, 0x34] * hexStringToIntArray('0x1234') // Returns: [0x12, 0x34] * hexStringToIntArray('12 34') // Returns: [0x12, 0x34] * hexStringToIntArray('xyz') // Returns: null (invalid hex) */ function hexStringToIntArray(hexString) { let adjustedString = hexString.replaceAll(" ", ""); adjustedString = adjustedString.startsWith("0x") ? adjustedString.slice(2) : adjustedString; const schema = v.pipe(v.string(), v.hexadecimal()); const result = v.safeParse(schema, adjustedString); if (!result.success) return null; const intArray = []; for (let i = 0; i < result.output.length; i += 2) { const byte = Number.parseInt(result.output.slice(i, i + 2), 16); intArray.push(byte); } return intArray; } /** * Converts a percentage value to a real value within a specified range. * The function performs linear interpolation between the minimum and maximum values * based on the given percentage. * * @param percentage The percentage value (must be between 0 and 100) * @param range Object containing min and max values for the target range * @param range.start The minimum value of the range * @param range.end The maximum value of the range * @returns The calculated value within the specified range * @throws {RangeError} When range.start is not less than range.end * @example * percentageToValue(50, {min: 0, max: 100}) // Returns: 50 * percentageToValue(25, {min: -10, max: 10}) // Returns: -5 * percentageToValue(0, {min: 5, max: 15}) // Returns: 5 * percentageToValue(100, {min: 5, max: 15}) // Returns: 15 */ function percentageToValue(percentage, range) { if (range.start >= range.end) throw new RangeError("Range start must be less than range end"); return (range.end - range.start) * (percentage / 100) + range.start; } /** * Converts a TULIP scale value to a real value within a specified range. * TULIP scale uses values from 2500 to 12500, where: * - 2500 represents 0% (minimum value) * - 7500 represents 50% (middle value) * - 12500 represents 100% (maximum value) * * The function first converts the TULIP value to a percentage, then maps * that percentage to the specified range using linear interpolation. * * @param tulipValue The TULIP scale value (must be between 2500 and 12500) * @param range Object containing min and max values for the target range * @param range.start The minimum value of the range * @param range.end The maximum value of the range * @returns The calculated value within the specified range * Uses {@link percentageToValue} internally, which may throw errors itself. * @example * TULIPValueToValue(2500, {min: 0, max: 100}) // Returns: 0 (0%) * TULIPValueToValue(7500, {min: 0, max: 100}) // Returns: 50 (50%) * TULIPValueToValue(12500, {min: 0, max: 100}) // Returns: 100 (100%) * TULIPValueToValue(5000, {min: -10, max: 10}) // Returns: -5 (25%) */ function TULIPValueToValue(tulipValue, range) { return percentageToValue((tulipValue - 2500) * 100 / 1e4 + 0, range); } /** * Converts a slope scale value to a real value within a specified range. * * The slope scale uses values from 0 to 10_000 (inclusive). This maps linearly * to a percentage between 0 and 100 by dividing the slope value by 100, and * then uses {@link percentageToValue} to map that percentage into the provided * range using linear interpolation. * * @param slopeValue The slope scale value (must be between 0 and 10_000 inclusive) * @param range Object containing min and max values for the target range * @param range.start The minimum value of the range * @param range.end The maximum value of the range * @returns The calculated value within the specified range * @throws {RangeError} When slopeValue is not between 0 and 10_000 * Uses {@link percentageToValue} internally, which may throw errors for invalid ranges or percentages. * @example * slopeValueToValue(0, {min: 0, max: 100}) // Returns: 0 (0%) * slopeValueToValue(5000, {min: 0, max: 100}) // Returns: 50 (50%) * slopeValueToValue(10000, {min: 0, max: 100}) // Returns: 100 (100%) * slopeValueToValue(2500, {min: -10, max: 10}) // Returns: 5 (25% of span -> 20) */ function slopeValueToValue(slopeValue, range) { if (slopeValue < 0 || slopeValue > 1e4) throw new RangeError(`Slope value must be between 0 and 10_000, is ${slopeValue}`); const percentage = slopeValue / 100; const span = range.end - range.start; return percentage / 100 * span; } /** * Rounds a number to a specified number of decimal places. * * This function attempts to smartly handle floating point imprecisions that can occur due to repeating values * (such as 0.998 actually being 0.99799999... etc.), which are not exactly representable in binary floating point. * * @param value The number to round. * @param decimals The number of decimal places to round to (default is 0). Negative values are treated as 0. * Values above 100 are clamped to 100. * @returns The rounded number. * @example * roundValue(3.14159, 2) // Returns: 3.14 * roundValue(123.456, 0) // Returns: 123 * roundValue(1.005, 2) // Returns: 1.01 */ function roundValue(value, decimals) { decimals = typeof decimals === "number" ? Math.min(Math.max(0, Math.floor(decimals)), 100) : void 0; if (Number.isInteger(value)) return value; const v = nstr(value + 5 * Number.EPSILON, { maxDecimals: decimals }); if (v === "") return 0; return Number.parseFloat(v); } /** * Converts a 4-byte tuple to a 32-bit IEEE 754 float (big-endian). * * This is a small, dependency-free helper that converts four bytes into * a JavaScript Number using a DataView. It returns the raw IEEE-754 * float value as produced by the platform. * * @param data Tuple containing exactly 4 bytes in big-endian order * @returns 32-bit floating point number (raw IEEE-754 value) */ function intTuple4ToFloat32(data) { const buffer = /* @__PURE__ */ new ArrayBuffer(4); const view = new DataView(buffer); view.setUint8(0, data[0] & 255); view.setUint8(1, data[1] & 255); view.setUint8(2, data[2] & 255); view.setUint8(3, data[3] & 255); return view.getFloat32(0); } /** * Converts a 4-byte tuple to a 32-bit IEEE 754 float and returns a * cleaned numeric value suitable for UI display. * * This wrapper applies a small post-processing step using `nstr` to * remove common floating-point precision artifacts (for example: * 0.30000000000000004 → "0.3"). The `threshold` parameter controls * detection sensitivity (non-negative integer). Higher values make the * function less eager to trim trailing digits. * * Note: `nstr` is used here centrally in `utils` so other modules do * not need to import it directly. * * @param data Tuple containing exactly 4 bytes in big-endian order * @param threshold Non-negative integer controlling artifact detection sensitivity (default: 3) * @returns Cleaned numeric value (parsed from `nstr` output) */ function intTuple4ToFloat32WithThreshold(data, threshold = 3) { threshold = Math.max(0, Math.floor(threshold)); const value = intTuple4ToFloat32(data); return Number.parseFloat(nstr(value, { threshold })); } //#endregion //#region ../parsers/src/parser.ts function defineParser(options) { const { codecs, throwOnMultipleDecode = true, parserName } = options; const roundingDecimals = getRoundingDecimals(options.roundingDecimals); const channelAdjustmentPermissions = checkCodecsValidity(options.codecs); function createError(message) { return { errors: [addPrefixToMessage(message)] }; } function addPrefixToMessage(message) { return `${parserName} (JS): ${message}`; } function decode(input) { let c; codecs.forEach((codec) => { if (codec.canTryDecode(input)) { if (!c) c = codec; else if (throwOnMultipleDecode) throw new Error(`Message could not be uniquely decoded. Multiple codecs matched the input.`); } }); if (!c) throw new Error(`Message could not be decoded. No codec matched the input.`); return c.decode(input); } function decodeUplink(input) { try { const validatedInput = safeParse(createUplinkInputSchema(), input); if (!validatedInput.success) throw new Error(`Input is not a valid for decoding. Check your input data.`); const result = decode(validatedInput.output); if (result.warnings) result.warnings = result.warnings.map(addPrefixToMessage); return result; } catch (error) { if (error instanceof Error) return createError(error.message); return createError(`Unknown error occurred during decoding in parser ${parserName} with input ${JSON.stringify(input)}`); } } function decodeHexUplink(input) { const validatedInput = safeParse(createHexUplinkInputSchema(), input); if (!validatedInput.success) return createError(`Input is not a valid for decoding. Check your input data.`); const intArray = hexStringToIntArray(validatedInput.output.bytes); if (!intArray) return createError(`Input bytes is not a valid hexadecimal string.`); return decodeUplink({ bytes: intArray, fPort: validatedInput.output.fPort, recvTime: validatedInput.output.recvTime }); } function encodeDownlink(input) { try { const codec = codecs.find((c) => c.protocol === input.protocol); if (!codec) throw new Error(`Codec with protocol ${input.protocol} not found in parser. Available protocols: ${codecs.map((c) => c.protocol).join(", ")}`); if (!("encode" in codec)) throw new Error(`Codec with protocol ${input.protocol} does not support encoding. Input could not be encoded.`); return codec.encode(input.input); } catch (error) { if (error instanceof Error) return createError(error.message); return createError(`Unknown error occurred during encoding in parser ${parserName} with input ${JSON.stringify(input)}`); } } function encodeMultipleDownlinks(input) { try { const codec = codecs.find((c) => c.protocol === input.protocol); if (!codec) throw new Error(`Codec with protocol ${input.protocol} not found in parser. Available protocols: ${codecs.map((c) => c.protocol).join(", ")}`); if (!("encodeMultiple" in codec)) throw new Error(`Codec with protocol ${input.protocol} does not support multiple encoding. Input could not be encoded.`); return codec.encodeMultiple(input.input); } catch (error) { if (error instanceof Error) return createError(error.message); return createError(`Unknown error occurred during multiple encoding in parser ${parserName} with input ${JSON.stringify(input)}`); } } return { decodeUplink, decodeHexUplink, encodeDownlink, encodeMultipleDownlinks, adjustMeasuringRange: (name, range) => { if (!(name in channelAdjustmentPermissions)) throw new Error(`Channel ${name} does not exist in parser ${parserName}. Cannot adjust measuring range.`); if (channelAdjustmentPermissions[name]) throw new Error(`Channel ${name} does not allow adjusting the measuring range in parser ${parserName}.`); codecs.forEach((codec) => { codec.adjustMeasuringRange(name, range); }); }, adjustRoundingDecimals: (decimals) => { const corrected = getRoundingDecimals(decimals, roundingDecimals); codecs.forEach((codec) => { codec.adjustRoundingDecimals(corrected); }); } }; } //#endregion //#region ../parsers/src/codecs/protocols.ts /** * TULIP2 protocol identifier. * Used for legacy devices with basic message handling (0x00-0x09 message types). */ const TULIP2_PROTOCOL = "TULIP2"; /** * TULIP3 protocol identifier. * Used for advanced devices with comprehensive sensor support (0x10-0x17 message types). */ const TULIP3_PROTOCOL = "TULIP3"; //#endregion //#region ../parsers/src/codecs/tulip2/index.ts /** * Creates a TULIP2 protocol codec for decoding and encoding IoT device messages. * * TULIP2 is a protocol that uses the first byte (0x00-0x09) as a message type identifier, * followed by payload data. Each message type is handled by a specific handler function. * * * @param options - Configuration object for the TULIP2 codec, see {@link TULIP2CodecOptions} * * @returns A fully configured TULIP2 codec with decode, encode, and adjustment capabilities * * @throws {Error} When channel names are duplicated * @throws {Error} When channel IDs are duplicated * @throws {Error} When channel ranges are invalid (start >= end) * * @example * ```typescript * // Define channels with unique names and IDs * const defineChannels () => ([ * { name: 'temperature', start: -40, end: 125, channelId: 0 }, * { name: 'humidity', start: 0, end: 100, channelId: 1 } * ] as const); * * // Define message handlers * const handlers = { * 0x01: (input, { roundingDecimals, channels }) => ({ * data: { temperature: 22.5, timestamp: Date.now() } * }), * 0x02: (input, { roundingDecimals, channels }) => ({ * data: { humidity: 55.0, timestamp: Date.now() } * }) * }; * * // Create codec * const codec = defineTULIP2Codec({ * deviceName: 'SensorDevice', * channels: defineChannels(), * handlers, * encodeHandler: (data) => [0x01, 0x42] // Optional encoder * }); * ``` * * @warning **ANTI-PATTERN**: Do not reuse the same channel array reference across multiple codec instances. * The channels array is mutated when `adjustMeasuringRange` is called, which can cause unexpected * behavior when multiple parsers share the same channel references. Always create fresh channel * arrays for each codec instance to avoid channel pollution: * * ```typescript * // ❌ WRONG - Reusing channel reference * const sharedChannels = [{ name: 'temp', start: 0, end: 100, channelId: 0 }]; * const codec1 = defineTULIP2Codec({ channels: sharedChannels, ... }); * const codec2 = defineTULIP2Codec({ channels: sharedChannels, ... }); // Will share mutations! * * // ✅ CORRECT - Fresh channel arrays * const codec1 = defineTULIP2Codec({ * channels: [{ name: 'temp', start: 0, end: 100, channelId: 0 }], ... * }); * const codec2 = defineTULIP2Codec({ * channels: [{ name: 'temp', start: 0, end: 100, channelId: 0 }], ... * }); * ``` * * @see {@link TULIP2CodecOptions} for detailed options interface * @see {@link MessageHandlers} for handler function signatures * @see {@link TULIP2Channel} for channel configuration interface */ function defineTULIP2Codec(options) { const codecName = `${options.deviceName}TULIP2`; let roundingDecimals = getRoundingDecimals(options.roundingDecimals); checkChannelsValidity(options.channels); options.channels.forEach((channel, index, array) => { const duplicate = array.find((c, i) => c.channelId === channel.channelId && i !== index); if (duplicate) throw new Error(`Duplicate channel ID found: ${channel.channelId} for channels ${channel.name} and ${duplicate.name} in ${codecName} Codec`); }); function canTryDecode(input) { const firstByte = input.bytes[0]; return typeof firstByte === "number" && firstByte in options.handlers; } function decode(input) { const firstByte = input.bytes[0]; if (typeof firstByte !== "number") throw new TypeError(`Input must have at least one byte for ${codecName} Codec`); const handler = options.handlers[firstByte]; if (handler) return handler(input, { roundingDecimals, channels: options.channels }); throw new TypeError(`No handler registered for byte ${firstByte} in ${codecName} Codec`); } function getChannels() { return options.channels.map((c) => ({ end: c.end, name: c.name, start: c.start, ...c.adjustMeasurementRangeDisallowed ? { adjustMeasurementRangeDisallowed: true } : {} })); } const codec = { name: codecName, protocol: TULIP2_PROTOCOL, getChannels, canTryDecode, decode, adjustMeasuringRange: (name, range) => { const channel = options.channels.find((channel) => channel.name === name); if (!channel) throw new Error(`Channel ${name} not found in ${options.deviceName}TULIP2 Codec`); channel.start = range.start; channel.end = range.end; }, adjustRoundingDecimals: (decimals) => { roundingDecimals = getRoundingDecimals(decimals, roundingDecimals); } }; if (options.encoderFactory) codec.encode = options.encoderFactory({ getChannels: () => options.channels }); if (options.multipleEncodeFactory) codec.encodeMultiple = options.multipleEncodeFactory({ getChannels: () => options.channels }); return codec; } //#endregion //#region ../parsers/src/devices/A2G/parser/tulip2/lookups.ts const MEASUREMENT_CHANNELS$1 = { pressure: 0, flow: 1, input_1: 2, input_2: 3, input_3: 4, input_4: 5, relay_status_1: 6, relay_status_2: 7 }; const PRODUCT_SUB_ID_NAMES$6 = { 0: "LoRaWAN", 1: "MIOTY" }; const HARDWARE_ASSEMBLY_TYPE_NAMES = { 0: "A2G HE0 Full Assembly", 1: "A2G HE1 1AO Assembly", 2: "A2G HE2 Modbus Assembly", 3: "A2G HE3 Modular Assembly", 128: "A2G LC1 LC1VAO", 129: "A2G LC2 CT", 130: "A2G LC3 BAT" }; const LPP_MEASURAND_NAMES_PRESSURE = { 3: "Pressure (gauge)", 4: "Pressure (absolute)", 5: "Pressure (differential)" }; const LPP_MEASURAND_NAMES_FLOW = { 6: "Flow (vol.)", 7: "Flow (mass)" }; const LPP_MEASURAND_NAMES_INPUT = { 70: "Input 1", 71: "Input 2", 72: "Input 3", 73: "Input 4" }; const LPP_MEASURAND_NAMES = { 1: "Temperature", 2: "Temperature difference", ...LPP_MEASURAND_NAMES_PRESSURE, ...LPP_MEASURAND_NAMES_FLOW, 8: "Force", 9: "Mass", 10: "Level", 11: "Length", 12: "Volume", 13: "Current", 14: "Voltage", 15: "Resistance", 16: "Capacitance", 17: "Inductance", 18: "Relative", 19: "Time", 20: "Frequency", 21: "Speed", 22: "Acceleration", 23: "Density", 24: "Density (gauge pressure at 20 °C)", 25: "Density (absolute pressure at 20 °C)", 26: "Humidity (relative)", 27: "Humidity (absolute)", 28: "Angle of rotation / inclination", 60: "Device specific", 61: "Device specific", 62: "Device specific", ...LPP_MEASURAND_NAMES_INPUT, 75: "Relay Status 1", 76: "Relay Status 2" }; const LPP_UNIT_NAMES_PRESSURE = { 1: "Pa", 2: "kPa", 3: "mbar", 4: "mmWC", 5: "inWC" }; const LPP_UNIT_NAMES_FLOW = { 10: "[m³/s] cubic metre per second", 11: "[m³/h] cubic metre per hour (cbm/h)", 12: "[l/s] litre per second", 13: "[cfm] cubic feet per minute", 14: "[m/s]", 15: "[ft/min]" }; const LPP_UNIT_NAMES_INPUT = { 0: "None", 20: "% rH", 21: "[g/m³]", 22: "[g/ft³]", 23: "[kJ/kg]", 24: "[BTU/lb]", 30: "normalized", 31: "ppm", 32: "[%] percent", 40: "°C", 41: "°F", 45: "V", 46: "bin" }; const LPP_UNIT_NAMES = { ...LPP_UNIT_NAMES_PRESSURE, ...LPP_UNIT_NAMES_FLOW, ...LPP_UNIT_NAMES_INPUT }; const TECHNICAL_ALARM_FLAGS = [ { name: "PressureSignalOverload", mask: 1 }, { name: "AnalogOutput1SignalOverload", mask: 2 }, { name: "AnalogOutput2SignalOverload", mask: 4 }, { name: "ModbusCommunicationError", mask: 8 }, { name: "VoltageInput1SignalOverload", mask: 16 }, { name: "VoltageInput2SignalOverload", mask: 32 }, { name: "TemperatureInput3SignalOverload", mask: 64 }, { name: "TemperatureInput4SignalOverload", mask: 128 } ]; const DEVICE_ALARM_FLAGS = [ { name: "ADCConverterError", mask: 128, byteIndex: 2 }, { name: "PressureSensorNoResponseError", mask: 64, byteIndex: 2 }, { name: "PressureSensorTimeoutError", mask: 32, byteIndex: 2 }, { name: "FactoryOptionsWriteError", mask: 16, byteIndex: 2 }, { name: "FactoryOptionsDeleteError", mask: 8, byteIndex: 2 }, { name: "InvalidFactoryOptionsError", mask: 4, byteIndex: 2 }, { name: "UserSettingsInvalidError", mask: 2, byteIndex: 2 }, { name: "UserSettingsReadWriteError", mask: 1, byteIndex: 2 }, { name: "ZeroOffsetOverRangeError", mask: 128, byteIndex: 3 }, { name: "InvalidSignalSourceSpecifiedError", mask: 64, byteIndex: 3 }, { name: "AnalogOutput2OverTemperatureError", mask: 32, byteIndex: 3 }, { name: "AnalogOutput2LoadFaultError", mask: 16, byteIndex: 3 }, { name: "AnalogOutput2OverRangeError", mask: 8, byteIndex: 3 }, { name: "AnalogOutput1OverTemperatureError", mask: 4, byteIndex: 3 }, { name: "AnalogOutput1LoadFaultError", mask: 2, byteIndex: 3 }, { name: "AnalogOutput1OverRangeError", mask: 1, byteIndex: 3 } ]; //#endregion //#region ../parsers/src/devices/A2G/parser/tulip2/index.ts const ERROR_VALUE_TUPLE = [ 255, 255, 255, 255 ]; function isErrorTuple(tuple) { return tuple[0] === ERROR_VALUE_TUPLE[0] && tuple[1] === ERROR_VALUE_TUPLE[1] && tuple[2] === ERROR_VALUE_TUPLE[2] && tuple[3] === ERROR_VALUE_TUPLE[3]; } function createTULIP2A2GChannels() { return [ { channelId: MEASUREMENT_CHANNELS$1.pressure, name: "pressure", start: 0, end: 100, adjustMeasurementRangeDisallowed: true }, { channelId: MEASUREMENT_CHANNELS$1.flow, name: "flow", start: 0, end: 100, adjustMeasurementRangeDisallowed: true }, { channelId: MEASUREMENT_CHANNELS$1.input_1, name: "input_1", start: 0, end: 100, adjustMeasurementRangeDisallowed: true }, { channelId: MEASUREMENT_CHANNELS$1.input_2, name: "input_2", start: 0, end: 100, adjustMeasurementRangeDisallowed: true }, { channelId: MEASUREMENT_CHANNELS$1.input_3, name: "input_3", start: 0, end: 100, adjustMeasurementRangeDisallowed: true }, { channelId: MEASUREMENT_CHANNELS$1.input_4, name: "input_4", start: 0, end: 1, adjustMeasurementRangeDisallowed: true }, { channelId: MEASUREMENT_CHANNELS$1.relay_status_1, name: "relay_status_1", start: 0, end: 1, adjustMeasurementRangeDisallowed: true }, { channelId: MEASUREMENT_CHANNELS$1.relay_status_2, name: "relay_status_2", start: 0, end: 1, adjustMeasurementRangeDisallowed: true } ]; } function createFloatFromTuple(tuple, roundingDecimals) { if (isErrorTuple(tuple)) throw new Error("Invalid data for channel - measurement: 0xffff, 65535"); return roundValue(intTuple4ToFloat32WithThreshold(tuple), roundingDecimals); } const handleDataMessage$12 = (input, options) => { if (input.bytes.length !== 6 && input.bytes.length !== 27) throw new Error(`Data message (0x01) requires 6 or 27 bytes, but received ${input.bytes.length} bytes`); const [messageType, configurationId] = [input.bytes[0], input.bytes[1]]; if (input.bytes.length === 6) { const value = createFloatFromTuple([ input.bytes[2], input.bytes[3], input.bytes[4], input.bytes[5] ], options.roundingDecimals); return { data: { messageType, configurationId, measurement: { channels: [{ channelId: MEASUREMENT_CHANNELS$1.pressure, channelName: "pressure", value }] } } }; } const pressureTuple = [ input.bytes[2], input.bytes[3], input.bytes[4], input.bytes[5] ]; const flowTuple = [ input.bytes[6], input.bytes[7], input.bytes[8], input.bytes[9] ]; const input1Tuple = [ input.bytes[10], input.bytes[11], input.bytes[12], input.bytes[13] ]; const input2Tuple = [ input.bytes[14], input.bytes[15], input.bytes[16], input.bytes[17] ]; const input3Tuple = [ input.bytes[18], input.bytes[19], input.bytes[20], input.bytes[21] ]; const input4Tuple = [ input.bytes[22], input.bytes[23], input.bytes[24], input.bytes[25] ]; const pressureValue = createFloatFromTuple(pressureTuple, options.roundingDecimals); const flowValue = createFloatFromTuple(flowTuple, options.roundingDecimals); const input1Value = createFloatFromTuple(input1Tuple, options.roundingDecimals); const input2Value = createFloatFromTuple(input2Tuple, options.roundingDecimals); const input3Value = createFloatFromTuple(input3Tuple, options.roundingDecimals); const input4Value = createFloatFromTuple(input4Tuple, options.roundingDecimals); const relayByte = input.bytes[26]; return { data: { messageType, configurationId, measurement: { channels: [ { channelId: MEASUREMENT_CHANNELS$1.pressure, channelName: "pressure", value: pressureValue }, { channelId: MEASUREMENT_CHANNELS$1.flow, channelName: "flow", value: flowValue }, { channelId: MEASUREMENT_CHANNELS$1.input_1, channelName: "input_1", value: input1Value }, { channelId: MEASUREMENT_CHANNELS$1.input_2, channelName: "input_2", value: input2Value }, { channelId: MEASUREMENT_CHANNELS$1.input_3, channelName: "input_3", value: input3Value }, { channelId: MEASUREMENT_CHANNELS$1.input_4, channelName: "input_4", value: input4Value }, { channelId: MEASUREMENT_CHANNELS$1.relay_status_1, channelName: "relay_status_1", value: relayByte & 1 }, { channelId: MEASUREMENT_CHANNELS$1.relay_status_2, channelName: "relay_status_2", value: relayByte >> 1 & 1 } ] } } }; }; const handleTechnicalAlarmMessage$11 = (input) => { if (input.bytes.length !== 3) throw new Error(`Technical alarm message (0x04) requires 3 bytes, but received ${input.bytes.length} bytes`); const configurationId = input.bytes[1]; const statusByte = input.bytes[2]; return { data: { messageType: 4, configurationId, technicalAlarms: Object.fromEntries(TECHNICAL_ALARM_FLAGS.map(({ name, mask }) => [name, (statusByte & mask) === mask])) } }; }; const handleDeviceAlarmMessage$11 = (input) => { if (input.bytes.length !== 4) throw new Error(`Device alarm message (0x05) requires 4 bytes, but received ${input.bytes.length} bytes`); const configurationId = input.bytes[1]; const alarmByte1 = input.bytes[2]; const alarmByte2 = input.bytes[3]; return { data: { messageType: 5, configurationId, deviceAlarms: Object.fromEntries(DEVICE_ALARM_FLAGS.map(({ name, mask, byteIndex }) => { return [name, ((byteIndex === 2 ? alarmByte1 : alarmByte2) & mask) === mask]; })) } }; }; const handleDeviceIdentificationMessage$11 = (input) => { if (input.bytes.length !== 33 && input.bytes.length !== 38) throw new Error(`Device identification message (0x07) requires 33 or 38 bytes, but received ${input.bytes.length} bytes`); const configurationId = input.bytes[1]; const productId = input.bytes[2]; if (productId !== 13) throw new Error(`Invalid productId ${productId} in device identification message. Expected 13 (A2G).`); const productSubId = input.bytes[3]; const productSubIdName = PRODUCT_SUB_ID_NAMES$6[productSubId]; const sensorFirmwareVersion = `${(input.bytes[4] >> 4).toString(10)}.${(input.bytes[4] & 15).toString(10)}.${input.bytes[5].toString(10)}`; const sensorHardwareVersion = input.bytes[6].toString(10); const hardwareAssemblyTypeId = input.bytes[7]; const hardwareAssemblyTypeName = HARDWARE_ASSEMBLY_TYPE_NAMES[hardwareAssemblyTypeId]; if (!hardwareAssemblyTypeName) throw new Error(`Unknown hardware assembly type ${hardwareAssemblyTypeId} in device identification message`); let serialNumber = ""; for (let i = 8; i < 24; i++) { const byte = input.bytes[i]; if (byte === 0) break; serialNumber += String.fromCharCode(byte); } const measurementRangeStartPressure = intTuple4ToFloat32WithThreshold([ input.bytes[24], input.bytes[25], input.bytes[26], input.bytes[27] ]); const measurementRangeEndPressure = intTuple4ToFloat32WithThreshold([ input.bytes[28], input.bytes[29], input.bytes[30], input.bytes[31] ]); const pressureUnit = input.bytes[32]; const pressureUnitName = LPP_UNIT_NAMES[pressureUnit]; if (!pressureUnitName) throw new Error(`Unknown pressure unit ${pressureUnit} in device identification message`); const channelConfigurations = [{ measurand: 3, measurandName: LPP_MEASURAND_NAMES_PRESSURE[3], measurementRangeStart: measurementRangeStartPressure, measurementRangeEnd: measurementRangeEndPressure, unit: pressureUnit, unitName: pressureUnitName }]; if (input.bytes.length === 33) return { data: { messageType: 7, configurationId, deviceInformation: { productId, productIdName: A2G_NAME, productSubId, productSubIdName, sensorFirmwareVersion, sensorHardwareVersion, hardwareAssemblyTypeId, hardwareAssemblyTypeName, serialNumber, channelConfigurations } } }; const flowUnit = input.bytes[33]; const flowUnitName = LPP_UNIT_NAMES[flowUnit]; if (!flowUnitName) throw new Error(`Unknown flow unit ${flowUnit} in device identification message`); const input1Unit = input.bytes[34]; const input1UnitName = LPP_UNIT_NAMES[input1Unit]; if (!input1UnitName) throw new Error(`Unknown input 1 unit ${input1Unit} in device identification message`); const input2Unit = input.bytes[35]; const input2UnitName = LPP_UNIT_NAMES[input2Unit]; if (!input2UnitName) throw new Error(`Unknown input 2 unit ${input2Unit} in device identification message`); const input3Unit = input.bytes[36]; const input3UnitName = LPP_UNIT_NAMES[input3Unit]; if (!input3UnitName) throw new Error(`Unknown input 3 unit ${input3Unit} in device identification message`); const input4Unit = input.bytes[37]; const input4UnitName = LPP_UNIT_NAMES[input4Unit]; if (!input4UnitName) throw new Error(`Unknown input 4 unit ${input4Unit} in device identification message`); const completeChannelConfiguration = [ channelConfigurations[0], { measurand: 6, measurandName: LPP_MEASURAND_NAMES_FLOW[6], unit: flowUnit, unitName: flowUnitName }, { measurand: 70, measurandName: LPP_MEASURAND_NAMES[70], unit: input1Unit, unitName: input1UnitName }, { measurand: 71, measurandName: LPP_MEASURAND_NAMES[71], unit: input2Unit, unitName: input2UnitName }, { measurand: 72, measurandName: LPP_MEASURAND_NAMES[72], unit: input3Unit, unitName: input3UnitName }, { measurand: 73, measurandName: LPP_MEASURAND_NAMES[73], unit: input4Unit, unitName: input4UnitName } ]; return { data: { messageType: 7, configurationId, deviceInformation: { productId, productIdName: A2G_NAME, productSubId, productSubIdName, sensorFirmwareVersion, sensorHardwareVersion, hardwareAssemblyTypeId, hardwareAssemblyTypeName, serialNumber, channelConfigurations: completeChannelConfiguration } } }; }; const handleKeepAliveMessage$11 = (input) => { if (input.bytes.length !== 3) throw new Error(`Keep alive message (0x08) requires 3 bytes, but received ${input.bytes.length} bytes`); const configurationId = input.bytes[1]; const statusByte = input.bytes[2]; if (statusByte === 127) throw new Error("Keep Alive message 08: The device reports an error during the calculation of the battery capacity."); return { data: { messageType: 8, configurationId, deviceStatistic: { batteryLevelNewEvent: (statusByte & 128) >> 7 === 1, batteryLevelPercent: statusByte & 127 } } }; }; function createTULIP2A2GCodec() { return defineTULIP2Codec({ deviceName: A2G_NAME, roundingDecimals: DEFAULT_ROUNDING_DECIMALS, channels: createTULIP2A2GChannels(), handlers: { 1: handleDataMessage$12, 4: handleTechnicalAlarmMessage$11, 5: handleDeviceAlarmMessage$11, 7: handleDeviceIdentificationMessage$11, 8: handleKeepAliveMessage$11 } }); } //#endregion //#region ../parsers/src/devices/A2G/parser/index.ts const A2G_NAME = "A2G"; function useParser() { return defineParser({ parserName: A2G_NAME, codecs: [createTULIP2A2GCodec()] }); } //#endregion //#region ../parsers/src/schemas/tulip2/downlink.ts function createConfigurationIdSchema(maxConfigId) { return v.optional(v.pipe(v.number("configurationId needs to be a number"), v.minValue(1, "configurationId needs to be at least 1"), v.maxValue(maxConfigId, `configurationId needs to be at most ${maxConfigId}`), v.integer("configurationId needs to be an integer"))); } function createByteLimitSchema() { return v.optional(v.pipe(v.number(), v.minValue(0), v.integer())); } function createGenericTULIP2DownlinkActionSchema(i) { const includeConfigurationId = i.meta?.configurationId ?? true; const includeByteLimit = i.meta?.byteLimit ?? true; const extension = i.extension ?? {}; if (includeConfigurationId && includeByteLimit) return v.object({ deviceAction: v.literal(i.action), configurationId: createConfigurationIdSchema(i.maxConfigId), byteLimit: createByteLimitSchema(), ...extension }); if (includeConfigurationId) return v.object({ deviceAction: v.literal(i.action), configurationId: createConfigurationIdSchema(i.maxConfigId), ...extension }); if (includeByteLimit) return v.object({ deviceAction: v.literal(i.action), byteLimit: createByteLimitSchema(), ...extension }); return v.object({ deviceAction: v.literal(i.action), ...extension }); } function createTULIP2DownlinkActionSchemaFactory(maxConfigId) { return (i) => { return createGenericTULIP2DownlinkActionSchema({ action: i.action, maxConfigId, extension: i.extension, meta: i.meta }); }; } function createTulip2DownlinkMainConfigurationSchema(featureFlags) { const baseSchema = { publicationFactorWhenAlarm: v.pipe(v.number(), v.minValue(1), v.maxValue(2880), v.integer()), publicationFactorWhenNoAlarm: v.pipe(v.number(), v.minValue(1), v.maxValue(2880), v.integer()) }; if (featureFlags.mainConfigBLE) Object.assign(baseSchema, { isBLEEnabled: v.boolean() }); if (featureFlags.mainConfigSingleMeasuringRate) Object.assign(baseSchema, { measuringRate: v.pipe(v.number(), v.minValue(60), v.maxValue(86400), v.integer()) }); else Object.assign(baseSchema, { measuringRateWhenNoAlarm: v.pipe(v.number(), v.minValue(60), v.maxValue(86400), v.integer()), measuringRateWhenAlarm: v.pipe(v.number(), v.minValue(60), v.maxValue(86400), v.integer()) }); return v.object(baseSchema); } function createTulip2DownlinkChannelSchema(channel, featureFlags, spanLimitFactors) { const span = channel.end - channel.start; const deadBandMaxSpanFactor = spanLimitFactors?.deadBandMaxSpanFactor ?? 1; const slopeMaxSpanFactor = spanLimitFactors?.slopeMaxSpanFactor ?? 1; const measureOffsetMinSpanFactor = spanLimitFactors?.measureOffsetMinSpanFactor ?? 1; const measureOffsetMaxSpanFactor = spanLimitFactors?.measureOffsetMaxSpanFactor ?? 1; const baseSchema = { alarms: v.optional(v.object({ deadBand: v.pipe(v.number(), v.minValue(0), v.maxValue(span * deadBandMaxSpanFactor), v.transform((v) => Math.round(v / span * 100 * 100))), lowThreshold: v.optional(v.pipe(v.number(), v.minValue(channel.start), v.maxValue(channel.end), v.transform((v) => Math.round((v - channel.start) / span * 100 * 100 + 2500)))), highThreshold: v.optional(v.pipe(v.number(), v.minValue(channel.start), v.maxValue(channel.end), v.transform((v) => Math.round((v - channel.start) / span * 100 * 100 + 2500)))), lowThresholdWithDelay: v.optional(v.object({ value: v.pipe(v.number(), v.minValue(channel.start), v.maxValue(channel.end), v.transform((v) => Math.round((v - channel.start) / span * 100 * 100 + 2500))), delay: v.pipe(v.number(), v.minValue(0), v.maxValue(65535), v.integer()) })), highThresholdWithDelay: v.optional(v.object({ value: v.pipe(v.number(), v.minValue(channel.start), v.maxValue(channel.end), v.transform((v) => Math.round((v - channel.start) / span * 100 * 100 + 2500))), delay: v.pipe(v.number(), v.minValue(0), v.maxValue(65535), v.integer()) })), risingSlope: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(span * slopeMaxSpanFactor), v.transform((v) => Math.round(v / span * 100 * 100)))), fallingSlope: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(span * slopeMaxSpanFactor), v.transform((v) => Math.round(v / span * 100 * 100)))) })) }; if (featureFlags.channelsStartupTime) Object.assign(baseSchema, { startUpTime: v.optional(v.pipe(v.number(), v.minValue(.1), v.maxValue(15), v.transform((v) => Math.round(v * 10)))) }); if (featureFlags.channelsMeasureOffset) Object.assign(baseSchema, { measureOffset: v.optional(v.pipe(v.number(), v.minValue(-measureOffsetMinSpanFactor * span), v.maxValue(measureOffsetMaxSpanFactor * span), v.transform((v) => Math.round(v / span * 100 * 100)))) }); return v.object(baseSchema); } function createTULIP2DownlinkConfigurationActionSchema(channels, featureFlags, spanLimitFactors) { const createActionSchema = createTULIP2DownlinkActionSchemaFactory(featureFlags.maxConfigId); const channelsBooleanOnly = new Set(featureFlags.channelsBooleanOnly ?? []); const channelSchemas = channels.reduce((acc, channel) => { const channelKey = `channel${channel.channelId}`; if (channelsBooleanOnly.has(channelKey)) { acc[channelKey] = v.optional(v.union([v.literal(false), v.literal(true)])); return acc; } acc[channelKey] = v.optional(v.union([ v.literal(false), v.literal(true), createTulip2DownlinkChannelSchema(channel, featureFlags, spanLimitFactors) ])); return acc; }, {}); return createActionSchema({ action: "configuration", extension: { mainConfiguration: v.optional(createTulip2DownlinkMainConfigurationSchema(featureFlags)), ...channelSchemas } }); } function createDownlinkResetToFactorySchema() { return v.object({ deviceAction: v.literal("resetToFactory") }); } function createDownlinkResetBatteryIndicatorSchema(featureFlags) { return createTULIP2DownlinkActionSchemaFactory(featureFlags.maxConfigId)({ action: "resetBatteryIndicator", meta: { byteLimit: false } }); } function createTULIP2DownlinkSchema(channels, featureFlags, extraActions, spanLimitFactors) { return v.variant("deviceAction", [ createDownlinkResetToFactorySchema(), createTULIP2DownlinkConfigurationActionSchema(channels, featureFlags, spanLimitFactors), ...extraActions ?? [] ]); } /** * Validate TULIP2 downlink input for a given set of channels and feature flags. * Has to be used as dynamic valibot schemas cannot be correctly inferred by TypeScript. * @param input Input to validate * @param channels TULIP2 channels * @param featureFlags Feature flags for the downlink * @returns Validated downlink input * @throws Error if the input is invalid. Should be caught and handled by the caller (parser that uses codec that uses this). */ function validateTULIP2DownlinkInput(input, channels, featureFlags, extraActions, spanLimitFactors) { const schema = createTULIP2DownlinkSchema(channels, featureFlags, extraActions, spanLimitFactors); const res = v.safeParse(schema, input); if (!res.success) throw new Error(v.summarize(res.issues)); return res.output; } //#endregion //#region ../parsers/src/devices/FLRU_NETRIS3/parser/tulip2/channels.ts const FLRUTULIP2_LEVEL_CHANNEL = { name: "level", channelId: 0, defaultRange: { start: 0, end: 1e3 } }; function createTULIP2FLRUChannels() { return [{ channelId: FLRUTULIP2_LEVEL_CHANNEL.channelId, name: FLRUTULIP2_LEVEL_CHANNEL.name, start: FLRUTULIP2_LEVEL_CHANNEL.defaultRange.start, end: FLRUTULIP2_LEVEL_CHANNEL.defaultRange.end }]; } //#endregion //#region ../parsers/src/devices/FLRU_NETRIS3/parser/tulip2/constants.ts const FLRU_DOWNLINK_FEATURE_FLAGS = { maxConfigId: 31, channelsStartupTime: false, channelsMeasureOffset: true, mainConfigBLE: false, mainConfigSingleMeasuringRate: false }; const FLRU_DOWNLINK_SPAN_LIMIT_FACTORS = { deadBandMaxSpanFactor: 1, slopeMaxSpanFactor: 1, measureOffsetMinSpanFactor: 1, measureOffsetMaxSpanFactor: 1 }; const FLRU_COMMANDS = { RESET_FACTORY: 1, SET_MAIN_CONFIG: 2, DISABLE_CHANNEL: 17, SET_PROCESS_ALARM: 32, SET_CHANNEL_PROPERTY: 48 }; const FLRU_DEFAULT_CONFIGURATION_ID = 1; const FLRU_DEFAULT_BYTE_LIMIT = 51; //#endregion //#region ../parsers/src/formatters/index.ts function getChannelKeys(input) { return Object.keys(input).filter((key) => /^channel\d+$/.test(key)); } function formatMainConfigurationInput(input) { return input.mainConfiguration; } function formatDisableChannelInput(input) { const config = {}; let hasConfig = false; const channelKeys = getChannelKeys(input); for (const key of channelKeys) if (input[key] === false) { config[key] = true; hasConfig = true; } return hasConfig ? config : void 0; } function formatProcessAlarmInput(input) { const config = {}; let hasConfig = false; const channelKeys = getChannelKeys(input); for (const key of channelKeys) { const channelValue = input[key]; if (!channelValue) continue; if (channelValue === true) { config[key] = true; hasConfig = true; } else if (typeof channelValue === "object" && "alarms" in channelValue && typeof channelValue.alarms === "object") { config[key] = channelValue.alarms; hasConfig = true; } } return hasConfig ? config : void 0; } function formatMeasureOffsetInput(input, featureFlags) { if (featureFlags?.channelsMeasureOffset === false) return; const config = {}; let hasConfig = false; const channelKeys = getChannelKeys(input); for (const key of channelKeys) { const channelValue = input[key]; if (channelValue && typeof channelValue === "object" && "measureOffset" in channelValue && channelValue.measureOffset !== void 0) { config[key] = { offset: channelValue.measureOffset }; hasConfig = true; } } return hasConfig ? config : void 0; } function formatStartupTimeInput(input, featureFlags) { if (featureFlags?.channelsStartupTime === false) return; const config = {}; let hasConfig = false; const channelKeys = getChannelKeys(input); for (const key of channelKeys) { const channelValue = input[key]; if (channelValue && typeof channelValue === "object" && "startUpTime" in channelValue && channelValue.startUpTime !== void 0) { config[key] = { startUpTime: channelValue.startUpTime }; hasConfig = true; } } return hasConfig ? config : void 0; } //#endregion //#region ../parsers/src/utils/encoding/ff