CAPAUT-253 FIrst stable implementation
This commit is contained in:
BIN
systacomfort2/icons/captica.png
Normal file
BIN
systacomfort2/icons/captica.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
39
systacomfort2/systacomfort2.html
Normal file
39
systacomfort2/systacomfort2.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<script type="text/javascript">
|
||||
RED.nodes.registerType('systacomfort2', {
|
||||
category: 'captica',
|
||||
color: '#37BEE4',
|
||||
defaults: {
|
||||
name: {value:""},
|
||||
topicPrefix: {value:""},
|
||||
listenPort: {value:""}
|
||||
},
|
||||
inputs: 0,
|
||||
outputs: 1,
|
||||
icon: 'captica.png',
|
||||
label: function () {
|
||||
return this.name || 'SystaComfort2'
|
||||
},
|
||||
outputLabels: 'data'
|
||||
})
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-template-name="systacomfort2">
|
||||
<div class="form-row">
|
||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" placeholder="Name">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-topicPrefix"><i class="fa fa-tag"></i> Topic Prefix</label>
|
||||
<input type="text" id="node-input-topicPrefix" placeholder="my/topic/prefix">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-listenPort"><i class="fa fa-tag"></i> Topic Prefix</label>
|
||||
<input type="text" id="node-input-listenPort" placeholder="default: 22460">
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="systacomfort2">
|
||||
<p>
|
||||
A capture device to read data from SysteComfort II devices
|
||||
</p>
|
||||
</script>
|
||||
169
systacomfort2/systacomfort2.js
Normal file
169
systacomfort2/systacomfort2.js
Normal file
@@ -0,0 +1,169 @@
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user