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.

741 lines (653 loc) 30.1 kB
import { VenusClientFactory } from './venusClientFactory.js'; import settings from './settings.js'; import dbusNative from 'dbus-native'; // Signal K plugin entry point export default function(app) { // Dynamic tracking of discovered Signal K paths let discoveredPaths = { batteries: new Map(), tanks: new Map(), environment: new Map(), switches: new Map() }; let lastSchemaUpdate = 0; const plugin = { id: 'signalk-to-venus', name: 'Signal K to Venus OS Bridge', description: 'Bridges Signal K data to Victron Venus OS via D-Bus', venusConnected: false, unsubscribe: null, clients: {}, connectivityInterval: null, // Function to generate dynamic schema based on discovered paths schema: function() { const baseSchema = { type: 'object', properties: { venusHost: { type: 'string', title: 'Venus OS Host', default: 'venus.local' }, productName: { type: 'string', title: 'Product Name', default: 'SignalK Virtual Device' }, interval: { type: 'number', title: 'Update Interval (ms)', default: 1000 } } }; // Add discovered devices grouped by type if any have been found if (hasDiscoveredPaths()) { // Add each device type with discovered paths Object.entries(discoveredPaths).forEach(([deviceType, pathMap]) => { if (pathMap.size > 0) { const deviceTitles = { 'batteries': 'Batteries', 'tanks': 'Tanks', 'environment': 'Environment', 'switches': 'Switches & Dimmers' }; baseSchema.properties[deviceType] = { type: 'object', title: deviceTitles[deviceType], properties: {} }; // Add each discovered path as a simple checkbox pathMap.forEach((pathInfo, devicePath) => { const safePathKey = devicePath.replace(/[^a-zA-Z0-9]/g, '_'); baseSchema.properties[deviceType].properties[safePathKey] = { type: 'boolean', title: `${pathInfo.displayName} (${devicePath})`, default: true // Enable devices by default for better user experience }; }); } }); } return baseSchema; }, // UI Schema to enhance the configuration interface uiSchema: function() { const uiSchema = {}; // Make each device type collapsible if devices have been discovered if (hasDiscoveredPaths()) { Object.keys(discoveredPaths).forEach(deviceType => { if (discoveredPaths[deviceType].size > 0) { uiSchema[deviceType] = { 'ui:field': 'collapsible', collapse: { field: 'ObjectField', wrapClassName: 'panel-group' } }; } }); } return uiSchema; }, start: function(options) { app.setPluginStatus('Starting Signal K to Venus OS bridge'); app.debug('Starting Signal K to Venus OS bridge'); const config = { ...settings, ...options }; plugin.clients = {}; plugin.venusConnected = false; // Track Venus connection status const activeClientTypes = new Set(); let dataUpdateCount = 0; let heartbeatToggle = false; // For alternating heartbeat display let venusReachable = false; // Track Venus OS reachability (assume unreachable until proven otherwise) const deviceTypeNames = { 'batteries': 'Batteries', 'tanks': 'Tanks', 'environment': 'Environment', 'switches': 'Switches' }; // Test Venus OS connectivity before processing any data async function testVenusConnectivity() { let testBus = null; try { // Create D-Bus connection with anonymous authentication for Venus OS using dbus-native try { testBus = dbusNative.createClient({ host: config.venusHost, port: 78, authMethods: ['ANONYMOUS'] // Try anonymous auth first for Venus OS }); } catch (createErr) { throw createErr; } // Test the connection by trying to list names (simple D-Bus operation) await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Connection timeout')); }, 3000); // Try a simple D-Bus operation to test connectivity try { testBus.listNames((err, names) => { clearTimeout(timeout); if (err) { reject(err); } else { resolve(); } }); } catch (syncErr) { clearTimeout(timeout); reject(syncErr); } }); venusReachable = true; plugin.venusConnected = true; app.setPluginStatus(`Venus OS reachable at ${config.venusHost}`); return true; } catch (err) { venusReachable = false; plugin.venusConnected = false; let errorMsg = `Venus OS not reachable at ${config.venusHost}`; if (err.code === 'ENOTFOUND') { errorMsg += ' (DNS resolution failed)'; } else if (err.code === 'ECONNREFUSED') { errorMsg += ' (connection refused - check D-Bus TCP setting)'; } else if (err.message.includes('timeout')) { errorMsg += ' (connection timeout)'; } else if (err.message.includes('dbus-keyrings') || err.message.includes('ENOENT')) { errorMsg += ' (D-Bus authentication failed - this is a known issue)'; } app.setPluginError(errorMsg); // Clear all existing clients when Venus becomes unreachable Object.keys(plugin.clients).forEach(key => { if (plugin.clients[key] && typeof plugin.clients[key] === 'object') { // Disconnect existing clients gracefully if (plugin.clients[key].disconnect) { plugin.clients[key].disconnect().catch(() => {}); } } delete plugin.clients[key]; }); return false; } finally { // Always disconnect the test bus if (testBus) { try { testBus.end(); } catch (disconnectErr) { // Silent disconnect error handling } } } } // Subscribe to Signal K updates for discovery and processing // We always subscribe to all device types for discovery, filtering happens later const subscriptions = [ { path: 'electrical.batteries.*', period: config.interval }, { path: 'tanks.*', period: config.interval }, { path: 'environment.*', period: config.interval }, { path: 'electrical.switches.*', period: config.interval } ]; // Subscribe to Signal K delta stream using multiple approaches for compatibility app.setPluginStatus('Setting up Signal K subscription'); let deltaCount = 0; let lastDataTime = Date.now(); // Try multiple subscription methods in order of compatibility if (app.streambundle && app.streambundle.getSelfBus) { // Method 1: Stream bundle getSelfBus (correct API usage) try { // Subscribe to all paths and filter in the callback plugin.unsubscribe = app.streambundle.getSelfBus().onValue(data => { // Validate the streambundle data before conversion if (!data || typeof data !== 'object' || !data.path) { return; } // Filter paths early - only process paths we care about const deviceType = identifyDeviceType(data.path); if (!deviceType) { // Path doesn't match any enabled device types, skip silently return; } // Skip null/undefined values at the source - don't process them at all if (data.value === null || data.value === undefined) { return; } // Convert the normalized delta format to standard delta format const delta = { context: data.context || 'vessels.self', updates: [{ source: data.source || { label: 'streambundle' }, timestamp: data.timestamp || new Date().toISOString(), values: [{ path: data.path, value: data.value }] }] }; processDelta(delta); }); } catch (err) { app.error('getSelfBus method failed:', err); } } else if (app.signalk && app.signalk.subscribe) { // Method 2: Direct signalk subscription (common method) try { const subscription = { context: 'vessels.self', subscribe: [ { path: '*', period: config.interval, format: 'delta', policy: 'ideal', minPeriod: 200 } ] }; plugin.unsubscribe = app.signalk.subscribe(subscription, delta => { processDelta(delta); }); } catch (err) { app.error('signalk.subscribe method failed:', err); } } else if (app.registerDeltaInputHandler) { // Method 3: Delta input handler (fallback) try { app.registerDeltaInputHandler((delta, next) => { if (delta.context === 'vessels.self') { processDelta(delta); } next(delta); }); } catch (err) { app.error('registerDeltaInputHandler method failed:', err); } } else { app.setPluginError('No compatible subscription method found'); return; } // Test Venus OS connectivity initially and periodically async function runConnectivityTest() { try { const isReachable = await testVenusConnectivity(); } catch (err) { app.error('Connectivity test error:', err); } } runConnectivityTest(); // Run initial test plugin.connectivityInterval = setInterval(runConnectivityTest, 120000); // Check every 2 minutes when testing (reduced frequency) // Function to process delta messages function processDelta(delta) { try { deltaCount++; lastDataTime = Date.now(); if (delta.updates) { delta.updates.forEach(update => { // Check if update and update.values are valid if (!update || !Array.isArray(update.values)) { return; } update.values.forEach(async pathValue => { try { // Check if pathValue exists and has required properties if (!pathValue || typeof pathValue !== 'object') { return; } if (!pathValue.path) { return; } // Skip null/undefined values - this should be rare if streambundle filtering works if (pathValue.value === undefined || pathValue.value === null) { return; } const deviceType = identifyDeviceType(pathValue.path); if (deviceType) { // Track this discovered path (always do discovery regardless of Venus OS connection) addDiscoveredPath(deviceType, pathValue.path, pathValue.value, config); // Only proceed with Venus OS operations if Venus is reachable and path is enabled if (venusReachable !== true) { // Venus OS not reachable, skip Venus operations but continue discovery return; } // Check if this specific path is enabled if (!isPathEnabled(deviceType, pathValue.path, config)) { return; // Skip disabled paths } if (!plugin.clients[deviceType]) { app.setPluginStatus(`Connecting to Venus OS at ${config.venusHost} for ${deviceTypeNames[deviceType]}`); try { plugin.clients[deviceType] = VenusClientFactory(config, deviceType); // Listen for data updates to show activity with heartbeat plugin.clients[deviceType].on('dataUpdated', (dataType, value) => { dataUpdateCount++; heartbeatToggle = !heartbeatToggle; // Alternate heartbeat const heartbeat = heartbeatToggle ? '♥︎' : '♡'; const activeList = Array.from(activeClientTypes).sort().join(', '); app.setPluginStatus(`Connected to Venus OS at ${config.venusHost} for [${activeList}] ${heartbeat}`); }); await plugin.clients[deviceType].handleSignalKUpdate(pathValue.path, pathValue.value); activeClientTypes.add(deviceTypeNames[deviceType]); const activeList = Array.from(activeClientTypes).sort().join(', '); app.setPluginStatus(`Connected to Venus OS at ${config.venusHost} for [${activeList}]`); } catch (err) { // Clean up connection error messages for better user experience let cleanMessage = err.message || err.toString(); if (cleanMessage.includes('ENOTFOUND')) { cleanMessage = `Venus OS device not found at ${config.venusHost} (DNS resolution failed)`; } else if (cleanMessage.includes('ECONNREFUSED')) { cleanMessage = `Venus OS device refused connection at ${config.venusHost}:78 (check D-Bus TCP setting)`; } else if (cleanMessage.includes('timeout')) { cleanMessage = `Venus OS connection timeout (${config.venusHost}:78)`; } app.setPluginError(`Venus OS not reachable: ${cleanMessage}`); // Mark this client as failed to prevent retries plugin.clients[deviceType] = null; // Only log the first connection error per device type to avoid spam if (!plugin.clients[`${deviceType}_error_logged`]) { app.error(`Cannot connect to Venus OS for ${deviceTypeNames[deviceType]}: ${cleanMessage}`); plugin.clients[`${deviceType}_error_logged`] = true; } return; } } else { // Client already exists - but check if it's null (failed connection) if (plugin.clients[deviceType] === null) { return; } try { await plugin.clients[deviceType].handleSignalKUpdate(pathValue.path, pathValue.value); } catch (err) { // Only log detailed errors if it's not a connection issue if (err.message && (err.message.includes('ENOTFOUND') || err.message.includes('ECONNREFUSED'))) { // Suppress frequent connection errors when Venus OS is not available // The main connection error is already logged during client creation // Mark client as failed plugin.clients[deviceType] = null; } else { app.error(`Error updating ${deviceType} client for ${pathValue.path}: ${err.message}`); } } } } } catch (err) { // Only log unexpected errors, suppress common connection errors if (!err.message || (!err.message.includes('ENOTFOUND') && !err.message.includes('ECONNREFUSED'))) { const pathInfo = pathValue?.path || 'unknown path'; app.error(`Unexpected error processing ${pathInfo}: ${err.message}`); } } }); }); } } catch (err) { app.error('Error processing delta:', err); } } // Monitor subscription health setTimeout(() => { if (deltaCount === 0) { app.setPluginStatus(`No Signal K data received - check server configuration`); } }, 5000); // Handle venus client value changes by setting values back to Signal K Object.values(plugin.clients).forEach(client => { client.on('valueChanged', async (venusPath, value) => { try { const signalKPath = mapVenusToSignalKPath(venusPath); if (signalKPath) { // Use Signal K's internal API instead of external PUT await app.putSelfPath(signalKPath, value, 'venus-bridge'); app.debug(`Updated Signal K path ${signalKPath} with value ${value}`); } } catch (err) { app.error(`Error updating Signal K path from venus ${venusPath}:`, err); } }); }); // Set initial status if no data comes in setTimeout(() => { if (activeClientTypes.size === 0) { // Check if any devices are enabled const hasEnabledDevices = ['batteries', 'tanks', 'environment', 'switches'].some(deviceType => { if (config[deviceType]) { return Object.values(config[deviceType]).some(enabled => enabled === true); } return false; }); if (!hasEnabledDevices) { const totalDiscovered = Object.values(discoveredPaths).reduce((sum, map) => sum + map.size, 0); if (totalDiscovered > 0) { app.setPluginStatus(`Device Discovery: Found ${totalDiscovered} Signal K devices - configure in settings`); } else { app.setPluginStatus('Discovering Signal K devices - check back in a moment'); } } else if (venusReachable === false) { const totalDiscovered = Object.values(discoveredPaths).reduce((sum, map) => sum + map.size, 0); app.setPluginStatus(`Discovery: ${totalDiscovered} devices found - Venus OS not connected at ${config.venusHost}`); } else { app.setPluginStatus(`Waiting for Signal K data (${config.venusHost})`); } } }, 2000); }, stop: function() { app.setPluginStatus('Stopping Signal K to Venus OS bridge'); app.debug('Stopping Signal K to Venus OS bridge'); // Clear connectivity interval if (plugin.connectivityInterval) { clearInterval(plugin.connectivityInterval); } if (plugin.unsubscribe) { plugin.unsubscribe(); } if (plugin.clients) { Object.values(plugin.clients).forEach(async client => { if (client && client.disconnect) { await client.disconnect(); } }); } app.setPluginStatus('Stopped'); } }; // Helper function to identify device type from Signal K path function identifyDeviceType(path) { // Filter out Cerbo GX relays (venus-0, venus-1) to prevent feedback loops if (path.match(/electrical\.switches\.venus-[01]\./)) { return null; } if (settings.batteryRegex.test(path)) return 'batteries'; if (settings.tankRegex.test(path)) return 'tanks'; if (settings.temperatureRegex.test(path) || settings.humidityRegex.test(path)) return 'environment'; if (settings.switchRegex.test(path) || settings.dimmerRegex.test(path)) return 'switches'; return null; } // Helper function to map Venus D-Bus paths back to Signal K paths function mapVenusToSignalKPath(venusPath) { // This would need proper mapping logic based on your venus client implementations // For switches/dimmers that support bidirectional updates if (venusPath.includes('/Switches/')) { const id = venusPath.match(/\/Switches\/([^\/]+)/)?.[1]; // Filter out Cerbo GX relays to prevent feedback loops if (id === 'venus-0' || id === 'venus-1') { return null; } if (venusPath.endsWith('/State')) { return `electrical.switches.${id}.state`; } else if (venusPath.endsWith('/DimLevel')) { return `electrical.switches.${id}.dimmingLevel`; } } return null; } // Helper function to check if any paths have been discovered function hasDiscoveredPaths() { return Object.values(discoveredPaths).some(pathMap => pathMap.size > 0); } // Function to add a discovered path to tracking function addDiscoveredPath(deviceType, path, value, config) { try { const pathMap = discoveredPaths[deviceType]; if (!pathMap) return; // Extract the device/sensor path (one level up from the property) const devicePath = getDevicePath(deviceType, path); if (!devicePath) return; if (!pathMap.has(devicePath)) { // Generate a human-readable display name let displayName = generateDisplayName(deviceType, devicePath); pathMap.set(devicePath, { displayName: displayName, firstSeen: new Date().toISOString(), lastValue: value, sampleValue: value, properties: new Set([path]) // Track which properties we've seen }); // Trigger schema update if enough time has passed const now = Date.now(); if (now - lastSchemaUpdate > 2000) { // Throttle updates to every 2 seconds lastSchemaUpdate = now; // Notify Signal K that the schema has changed (if supported) if (app.handleMessage && typeof app.handleMessage === 'function') { try { app.handleMessage(plugin.id, { type: 'schema-update', timestamp: new Date().toISOString() }); } catch (err) { // Schema update notification not supported - ignore silently } } } // Update status with discovered paths count const totalPaths = Object.values(discoveredPaths).reduce((sum, map) => sum + map.size, 0); const statusMsg = plugin.venusConnected ? `Connected to Venus OS - ${totalPaths} devices discovered` : `Device Discovery: Found ${totalPaths} Signal K devices (Venus OS: ${config.venusHost})`; app.setPluginStatus(statusMsg); } else { // Update last seen value and add this property to the set const deviceInfo = pathMap.get(devicePath); deviceInfo.lastValue = value; deviceInfo.properties.add(path); } } catch (err) { app.error(`Error in addDiscoveredPath: ${err.message}`); } } // Function to extract device path from full property path function getDevicePath(deviceType, fullPath) { switch (deviceType) { case 'batteries': // electrical.batteries.0.voltage -> electrical.batteries.0 const batteryMatch = fullPath.match(/^(electrical\.batteries\.[^.]+)/); return batteryMatch ? batteryMatch[1] : null; case 'tanks': // tanks.blackWater.0.currentLevel -> tanks.blackWater.0 const tankMatch = fullPath.match(/^(tanks\.[^.]+\.[^.]+)/); return tankMatch ? tankMatch[1] : null; case 'environment': // environment.water.temperature -> environment.water.temperature (keep specific for single properties) // propulsion.main.temperature -> propulsion.main.temperature return fullPath; case 'switches': // electrical.switches.nav.state -> electrical.switches.nav const switchMatch = fullPath.match(/^(electrical\.switches\.[^.]+)/); return switchMatch ? switchMatch[1] : null; } return null; } // Function to generate human-readable display names function generateDisplayName(deviceType, devicePath) { switch (deviceType) { case 'batteries': // electrical.batteries.0 -> Battery (if only one) or Battery 0, Battery 1 (if multiple) const batteryMatch = devicePath.match(/electrical\.batteries\.(\d+|[^.]+)/); if (batteryMatch) { const batteryId = batteryMatch[1]; // Count total batteries to decide if we need numbers const totalBatteries = discoveredPaths.batteries ? discoveredPaths.batteries.size : 0; if (totalBatteries <= 1 && batteryId === '0') { return 'Battery'; } return `Battery ${batteryId}`; } break; case 'tanks': // tanks.freshWater.starboard -> Freshwater starboard (if specific name) const tankMatch = devicePath.match(/tanks\.([^.]+)\.([^.]+)/); if (tankMatch) { let tankType = tankMatch[1]; const tankId = tankMatch[2]; // Can be any alphanumeric string per Signal K spec // Remove camel case and capitalize first letter tankType = tankType.replace(/([A-Z])/g, ' $1').trim(); tankType = tankType.charAt(0).toUpperCase() + tankType.slice(1).toLowerCase(); // Check if we have multiple tanks of this type const tanksOfThisType = Array.from(discoveredPaths.tanks?.keys() || []) .filter(path => path.includes(`tanks.${tankMatch[1]}.`)).length; // Use generic ID detection instead of assuming only '0' is generic const isGenericId = ['0', 'main', 'primary', 'default'].includes(tankId.toLowerCase()); // If single tank with generic ID, omit the ID if (tanksOfThisType <= 1 && isGenericId) { return tankType; } else { // Multiple tanks or specific ID - include the ID return `${tankType} ${tankId}`; } } break; case 'environment': // environment.water.temperature -> Water temperature // propulsion.main.temperature -> Main temperature if (devicePath.includes('temperature')) { const tempMatch = devicePath.match(/environment\.([^.]+)\.temperature|propulsion\.([^.]+)\.temperature/); if (tempMatch) { let sensor = tempMatch[1] || tempMatch[2]; // Remove camel case and capitalize first letter sensor = sensor.replace(/([A-Z])/g, ' $1').trim(); sensor = sensor.charAt(0).toUpperCase() + sensor.slice(1).toLowerCase(); return `${sensor} temperature`; } } else if (devicePath.includes('humidity') || devicePath.includes('relativeHumidity')) { const humMatch = devicePath.match(/environment\.([^.]+)\.(humidity|relativeHumidity)/); if (humMatch) { let sensor = humMatch[1]; // Remove camel case and capitalize first letter sensor = sensor.replace(/([A-Z])/g, ' $1').trim(); sensor = sensor.charAt(0).toUpperCase() + sensor.slice(1).toLowerCase(); return `${sensor} humidity`; } } break; case 'switches': // electrical.switches.nav -> Nav (if functional name) const switchMatch = devicePath.match(/electrical\.switches\.([^.]+)/); if (switchMatch) { let switchId = switchMatch[1]; // Remove camel case and capitalize first letter switchId = switchId.replace(/([A-Z])/g, ' $1').trim(); switchId = switchId.charAt(0).toUpperCase() + switchId.slice(1).toLowerCase(); // If it's a functional name (not just a number), omit 'Switch' if (!/^\d+$/.test(switchMatch[1])) { return switchId; } else { // It's just a number, keep 'Switch' prefix return `Switch ${switchId}`; } } break; } // Fallback to path-based name with camel case removed const fallback = devicePath.split('.').pop() || devicePath; return fallback.replace(/([A-Z])/g, ' $1').trim() .charAt(0).toUpperCase() + fallback.slice(1).toLowerCase(); } // Function to check if a path is enabled in configuration function isPathEnabled(deviceType, fullPath, config) { // Get the device path for checking individual configuration const devicePath = getDevicePath(deviceType, fullPath); if (!devicePath) return false; // Default to disabled if we can't parse the path // Check if this specific device is enabled in the new configuration structure if (config[deviceType]) { const safePathKey = devicePath.replace(/[^a-zA-Z0-9]/g, '_'); return config[deviceType][safePathKey] === true; } // Default to enabled for newly discovered devices during initial discovery // This allows the device to be processed and appear in the UI return true; } return plugin; }