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
JavaScript
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