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