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,058 lines (912 loc) • 49.4 kB
JavaScript
import { VEDBusService } from './vedbus.js';
import { DEVICE_CONFIGS } from './deviceConfigs.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;
this.logger = logger || { debug: () => {}, error: () => {} }; // Fallback logger
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;
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
}
// Set Signal K app reference for getting current values
setSignalKApp(app) {
this.signalKApp = app;
}
// Helper function to get current Signal K value
_getCurrentSignalKValue(path) {
if (this.signalKApp && this.signalKApp.getSelfPath) {
try {
return this.signalKApp.getSelfPath(path);
} catch (err) {
this.logger.debug(`Could not get Signal K value for ${path}: ${err.message}`);
return null;
}
}
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);
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
// 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);
console.log(`🔋 Initialization: Converted Signal K capacity: ${signalKCapacity}J ÷ (${currentVoltage}V × 3600) = ${workingCapacity.toFixed(1)}Ah`);
} else {
// Fallback: use typical 12V if voltage not available
workingCapacity = signalKCapacity / (12 * 3600);
console.log(`🔋 Initialization: Converted Signal K capacity (12V fallback): ${signalKCapacity}J ÷ (12V × 3600) = ${workingCapacity.toFixed(1)}Ah`);
}
} else if (this.settings.batteryCapacity) {
workingCapacity = this.settings.batteryCapacity;
console.log(`🔋 Initialization: Using settings capacity: ${workingCapacity}Ah (Signal K capacity not available)`);
}
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');
}
}
}
} 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');
await deviceService.updateProperty('/Info/MaxChargeCurrent', 100, 'i', 'Max charge current');
await deviceService.updateProperty('/Info/MaxDischargeCurrent', 100, 'i', 'Max discharge current');
await deviceService.updateProperty('/Info/MaxChargeVoltage', 14.4, 'd', 'Max charge voltage');
// 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) {
console.error(`❌ Error creating device instance for ${basePath} (from path: ${path}):`, error);
console.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
console.warn(`⚠️ Timeout waiting for device creation: ${basePath}`);
return null;
}
return existing;
}
}
_extractBasePath(path) {
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'
return path.replace(/\.(currentLevel|capacity|name|currentVolume|voltage)$/, '');
case 'battery':
return path.replace(/\.(voltage|current|stateOfCharge|consumed|timeRemaining|relay|temperature|name|capacity\..*|power)$/, '');
case 'switch':
return path.replace(/\.(state|dimmingLevel|position|name)$/, '');
case 'environment':
return path.replace(/\.(temperature|humidity|relativeHumidity)$/, '');
default:
return path;
}
}
_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
if (tanksOfThisType <= 1 && isGenericId) {
tankName = baseTypeName;
} else {
// Multiple tanks or specific ID - include the ID
// Convert numeric IDs to start from 1 instead of 0
let displayLocation = tankLocation;
if (/^\d+$/.test(tankLocation)) {
displayLocation = (parseInt(tankLocation) + 1).toString();
}
tankName = `${baseTypeName} ${displayLocation}`;
}
} else {
// Unknown tank type - check if we have multiple tanks
const totalTanks = Array.from(this.deviceInstances.keys())
.filter(devicePath => devicePath.includes('tanks.')).length;
// Use generic ID detection
const isGenericId = ['0', 'main', 'primary', 'default'].includes(tankLocation.toLowerCase());
// If single tank with generic ID, omit the ID
if (totalTanks <= 1 && isGenericId) {
tankName = 'Unknown Tank';
} else {
// Multiple tanks or specific ID - include the ID
// Convert numeric IDs to start from 1 instead of 0
let displayLocation = tankLocation;
if (/^\d+$/.test(tankLocation)) {
displayLocation = (parseInt(tankLocation) + 1).toString();
}
tankName = `Unknown Tank ${displayLocation}`;
}
}
}
return tankName;
}
_getFluidType(path) {
const parts = path.split('.');
let fluidType = 0;
if (parts.length >= 3) {
const tankType = parts[1]; // e.g., 'fuel', 'freshWater', 'wasteWater'
fluidType = this.deviceConfig.fluidTypes[tankType].value ?? 0;
}
return fluidType;
}
_getBatteryName(path) {
const parts = path.split('.');
if (parts.length >= 3) {
const batteryId = parts[2]; // e.g., '0', '1', 'main', 'house', 'starter'
// Check if we have multiple batteries
const totalBatteries = Array.from(this.deviceInstances.keys())
.filter(devicePath => devicePath.includes('electrical.batteries.')).length;
const isGenericId = ['0', 'main', 'primary', 'default'].includes(batteryId.toLowerCase());
if (totalBatteries <= 1 && isGenericId) {
return 'Battery';
}
// Convert numeric IDs to start from 1 instead of 0
let displayId = batteryId;
if (/^\d+$/.test(batteryId)) {
displayId = (parseInt(batteryId) + 1).toString();
} else {
// For non-numeric IDs, capitalize first letter
displayId = batteryId.charAt(0).toUpperCase() + batteryId.slice(1);
}
return `Battery ${displayId}`;
}
return 'Battery';
}
_getSwitchName(path) {
const parts = path.split('.');
if (parts.length >= 3) {
const switchId = parts[2]; // e.g., 'nav', 'anchor', 'cabinLights', '0', '1'
// Check if we have multiple switches
const totalSwitches = Array.from(this.deviceInstances.keys())
.filter(devicePath => devicePath.includes('electrical.switches.')).length;
// If it's a functional name (not just a number), use it directly
if (!/^\d+$/.test(switchId)) {
// Convert camelCase to Title Case with spaces
return switchId.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
} else {
// It's a numeric ID - check if we should omit the number for single devices
const numericId = parseInt(switchId);
if (numericId === 0 && totalSwitches <= 1) {
// Single device with ID 0 - omit the number
return 'Switch';
} else {
// Multiple devices or ID > 0 - convert to start from 1 instead of 0
const displayId = (numericId + 1).toString();
return `Switch ${displayId}`;
}
}
}
return 'Switch';
}
_getEnvironmentName(path) {
const parts = path.split('.');
if (parts.length >= 3) {
const environmentType = parts[1]; // e.g., 'water', 'air', 'inside', 'outside'
// Remove camel case and capitalize first letter
let sensor = environmentType.replace(/([A-Z])/g, ' $1').trim();
sensor = sensor.charAt(0).toUpperCase() + sensor.slice(1).toLowerCase();
// Return just the location name - Venus OS will show the measurement types separately
return sensor;
}
return 'Environment sensor';
}
async handleSignalKUpdate(path, value) {
try {
// Validate input parameters
if (value === null || value === undefined) {
return;
}
// Check if this path is relevant for our device type
if (!this._isRelevantPath(path)) {
return;
}
// Initialize if not already done
const deviceInstance = await this._getOrCreateDeviceInstance(path);
if (!deviceInstance) {
console.error(`Failed to create device instance for ${path}`);
return;
}
// Get the device service
const deviceService = this.deviceServices.get(deviceInstance.basePath);
if (!deviceService) {
console.error(`No device service found for ${deviceInstance.basePath}`);
return;
}
// Check if device service is connected and ready for data updates
if (!deviceService.isConnected) {
console.warn(`⚠️ RACE CONDITION: Device service ${deviceInstance.basePath} not connected yet - data update ${path} = ${value} will be dropped`);
return;
}
// Throttled logging for data updates to reduce noise
const now = Date.now();
const lastLogTime = this._lastDataUpdateLog.get(deviceInstance.basePath) || 0;
if (now - lastLogTime > this._dataUpdateLogInterval) {
this._lastDataUpdateLog.set(deviceInstance.basePath, now);
}
// Handle the update based on device type
await this._handleDeviceSpecificUpdate(path, value, deviceService, deviceInstance);
} catch (err) {
// Handle connection errors gracefully
if (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' || err.code === 'EPIPE') {
this.logger.debug(`Connection lost while updating ${path} - Venus OS may be restarting`);
// Don't throw the error, just log it
} else {
console.error(`❌ Error in handleSignalKUpdate for ${path}:`, err);
console.error(`❌ Error stack:`, err.stack);
// Don't throw the error, just log it to prevent higher-level catching
}
}
}
_isRelevantPath(path) {
switch (this._internalDeviceType) {
case 'tank':
return path.startsWith('tanks.');
case 'battery':
return path.startsWith('electrical.batteries.');
case 'switch':
return path.startsWith('electrical.switches.');
case 'environment':
return path.startsWith('environment.');
default:
return false;
}
}
async _handleDeviceSpecificUpdate(path, value, deviceService, deviceInstance) {
const deviceName = deviceInstance.name;
switch (this._internalDeviceType) {
case 'tank':
await this._handleTankUpdate(path, value, deviceService, deviceName);
break;
case 'battery':
await this._handleBatteryUpdate(path, value, deviceService, deviceName);
break;
case 'switch':
await this._handleSwitchUpdate(path, value, deviceService, deviceName);
break;
case 'environment':
await this._handleEnvironmentUpdate(path, value, deviceService, deviceName);
break;
}
}
async _handleTankUpdate(path, value, deviceService, deviceName) {
if (path.includes('currentLevel')) {
if (typeof value === 'number' && !isNaN(value)) {
const levelPercent = value * 100;
await deviceService.updateProperty('/Level', levelPercent, 'd', `${deviceName} level`);
if ("/Capacity" in deviceService.deviceData)
{
await deviceService.updateProperty('/Remaining', value * deviceService.deviceData["/Capacity"], 'd', `${deviceName} level`);
}
this.emit('dataUpdated', 'Tank Level', `${deviceName}: ${levelPercent.toFixed(1)}%`);
}
} else if (path.includes('capacity')) {
if (typeof value === 'number' && !isNaN(value)) {
await deviceService.updateProperty('/Capacity', value, 'd', `${deviceName} capacity`);
this.emit('dataUpdated', 'Tank Capacity', `${deviceName}: ${value.toFixed(1)}L`);
}
} else if (path.includes('name')) {
if (typeof value === 'string') {
await deviceService.updateProperty('/CustomName', value, 's', `${deviceName}`);
this.emit('dataUpdated', 'Tank Name', `${deviceName}: ${value}`);
}
} else if (path.includes('currentVolume')) {
if (typeof value === 'number' && !isNaN(value)) {
await deviceService.updateProperty('/Volume', value, 'd', `${deviceName} volume`);
this.emit('dataUpdated', 'Tank Volume', `${deviceName}: ${value.toFixed(1)}L`);
}
} else if (path.includes('voltage')) {
if (typeof value === 'number' && !isNaN(value)) {
await deviceService.updateProperty('/RawUnit', 'V', 's', `${deviceName} voltage`);
await deviceService.updateProperty('/RawValue', value, 'd', `${deviceName} voltage`);
this.emit('dataUpdated', 'Tank Voltage', `${deviceName}: ${value.toFixed(2)}V`);
}
}
}
async _handleBatteryUpdate(path, value, deviceService, deviceName) {
if (path.includes('voltage')) {
if (typeof value === 'number' && !isNaN(value)) {
await deviceService.updateProperty('/Dc/0/Voltage', value, 'd', `${deviceName} voltage`);
this.emit('dataUpdated', 'Battery Voltage', `${deviceName}: ${value.toFixed(2)}V`);
// Calculate power if we have both voltage and current
await this._calculateAndUpdatePower(deviceService, deviceName);
// Only trigger system service updates for battery monitors (BMV)
if (this._internalDeviceType === 'battery') {
await this._notifySystemService(deviceService, deviceName);
await this._triggerSystemServiceRefresh(deviceService, deviceName);
}
}
} else if (path.includes('current')) {
if (typeof value === 'number' && !isNaN(value)) {
await deviceService.updateProperty('/Dc/0/Current', value, 'd', `${deviceName} current`);
this.emit('dataUpdated', 'Battery Current', `${deviceName}: ${value.toFixed(1)}A`);
// Calculate power if we have both voltage and current
await this._calculateAndUpdatePower(deviceService, deviceName);
// Only trigger system service updates for battery monitors (BMV)
if (this._internalDeviceType === 'battery') {
await this._notifySystemService(deviceService, deviceName);
await this._triggerSystemServiceRefresh(deviceService, deviceName);
}
}
} else if (path.includes('stateOfCharge') || (path.includes('capacity') && path.includes('state'))) {
if (typeof value === 'number' && !isNaN(value)) {
const socPercent = value > 1 ? value : value * 100;
await deviceService.updateProperty('/Soc', socPercent, 'd', `${deviceName} state of charge`);
this.emit('dataUpdated', 'Battery SoC', `${deviceName}: ${socPercent.toFixed(1)}%`);
// Update battery dummy data (especially consumed Ah based on SOC)
await this._updateBatteryDummyData(deviceService, deviceName);
// This is critical - trigger system service update when SOC changes for BMV
if (this._internalDeviceType === 'battery') {
await this._notifySystemService(deviceService, deviceName);
await this._triggerSystemServiceRefresh(deviceService, deviceName);
}
}
} else if (path.includes('timeRemaining')) {
if (typeof value === 'number' && !isNaN(value) && value !== null) {
// timeRemaining is in seconds, convert to Venus OS format
let timeToGoSeconds = Math.round(value);
// Log the conversion for debugging
const hours = Math.floor(timeToGoSeconds / 3600);
const minutes = Math.floor((timeToGoSeconds % 3600) / 60);
// Use integer type as per Victron specification
await deviceService.updateProperty('/TimeToGo', timeToGoSeconds, 'i', `${deviceName} time to go`);
this.emit('dataUpdated', 'Battery Time to Go', `${deviceName}: ${hours}h ${minutes}m`);
} else {
// Ignoring null/invalid timeRemaining value
}
} else if (path.includes('capacity') && !path.includes('state')) {
if (typeof value === 'number' && !isNaN(value)) {
// Signal K capacity is in Joules, convert to Ah: Joules / (Voltage * 3600)
const currentVoltage = deviceService.deviceData['/Dc/0/Voltage'];
let capacityAh;
if (currentVoltage && typeof currentVoltage === 'number' && currentVoltage > 0) {
capacityAh = value / (currentVoltage * 3600);
console.log(`🔋 Capacity conversion: ${value}J ÷ (${currentVoltage}V × 3600) = ${capacityAh.toFixed(1)}Ah`);
} else {
// Fallback: use typical 12V if voltage not available
capacityAh = value / (12 * 3600);
console.log(`🔋 Capacity conversion (12V fallback): ${value}J ÷ (12V × 3600) = ${capacityAh.toFixed(1)}Ah`);
}
await deviceService.updateProperty('/Capacity', capacityAh, 'd', `${deviceName} capacity`);
this.emit('dataUpdated', 'Battery Capacity', `${deviceName}: ${capacityAh.toFixed(1)}Ah`);
// Update battery dummy data with new capacity
await this._updateBatteryDummyData(deviceService, deviceName);
}
} else if (path.includes('consumed')) {
if (typeof value === 'number' && !isNaN(value)) {
// Consumed amphours
await deviceService.updateProperty('/ConsumedAmphours', value, 'd', `${deviceName} consumed`);
this.emit('dataUpdated', 'Battery Consumed', `${deviceName}: ${value.toFixed(1)}Ah`);
}
} else if (path.includes('power')) {
if (typeof value === 'number' && !isNaN(value)) {
await deviceService.updateProperty('/Dc/0/Power', value, 'd', `${deviceName} power`);
this.emit('dataUpdated', 'Battery Power', `${deviceName}: ${value.toFixed(1)}W`);
}
} else if (path.includes('temperature')) {
if (typeof value === 'number' && !isNaN(value)) {
let tempCelsius;
// Convert temperature from Kelvin to Celsius if needed
// SignalK typically uses Kelvin for temperatures
// Normal battery temperatures are -40°C to +80°C (-40°F to +176°F)
// In Kelvin: 233K to 353K
if (value > 100) {
// Likely Kelvin (anything above 100 is probably Kelvin)
tempCelsius = value - 273.15;
} else {
// Likely already in Celsius
tempCelsius = value;
}
// Sanity check for reasonable battery temperatures
if (tempCelsius < -50 || tempCelsius > 100) {
console.warn(`⚠️ Battery temperature seems unreasonable: ${tempCelsius.toFixed(1)}°C (from ${value})`);
}
await deviceService.updateProperty('/Dc/0/Temperature', tempCelsius, 'd', `${deviceName} temperature`);
this.emit('dataUpdated', 'Battery Temperature', `${deviceName}: ${tempCelsius.toFixed(1)}°C`);
}
} else {
// Unhandled battery path - could log for debugging if needed
}
}
async _handleSwitchUpdate(path, value, deviceService, deviceName) {
if (path.includes('state')) {
if (typeof value === 'boolean') {
const stateValue = value ? 1 : 0;
await deviceService.updateProperty('/State', stateValue, 'i', `${deviceName} state`);
this.emit('dataUpdated', 'Switch State', `${deviceName}: ${value ? 'ON' : 'OFF'}`);
}
} else if (path.includes('dimmingLevel')) {
if (typeof value === 'number' && !isNaN(value)) {
const levelPercent = value > 1 ? value : value * 100;
await deviceService.updateProperty('/DimmingLevel', levelPercent, 'i', `${deviceName} dimming level`);
this.emit('dataUpdated', 'Switch Dimming', `${deviceName}: ${levelPercent.toFixed(0)}%`);
}
} else if (path.includes('position')) {
if (typeof value === 'number' && !isNaN(value)) {
await deviceService.updateProperty('/Position', value, 'i', `${deviceName} position`);
this.emit('dataUpdated', 'Switch Position', `${deviceName}: ${value}`);
}
}
}
async _handleEnvironmentUpdate(path, value, deviceService, deviceName) {
if (path.includes('temperature')) {
if (typeof value === 'number' && !isNaN(value)) {
const tempCelsius = value > 200 ? value - 273.15 : value; // Convert from Kelvin if needed
await deviceService.updateProperty('/Temperature', tempCelsius, 'd', `${deviceName} temperature`);
this.emit('dataUpdated', 'Environment Temperature', `${deviceName}: ${tempCelsius.toFixed(1)}°C`);
}
} else if (path.includes('humidity') || path.includes('relativeHumidity')) {
if (typeof value === 'number' && !isNaN(value)) {
const humidityPercent = value > 1 ? value : value * 100;
this.logger.debug(`Environment ${deviceName}: Updating /Humidity = ${humidityPercent.toFixed(1)}%`);
await deviceService.updateProperty('/Humidity', humidityPercent, 'd', `${deviceName} humidity`);
this.emit('dataUpdated', 'Environment Humidity', `${deviceName}: ${humidityPercent.toFixed(1)}%`);
}
}
}
async disconnect() {
// Disconnect individual device services
for (const deviceService of this.deviceServices.values()) {
if (deviceService && typeof deviceService.disconnect === 'function') {
try {
deviceService.disconnect();
} catch (err) {
// Ignore disconnect errors
}
}
}
// Disconnect the main bus
if (this.bus) {
try {
this.bus.end();
} catch (err) {
// Ignore disconnect errors
}
}
this.bus = null;
this.deviceInstances.clear();
this.deviceServices.clear();
this.exportedInterfaces.clear();
}
async _calculateAndUpdatePower(deviceService, deviceName) {
// Calculate power from voltage and current if both are available
const voltage = deviceService.deviceData['/Dc/0/Voltage'];
const current = deviceService.deviceData['/Dc/0/Current'];
if (typeof voltage === 'number' && typeof current === 'number' && !isNaN(voltage) && !isNaN(current)) {
const power = voltage * current;
await deviceService.updateProperty('/Dc/0/Power', power, 'd', `${deviceName} power`);
this.emit('dataUpdated', 'Battery Power', `${deviceName}: ${power.toFixed(1)}W`);
}
}
async _updateBatteryDummyData(deviceService, deviceName) {
// Only update dummy data for battery devices
if (this._internalDeviceType !== 'battery') {
return;
}
// Update dummy data for values that might not be coming from Signal K
// Check if device service is connected
if (!deviceService.isConnected) {
console.warn(`Device service for ${deviceName} is not connected - skipping dummy data update`);
return;
}
// Get current values from the device service
const currentSoc = deviceService.deviceData['/Soc'];
const capacity = deviceService.deviceData['/Capacity'];
const current = deviceService.deviceData['/Dc/0/Current'];
const voltage = deviceService.deviceData['/Dc/0/Voltage'];
// Only update consumed amp hours if we have SOC and capacity (from device or settings)
if (typeof currentSoc === 'number' && !isNaN(currentSoc)) {
// Use device capacity if available, otherwise fall back to settings
let workingCapacity = capacity;
if (!workingCapacity && this.settings.batteryCapacity) {
workingCapacity = this.settings.batteryCapacity;
}
if (typeof workingCapacity === 'number' && !isNaN(workingCapacity)) {
// Calculate consumed Ah based on SOC: consumed = capacity * (100 - SOC) / 100
const consumedAh = workingCapacity * (100 - currentSoc) / 100;
try {
await deviceService.updateProperty('/ConsumedAmphours', consumedAh, 'd', `${deviceName} consumed Ah`);
} catch (err) {
if (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' || err.code === 'EPIPE') {
this.logger.debug(`Connection lost while updating consumed Ah for ${deviceName}`);
} else {
console.error(`Error updating consumed Ah for ${deviceName}:`, err);
}
}
}
}
// Update voltage tracking for system service compatibility
if (typeof voltage === 'number' && !isNaN(voltage)) {
try {
const currentMinVoltage = deviceService.deviceData['/History/MinimumVoltage'] || voltage;
const currentMaxVoltage = deviceService.deviceData['/History/MaximumVoltage'] || voltage;
// Update min/max voltage tracking
if (voltage < currentMinVoltage) {
await deviceService.updateProperty('/History/MinimumVoltage', voltage, 'd', `${deviceName} minimum voltage`);
}
if (voltage > currentMaxVoltage) {
await deviceService.updateProperty('/History/MaximumVoltage', voltage, 'd', `${deviceName} maximum voltage`);
}
// Update mid voltage (can be same as main voltage for single battery systems)
await deviceService.updateProperty('/Dc/0/MidVoltage', voltage, 'd', `${deviceName} mid voltage`);
// Calculate mid voltage deviation (for single battery, this is typically 0)
await deviceService.updateProperty('/Dc/0/MidVoltageDeviation', 0.0, 'd', `${deviceName} mid voltage deviation`);
} catch (err) {
if (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' || err.code === 'EPIPE') {
this.logger.debug(`Connection lost while updating voltage tracking for ${deviceName}`);
} else {
console.error(`Error updating voltage tracking for ${deviceName}:`, err);
}
}
}
// Update time to go based on current consumption or charge time
// Only calculate if Signal K hasn't provided timeRemaining data
if (typeof current === 'number' && !isNaN(current) && current !== 0 &&
typeof currentSoc === 'number') {
// Use configured battery capacity if device capacity is not available
let workingCapacity = capacity;
if (!workingCapacity && this.settings.batteryCapacity) {
workingCapacity = this.settings.batteryCapacity;
}
if (typeof workingCapacity === 'number' && !isNaN(workingCapacity)) {
// Find the basePath for this deviceService to check Signal K timeRemaining
let basePath = null;
for (const [path, service] of this.deviceServices.entries()) {
if (service === deviceService) {
basePath = path;
break;
}
}
if (basePath) {
// Check if Signal K has provided timeRemaining data for this battery
const timeRemainingPath = `${basePath}.capacity.timeRemaining`;
const signalKTimeRemaining = this._getCurrentSignalKValue(timeRemainingPath);
// Extract numeric value from Signal K response (might be wrapped in object)
let timeRemainingValue = null;
if (signalKTimeRemaining !== null && signalKTimeRemaining !== undefined) {
if (typeof signalKTimeRemaining === 'number') {
timeRemainingValue = signalKTimeRemaining;
} else if (typeof signalKTimeRemaining === 'object') {
// Try multiple common Signal K object patterns
if (signalKTimeRemaining.value !== undefined) {
timeRemainingValue = signalKTimeRemaining.value;
} else if (signalKTimeRemaining.val !== undefined) {
timeRemainingValue = signalKTimeRemaining.val;
} else if (signalKTimeRemaining.v !== undefined) {
timeRemainingValue = signalKTimeRemaining.v;
} else if (typeof signalKTimeRemaining.valueOf === 'function') {
const extracted = signalKTimeRemaining.valueOf();
if (typeof extracted === 'number') {
timeRemainingValue = extracted;
}
}
}
}
const hasSignalKTimeToGo = typeof timeRemainingValue === 'number' && !isNaN(timeRemainingValue) && timeRemainingValue !== null && timeRemainingValue > 0;
// Calculate if Signal K hasn't provided timeRemaining
const shouldCalculate = !hasSignalKTimeToGo;
let timeToGoSeconds;
if (shouldCalculate) {
// Calculate our own TTG when Signal K doesn't provide timeRemaining
if (current < 0) {
// Battery is discharging - calculate time until empty (fallback when Signal K doesn't provide timeRemaining)
const remainingCapacity = workingCapacity * (currentSoc / 100);
const timeToGoHours = remainingCapacity / Math.abs(current);
timeToGoSeconds = Math.round(timeToGoHours * 3600);
} else {
// Battery is charging - calculate time to 100% SoC
const remainingCapacityToFull = workingCapacity * ((100 - currentSoc) / 100);
const chargeTimeHours = remainingCapacityToFull / current;
timeToGoSeconds = Math.round(chargeTimeHours * 3600);
}
} else {
// Use Signal K provided timeRemaining value
timeToGoSeconds = Math.round(timeRemainingValue);
}
try {
await deviceService.updateProperty('/TimeToGo', timeToGoSeconds, 'i', `${deviceName} time to go`);
} catch (err) {
if (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' || err.code === 'EPIPE') {
this.logger.debug(`Connection lost while updating time to go for ${deviceName}`);
} else {
console.error(`🔋 TTG calculation - Error updating /TimeToGo:`, err);
}
}
}
}
}
// NOTE: We no longer calculate fake time to go values without real current data
// This prevents generating misleading information for Venus OS
// NOTE: We no longer generate fake temperature data
// If a battery doesn't provide temperature, Venus OS will simply not show temperature data
// This is much better than showing fake values that could mislead users
}
async _notifySystemService(deviceService, deviceName) {
// Only run for battery services - simplified Venus OS system service refresh
if (this._internalDeviceType !== 'battery') {
return;
}
try {
// Basic state updates to wake up system service - minimal approach
await deviceService.updateProperty('/Connected', 1, 'i', `${deviceName} connected`);
await deviceService.updateProperty('/State', 1, 'i', `${deviceName} active`);
await deviceService.updateProperty('/System/BatteryService', 1, 'i', `${deviceName} battery service active`);
await deviceService.updateProperty('/DeviceType', 512, 'i', `${deviceName} device type`);
} catch (err) {
if (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' || err.code === 'EPIPE') {
this.logger.debug(`Connection lost while notifying system service for ${deviceName}`);
} else {
console.error(`Error notifying system service for ${deviceName}:`, err);
}
}
}
async _triggerSystemServiceRefresh(deviceService, deviceName) {
// Only run for battery services - rate-limited D-Bus signal refresh
if (this._internalDeviceType !== 'battery') {
return;
}
const now = Date.now();
const lastRefresh = this._lastSystemRefresh || 0;
// Rate limit: only refresh once every 2 seconds to prevent spam
if (now - lastRefresh < 2000) {
return;
}
this._lastSystemRefresh = now;
try {
// Get current values from deviceData - NO DEFAULT VALUES!
// Only update if we have real values in deviceData
const socValue = deviceService.deviceData['/Soc'];
const currentValue = deviceService.deviceData['/Dc/0/Current'];
const voltageValue = deviceService.deviceData['/Dc/0/Voltage'];
// Only update the core BMV values if we have real data
// This prevents sending fake default values to Venus OS
if (typeof socValue === 'number' && !isNaN(socValue)) {
await deviceService.updateProperty('/Soc', socValue, 'd', `${deviceName} state of charge`);
}
if (typeof currentValue === 'number' && !isNaN(currentValue)) {
await deviceService.updateProperty('/Dc/0/Current', currentValue, 'd', `${deviceName} current`);
}
if (typeof voltageValue === 'number' && !isNaN(voltageValue)) {
await deviceService.updateProperty('/Dc/0/Voltage', voltageValue, 'd', `${deviceName} voltage`);
}
} catch (err) {
console.error(`Error in system service refresh for ${deviceName}:`, err);
}
}
}