UNPKG

signalk-to-venus

Version:

Injects batteries, tanks, environment sensors, and switches as virtual devices and battery monitor into the Victron Cerbo GX Venus OS.

1,127 lines (949 loc) 92.4 kB
import { VEDBusService } from './vedbus.js'; import { DEVICE_CONFIGS } from './deviceConfigs.js'; import { HistoryPersistence } from './historyPersistence.js'; import EventEmitter from 'events'; /** * Unified VenusClient that uses the central VEDBus service for all device types * This replaces the individual device clients with a single, configurable implementation */ export class VenusClient extends EventEmitter { constructor(settings, deviceType, logger = null) { super(); this.settings = settings; this.deviceType = deviceType; // Create a proper logger wrapper for SignalK app or fallback if (logger && typeof logger.debug === 'function' && typeof logger.error === 'function') { this.logger = { debug: (msg) => logger.debug(`[signalk-to-venus] ${msg}`), error: (msg) => logger.error(`[signalk-to-venus] ${msg}`), warn: (msg) => typeof logger.warn === 'function' ? logger.warn(`[signalk-to-venus] ${msg}`) : logger.error(`[signalk-to-venus] WARN: ${msg}`) }; } else { // Fallback logger for tests or when no logger provided this.logger = { debug: () => {}, error: () => {}, warn: () => {} }; } this.signalKApp = null; // Store reference to Signal K app for getting current values // Map plural device types to singular for internal configuration lookup const deviceTypeMap = { 'batteries': 'battery', 'tanks': 'tank', 'switches': 'switch', 'environment': 'environment' }; const configDeviceType = deviceTypeMap[deviceType] || deviceType; this.deviceConfig = DEVICE_CONFIGS[configDeviceType]; if (!this.deviceConfig) { throw new Error(`Unsupported device type: ${deviceType}. Supported types: ${Object.keys(deviceTypeMap).join(', ')}`); } // Store the internal config device type for logic operations this._internalDeviceType = configDeviceType; // Configuration timeouts to prevent connection leaks this.settings.connectionTimeout = this.settings.connectionTimeout || 3000; // Fast timeout this.settings.maxReconnectAttempts = this.settings.maxReconnectAttempts || 5; // Fewer reconnect attempts this.bus = null; this.deviceIndex = 0; // For unique device indexing this.deviceCounts = {}; // Track how many devices of each type we have this.deviceInstances = new Map(); // Track device instances by Signal K path this.deviceServices = new Map(); // Track individual device services this.exportedInterfaces = new Set(); // Track which D-Bus interfaces have been exported // Throttle mechanism for reducing noisy "Processing data update" logs this._lastDataUpdateLog = new Map(); // Map of deviceInstance.basePath -> last log timestamp this._dataUpdateLogInterval = 10000; // Log every 10 seconds per device // Device update logging throttle this._lastDeviceValues = new Map(); // Map of basePath+path -> last value to detect significant changes // History tracking for VRM consumption calculations this.historyData = new Map(); // Map of devicePath -> history tracking object this.lastUpdateTime = new Map(); // Map of devicePath -> last update timestamp this.energyAccumulators = new Map(); // Map of devicePath -> energy accumulation data // History persistence this.historyPersistence = new HistoryPersistence('./signalk-battery-history.json', this.logger); this._historyLoaded = false; } // Set Signal K app reference for getting current values setSignalKApp(app) { this.signalKApp = app; } // Helper method to determine if device update should be logged (reduce noise) _shouldLogDeviceUpdate(basePath, path, value) { const key = `${basePath}:${path}`; const lastValue = this._lastDeviceValues.get(key); // Always log the first value for each path if (lastValue === undefined) { this._lastDeviceValues.set(key, value); return true; } // Determine significant change thresholds based on path type let threshold = 0; if (path.includes('voltage')) { threshold = 0.1; // 0.1V change } else if (path.includes('current')) { threshold = 1.0; // 1A change } else if (path.includes('stateOfCharge')) { threshold = 0.01; // 1% change } else if (path.includes('timeRemaining')) { threshold = 300; // 5 minute change } else { threshold = 0; // Log all other changes } // Check if change is significant const isSignificant = Math.abs(value - lastValue) >= threshold; if (isSignificant) { this._lastDeviceValues.set(key, value); return true; } return false; } // Load history data from persistent storage async loadHistoryData() { if (this._historyLoaded) { return; // Already loaded } try { const loaded = await this.historyPersistence.loadHistoryData(); this.historyData = loaded.historyData; this.lastUpdateTime = loaded.lastUpdateTime; this.energyAccumulators = loaded.energyAccumulators; this._historyLoaded = true; this.logger.debug(`Loaded persistent history data for ${this.historyData.size} devices`); // Clean up any invalid entries that may have been loaded this.cleanupHistoryData(); // Start periodic saving this.historyPersistence.startPeriodicSaving( this.historyData, this.energyAccumulators, this.lastUpdateTime ); // Start periodic history updates to ensure consumption tracking even without Signal K updates this.startPeriodicHistoryUpdates(); } catch (error) { this.logger.error(`Failed to load history data: ${error.message}`); this._historyLoaded = true; // Mark as loaded even if failed to prevent retries } } // Save history data to persistent storage async saveHistoryData() { if (!this._historyLoaded) { return; // Not loaded yet, nothing to save } // Clean up invalid entries before saving this.cleanupHistoryData(); try { await this.historyPersistence.saveHistoryData( this.historyData, this.energyAccumulators, this.lastUpdateTime ); } catch (error) { this.logger.error(`Failed to save history data: ${error.message}`); } } // Clean up invalid history data entries cleanupHistoryData() { const keysToRemove = []; this.logger.debug(`Cleanup: Starting with ${this.historyData.size} devices in history`); // Find invalid keys for (const [key, value] of this.historyData.entries()) { this.logger.debug(`Cleanup: Checking device ${key}`, value); // Remove undefined, null, or empty keys if (!key || key === 'undefined' || key === 'null') { this.logger.debug(`Cleanup: Removing invalid key: ${key}`); keysToRemove.push(key); continue; } // Remove entries with all default values (no real data collected) const hasRealData = (value.dischargedEnergy > 0) || (value.chargedEnergy > 0) || (value.totalAhDrawn > 0.001) || (value.minimumVoltage !== null && value.minimumVoltage > 5.0 && value.minimumVoltage < 50.0) || (value.maximumVoltage !== null && value.maximumVoltage > 5.0 && value.maximumVoltage < 50.0); this.logger.debug(`Cleanup: Device ${key} hasRealData=${hasRealData}, discharged=${value.dischargedEnergy}, charged=${value.chargedEnergy}, totalAh=${value.totalAhDrawn}, minV=${value.minimumVoltage}, maxV=${value.maximumVoltage}`); if (!hasRealData) { this.logger.debug(`Cleanup: Removing device with no real data: ${key}`); keysToRemove.push(key); } } // Remove invalid entries from all maps for (const key of keysToRemove) { this.logger.debug(`Removing invalid history entry: ${key}`); this.historyData.delete(key); this.energyAccumulators.delete(key); this.lastUpdateTime.delete(key); } if (keysToRemove.length > 0) { this.logger.debug(`Cleaned up ${keysToRemove.length} invalid history entries`); } } // Initialize history tracking for a battery device async initializeHistoryTracking(devicePath, initialVoltage) { // Validate devicePath to prevent undefined keys if (!devicePath || devicePath === 'undefined' || devicePath === 'null') { this.logger.error(`Invalid devicePath for history initialization: ${devicePath}`); return; } // Don't reinitialize if already exists (prevents conflicts) if (this.historyData.has(devicePath)) { return; } // Load history data if not already loaded await this.loadHistoryData(); const now = Date.now(); // Only use initial voltage if it's a real measured value // Don't initialize min/max with fallback values const hasRealInitialVoltage = (typeof initialVoltage === 'number' && !isNaN(initialVoltage) && initialVoltage !== 12.0); const validInitialVoltage = hasRealInitialVoltage ? initialVoltage : 12.0; // 12.0 only for accumulator // Check if we have existing history data for this device (may have been loaded from disk) if (!this.historyData.has(devicePath)) { this.historyData.set(devicePath, { minimumVoltage: null, // Will be set to first real voltage value maximumVoltage: null, // Will be set to first real voltage value dischargedEnergy: 0, // kWh chargedEnergy: 0, // kWh totalAhDrawn: 0 // Ah }); this.lastUpdateTime.set(devicePath, now); this.energyAccumulators.set(devicePath, { lastCurrent: 0, lastVoltage: validInitialVoltage, lastTimestamp: now }); this.logger.debug(`Initialized new history tracking for ${devicePath}`); } else { // Update existing data with current voltage if needed and validate existing values const existing = this.historyData.get(devicePath); // Validate and fix any NaN values in existing data if (isNaN(existing.minimumVoltage)) existing.minimumVoltage = null; if (isNaN(existing.maximumVoltage)) existing.maximumVoltage = null; if (isNaN(existing.dischargedEnergy)) existing.dischargedEnergy = 0; if (isNaN(existing.chargedEnergy)) existing.chargedEnergy = 0; if (isNaN(existing.totalAhDrawn)) existing.totalAhDrawn = 0; // Also validate loaded accumulator data const existingAccumulator = this.energyAccumulators.get(devicePath); if (existingAccumulator) { if (isNaN(existingAccumulator.lastCurrent)) existingAccumulator.lastCurrent = 0; if (isNaN(existingAccumulator.lastVoltage) || existingAccumulator.lastVoltage === 0) { // If we have a real voltage now, use it, otherwise use fallback if (initialVoltage && initialVoltage > 5.0) { existingAccumulator.lastVoltage = initialVoltage; } else { existingAccumulator.lastVoltage = 12.0; // Fallback for accumulator calculations } } if (isNaN(existingAccumulator.lastTimestamp)) existingAccumulator.lastTimestamp = Date.now(); } // Update min/max voltage with valid initial voltage ONLY if it's a real voltage value // Don't initialize with fallback values (12.0) - only use actual measured voltages if (hasRealInitialVoltage) { if (initialVoltage < existing.minimumVoltage || existing.minimumVoltage === null) { existing.minimumVoltage = initialVoltage; } if (initialVoltage > existing.maximumVoltage || existing.maximumVoltage === null) { existing.maximumVoltage = initialVoltage; } } this.logger.debug(`Restored existing history tracking for ${devicePath}`); } } // Update history data based on current battery values async updateHistoryData(devicePath, voltage, current, power) { // Validate devicePath to prevent undefined keys if (!devicePath || devicePath === 'undefined' || devicePath === 'null') { this.logger.error(`Invalid devicePath for history update: ${devicePath}`); return null; } if (!this.historyData.has(devicePath)) { // If history data hasn't been loaded yet, ensure it's loaded first // This prevents creating empty data that overwrites loaded values if (!this._historyLoaded) { this.logger.debug(`History not loaded yet for ${devicePath}, loading first...`); await this.loadHistoryData(); } // After loading, check again if we now have the device data if (!this.historyData.has(devicePath)) { // Device not in loaded data, so create new entry const validInitialVoltage = (typeof voltage === 'number' && !isNaN(voltage)) ? voltage : null; if (validInitialVoltage !== null) { // Initialize properly through the normal initialization path await this.initializeHistoryTracking(devicePath, validInitialVoltage); } else { // Create minimal structure for devices without valid voltage this.historyData.set(devicePath, { minimumVoltage: null, maximumVoltage: null, dischargedEnergy: 0, chargedEnergy: 0, totalAhDrawn: 0 }); this.lastUpdateTime.set(devicePath, Date.now()); this.energyAccumulators.set(devicePath, { lastCurrent: 0, lastVoltage: 12.0, lastTimestamp: Date.now() }); this.logger.debug(`Created minimal history structure for ${devicePath} (no valid voltage)`); } } } const history = this.historyData.get(devicePath); const accumulator = this.energyAccumulators.get(devicePath); // Safety check - should not happen anymore but just in case if (!history) { this.logger.error(`History data not available for ${devicePath} - this should not happen`); return null; } const now = Date.now(); const lastTime = this.lastUpdateTime.get(devicePath) || now; // Validate input values to prevent NaN propagation const validVoltage = (typeof voltage === 'number' && !isNaN(voltage)) ? voltage : null; const validCurrent = (typeof current === 'number' && !isNaN(current)) ? current : null; const validPower = (typeof power === 'number' && !isNaN(power)) ? power : null; // Update min/max voltage only with valid values in realistic range (5V-50V) if (validVoltage !== null && validVoltage > 5.0 && validVoltage < 50.0) { const minInRange = (typeof history.minimumVoltage === 'number' && history.minimumVoltage > 5.0 && history.minimumVoltage < 50.0); const maxInRange = (typeof history.maximumVoltage === 'number' && history.maximumVoltage > 5.0 && history.maximumVoltage < 50.0); if (!minInRange || validVoltage < history.minimumVoltage) { history.minimumVoltage = validVoltage; } if (!maxInRange || validVoltage > history.maximumVoltage) { history.maximumVoltage = validVoltage; } } // Calculate energy accumulation if we have valid previous data if (accumulator && validCurrent !== null && validVoltage !== null) { const deltaTimeHours = (now - lastTime) / (1000 * 3600); // Convert to hours // Only log time calculation if deltaTimeHours is suspiciously large (> 0.1h = 6 minutes) if (deltaTimeHours > 0.1) { this.logger.debug(`Large time gap for ${devicePath}: deltaHours=${deltaTimeHours.toFixed(3)}h`); } if (deltaTimeHours > 0 && deltaTimeHours < 1) { // Sanity check: less than 1 hour // Get solar and alternator currents for the new calculation method const solarCurrent = this._getSolarCurrent() || 0; const alternatorCurrent = this._getAlternatorCurrent() || 0; // Your corrected specification: // Cumulative Ah drawn = S + L - A (Solar + Alternator - Battery Current) // Discharged energy: If A < 0 then use A (battery current itself) // Charged energy: If A > 0 then use A (battery current itself) // Calculate discharge consumption using S + L - A formula const dischargeConsumption = solarCurrent + alternatorCurrent - validCurrent; // Clamp discharge consumption to 0 if negative (prevents false values) const clampedDischargeConsumption = Math.max(0, dischargeConsumption); // Accumulate total Ah drawn using the clamped consumption if (clampedDischargeConsumption > 0) { const consumptionDelta = clampedDischargeConsumption * deltaTimeHours; if (!isNaN(history.totalAhDrawn)) { history.totalAhDrawn += consumptionDelta; } else { history.totalAhDrawn = consumptionDelta; } // Only log large consumption changes to reduce noise if (consumptionDelta > 0.01) { // > 0.01Ah = 10mAh this.logger.debug(`Total Ah drawn: +${consumptionDelta.toFixed(3)}Ah (total: ${history.totalAhDrawn.toFixed(1)}Ah)`); } } if (validCurrent < 0) { // Battery is discharging - use battery current (A) for discharged energy const dischargeEnergyDelta = (validVoltage * Math.abs(validCurrent) * deltaTimeHours) / 1000; // kWh if (!isNaN(history.dischargedEnergy)) { history.dischargedEnergy += dischargeEnergyDelta; } else { history.dischargedEnergy = dischargeEnergyDelta; } // Only log significant discharge energy changes if (dischargeEnergyDelta > 0.001) { // > 1Wh this.logger.debug(`Battery discharging: ${validCurrent.toFixed(1)}A → +${dischargeEnergyDelta.toFixed(3)}kWh discharged (total: ${history.dischargedEnergy.toFixed(3)}kWh)`); } } else if (validCurrent > 0) { // Battery is charging - use battery current (A) for charged energy const chargeEnergyDelta = (validVoltage * validCurrent * deltaTimeHours) / 1000; // kWh if (!isNaN(history.chargedEnergy)) { history.chargedEnergy += chargeEnergyDelta; } else { history.chargedEnergy = chargeEnergyDelta; } // Only log significant charge energy changes if (chargeEnergyDelta > 0.001) { // > 1Wh this.logger.debug(`Battery charging: ${validCurrent.toFixed(1)}A → +${chargeEnergyDelta.toFixed(3)}kWh charged (total: ${history.chargedEnergy.toFixed(3)}kWh)`); } } // Update lastUpdateTime only when energy calculations were performed this.lastUpdateTime.set(devicePath, now); } else if (deltaTimeHours >= 1) { // Log if deltaTime is suspiciously large (≥ 1 hour) this.logger.debug(`Large deltaTime for ${devicePath}: ${deltaTimeHours.toFixed(2)}h - updating timestamp only`); this.lastUpdateTime.set(devicePath, now); } else { // Normal case: update timestamp without logging this.lastUpdateTime.set(devicePath, now); } } // Update accumulator with valid values - only update if we have valid data // This should happen regardless of whether energy calculation occurred if (accumulator) { if (validCurrent !== null) { accumulator.lastCurrent = validCurrent; } if (validVoltage !== null) { accumulator.lastVoltage = validVoltage; } accumulator.lastTimestamp = now; } // Ensure all history values are valid numbers if (isNaN(history.dischargedEnergy)) history.dischargedEnergy = 0; if (isNaN(history.chargedEnergy)) history.chargedEnergy = 0; if (isNaN(history.totalAhDrawn)) history.totalAhDrawn = 0; // Note: minimumVoltage and maximumVoltage can be null until first real voltage is received return history; } // Helper methods to get solar and alternator current for energy calculations _getSolarCurrent() { if (!this.signalKApp) return 0; try { let totalSolarCurrent = 0; // Get solar devices from configuration const solarDevices = this.settings.batteryMonitor?.directDcDevices?.filter(device => device.type === 'solar') || []; // Only log solar device count if it's > 0 to reduce noise if (solarDevices.length > 0) { this.logger.debug(`Solar devices configured: ${solarDevices.length}`); } for (const device of solarDevices) { const currentPath = device.currentPath; if (currentPath) { const value = this._getCurrentSignalKValue(currentPath); if (value !== null && typeof value === 'number' && !isNaN(value) && value >= 0) { totalSolarCurrent += value; // Only log individual device currents if > 0.1A to reduce noise if (value > 0.1) { this.logger.debug(`Solar device ${device.basePath}: ${value.toFixed(1)}A`); } } else { // Only log invalid values for debugging this.logger.debug(`Solar device ${device.basePath}: invalid value ${value} at path ${currentPath}`); } } } // If no configured devices found any current, try to detect common solar paths if (totalSolarCurrent === 0 && solarDevices.length > 0) { // First, specifically test the user's confirmed path const userSolarPath = 'electrical.solar.278.current'; const userValue = this._getCurrentSignalKValue(userSolarPath); this.logger.debug(`Direct test of user's solar path '${userSolarPath}': ${userValue} (type=${typeof userValue})`); const commonSolarPaths = [ 'electrical.solar.current', 'electrical.solar.0.current', 'electrical.solar.1.current', 'electrical.solar.panelsCurrent', 'electrical.chargers.solar.current' ]; for (const path of commonSolarPaths) { const value = this._getCurrentSignalKValue(path); if (value !== null && typeof value === 'number' && !isNaN(value) && value > 0) { this.logger.debug(`Found solar current at common path '${path}': ${value}A`); // Don't add to total, just log for discovery } } } // Only log total solar current if > 0 to reduce noise if (totalSolarCurrent > 0.1) { this.logger.debug(`Total solar current: ${totalSolarCurrent.toFixed(1)}A from ${solarDevices.length} devices`); } return totalSolarCurrent; } catch (err) { this.logger.debug(`Error getting solar current: ${err.message}`); return 0; } } _getAlternatorCurrent() { if (!this.signalKApp) return 0; try { let totalAlternatorCurrent = 0; // Get alternator devices from configuration const alternatorDevices = this.settings.batteryMonitor?.directDcDevices?.filter(device => device.type === 'alternator') || []; // Only log alternator device count if it's > 0 to reduce noise if (alternatorDevices.length > 0) { this.logger.debug(`Alternator devices configured: ${alternatorDevices.length}`); } for (const device of alternatorDevices) { const currentPath = device.currentPath; if (currentPath) { const value = this._getCurrentSignalKValue(currentPath); if (value !== null && typeof value === 'number' && !isNaN(value) && value >= 0) { totalAlternatorCurrent += value; // Only log individual device currents if > 0.1A to reduce noise if (value > 0.1) { this.logger.debug(`Alternator device ${device.basePath}: ${value.toFixed(1)}A`); } } else { // Only log invalid values for debugging this.logger.debug(`Alternator device ${device.basePath}: invalid value ${value} at path ${currentPath}`); } } } // If no configured devices found any current, try to detect common alternator paths if (totalAlternatorCurrent === 0 && alternatorDevices.length > 0) { this.logger.debug('No alternator current from configured devices, trying common paths...'); const commonAlternatorPaths = [ 'electrical.alternators.current', 'electrical.alternators.0.current', 'electrical.alternators.1.current', 'electrical.alternator.current', 'propulsion.main.alternator.current', 'propulsion.port.alternator.current', 'propulsion.starboard.alternator.current' ]; for (const path of commonAlternatorPaths) { const value = this._getCurrentSignalKValue(path); if (value !== null && typeof value === 'number' && !isNaN(value) && value > 0) { this.logger.debug(`Found alternator current at common path '${path}': ${value}A`); // Don't add to total, just log for discovery } } } // Only log total alternator current if > 0 to reduce noise if (totalAlternatorCurrent > 0.1) { this.logger.debug(`Total alternator current: ${totalAlternatorCurrent.toFixed(1)}A from ${alternatorDevices.length} devices`); } return totalAlternatorCurrent; } catch (err) { this.logger.debug(`Error getting alternator current: ${err.message}`); return 0; } } // Calculate total system power consumption including all energy sources _calculateSystemPower(batteryDevicePath, batteryVoltage, batteryCurrent, batteryPower) { // Start with battery power as baseline const baseBatteryPower = batteryPower !== null ? batteryPower : (batteryVoltage * batteryCurrent); let totalSystemPower = baseBatteryPower; let solarPower = 0; let alternatorPower = 0; let shorePower = 0; let hasAdditionalSources = false; if (!this.signalKApp) { return { totalSystemPower: baseBatteryPower, solarPower: 0, alternatorPower: 0, shorePower: 0, hasAdditionalSources: false }; } try { // Get solar power from Signal K // Common solar paths: electrical.solar.*, electrical.chargers.*, etc. const solarPaths = [ 'electrical.solar.current', 'electrical.solar.power', 'electrical.chargers.solar.current', 'electrical.chargers.solar.power' ]; for (const path of solarPaths) { const value = this._getCurrentSignalKValue(path); if (value !== null && typeof value === 'number' && !isNaN(value)) { if (path.includes('power')) { solarPower += Math.abs(value); } else if (path.includes('current')) { solarPower += Math.abs(value) * batteryVoltage; // I * V = P } hasAdditionalSources = true; } } // Get alternator power from Signal K // Common alternator paths: electrical.alternators.*, propulsion.*.alternator.* const alternatorPaths = [ 'electrical.alternators.current', 'electrical.alternators.power', 'propulsion.main.alternator.current', 'propulsion.main.alternator.power', 'propulsion.port.alternator.current', 'propulsion.port.alternator.power', 'propulsion.starboard.alternator.current', 'propulsion.starboard.alternator.power' ]; for (const path of alternatorPaths) { const value = this._getCurrentSignalKValue(path); if (value !== null && typeof value === 'number' && !isNaN(value)) { if (path.includes('power')) { alternatorPower += Math.abs(value); } else if (path.includes('current')) { alternatorPower += Math.abs(value) * batteryVoltage; // I * V = P } hasAdditionalSources = true; } } // Get shore power from Signal K // Common shore power paths: electrical.shore.*, electrical.chargers.shore.* const shorePaths = [ 'electrical.shore.current', 'electrical.shore.power', 'electrical.chargers.shore.current', 'electrical.chargers.shore.power', 'electrical.chargers.mains.current', 'electrical.chargers.mains.power' ]; for (const path of shorePaths) { const value = this._getCurrentSignalKValue(path); if (value !== null && typeof value === 'number' && !isNaN(value)) { if (path.includes('power')) { shorePower += Math.abs(value); } else if (path.includes('current')) { shorePower += Math.abs(value) * batteryVoltage; // I * V = P } hasAdditionalSources = true; } } // Calculate total system consumption // If battery is discharging (negative current), add other sources // If battery is charging (positive current), account for excess generation if (batteryCurrent < 0) { // Battery discharging: Total consumption = Battery discharge + Direct consumption from other sources totalSystemPower = Math.abs(baseBatteryPower) + solarPower + alternatorPower + shorePower; } else if (batteryCurrent > 0) { // Battery charging: Some generation goes to battery, some to direct consumption // We can't easily determine the split without load monitoring, so we use a heuristic const totalGeneration = solarPower + alternatorPower + shorePower; const batteryChargePower = Math.abs(baseBatteryPower); if (totalGeneration > batteryChargePower) { // Excess generation goes to direct consumption totalSystemPower = totalGeneration - batteryChargePower; } else { // All generation goes to battery, no additional consumption tracked totalSystemPower = 0; // No net consumption when charging from external sources } } } catch (error) { this.logger.debug(`Error calculating system power: ${error.message}`); // Fallback to battery power only totalSystemPower = baseBatteryPower; } return { totalSystemPower: totalSystemPower, solarPower: solarPower, alternatorPower: alternatorPower, shorePower: shorePower, hasAdditionalSources: hasAdditionalSources }; } // Helper function to get current Signal K value _getCurrentSignalKValue(path) { if (this.signalKApp && this.signalKApp.getSelfPath) { try { const rawValue = this.signalKApp.getSelfPath(path); // Only log if rawValue is undefined/null to help debug missing data if (rawValue === undefined || rawValue === null) { this.logger.debug(`SignalK getSelfPath('${path}') returned: ${rawValue} (type: ${typeof rawValue})`); } // Handle SignalK value objects that have nested .value property if (rawValue && typeof rawValue === 'object' && rawValue.value !== undefined) { const extractedValue = rawValue.value; return extractedValue; } // Return raw value if it's already a primitive return rawValue; } catch (err) { this.logger.debug(`Could not get Signal K value for ${path}: ${err.message}`); return null; } } this.logger.debug(`SignalK app not available for path: ${path}`); return null; } // 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 _getOrCreateDeviceInstance(path) { // Extract the base device path using device-specific logic const basePath = this._extractBasePath(path); // Validate basePath if (!basePath) { this.logger.error(`Failed to extract valid basePath from: ${path}`); return null; } if (!this.deviceInstances.has(basePath)) { // Mark that we're creating this device to prevent duplicate creation this.deviceInstances.set(basePath, 'creating'); try { // Create a deterministic index based on the path hash to ensure consistency const index = this._generateStableIndex(basePath); const deviceInstance = { index: index, name: this._getDeviceName(path), basePath: basePath }; // Create device service for this device with its own D-Bus connection const deviceService = new VEDBusService( `SignalK${deviceInstance.index}`, deviceInstance, this.settings, this.deviceConfig, this.logger, (path) => this._getCurrentSignalKValue(path) // Signal K value getter ); await deviceService.init(); // Initialize the device service // Store the basePath on the deviceService for easy access deviceService.basePath = basePath; // Debug: Verify basePath is correctly set if (!deviceService.basePath) { this.logger.error(`Failed to set basePath on deviceService: ${basePath}`); } else { this.logger.debug(`Successfully set basePath on deviceService: ${deviceService.basePath}`); } // we should really have a vedbus-tank, vedbus-battery, etc to get rid of this. switch (this._internalDeviceType) { case 'tank': await deviceService.updateProperty('/FluidType', this._getFluidType(path), 'i', `Fluid Type`); break; case 'battery': // Initialize battery monitor properties - Venus OS requires all these paths to be present await deviceService.updateProperty('/System/HasBatteryMonitor', 1, 'i', 'Has battery monitor'); // NOTE: Battery capacity will only be set when real Signal K data arrives // No more fake default capacity to prevent false data pollution // IMPORTANT: Don't initialize ConsumedAmphours with fake data // This will be calculated from real SOC when Signal K data arrives // CRITICAL: Don't initialize battery data properties with fake values! // Only initialize if we have real Signal K values available // These properties will be set when actual Signal K data arrives // Check if we have real Signal K values and use those for initialization const basePath = deviceInstance.basePath; if (basePath && this.signalKApp) { try { // Try to get real current values from Signal K const currentSoc = this._getCurrentSignalKValue(`${basePath}.capacity.stateOfCharge`); const currentVoltage = this._getCurrentSignalKValue(`${basePath}.voltage`); const currentCurrent = this._getCurrentSignalKValue(`${basePath}.current`); const currentPower = this._getCurrentSignalKValue(`${basePath}.power`); const currentTemp = this._getCurrentSignalKValue(`${basePath}.temperature`); // Only initialize properties if we have real values if (currentSoc !== null && currentSoc !== undefined && typeof currentSoc === 'number') { const socPercent = currentSoc > 1 ? currentSoc : currentSoc * 100; await deviceService.updateProperty('/Soc', socPercent, 'd', 'State of charge'); this.logger.debug(`Initialized SOC with real Signal K value: ${socPercent}%`); } if (currentVoltage !== null && currentVoltage !== undefined && typeof currentVoltage === 'number') { await deviceService.updateProperty('/Dc/0/Voltage', currentVoltage, 'd', 'Battery voltage'); this.logger.debug(`Initialized voltage with real Signal K value: ${currentVoltage}V`); } if (currentCurrent !== null && currentCurrent !== undefined && typeof currentCurrent === 'number') { await deviceService.updateProperty('/Dc/0/Current', currentCurrent, 'd', 'Battery current'); this.logger.debug(`Initialized current with real Signal K value: ${currentCurrent}A`); } if (currentPower !== null && currentPower !== undefined && typeof currentPower === 'number') { await deviceService.updateProperty('/Dc/0/Power', currentPower, 'd', 'Battery power'); this.logger.debug(`Initialized power with real Signal K value: ${currentPower}W`); } if (currentTemp !== null && currentTemp !== undefined && typeof currentTemp === 'number') { // Convert temperature if needed (from Kelvin) const tempCelsius = currentTemp > 100 ? currentTemp - 273.15 : currentTemp; await deviceService.updateProperty('/Dc/0/Temperature', tempCelsius, 'd', 'Battery temperature'); this.logger.debug(`Initialized temperature with real Signal K value: ${tempCelsius}°C`); } // Calculate initial consumed Ah and time to go if we have SOC and capacity if (currentSoc !== null && typeof currentSoc === 'number') { const socPercent = currentSoc > 1 ? currentSoc : currentSoc * 100; // Try to get real capacity data from Signal K, fall back to settings const capacityPath = `${basePath}.capacity.nominal`; const signalKCapacity = this.signalKApp.getSelfPath(capacityPath); // Use Signal K capacity if available, otherwise use settings capacity let workingCapacity = null; if (signalKCapacity && typeof signalKCapacity === 'number' && signalKCapacity > 0) { // Signal K capacity is in Joules, convert to Ah: Joules / (Voltage * 3600) if (currentVoltage && typeof currentVoltage === 'number' && currentVoltage > 0) { workingCapacity = signalKCapacity / (currentVoltage * 3600); } else { // Fallback: use typical 12V if voltage not available workingCapacity = signalKCapacity / (12 * 3600); } } else if (this.settings.batteryMonitor?.batteryCapacity) { workingCapacity = this.settings.batteryMonitor.batteryCapacity; } if (workingCapacity && typeof workingCapacity === 'number' && workingCapacity > 0) { const consumedAh = workingCapacity * (100 - socPercent) / 100; await deviceService.updateProperty('/ConsumedAmphours', consumedAh, 'd', 'Consumed Ah'); await deviceService.updateProperty('/Capacity', workingCapacity, 'd', 'Battery capacity'); // Calculate realistic time to go based on SOC and capacity if (currentCurrent !== null && typeof currentCurrent === 'number' && currentCurrent !== 0) { let timeToGoSeconds; if (currentCurrent < 0) { // Battery is discharging - calculate time until empty const remainingCapacity = workingCapacity * (socPercent / 100); const timeToGoHours = remainingCapacity / Math.abs(currentCurrent); timeToGoSeconds = Math.round(timeToGoHours * 3600); } else { // Battery is charging - calculate time to 100% SoC const remainingCapacityToFull = workingCapacity * ((100 - socPercent) / 100); const chargeTimeHours = remainingCapacityToFull / currentCurrent; timeToGoSeconds = Math.round(chargeTimeHours * 3600); } await deviceService.updateProperty('/TimeToGo', timeToGoSeconds, 'i', 'Time to go'); } } } // Initialize history tracking for this battery await this.initializeHistoryTracking(basePath, currentVoltage || 12.0); } catch (err) { this.logger.debug(`Could not get initial Signal K values for battery initialization: ${err.message}`); // Don't set any default values - let updateProperty handle first real values } } // NOTE: We no longer initialize /Soc, /Dc/0/Voltage, /Dc/0/Current, /Dc/0/Power with fake defaults // These will only be set when real Signal K data arrives via handleSignalKUpdate // Initialize relay state (normally closed for battery monitors) await deviceService.updateProperty('/Relay/0/State', 0, 'i', 'Battery relay state'); // Additional battery monitor specific paths that Venus OS might need await deviceService.updateProperty('/System/BatteryService', 1, 'i', 'Battery service'); // Critical properties for BMV recognition by Venus OS system service await deviceService.updateProperty('/System/NrOfBatteries', 1, 'i', 'Number of batteries'); // NOTE: Min/Max cell voltage removed - they'll be set with real data only // Initialize additional paths that might be needed for proper battery monitor display // State: 0 = Offline, 1 = Online, 2 = Error, 3 = Unavailable - use 1 for Online await deviceService.updateProperty('/State', 1, 'i', 'Battery state'); await deviceService.updateProperty('/ErrorCode', 0, 'i', 'Error code'); await deviceService.updateProperty('/Alarms/LowVoltage', 0, 'i', 'Low voltage alarm'); await deviceService.updateProperty('/Alarms/HighVoltage', 0, 'i', 'High voltage alarm'); await deviceService.updateProperty('/Alarms/LowSoc', 0, 'i', 'Low SOC alarm'); await deviceService.updateProperty('/Alarms/HighCurrent', 0, 'i', 'High current alarm'); await deviceService.updateProperty('/Alarms/HighTemperature', 0, 'i', 'High temperature alarm'); await deviceService.updateProperty('/Alarms/LowTemperature', 0, 'i', 'Low temperature alarm'); // Add Connected property which Venus OS requires for BMV recognition await deviceService.updateProperty('/Connected', 1, 'i', 'Connected'); // Add DeviceType property - 512 is the code for BMV await deviceService.updateProperty('/DeviceType', 512, 'i', 'Device type'); // Add critical system integration properties that Venus OS system service needs // These are essential for proper VRM integration and BMV recognition await deviceService.updateProperty('/Info/BatteryLowVoltage', 0, 'i', 'Battery low voltage info'); // NOTE: Removed default MaxChargeCurrent, MaxDischargeCurrent, MaxChargeVoltage - only set with real data // NOTE: History properties no longer have fake defaults - they'll be set with real data only // NOTE: Min/Max voltage tracking removed - will be implemented with real data only // NOTE: Mid voltage properties removed - they'll be set with real data only // Add balancer information for system service await deviceService.updateProperty('/Balancer', 0, 'i', 'Balancer active'); await deviceService.updateProperty('/Io/AllowToCharge', 1, 'i', 'Allow to charge'); await deviceService.updateProperty('/Io/AllowToDischarge', 1, 'i', 'Allow to discharge'); await deviceService.updateProperty('/Io/ExternalRelay', 0, 'i', 'External relay'); break; case 'switch': case 'environment': default: break; } this.deviceServices.set(basePath, deviceService); this.deviceInstances.set(basePath, deviceInstance); this.logger.debug(`Successfully created device instance for ${basePath} as ${this._internalDeviceType} with VRM instance ${deviceInstance.index}`); return deviceInstance; } catch (error) { this.logger.error(`❌ Error creating device instance for ${basePath} (from path: ${path}):`, error); this.logger.error(`❌ Error stack:`, error.stack); // Remove the entry to allow retry on next call this.deviceInstances.delete(basePath); return null; } } else { const existing = this.deviceInstances.get(basePath); if (existing === 'creating') { // Device is currently being created, wait a bit and try again // Wait for creation to complete with timeout const maxWaitTime = 5000; // 5 seconds max wait const pollInterval = 200; // Check every 200ms let waitTime = 0; while (waitTime < maxWaitTime) { await new Promise(resolve => setTimeout(resolve, pollInterval)); waitTime += pollInterval; const updated = this.deviceInstances.get(basePath); if (updated !== 'creating') { // Creation completed (either success or failure) return updated || null; } } // Timeout waiting for creation this.logger.warn(`⚠️ Timeout waiting for device creation: ${basePath}`); return null; } return existing; } } _extractBasePath(path) { if (!path || typeof path !== 'string') { this.logger.error(`Invalid path provided to _extractBasePath: ${path}`); return null; } let basePath; switch (this._internalDeviceType) { case 'tank': // Handle tank paths like 'tanks.freshWater.0.capacity' -> 'tanks.freshWater.0' // and also 'tanks.freshWater.0.currentLevel' -> 'tanks.freshWater.0' basePath = path.replace(/\.(currentLevel|capacity|name|currentVolume|voltage)$/, ''); break; case 'battery': basePath = path.replace(/\.(voltage|current|stateOfCharge|consumed|timeRemaining|relay|temperature|name|capacity\..*|power)$/, ''); break; case 'switch': basePath = path.replace(/\.(state|dimmingLevel|position|name)$/, ''); break; case 'environment': basePath = path.replace(/\.(temperature|humidity|relativeHumidity)$/, ''); break; default: basePath = path; break; } // Ensure we never return empty string or invalid values if (!basePath || basePath.trim() === '') { this.logger.error(`Extracted basePath is empty from path: ${path}`); return null; } return basePath; } _generateStableIndex(basePath) { // Generate a stable index based on the base path to ensure the same device // always gets the same index, even across restarts let hash = 0; for (let i = 0; i < basePath.length; i++) { const char = basePath.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } // Ensure we get a positive number within a reasonable range (0-999) return Math.abs(hash) % 1000; } _getDeviceName(path) { switch (this._internalDeviceType) { case 'tank': return this._getTankName(path); case 'battery': return this._getBatteryName(path); case 'switch': return this._getSwitchName(path); case 'environment': return this._getEnvironmentName(path); default: return 'Unknown Device'; } } _getTankName(path) { const parts = path.split('.'); let tankName = 'Unknown Tank'; if (parts.length >= 3) { const tankType = parts[1]; // e.g., 'fuel', 'freshWater', 'wasteWater' const tankLocation = parts[2]; // e.g., 'starboard', 'port', 'main', '0' const fluidTypeConfig = this.deviceConfig.fluidTypes[tankType]; if (fluidTypeConfig) { let baseTypeName = fluidTypeConfig.name; // Remove spaces and fix capitalization for consistency (Fresh Water -> Freshwater) baseTypeName = baseTypeName.replace(/\s+/g, '').toLowerCase(); baseTypeName = baseTypeName.charAt(0).toUpperCase() + baseTypeName.slice(1); // Check if we have multiple tanks of this type const tanksOfThisType = Array.from(this.deviceInstances.keys()) .filter(devicePath => devicePath.includes(`tanks.${tankType}.`)).length; // Use generic ID detection const isGenericId = ['0', 'main', 'primary', 'default'].includes(tankLocation.toLowerCase()); // If single tank with generic ID, omit the ID