diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b5ca2ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +MIT Licence Copyright (c) captica GmbH - Marc Böhm (marc.boehm@captica.de) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..22bf44d --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ + +

+ +# captica: Node-RED Node for SystaComfort II + +Receive data from SystaComfort II devices in Node-RED flows + +This Node-RED node is capturing the packets from a SystaComfort II device and prepares the data for further processing. + +## Supported values + +|Value-Key|Topic|Description| +|--|--|--| +|BOILER_START_COUNTER|boiler/starts/get|Starts of boiler gas unit| +|BOILER_WORKING_HOURS|boiler/workinghours/get|Overall working hours of boiler| +|BUFFER_TANK_TEMPERATURE_TOP|buffer/temperature-top/get|Top temperature of buffer tank| +|BUFFER_TANK_TEMPERATURE_BOTTOM|buffer/temperature-bottom/get|Bottom temperature of buffer tank| +|SOLAR_YIELD_CURRENT|solar/yield-current/get|Current yield of solar| +|SOLAR_YIELD_DAY|solar/yield-day/get|Day yield of solar| +|SOLAR_YIELD_TOTAL|solar/yield-total/get|Total yield of solar| +|SOLAR_TEMPERATURE_COLLECTOR|solar/temperature-collector/get|Current temperature of solar collector| +|SYSTEM_TEMPERATURE_FLOW|system/temperature-flow/get|Current temperature of system flow| +|SYSTEM_TEMPERATURE_FLOW_RETURN|system/temperature-flowreturn/get|Current temperature of system flow (return)| +|SYSTEM_TEMPERATURE_EXTERNAL|system/temperature-external/get|External temperature of system| +|SYSTEM_TEMPERATURE_PROCESS_WATER_CURRENT|system/temperature-processwater-current/get|Current temperature of system process water| +|SYSTEM_TEMPERATURE_PROCESS_WATER_TARGET|system/temperature-processwater-target/get|Target temperature of system process water| +|SYSTEM_TEMPERATURE_ROOM_TARGET|system/temperature-room-target/get|Target room temperature| + +## Compatible SystaComfort II devices + +|Device|Software-Version|Hardware-Version|Basis-System|Compatible?| +|--|--|--|--|--| +|SystaComfort II|V1.16|V1.21|V1.13|yes - except SYSTEM_TEMPERATURE_FLOW_RETURN is not available| + +## Node Parameters +|Parameter|Description| +|--|--| +|Name|Name of node on flow| +|Topic Prefix|Prefix will be added to topic property on output message payload| +|Listen Port|Port of UDP Server to listen on. Useful on multiple devices| + +## Connectivity +To capture the published data from your SystaComfort II device you have to redirect the traffic on your local network. If you have a local DNS Forwarder you can just create a static host override. The domain `pradigma.remoteportal.de` needs to be resolved to your internal Node-RED ip address. diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml new file mode 100644 index 0000000..4740195 --- /dev/null +++ b/bitbucket-pipelines.yml @@ -0,0 +1,29 @@ +image: node:16 + +definitions: + steps: + # Build nodes + - step: &step-build-nodes + name: Build + caches: + - node + script: + - npm install + + # Publish node + - step: &step-publish-nodes + name: Publish Nodes + script: + - pipe: atlassian/npm-publish:0.3.2 + variables: + NPM_TOKEN: $CAPTICA_NPM_TOKEN + FOLDER: './' + EXTRA_ARGS: '--access public' +pipelines: + default: + - step: *step-build-nodes + + tags: + v*: + - step: *step-build-nodes + - step: *step-publish-nodes \ No newline at end of file diff --git a/img/captica_favicon.png b/img/captica_favicon.png new file mode 100644 index 0000000..11bddba Binary files /dev/null and b/img/captica_favicon.png differ diff --git a/img/captica_logo.svg b/img/captica_logo.svg new file mode 100644 index 0000000..7dea9c6 --- /dev/null +++ b/img/captica_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..dbfaa32 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "@captica/node-red-systacomfort2", + "version": "1.0.0", + "description": "Node-RED node to connect and read data from SystaComfort II heating/solar systems", + "author": "Marc Böhm (marc.boehm@captica.de)", + "homepage": "http://www.captica.de", + "repository": { + "type": "git", + "url": "bitbucket:captica-dev/de.captica.nodered.node.systacomfort2" + }, + "dependencies": { + }, + "keywords": [ "node-red" ], + "node-red" : { + "nodes": { + "systacomfort2": "systacomfort2/systacomfort2.js" + } + } +} \ No newline at end of file diff --git a/systacomfort2/icons/captica.png b/systacomfort2/icons/captica.png new file mode 100644 index 0000000..bacad4c Binary files /dev/null and b/systacomfort2/icons/captica.png differ diff --git a/systacomfort2/systacomfort2.html b/systacomfort2/systacomfort2.html new file mode 100644 index 0000000..1a63ee6 --- /dev/null +++ b/systacomfort2/systacomfort2.html @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file diff --git a/systacomfort2/systacomfort2.js b/systacomfort2/systacomfort2.js new file mode 100644 index 0000000..3700127 --- /dev/null +++ b/systacomfort2/systacomfort2.js @@ -0,0 +1,168 @@ + +/** + * 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-total/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 = ((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(`SystaComfort2: 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 systa 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) +} \ No newline at end of file