/** * SystaComfort II node implementation * * @param {*} RED */ const DGRAM = require('node:dgram') const BITMASK_2_BYTES = 0xFF const BITMASK_4_BYTES = 0xFFFF const PARADIGMA_EMPTY_VALUE = -300 const PARADIGMA_OFFSET_COUNTER_REPLY = 0x3FBF const PARADIGMA_OFFSET_MAC_REPLY = 0x8E83 const PARADIGMA_REPLY_BYTE_12 = 0x01 const PARADIGMA_UDP_PORT_DEFAULT = 22460 const REPLY_LENGTH = 20 const MAC_UNKNOWN_SEGEMENT = '??' // Enumeration of available data channels const SYSTACOMFORT_VALUES = { BOILER_START_COUNTER: { dataPosition: 748, topic: 'boiler/starts/get', description: 'Starts Brenner', unit: null, valueDivider: 1 }, BOILER_WORKING_HOURS: { dataPosition: 740, topic: 'boiler/workinghours/get', description: 'Betriebsstunden Brenner', unit: 'h', valueDivider: 1 }, BUFFER_TANK_TEMPERATURE_TOP: { dataPosition: 40, topic: 'buffer/temperature-top/get', description: 'Temperatur Pufferspeicher oben (TPO)', unit: '°C', valueDivider: 10 }, BUFFER_TANK_TEMPERATURE_BOTTOM: { dataPosition: 44, topic: 'buffer/temperature-bottom/get', description: 'Temperatur Pufferspeicher unten (TPU)', unit: '°C', valueDivider: 10 }, SOLAR_YIELD_CURRENT: { dataPosition: 752, topic: 'solar/yield-current/get', description: 'Solargewinn aktuell', unit: 'kW', valueDivider: 10 }, SOLAR_YIELD_DAY: { dataPosition: 756, topic: 'solar/yield-day/get', description: 'Solargewinn pro Tag', unit: 'kWh', valueDivider: 1 }, SOLAR_YIELD_TOTAL: { dataPosition: 760, topic: 'solar/yield-overall/get', description: 'Solargewinn gesamt', unit: 'kWh', valueDivider: 1 }, SOLAR_TEMPERATURE_COLLECTOR: { dataPosition: 68, topic: 'solar/temperature-collector/get', description: 'Kollektortemperatur', unit: '°C', valueDivider: 10 }, SYSTEM_TEMPERATURE_FLOW: { dataPosition: 28, topic: 'system/temperature-flow/get', description: 'Vorlauftemperatur Heizung (Ist)', unit: '°C', valueDivider: 10 }, SYSTEM_TEMPERATURE_FLOW_RETURN: { dataPosition: 32, topic: 'system/temperature-flowreturn/get', description: 'Rücklauftemperatur Heizung', unit: '°C', valueDivider: 10 }, SYSTEM_TEMPERATURE_EXTERNAL: { dataPosition: 24, topic: 'system/temperature-external/get', description: 'Außentemperatur', unit: '°C', valueDivider: 10 }, SYSTEM_TEMPERATURE_PROCESS_WATER_CURRENT: { dataPosition: 36, topic: 'system/temperature-processwater-current/get', description: 'Warmwassertemperatur (Ist / TWO)', unit: '°C', valueDivider: 10 }, SYSTEM_TEMPERATURE_PROCESS_WATER_TARGET: { dataPosition: 112, topic: 'system/temperature-processwater-target/get', description: 'Warmwassertemperatur (Soll)', unit: '°C', valueDivider: 10 }, SYSTEM_TEMPERATURE_ROOM_TARGET: { dataPosition: 116, topic: 'system/temperature-room-target/get', description: 'Raumtemperatur (Soll)', unit: '°C', valueDivider: 10 } } module.exports = function(RED) { /** * Create a two character based mac address segment * @param {*} segment * @returns */ function createMACPart(segment) { return ( segment === null ? MAC_UNKNOWN_SEGEMENT : segment.toString(16).padStart(2,'0') ) } /** * Parse datagram message recieved on upd socket from systa device * @param {*} data * @returns */ function parseData(data, topicPrefix) { // Create mac address from first 5 bytes (reorder) let deviceAddress = `${createMACPart(data[3])}:${createMACPart(data[2])}:${createMACPart(data[1])}:${createMACPart(data[0])}:${createMACPart(data[5])}:${createMACPart(data[4])}` // Message type let packetType = data[16]; // Calculate counter from two bytes let counter = ( ( data[6] & BITMASK_2_BYTES ) << 0 ) | ( ( data[7] & BITMASK_2_BYTES ) << 8 ); // Process message of type 1 only if ( packetType == 0x01 ) { let resultMessage = { payload: {}, deviceAddress: deviceAddress, messageCounter: counter, packetType: packetType } Object.entries(SYSTACOMFORT_VALUES).forEach( ([key, valueDescriptor]) => { let dataValue = null let startPosition = valueDescriptor.dataPosition if ( data.length > startPosition ) { let dataValueRaw = null dataValueRaw = ((data[startPosition] & BITMASK_2_BYTES)) | ((data[startPosition+1] & BITMASK_2_BYTES) << 8 ) | ((data[startPosition+2] & BITMASK_2_BYTES) << 16 ) | ((data[startPosition+3] & BITMASK_2_BYTES) << 24 ) if ( dataValueRaw != PARADIGMA_EMPTY_VALUE ) { dataValue = (dataValueRaw / valueDescriptor.valueDivider) } let topic = valueDescriptor.topic // Add prefix if necessar if ( topicPrefix ) { topic = topicPrefix + (topicPrefix.endsWith('/') ? '' : '/') + valueDescriptor.topic } resultMessage.payload[key] = { value: dataValue, topic: topic, description: valueDescriptor.description, unit: valueDescriptor.unit } } }) return resultMessage } return null } /** * Create reply message to answer based on incoming data * @param {*} msg */ function createResponse(data) { let reply = Buffer.alloc(REPLY_LENGTH) // Initialize the first 8 bytes by copy the recieved one. Fill all others with 0 for ( let i = 0; i < REPLY_LENGTH; i++) { reply[i] = ( i < 8 ? data[i] : 0x00 ) } // Static part of reply, always 1 reply[12] = PARADIGMA_REPLY_BYTE_12 // Generate a mac id. Using as first byte the second last mac byte and the second byte is the last mac byte // Adding an offset and forcing 4 bytes (0xFFFF) mask let macID = ( ( data[4] & BITMASK_2_BYTES ) + ( data[5] << 8 ) + PARADIGMA_OFFSET_MAC_REPLY ) & BITMASK_4_BYTES // first (right) byte is used on position 16, second (left) byte on 17. using bit shifting to force 1 byte reply[16] = macID & BITMASK_2_BYTES reply[17] = macID >> 8 // Generate a counter id. Sum the counter bytes and add a static offset // Adding an offset and forcing 4 bytes (0xFFFF) mask let counterReply = ( ( data[6] & BITMASK_2_BYTES ) + ( data[7] << 8 ) + PARADIGMA_OFFSET_COUNTER_REPLY ) & BITMASK_4_BYTES reply[18] = counterReply & BITMASK_2_BYTES reply[19] = counterReply >> 8 return reply } /** * Node object function * @param {*} config */ function systaComfort2Device(config) { RED.nodes.createNode(this, config) const node = this const udpServer = DGRAM.createSocket('udp4') // Handle exceptions on server udpServer.on('error', (err) => { node.error(`SysteComfort2: Error on udp server connection: ${err.stack}`) node.status({ fill: 'red', shape: 'ring', text: 'Error on udp server connection' }) udpServer.close() }) // Handle messages udpServer.on('message', (data, rinfo) => { node.log(`Recieved message from ${rinfo.address}:${rinfo.port} at ${new Date().toLocaleString()}`) node.status({ fill: 'green', shape: 'dot', text: `Recieved message from ${rinfo.address}:${rinfo.port} at ${new Date().toLocaleString()}` }) // Create response and send back to client let responseData = createResponse(data) const client = DGRAM.createSocket('udp4') client.send(responseData, rinfo.port, rinfo.address, (err) => { client.close() }) // Parse data receieved from syste comfort device let msgData = parseData(data, config.topicPrefix) if ( msgData ) { msgData.deviceIP = rinfo.address msgData.devicePort = rinfo.port node.send(msgData) } }) // Handle listen state udpServer.on('listening', () => { const address = udpServer.address() node.status({ fill: 'green', shape: 'dot', text: `Server is listening: ${address.address}:${address.port}` }) }) udpServer.bind(config.listenPort || PARADIGMA_UDP_PORT_DEFAULT) // Cleanup hooks node.on('close', () => { if ( udpServer ) { udpServer.close() } }) } /** * Register node object to Node-RED instance */ RED.nodes.registerType('systacomfort2', systaComfort2Device) }