UNPKG

signalk-to-venus

Version:

Injects battery, tank, temperature, humidity, and switch data from Signal K into the Victron Cerbo GX D-Bus via Venus OS.

399 lines (356 loc) 13.1 kB
import dbusNative from 'dbus-native'; import EventEmitter from 'events'; export class VenusClient extends EventEmitter { constructor(settings, deviceType) { super(); this.settings = settings; this.deviceType = deviceType; this.bus = null; this.batteryData = {}; this.lastInitAttempt = 0; this.VBUS_SERVICE = `com.victronenergy.virtual.${deviceType}`; this.managementProperties = {}; } // Helper function to wrap values in D-Bus variant format wrapValue(type, value) { return [type, value]; } // Helper function to get D-Bus type for JavaScript values getType(value) { if (typeof value === 'string') return 's'; if (typeof value === 'number' && Number.isInteger(value)) return 'i'; if (typeof value === 'number') return 'd'; if (typeof value === 'boolean') return 'b'; return 'v'; // variant for unknown types } async init() { try { // Create D-Bus connection using dbus-native with anonymous authentication this.bus = dbusNative.createClient({ host: this.settings.venusHost, port: 78, authMethods: ['ANONYMOUS'] }); // Request service name await new Promise((resolve, reject) => { this.bus.requestName(this.VBUS_SERVICE, 0, (err, result) => { if (err) reject(err); else resolve(result); }); }); this._exportMgmt(); this._exportBatteryInterface(); this._exportRootInterface(); // Export root interface for VRM compatibility } catch (err) { // Convert errors to more user-friendly messages if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED') { throw new Error(`Cannot connect to Venus OS at ${this.settings.venusHost}:78 - ${err.code}`); } else if (err.message && err.message.includes('timeout')) { throw new Error(`Connection timeout to Venus OS at ${this.settings.venusHost}:78`); } throw new Error(err.message || err.toString()); } } _exportMgmt() { // Define the BusItem interface descriptor for dbus-native with enhanced signatures const busItemInterface = { name: "com.victronenergy.BusItem", methods: { GetValue: ["", "v", [], ["value"]], SetValue: ["v", "i", ["value"], ["result"]], GetText: ["", "s", [], ["text"]], }, signals: { PropertiesChanged: ["a{sv}", ["changes"]] } }; // Export management properties using dbus-native with proper interface descriptors const mgmtInterface = { GetValue: () => { return this.wrapValue('i', 1); // Connected = 1 (integer) }, SetValue: (val) => { return 0; // Success }, GetText: () => { return 'Connected'; } }; this.bus.exportInterface(mgmtInterface, '/Mgmt/Connection', busItemInterface); this.managementProperties['/Mgmt/Connection'] = { value: 1, text: 'Connected' }; // Product Name - Required for Venus OS recognition const productNameInterface = { GetValue: () => { return this.wrapValue('s', 'SignalK Virtual Battery'); }, SetValue: (val) => { return 0; }, GetText: () => { return 'Product name'; } }; this.bus.exportInterface(productNameInterface, '/ProductName', busItemInterface); this.managementProperties['/ProductName'] = { value: 'SignalK Virtual Battery', text: 'Product name' }; // Device Instance - Required for unique identification const deviceInstanceInterface = { GetValue: () => { return this.wrapValue('u', 100); // Unsigned integer for device instance }, SetValue: (val) => { return 0; }, GetText: () => { return 'Device instance'; } }; this.bus.exportInterface(deviceInstanceInterface, '/DeviceInstance', busItemInterface); this.managementProperties['/DeviceInstance'] = { value: 100, text: 'Device instance' }; // Custom Name const customNameInterface = { GetValue: () => { return this.wrapValue('s', 'SignalK Battery'); }, SetValue: (val) => { return 0; }, GetText: () => { return 'Custom name'; } }; this.bus.exportInterface(customNameInterface, '/CustomName', busItemInterface); this.managementProperties['/CustomName'] = { value: 'SignalK Battery', text: 'Custom name' }; // Process Name and Version - Required for VRM registration const processNameInterface = { GetValue: () => { return this.wrapValue('s', 'signalk-battery-sensor'); }, SetValue: (val) => { return 0; }, GetText: () => { return 'Process name'; } }; this.bus.exportInterface(processNameInterface, '/Mgmt/ProcessName', busItemInterface); this.managementProperties['/Mgmt/ProcessName'] = { value: 'signalk-battery-sensor', text: 'Process name' }; const processVersionInterface = { GetValue: () => { return this.wrapValue('s', '1.0.12'); }, SetValue: (val) => { return 0; }, GetText: () => { return 'Process version'; } }; this.bus.exportInterface(processVersionInterface, '/Mgmt/ProcessVersion', busItemInterface); this.managementProperties['/Mgmt/ProcessVersion'] = { value: '1.0.12', text: 'Process version' }; } _exportBatteryInterface() { // Export battery-specific D-Bus interfaces using dbus-native const batteryPaths = { '/Dc/0/Voltage': { value: 0, type: 'd', text: 'Voltage' }, '/Dc/0/Current': { value: 0, type: 'd', text: 'Current' }, '/Soc': { value: 0, type: 'd', text: 'State of charge' }, '/ConsumedAmphours': { value: 0, type: 'd', text: 'Consumed Amphours' }, '/TimeToGo': { value: 0, type: 'd', text: 'Time to go' }, '/Dc/0/Temperature': { value: 0, type: 'd', text: 'Temperature' }, '/Relay/0/State': { value: 0, type: 'd', text: 'Relay state' } }; // Export each battery property Object.entries(batteryPaths).forEach(([path, config]) => { this._exportProperty(path, config); }); } _exportProperty(path, config) { // Define the BusItem interface descriptor for dbus-native with enhanced signatures const busItemInterface = { name: "com.victronenergy.BusItem", methods: { GetValue: ["", "v", [], ["value"]], SetValue: ["v", "i", ["value"], ["result"]], GetText: ["", "s", [], ["text"]], }, signals: { PropertiesChanged: ["a{sv}", ["changes"]] } }; // Store initial value this.batteryData[path] = config.value; const propertyInterface = { GetValue: () => { return this.wrapValue(config.type, this.batteryData[path] || 0); }, SetValue: (val) => { const actualValue = Array.isArray(val) ? val[1] : val; this.batteryData[path] = actualValue; this.emit('valueChanged', path, actualValue); return 0; // Success }, GetText: () => { return config.text; } }; this.bus.exportInterface(propertyInterface, path, busItemInterface); } _updateValue(path, value) { if (this.batteryData.hasOwnProperty(path)) { this.batteryData[path] = value; } } async handleSignalKUpdate(path, value) { try { // Validate input parameters if (typeof value !== 'number' || value === null || value === undefined || isNaN(value)) { // Skip invalid battery values silently return; } if (!this.bus) { // Only try to initialize once every 30 seconds to avoid spam const now = Date.now(); if (!this.lastInitAttempt || (now - this.lastInitAttempt) > 30000) { this.lastInitAttempt = now; await this.init(); } else { // Skip silently if we recently failed to connect return; } } if (path.includes('voltage')) { this._updateValue('/Dc/0/Voltage', value); this.emit('dataUpdated', 'Battery Voltage', `${value.toFixed(2)}V`); } else if (path.includes('current')) { this._updateValue('/Dc/0/Current', value); this.emit('dataUpdated', 'Battery Current', `${value.toFixed(1)}A`); } else if (path.includes('stateOfCharge') || (path.includes('capacity') && path.includes('state'))) { this._updateValue('/Soc', value); this.emit('dataUpdated', 'State of Charge', `${Math.round(value * 100)}%`); } else if (path.includes('consumed') || (path.includes('capacity') && path.includes('consumed'))) { this._updateValue('/ConsumedAmphours', value); this.emit('dataUpdated', 'Consumed Ah', `${value.toFixed(1)}Ah`); } else if (path.includes('timeRemaining') || (path.includes('capacity') && path.includes('time'))) { if (value !== null) { this._updateValue('/TimeToGo', value); this.emit('dataUpdated', 'Time Remaining', `${Math.round(value/60)}min`); } } else if (path.includes('relay')) { this._updateValue('/Relay/0/State', value); this.emit('dataUpdated', 'Relay', value ? 'On' : 'Off'); } else if (path.includes('temperature')) { this._updateValue('/Dc/0/Temperature', value); this.emit('dataUpdated', 'Battery Temp', `${value.toFixed(1)}°C`); } else if (path.includes('name')) { // Handle battery name/label - not updating D-Bus value, just for logging this.emit('dataUpdated', 'Battery Name', value); } else { // Silently ignore unknown battery paths instead of throwing errors // Silently ignore unknown battery paths return; } } catch (err) { throw new Error(err.message); } } _exportRootInterface() { // Export a root interface for VRM compatibility - includes all management properties const rootInterface = { name: "com.victronenergy.BusItem", methods: { GetItems: ["", "a{sa{sv}}", [], ["items"]], GetValue: ["s", "v", ["path"], ["value"]], SetValue: ["sv", "i", ["path", "value"], ["result"]], GetText: ["s", "s", ["path"], ["text"]], }, signals: { ItemsChanged: ["a{sa{sv}}", ["changes"]], PropertiesChanged: ["a{sv}", ["changes"]] } }; const rootInterfaceImpl = { GetItems: () => { // Return all management properties and battery data const items = {}; // Add management properties Object.entries(this.managementProperties).forEach(([path, info]) => { items[path] = { Value: this.wrapValue(this.getType(info.value), info.value), Text: ['s', info.text] }; }); // Add battery data properties Object.entries(this.batteryData).forEach(([path, value]) => { const batteryPaths = { '/Dc/0/Voltage': 'Voltage', '/Dc/0/Current': 'Current', '/Soc': 'State of charge', '/ConsumedAmphours': 'Consumed Amphours', '/TimeToGo': 'Time to go', '/Dc/0/Temperature': 'Temperature', '/Relay/0/State': 'Relay state' }; const text = batteryPaths[path] || 'Battery property'; items[path] = { Value: this.wrapValue('d', value), Text: ['s', text] }; }); return [items]; }, GetValue: (path) => { if (this.managementProperties[path]) { return this.wrapValue(this.getType(this.managementProperties[path].value), this.managementProperties[path].value); } if (this.batteryData[path] !== undefined) { return this.wrapValue('d', this.batteryData[path]); } throw new Error(`Path ${path} not found`); }, SetValue: (path, value) => { if (this.batteryData[path] !== undefined) { const actualValue = Array.isArray(value) ? value[1] : value; this.batteryData[path] = actualValue; this.emit('valueChanged', path, actualValue); return 0; } return -1; // Error }, GetText: (path) => { if (this.managementProperties[path]) { return this.managementProperties[path].text; } const batteryPaths = { '/Dc/0/Voltage': 'Voltage', '/Dc/0/Current': 'Current', '/Soc': 'State of charge', '/ConsumedAmphours': 'Consumed Amphours', '/TimeToGo': 'Time to go', '/Dc/0/Temperature': 'Temperature', '/Relay/0/State': 'Relay state' }; return batteryPaths[path] || 'Battery property'; } }; this.bus.exportInterface(rootInterfaceImpl, '/', rootInterface); } async disconnect() { if (this.bus) { try { this.bus.end(); } catch (err) { // Ignore disconnect errors } this.bus = null; this.batteryData = {}; } } }