Merged in feature/CAPAUT-254 (pull request #2)
Feature/CAPAUT-254 Approved-by: Tobias Lehmann
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -48,3 +48,5 @@ Thumbs.db
|
|||||||
*.mov
|
*.mov
|
||||||
*.wmv
|
*.wmv
|
||||||
|
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ definitions:
|
|||||||
- node
|
- node
|
||||||
script:
|
script:
|
||||||
- npm install
|
- npm install
|
||||||
|
- npm test
|
||||||
|
|
||||||
# Publish node
|
# Publish node
|
||||||
- step: &step-publish-nodes
|
- step: &step-publish-nodes
|
||||||
|
|||||||
4509
package-lock.json
generated
Normal file
4509
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -8,12 +8,23 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "bitbucket:captica-dev/de.captica.nodered.node.systacomfort2"
|
"url": "bitbucket:captica-dev/de.captica.nodered.node.systacomfort2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"keywords": [
|
||||||
},
|
"captica",
|
||||||
"keywords": [ "node-red" ],
|
"systacomfort",
|
||||||
"node-red" : {
|
"systacomfort2",
|
||||||
|
"node-red"
|
||||||
|
],
|
||||||
|
"node-red": {
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"systacomfort2": "systacomfort2/systacomfort2.js"
|
"systacomfort2": "systacomfort2/systacomfort2.js"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "nyc mocha"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"chai": "^4.3.6",
|
||||||
|
"mocha": "^10.0.0",
|
||||||
|
"nyc": "^15.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
113
systacomfort2/dataparser.js
Normal file
113
systacomfort2/dataparser.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataParser = {
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
let resultMessage = null
|
||||||
|
// 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 ) {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { dataParser, SYSTACOMFORT_VALUES }
|
||||||
@@ -4,128 +4,24 @@
|
|||||||
*
|
*
|
||||||
* @param {*} RED
|
* @param {*} RED
|
||||||
*/
|
*/
|
||||||
const DGRAM = require('node:dgram')
|
const dgram = require('node:dgram')
|
||||||
const BITMASK_2_BYTES = 0xFF
|
const { dataParser } = require('./dataparser')
|
||||||
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 PARADIGMA_UDP_PORT_DEFAULT = 22460
|
||||||
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 +30,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 +60,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)
|
||||||
}
|
}
|
||||||
155
test/systacomfort2-spec.js
Normal file
155
test/systacomfort2-spec.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* Test implementation of systacomfort2 node
|
||||||
|
*
|
||||||
|
* Project: de.captica.nodered.node.systacomfort2
|
||||||
|
* Author: Marc Böhm <marc.boehm@captica.de>
|
||||||
|
* Copyright (c) captica GmbH est. 2022 / MIT License
|
||||||
|
*/
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const dgram = require('node:dgram')
|
||||||
|
const systaComfort2Node = require('../systacomfort2/systacomfort2')
|
||||||
|
const { dataParser, SYSTACOMFORT_VALUES } = require('../systacomfort2/dataparser')
|
||||||
|
const { assert } = require('node:console')
|
||||||
|
|
||||||
|
const MESSAGE_INIT = 'AJe+LI4NzW0JCQwAMtoAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWAIAAAEAAAACAAAAAAAAADIAAAAyAAAAMgAAAGQAAAC4MP4/APgBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABqAwAAGgMAAE4DAAA8AwAAUgEAAAAAAAA5AAAAlwMAAA0AAAAAAAAAvAIAAIQDAAC8AgAAvAAAAC6TBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAy9tHAsmsAQBQAAAANwAAAAAAAAAEAAAAGgMAAGsDAABOAwAAUgEAAAAAAABrAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAruTDkA=='
|
||||||
|
const MESSAGE_INIT_ANSWER = 'AJe+LI4NzW0AAAAAAQAAABGcjK0='
|
||||||
|
const MESSAGE_DATA = 'AJe+LI4NzG0JCQwAMtoAAAEAAAAAAAAAOgEAAFYBAADU/v//SwMAAEUDAABCAwAA1P7//9T+///U/v//AAAAAAAAAABrAwAAXwIAAFoCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFgCAADNAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAyAAAAOYAAACgAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAA+gAAAAAAAAAGAAAAAAAAACYCAADIAAAAZAAAABQAAAB4AAAAAAAAADIAAABQAAAAZAAAAAUAAAACAAAAMgAAAB4AAAAAAAAAAAAAAAAAAAADAAAAMgAAAPQBAAAEAAAAMgAAAHISAADYAQAAAAAAAAAAAAABAAAAyAAAANwAAACWAAAAAAAAAAAAAAAAAAAAIwcAACQHAAAAAAAAXgEAAAAAAAANAAAAAAAAALwCAADIAAAAZAAAABQAAAB4AAAAAAAAAAAAAADIAAAAZAAAAAUAAAACAAAAMgAAAB4AAAAAAAAAAAAAAAAAAAADAAAAMgAAAPQBAAAEAAAAMgAAACMHAAAAAAAAAAAAAAAAAAABAAAAyAAAANwAAACWAAAAAAAAAAAAAAAAAAAAIwcAACQHAABeAQAAAAAAAA0AAAAAAAAAvAIAAMgAAABkAAAAFAAAAHgAAAAAAAAAAAAAAMgAAABkAAAABQAAAAIAAAAAAAAAAAAAAAMAAAAyAAAA9AEAAAQAAAAyAAAAIwcAAAAAAABYAgAAigIAAAEAAABkAAAAZAAAAAAAAAAyAAAAUgMAAAAAAAAFAAAAtgMAAAAAAAAyAAAAAAAAAJABAABkAAAAAQAAAAEAAAADAAAAAwAAAAMAAAAPAAAAMgAAAKQAAAAAAAAAAAAAABMAAAAFAAAAAAAAAAAAAAAjHAAAIwAAAAVZAAANAAAAEgAAAB51AAAcAAAAAAAAAAAAAAAAAAAALAEAAIoCAABkAAAAGQAAAAAAAAAAAAAA+gAAABgBAAAAAAAAAAAAAAAAAAC8AgAAAAAAAMgAAABkAAAAAgAAAMgAAAAeAAAAAAAAABQAAAAeAAAAHgAAADIAAAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAAAAAAAAAAAAAQAAAAEAAAAHAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/h54Ug=='
|
||||||
|
const TOPIC_PREFIX = 'my/custom/topic/prefix'
|
||||||
|
const CONFIG = { listenPort: 44556 }
|
||||||
|
const OUTPUT_MESSAGE_PAYLOAD = {
|
||||||
|
BOILER_START_COUNTER: 22789,
|
||||||
|
BOILER_WORKING_HOURS: 7203,
|
||||||
|
BUFFER_TANK_TEMPERATURE_TOP: 83.7,
|
||||||
|
BUFFER_TANK_TEMPERATURE_BOTTOM: 83.4,
|
||||||
|
SOLAR_YIELD_CURRENT: 1.3,
|
||||||
|
SOLAR_YIELD_DAY: 18,
|
||||||
|
SOLAR_YIELD_TOTAL: 29982,
|
||||||
|
SOLAR_TEMPERATURE_COLLECTOR:87.5,
|
||||||
|
SYSTEM_TEMPERATURE_FLOW: 34.2,
|
||||||
|
SYSTEM_TEMPERATURE_FLOW_RETURN: null,
|
||||||
|
SYSTEM_TEMPERATURE_EXTERNAL: 31.4,
|
||||||
|
SYSTEM_TEMPERATURE_PROCESS_WATER_CURRENT: 84.3,
|
||||||
|
SYSTEM_TEMPERATURE_PROCESS_WATER_TARGET: 60,
|
||||||
|
SYSTEM_TEMPERATURE_ROOM_TARGET: 20.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node-RED registration/handling
|
||||||
|
*/
|
||||||
|
describe('SystaComfort2 - Node-RED node', () => {
|
||||||
|
// Constructor will be announced on register. We have to keep the object for later tests
|
||||||
|
let testNodeConstructor = null
|
||||||
|
let closeCallback = null
|
||||||
|
// Check node registration first. We fake the RED-objec to capture the registration part
|
||||||
|
systaComfort2Node({
|
||||||
|
nodes: {
|
||||||
|
registerType: function(name, nodeConstructor) {
|
||||||
|
it('should be registered on node-RED as a new node type', () => {
|
||||||
|
expect(name).to.equal('systacomfort2')
|
||||||
|
expect(nodeConstructor).to.not.be.undefined
|
||||||
|
})
|
||||||
|
testNodeConstructor = nodeConstructor
|
||||||
|
},
|
||||||
|
createNode: function(context, config) {
|
||||||
|
// Extend context with additional special function (normally provided by node red context object)
|
||||||
|
context.on = function(eventName, callback) {
|
||||||
|
closeCallback = callback
|
||||||
|
}
|
||||||
|
context.status = function(statusObj) {
|
||||||
|
it('status object should be present', () => {
|
||||||
|
expect(configObj).to.not.be.undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
it('should be provided the same config object', () => {
|
||||||
|
expect(config).to.be.equal(CONFIG)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Call the node constructor
|
||||||
|
*/
|
||||||
|
it('should be possible to call node constructor and access udp server', () => {
|
||||||
|
try {
|
||||||
|
testNodeConstructor(CONFIG)
|
||||||
|
// Create a udp client to connect to service
|
||||||
|
const client = dgram.createSocket('udp4')
|
||||||
|
client.connect(CONFIG.listenPort, (err) => {
|
||||||
|
expect(err).to.be.undefined
|
||||||
|
client.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
// Don't forget to send close event after tests
|
||||||
|
closeCallback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data parser
|
||||||
|
*/
|
||||||
|
describe('SystaComfort2 - Data Parser', function(done) {
|
||||||
|
/**
|
||||||
|
* MAC address utils
|
||||||
|
*/
|
||||||
|
describe('#createMACPart()', () => {
|
||||||
|
it('should convert a byte into string and pad with a leading "0"', () => {
|
||||||
|
expect(dataParser.createMACPart(15)).to.equal('0f')
|
||||||
|
})
|
||||||
|
it('should convert a byte into as string and NOT pad with a leading "0"', () => {
|
||||||
|
expect(dataParser.createMACPart(255)).to.equal('ff')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Check init answer conversion
|
||||||
|
*/
|
||||||
|
describe('#createResponse()', () => {
|
||||||
|
it('should create an response data buffer based on the input message', () => {
|
||||||
|
let answerBuffer = dataParser.createResponse(Buffer.from(MESSAGE_INIT, 'base64'))
|
||||||
|
expect(answerBuffer).to.not.be.undefined
|
||||||
|
expect(answerBuffer.toString('base64')).to.equal(MESSAGE_INIT_ANSWER)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Check data parser
|
||||||
|
*/
|
||||||
|
describe('#parseData()', () => {
|
||||||
|
it('should parse the data message and return an null object because of invalid packet type != 1', () => {
|
||||||
|
let outputMsg = dataParser.parseData(Buffer.from(MESSAGE_INIT, 'base64'))
|
||||||
|
expect(outputMsg).to.be.null
|
||||||
|
})
|
||||||
|
it('should parse the data message and provide a structured output message', () => {
|
||||||
|
let outputMsg = dataParser.parseData(Buffer.from(MESSAGE_DATA, 'base64'))
|
||||||
|
expect(outputMsg).to.not.be.undefined
|
||||||
|
})
|
||||||
|
it('should parse the data message and return an structured output message where payload topic starts with: ' + TOPIC_PREFIX, () => {
|
||||||
|
let outputMsg = dataParser.parseData(Buffer.from(MESSAGE_DATA, 'base64'), TOPIC_PREFIX)
|
||||||
|
expect(outputMsg).to.not.be.undefined
|
||||||
|
expect(outputMsg).to.have.own.property('payload')
|
||||||
|
expect(outputMsg.payload).to.not.be.undefined
|
||||||
|
Object.values(outputMsg.payload).forEach(valueObj => {
|
||||||
|
expect(valueObj).to.not.be.undefined
|
||||||
|
expect(valueObj).to.have.own.property('topic')
|
||||||
|
expect(valueObj.topic).to.not.be.null
|
||||||
|
expect(valueObj.topic.startsWith(TOPIC_PREFIX)).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Check values
|
||||||
|
*/
|
||||||
|
let outputMsg = dataParser.parseData(Buffer.from(MESSAGE_DATA, 'base64'))
|
||||||
|
Object.entries(SYSTACOMFORT_VALUES).forEach(([key, valueObj]) => {
|
||||||
|
const expectedValue = OUTPUT_MESSAGE_PAYLOAD[key]
|
||||||
|
it(`should have the value ${key} in payload object and with value ${expectedValue}, description, topic and unit`, () => {
|
||||||
|
expect(outputMsg.payload).to.have.own.property(key)
|
||||||
|
let payloadValue = outputMsg.payload[key]
|
||||||
|
expect(payloadValue).to.not.be.undefined
|
||||||
|
expect(payloadValue.value).to.be.equal(expectedValue)
|
||||||
|
expect(payloadValue.topic).to.be.equal(valueObj.topic)
|
||||||
|
expect(payloadValue.description).to.be.equal(valueObj.description)
|
||||||
|
expect(payloadValue.unit).to.be.equal(valueObj.unit)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user