node-red-contrib-victron-vedirect-usb
Version:
Access the Victron equipment with a USB VE.Direct cable
178 lines (147 loc) • 5.66 kB
JavaScript
module.exports = function (RED) {
'use strict'
const fs = require('fs')
const path = require('path')
const VEDirect = require('../services/vedirect')
const { SerialPort } = require('serialport')
const { parseTimeout, isStale, getStatusDisplay } = require('../lib/stale-detector')
const { resolveSerialPort } = require('../lib/port-resolver')
const debug = require('debug')('vedirect:node')
// /dev/serial/by-id/... symlinks are maintained by udev on Linux and stay
// pointing at the same physical device across reboots/replugs, unlike
// /dev/ttyUSB* device names which can swap around. Where available, attach
// that stable path to each listed port so it can be offered in the UI.
function withByIdPaths (ports) {
const byIdDir = '/dev/serial/by-id'
let entries
try {
entries = fs.readdirSync(byIdDir)
} catch (err) {
// Not on Linux, or no by-id symlinks present (e.g. macOS, Windows)
return ports
}
const byIdPathByRealPath = {}
entries.forEach((entry) => {
const linkPath = path.join(byIdDir, entry)
try {
byIdPathByRealPath[fs.realpathSync(linkPath)] = linkPath
} catch (err) {
debug('Skipping broken symlink %s: %o', linkPath, err)
}
})
return ports.map((port) => {
const byId = byIdPathByRealPath[port.path]
return byId ? { ...port, byId } : port
})
}
function VEDirectUSB (config) {
RED.nodes.createNode(this, config)
const node = this
debug('Initializing VEDirectUSB node on port %s', config.port)
let dataReader = null
let closed = false
let accumulatedData = {} // Accumulated data across all frames
let productName = null
let dataEventCount = 0
let lastDataTime = null
let staleCheckInterval = null
// Parse timeout configuration
const timeoutMs = parseTimeout(config.timeout)
debug('Stale detection timeout: %s ms', timeoutMs)
// Function to update status based on current state
function updateStatus () {
const stale = isStale(lastDataTime, timeoutMs)
const status = getStatusDisplay(stale, productName)
if (stale) {
debug('Data is stale')
}
node.status(status)
}
// Set up interval to check for stale data
if (timeoutMs) {
debug('Starting stale data check interval')
staleCheckInterval = setInterval(() => {
updateStatus()
}, 1000) // Check every second
}
// Resolve the port to open, preferring the USB serial number (if configured)
// over the raw path, since Linux does not guarantee ttyUSB* names stay
// attached to the same physical device across reboots/replugs.
node.status({ fill: 'blue', shape: 'ring', text: 'connecting' })
SerialPort.list()
.then((ports) => resolveSerialPort(config.port, config.serialNumber, ports))
.catch((err) => {
debug('Failed to list serial ports, falling back to configured path: %o', err)
return config.port
})
.then((resolvedPath) => {
if (closed) {
return
}
if (resolvedPath !== config.port) {
debug('Resolved serial number %s to %s (configured path was %s)', config.serialNumber, resolvedPath, config.port)
}
dataReader = new VEDirect(resolvedPath)
dataReader.on('data', (data) => {
dataEventCount++
lastDataTime = Date.now()
debug('Received data event #%d at %d', dataEventCount, lastDataTime)
debug('Frame has %d fields', Object.keys(data).length)
// Merge new data into accumulated data (overwrites existing fields)
accumulatedData = { ...accumulatedData, ...data }
debug('Accumulated data now has %d total fields', Object.keys(accumulatedData).length)
// Store and display product name when we first see it
if (data.PID && data.PID.product) {
productName = data.PID.product
debug('Product identified: %s', productName)
}
updateStatus()
})
dataReader.on('error', (error) => {
debug('Error from dataReader: %o', error)
node.status({ fill: 'red', shape: 'dot', text: error })
node.warn(error)
})
})
let inputCount = 0
node.on('input', function (msg) {
inputCount++
debug('Input triggered #%d', inputCount)
// Check if data is stale
if (isStale(lastDataTime, timeoutMs)) {
debug('Data is stale, not sending output')
updateStatus()
return // Don't send anything
}
// Output the accumulated data containing all fields seen so far
if (Object.keys(accumulatedData).length > 0) {
debug('Sending accumulated data with %d fields', Object.keys(accumulatedData).length)
msg.payload = accumulatedData
} else {
debug('No data available yet')
msg.payload = {}
node.status({ fill: 'yellow', shape: 'ring', text: 'waiting for data' })
}
node.send(msg)
})
node.on('close', function () {
debug('Closing node, clearing interval and closing serial port')
closed = true
if (staleCheckInterval) {
clearInterval(staleCheckInterval)
}
if (dataReader) {
dataReader.serial.close()
}
})
}
RED.nodes.registerType('victron-vedirect-usb', VEDirectUSB)
RED.httpAdmin.get('/victron/vedirect-ports', (_req, res) => {
SerialPort.list().then((ports) => {
res.json(withByIdPaths(ports))
}, (err) => {
RED.log.error(err)
res.status(500).json({ error: err.message })
})
})
}