CAPAUT-254 Reorg files, add tests

This commit is contained in:
2022-07-27 23:52:55 +02:00
parent 909d4876b7
commit db4e309439
6 changed files with 1742 additions and 124 deletions

View File

@@ -8,7 +8,8 @@ definitions:
caches: caches:
- node - node
script: script:
- npm install - npm install
- npm test
# Publish node # Publish node
- step: &step-publish-nodes - step: &step-publish-nodes

1570
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,23 @@
"version": "1.0.0", "version": "1.0.0",
"description": "Node-RED node to connect and read data from SystaComfort II heating/solar systems", "description": "Node-RED node to connect and read data from SystaComfort II heating/solar systems",
"author": "Marc Böhm (marc.boehm@captica.de)", "author": "Marc Böhm (marc.boehm@captica.de)",
"homepage": "http://www.captica.de", "homepage": "http://www.captica.de",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "bitbucket:captica-dev/de.captica.nodered.node.systacomfort2" "url": "bitbucket:captica-dev/de.captica.nodered.node.systacomfort2"
},
"dependencies": {
}, },
"keywords": [ "node-red" ], "keywords": [
"node-red" : { "node-red"
],
"node-red": {
"nodes": { "nodes": {
"systacomfort2": "systacomfort2/systacomfort2.js" "systacomfort2": "systacomfort2/systacomfort2.js"
} }
},
"scripts": {
"test": "mocha"
},
"devDependencies": {
"mocha": "^10.0.0"
} }
} }

111
systacomfort2/dataparser.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* SystaComfort II data parser
*/
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 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 = {
/**
* Create a two character based mac address segment
* @param {*} segment
* @returns
*/
createMACPart: function(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
*/
parseData: function(data, topicPrefix) {
// Create mac address from first 5 bytes (reorder)
let deviceAddress = `${this.createMACPart(data[3])}:${this.createMACPart(data[2])}:${this.createMACPart(data[1])}:${this.createMACPart(data[0])}:${this.createMACPart(data[5])}:${this.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
*/
createResponse: function(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
}
}

View File

@@ -4,128 +4,23 @@
* *
* @param {*} RED * @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 PARADIGMA_UDP_PORT_DEFAULT = 22460
const REPLY_LENGTH = 20 const dgram = require('node:dgram')
const MAC_UNKNOWN_SEGEMENT = '??' const dataParser = require('./dataparser')
// 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) { 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 * Node object function
* @param {*} config * @param {*} config
*/ */
function systaComfort2Device(config) { function systaComfort2(config) {
RED.nodes.createNode(this, config) RED.nodes.createNode(this, config)
const node = this const node = this
const udpServer = DGRAM.createSocket('udp4') const udpServer = dgram.createSocket('udp4')
// Handle exceptions on server // Handle exceptions on server
udpServer.on('error', (err) => { udpServer.on('error', (err) => {
node.error(`SystaComfort2: Error on udp server connection: ${err.stack}`) node.error(`SystaComfort II: Error on udp server connection: ${err.stack}`)
node.status({ fill: 'red', shape: 'ring', text: 'Error on udp server connection' }) node.status({ fill: 'red', shape: 'ring', text: 'Error on udp server connection' })
udpServer.close() udpServer.close()
}) })
@@ -134,13 +29,13 @@ module.exports = function(RED) {
node.log(`Recieved message from ${rinfo.address}:${rinfo.port} at ${new Date().toLocaleString()}`) 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()}` }) 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 // Create response and send back to client
let responseData = createResponse(data) let responseData = dataParser.createResponse(data)
const client = DGRAM.createSocket('udp4') const client = dgram.createSocket('udp4')
client.send(responseData, rinfo.port, rinfo.address, (err) => { client.send(responseData, rinfo.port, rinfo.address, (err) => {
client.close() client.close()
}) })
// Parse data receieved from systa comfort device // Parse data receieved from systa comfort device
let msgData = parseData(data, config.topicPrefix) let msgData = dataParser.parseData(data, config.topicPrefix)
if ( msgData ) { if ( msgData ) {
msgData.deviceIP = rinfo.address msgData.deviceIP = rinfo.address
msgData.devicePort = rinfo.port msgData.devicePort = rinfo.port
@@ -164,5 +59,5 @@ module.exports = function(RED) {
/** /**
* Register node object to Node-RED instance * Register node object to Node-RED instance
*/ */
RED.nodes.registerType('systacomfort2', systaComfort2Device) RED.nodes.registerType('systacomfort2', systaComfort2)
} }

35
test/systacomfort2.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* SystaComfort II node test
*
*/
var assert = require('assert')
var systaComfort2Node = require('../systacomfort2/systacomfort2')
var dataParser = require('../systacomfort2/dataparser')
const RED_MOCK = {
nodes: {
registerType: function(name, obj) {
it('should be registered on node-RED as a new node type', () => {
assert.equal('systacomfort2', name)
assert.ok(obj)
})
}
}
}
describe('SystaComfort2 - Node-RED node', function() {
// Check node registration first
systaComfort2Node(RED_MOCK)
})
describe('SystaComfort2 - Data Parser', function() {
describe('#createMACPart()', function() {
it('should convert a byte into string and pad with a leading "0"', function() {
assert.equal(dataParser.createMACPart(15), '0f')
})
it('should convert a byte into as string and NOT pad with a leading "0"', function() {
assert.equal(dataParser.createMACPart(255), 'ff')
})
})
})