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,303 lines (1,154 loc) • 54.7 kB
JavaScript
import dbusNative from 'dbus-native';
import EventEmitter from 'events';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
// Read package.json to get the current version
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJson = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
const PACKAGE_VERSION = packageJson.version;
// Get Variant constructor - dbus-native exports it differently
const Variant = dbusNative.Variant || dbusNative.variant || function(type, value) {
return { type, value };
};
/**
* Central VEDBus service class inspired by velib_python/vedbus.py
* This class provides a common D-Bus service implementation for all Venus OS devices
*/
export class VEDBusService extends EventEmitter {
constructor(serviceName, deviceInstance, settings, deviceConfig, logger = null, signalKValueGetter = null) {
super();
this.serviceName = serviceName;
this.deviceInstance = deviceInstance;
this.settings = settings;
this.deviceConfig = deviceConfig;
this.logger = logger || { debug: () => {}, error: () => {} }; // Fallback logger
this.signalKValueGetter = signalKValueGetter; // Function to get current Signal K values
this.dbusServiceName = `com.victronenergy.${deviceConfig.serviceType}.${serviceName}`;
this.deviceData = {};
this.exportedInterfaces = {};
this.bus = null;
this.vrmInstanceId = deviceInstance.index;
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.connectionHealthTimer = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
// Generate unique serial number like dbus-serialbattery - combine serviceType and instance
// const uniqueSerial = `SK_${deviceConfig.serviceType}_${serviceName}_${this.vrmInstanceId}`;
const uniqueSerial = `SK${deviceConfig.serviceType}${serviceName}`.toUpperCase();
// Management properties that are common to all devices
this.managementProperties = {
"/Mgmt/ProcessName": { type: "s", value: deviceConfig.processName, text: "Process name", immutable: true },
"/Mgmt/ProcessVersion": { type: "s", value: PACKAGE_VERSION, text: "Process version", immutable: true },
"/Mgmt/Connection": { type: "i", value: 1, text: "Connected", immutable: true },
"/DeviceInstance": { type: "i", value: this.vrmInstanceId, text: "Device instance", immutable: true },
"/ProductId": { type: "i", value: deviceConfig.serviceType === 'battery' ? 0xBA77 : 0, text: "Product ID", immutable: true },
"/ProductName": { type: "s", value: deviceConfig.productName, text: "Product name", immutable: true },
"/FirmwareVersion": { type: "s", value: PACKAGE_VERSION, text: "Firmware Version", immutable: true },
"/HardwareVersion": { type: "s", value: "1.0", text: "Hardware Version", immutable: true },
"/Connected": { type: "i", value: 1, text: "Connected", immutable: true },
"/Serial": { type: "s", value: uniqueSerial, text: "Serial number", immutable: true },
"/CustomName": { type: "s", value: deviceInstance.name, text: "Custom name" },
...deviceConfig.additionalProperties
};
}
// Utility function to safely create D-Bus Variants
_safeVariant(type, value) {
// Validate inputs to prevent "Missing or invalid serial" errors
if (value === null || value === undefined) {
console.warn(`⚠️ Cannot create Variant for null/undefined value (type: ${type})`);
return null;
}
try {
switch (type) {
case 'd': // double
if (typeof value === 'number' && isFinite(value) && !isNaN(value)) {
// Try different ways to create Variant based on dbus-native version
if (typeof Variant === 'function') {
return new Variant('d', value);
} else {
return [type, value]; // Fallback to simple array format
}
}
console.warn(`⚠️ Invalid double value for Variant: ${value} (type: ${typeof value})`);
return null;
case 'i': // integer
if (typeof value === 'number' && Number.isInteger(value) && isFinite(value)) {
if (typeof Variant === 'function') {
return new Variant('i', value);
} else {
return [type, value];
}
}
console.warn(`⚠️ Invalid integer value for Variant: ${value} (type: ${typeof value})`);
return null;
case 's': // string
if (typeof value === 'string' && value.length > 0) {
if (typeof Variant === 'function') {
return new Variant('s', value);
} else {
return [type, value];
}
}
console.warn(`⚠️ Invalid string value for Variant: ${value} (type: ${typeof value})`);
return null;
case 'b': // boolean
if (typeof value === 'boolean') {
if (typeof Variant === 'function') {
return new Variant('b', value);
} else {
return [type, value];
}
}
console.warn(`⚠️ Invalid boolean value for Variant: ${value} (type: ${typeof value})`);
return null;
default:
console.warn(`⚠️ Unsupported Variant type: ${type}`);
return null;
}
} catch (err) {
console.error(`❌ Error creating Variant(${type}, ${value}):`, err.message);
// Fallback to array format if Variant constructor fails
return [type, value];
}
}
async init(initialValues = null) {
// Create own D-Bus connection and register service
await this._createBusConnection();
// Get the proper serial number from management properties
const serialNumber = this.managementProperties["/Serial"].value;
// Store serial in device data for consistency
this.deviceData['/Serial'] = serialNumber;
// For BMV devices, immediately export all required properties to prevent "Missing or invalid serial" errors
if (this.deviceConfig.serviceType === 'battery') {
// Export all management properties immediately
Object.entries(this.managementProperties).forEach(([path, config]) => {
this._exportProperty(path, config);
});
// Try to get current Signal K values if a getter is provided
let currentValues = {};
if (this.signalKValueGetter && this.deviceInstance.basePath) {
try {
const basePath = this.deviceInstance.basePath;
// Map Signal K paths to Venus paths for batteries
const signalKToVenusPaths = {
[`${basePath}.capacity.stateOfCharge`]: '/Soc',
[`${basePath}.voltage`]: '/Dc/0/Voltage',
[`${basePath}.current`]: '/Dc/0/Current',
[`${basePath}.power`]: '/Dc/0/Power'
};
for (const [signalKPath, venusPath] of Object.entries(signalKToVenusPaths)) {
const value = this.signalKValueGetter(signalKPath);
if (value !== null && value !== undefined && typeof value === 'number' && isFinite(value)) {
// Convert units if necessary
if (venusPath === '/Soc' && value >= 0 && value <= 1) {
currentValues[venusPath] = value * 100; // Convert 0-1 to 0-100%
} else if (venusPath === '/Soc' && value >= 0 && value <= 100) {
currentValues[venusPath] = value; // Already in percentage
} else {
currentValues[venusPath] = value;
}
this.logger.debug(`Initializing ${venusPath} with real Signal K value: ${currentValues[venusPath]} (from ${signalKPath})`);
}
}
} catch (err) {
this.logger.debug(`Could not get initial Signal K values: ${err.message}`);
}
}
// Export minimal BMV properties with real Signal K values if available
// IMPORTANT: Only export properties if we have real values - no fake defaults!
const minimalBMVProperties = {
'/Soc': {
value: currentValues['/Soc'] ?? initialValues?.['/Soc'] ?? null,
type: 'd',
text: 'State of charge (%)'
},
'/Dc/0/Voltage': {
value: currentValues['/Dc/0/Voltage'] ?? initialValues?.['/Dc/0/Voltage'] ?? null,
type: 'd',
text: 'DC voltage'
},
'/Dc/0/Current': {
value: currentValues['/Dc/0/Current'] ?? initialValues?.['/Dc/0/Current'] ?? null,
type: 'd',
text: 'DC current'
},
'/Dc/0/Power': {
value: currentValues['/Dc/0/Power'] ?? initialValues?.['/Dc/0/Power'] ?? null,
type: 'd',
text: 'DC power'
}
};
Object.entries(minimalBMVProperties).forEach(([path, config]) => {
// CRITICAL: Only export if we have a real value - don't pollute Venus OS with fake data
if (config.value !== null && config.value !== undefined && !this.deviceData[path]) {
this.deviceData[path] = config.value;
this._exportProperty(path, config);
this.logger.debug(`Initialized ${path} with real value: ${config.value}`);
} else if (config.value === null || config.value === undefined) {
// Skip initialization - property will be exported when first real Signal K data arrives
this.logger.debug(`Skipping initialization of ${path} - waiting for real Signal K data to avoid fake defaults`);
}
});
} else {
// For non-BMV devices, just export management properties
Object.entries(this.managementProperties).forEach(([path, config]) => {
this._exportProperty(path, config);
});
}
await this._registerInSettings();
await this._registerService();
}
async _createBusConnection() {
try {
// Check if we're in test mode (vitest environment)
const isTestMode = typeof globalThis?.describe !== 'undefined' ||
process.env.NODE_ENV === 'test' ||
this.settings.venusHost === 'test.local';
if (isTestMode) {
// In test mode, create a mock bus
this.bus = {
requestName: (name, flags, callback) => callback(null, 0),
exportInterface: () => {},
end: () => {},
invoke: (options, callback) => callback(null, [])
};
this.isConnected = true; // CRITICAL: Set connected state for test mode
} else {
// Create individual D-Bus connection for this service
this.bus = dbusNative.createClient({
host: this.settings.venusHost,
port: this.settings.port || 78,
authMethods: ['ANONYMOUS']
});
// Set up connection monitoring
this._setupConnectionMonitoring();
// Wait for bus to be ready (if it has event support)
if (typeof this.bus.on === 'function') {
await new Promise((resolve, reject) => {
this.bus.on('connect', () => {
this.isConnected = true;
this.reconnectAttempts = 0;
resolve();
});
this.bus.on('error', (err) => {
console.error(`D-Bus connection error for ${this.dbusServiceName}:`, err);
this.isConnected = false;
reject(err);
});
});
} else {
this.isConnected = true;
}
}
} catch (err) {
console.error(`Failed to create D-Bus connection for ${this.deviceConfig.serviceType} service ${this.dbusServiceName}:`, err);
throw err;
}
}
_setupConnectionMonitoring() {
if (!this.bus || typeof this.bus.on !== 'function') {
return;
}
// Monitor connection state
this.bus.on('disconnect', () => {
this.isConnected = false;
this._scheduleReconnect();
});
this.bus.on('error', (err) => {
// Handle different types of connection errors
if (err.code === 'ECONNRESET') {
// D-Bus connection reset (Venus OS restarted)
} else if (err.code === 'ECONNREFUSED') {
// D-Bus connection refused (Venus OS not ready)
} else if (err.code === 'ENOTFOUND') {
// D-Bus host not found (DNS issue)
} else {
console.error(`D-Bus error for ${this.dbusServiceName}:`, err);
}
this.isConnected = false;
this._scheduleReconnect();
});
// Handle connection stream errors (like ECONNRESET)
if (this.bus.connection && typeof this.bus.connection.on === 'function') {
this.bus.connection.on('error', (err) => {
if (err.code === 'ECONNRESET') {
// D-Bus stream reset (Venus OS restarted)
} else {
console.error(`D-Bus stream error for ${this.dbusServiceName}:`, err);
}
this.isConnected = false;
this._scheduleReconnect();
});
}
// Monitor connection health with periodic pings
this._startConnectionHealthCheck();
// Monitor service registration status
this._startServiceRegistrationCheck();
}
_startServiceRegistrationCheck() {
// Check service registration every 2 minutes
if (this.serviceRegistrationTimer) {
clearInterval(this.serviceRegistrationTimer);
}
this.serviceRegistrationTimer = setInterval(async () => {
if (!this.isConnected) {
return;
}
try {
// Check if our service is still registered by trying to call GetItems on ourselves
await new Promise((resolve, reject) => {
this.bus.invoke({
destination: this.dbusServiceName,
path: '/',
interface: 'com.victronenergy.BusItem',
member: 'GetItems'
}, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
} catch (err) {
console.error(`Service registration check failed for ${this.dbusServiceName}:`, err);
await this._attemptFullReRegistration();
}
}, 120000); // Every 2 minutes
}
_startConnectionHealthCheck() {
// Check connection health every 60 seconds
if (this.connectionHealthTimer) {
clearInterval(this.connectionHealthTimer);
}
this.connectionHealthTimer = setInterval(async () => {
if (!this.isConnected) {
return;
}
try {
// Try to ping the D-Bus daemon
await new Promise((resolve, reject) => {
this.bus.invoke({
destination: 'org.freedesktop.DBus',
path: '/org/freedesktop/DBus',
interface: 'org.freedesktop.DBus',
member: 'Ping'
}, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
} catch (err) {
console.error(`Connection health check failed for ${this.dbusServiceName}:`, err);
this.isConnected = false;
this._scheduleReconnect();
}
}, 60000); // Every 60 seconds
}
_scheduleReconnect() {
if (this.reconnectTimer) {
return; // Already scheduled
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`Max reconnect attempts reached for ${this.dbusServiceName}`);
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); // Exponential backoff, max 30s
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
await this._attemptReconnect();
}, delay);
}
async _attemptReconnect() {
try {
// Clean up old connection
if (this.bus) {
try {
this.bus.end();
} catch (err) {
// Ignore cleanup errors
}
}
// Create new connection
await this._createBusConnection();
// Re-register service
await this._registerService();
} catch (err) {
console.error(`Failed to reconnect ${this.dbusServiceName}:`, err);
this._scheduleReconnect();
}
}
async _attemptFullReRegistration() {
try {
// Close existing connection
if (this.bus) {
try {
this.bus.end();
} catch (err) {
// Ignore errors during cleanup
}
}
// Clear timers
this.stopHeartbeat();
if (this.connectionHealthTimer) {
clearInterval(this.connectionHealthTimer);
this.connectionHealthTimer = null;
}
if (this.serviceRegistrationTimer) {
clearInterval(this.serviceRegistrationTimer);
this.serviceRegistrationTimer = null;
}
// Re-initialize everything
await this._createBusConnection();
await this._registerInSettings();
await this._registerService();
} catch (err) {
console.error(`Failed to re-register ${this.dbusServiceName}:`, err);
// Schedule a reconnect attempt
this._scheduleReconnect();
}
}
async _registerInSettings() {
try {
// Proposed class and VRM instance
const proposedInstance = `${this.deviceConfig.serviceType}:${this.deviceInstance.index}`;
// Create settings array following Victron's Settings API format
const settingsArray = [
[
['path', ['s', `/Settings/Devices/${this.serviceName}/ClassAndVrmInstance`]],
['default', ['s', proposedInstance]],
['type', ['s', 's']],
['description', ['s', 'Class and VRM instance']]
],
[
['path', ['s', `/Settings/Devices/${this.serviceName}/CustomName`]],
['default', ['s', this.deviceInstance.name]],
['type', ['s', 's']],
['description', ['s', 'Custom name']]
]
];
// Call the Venus OS Settings API to register the device
const settingsResult = await new Promise((resolve, reject) => {
this.bus.invoke({
destination: 'com.victronenergy.settings',
path: '/',
'interface': 'com.victronenergy.Settings',
member: 'AddSettings',
signature: 'aa{sv}',
body: [settingsArray]
}, (err, result) => {
if (err) {
this.logger.debug('Settings API error:', err);
reject(new Error(`Settings registration failed: ${err.message || err}`));
} else {
resolve(result);
}
});
});
// Extract the actual assigned instance ID from the Settings API result
let actualInstance = this.deviceInstance.index;
if (settingsResult && settingsResult.length > 0) {
// Parse the Settings API response format
for (const result of settingsResult) {
if (result && Array.isArray(result)) {
// Look for the ClassAndVrmInstance result
const pathEntry = result.find(entry => entry && entry[0] === 'path');
const valueEntry = result.find(entry => entry && entry[0] === 'value');
if (pathEntry && valueEntry &&
pathEntry[1] && pathEntry[1][1] && pathEntry[1][1][0] && pathEntry[1][1][0].includes('ClassAndVrmInstance') &&
valueEntry[1] && valueEntry[1][1] && valueEntry[1][1][0]) {
const actualProposedInstance = valueEntry[1][1][0]; // Extract the actual assigned value
const instanceMatch = actualProposedInstance.match(new RegExp(`${this.deviceConfig.serviceType}:(\\d+)`));
if (instanceMatch) {
actualInstance = parseInt(instanceMatch[1]);
// Update the instance to match the assigned instance
this.vrmInstanceId = actualInstance;
this.managementProperties["/DeviceInstance"].value = actualInstance;
}
}
}
}
}
} catch (err) {
console.error(`Settings registration failed for ${this.deviceConfig.serviceType} ${this.serviceName}:`, err);
}
}
async _registerService() {
try {
// Request service name on our own bus connection FIRST
await new Promise((resolve, reject) => {
this.bus.requestName(this.dbusServiceName, 0, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
// Export management interface AFTER service name is registered
this._exportManagementInterface();
// Verify that critical properties are properly set
const serialNumber = this.deviceData['/Serial'] || this.managementProperties['/Serial']?.value;
const deviceInstance = this.deviceData['/DeviceInstance'] || this.managementProperties['/DeviceInstance']?.value;
if (!serialNumber) {
console.error(`❌ CRITICAL: No serial number set for ${this.dbusServiceName}! This will cause Venus OS validation failures.`);
}
// Initialize connection status in device data for heartbeat
this.deviceData["/Mgmt/Connection"] = 1;
// CRITICAL: Set connected state immediately after successful service registration
// This ensures data updates will work even if the D-Bus 'connect' event doesn't fire
// and prevents race conditions during ServiceAnnouncement
this.isConnected = true;
// Start heartbeat to keep service alive
this.startHeartbeat();
// Send ServiceAnnouncement so systemcalc picks up the service for GX Touch
await this._sendServiceAnnouncement();
} catch (err) {
console.error(`Failed to register ${this.deviceConfig.serviceType} service ${this.dbusServiceName}:`, err);
throw err;
}
}
async _sendServiceAnnouncement() {
try {
// Skip ServiceAnnouncement in test mode
const isTestMode = typeof globalThis?.describe !== 'undefined' ||
process.env.NODE_ENV === 'test' ||
this.settings.venusHost === 'test.local';
if (isTestMode) {
return;
}
// Try multiple approaches to announce our service to Venus OS
let announcementSent = false;
// Method 1: Try com.victronenergy.busitem (standard approach)
try {
const busitemExists = await this._checkBusitemService();
if (busitemExists) {
await this._sendBusitemAnnouncement();
announcementSent = true;
}
} catch (err) {
// Busitem announcement failed - try next method
}
// Method 2: Try com.victronenergy.system (alternative approach)
if (!announcementSent) {
try {
await this._sendSystemAnnouncement();
announcementSent = true;
} catch (err) {
// System announcement failed - try next method
}
}
// Method 3: Manual registration with systemcalc
if (!announcementSent) {
try {
await this._registerWithSystemcalc();
announcementSent = true;
} catch (err) {
// Direct systemcalc registration failed
}
}
if (!announcementSent) {
console.warn(`⚠️ All ServiceAnnouncement methods failed for ${this.dbusServiceName} - service may not be visible in Venus OS UI`);
}
} catch (err) {
console.error(`Failed to send ServiceAnnouncement for ${this.dbusServiceName}:`, err);
// Don't throw here - service registration should still succeed even if announcement fails
}
}
async _checkBusitemService() {
try {
// Check if the busitem service exists by trying to get its introspection
await new Promise((resolve, reject) => {
this.bus.invoke({
destination: 'com.victronenergy.busitem',
path: '/',
interface: 'org.freedesktop.DBus.Introspectable',
member: 'Introspect'
}, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
return true;
} catch (err) {
return false;
}
}
async _sendBusitemAnnouncement() {
const connectionName = 'SignalK';
await new Promise((resolve, reject) => {
this.bus.invoke({
destination: 'com.victronenergy.busitem',
path: '/',
interface: 'com.victronenergy.BusItem',
member: 'ServiceAnnouncement',
signature: 'siiss',
body: [
this.dbusServiceName, // s serviceName
this.vrmInstanceId, // i deviceInstance
this.managementProperties["/ProductId"].value, // i productId
this.managementProperties["/ProductName"].value, // s productName
connectionName // s connection
]
}, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
async _sendSystemAnnouncement() {
// Try to announce via com.victronenergy.system
await new Promise((resolve, reject) => {
this.bus.invoke({
destination: 'com.victronenergy.system',
path: '/',
interface: 'com.victronenergy.BusItem',
member: 'ServiceAnnouncement',
signature: 'siiss',
body: [
this.dbusServiceName,
this.vrmInstanceId,
this.managementProperties["/ProductId"].value,
this.managementProperties["/ProductName"].value,
'SignalK'
]
}, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
async _registerWithSystemcalc() {
// Try to register directly with systemcalc by calling a method that triggers service discovery
await new Promise((resolve, reject) => {
this.bus.invoke({
destination: 'com.victronenergy.system',
path: '/ServiceMapping',
interface: 'com.victronenergy.BusItem',
member: 'GetValue'
}, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
_exportManagementInterface() {
const busItemInterface = {
name: "com.victronenergy.BusItem",
methods: {
GetItems: ["", "a{sa{sv}}", [], ["items"]],
GetValue: ["", "v", [], ["value"]],
SetValue: ["v", "i", ["value"], ["result"]],
GetText: ["", "s", [], ["text"]],
},
signals: {
ItemsChanged: ["a{sa{sv}}", ["changes"]]
}
};
// Export root interface with GetItems and GetValue
const rootInterface = {
GetItems: () => {
const items = [];
// Add management properties (immutable and current values)
Object.entries(this.managementProperties).forEach(([path, config]) => {
const currentValue = config.immutable ? config.value : this.deviceData[path];
items.push([path, [
["Value", this._wrapValue(config.type, currentValue)],
["Text", this._wrapValue("s", config.text)],
]]);
});
// Add device data properties with proper typing
Object.entries(this.deviceData).forEach(([path, value]) => {
if (this.managementProperties[path]) return; // Skip duplicates
const text = this.deviceConfig.pathMappings?.[path] || `${this.deviceConfig.serviceType} property`;
const type = this.deviceConfig.pathTypes?.[path] || 'd';
items.push([path, [
["Value", this._wrapValue(type, value)],
["Text", this._wrapValue('s', text)],
]]);
});
// CRITICAL: For battery services, ensure critical BMV properties are present
// But only export them if we have real values (no fake defaults)
if (this.deviceConfig.serviceType === 'battery') {
const criticalBMVProperties = ['/Serial', '/DeviceInstance'];
criticalBMVProperties.forEach(path => {
if (!items.find(item => item[0] === path)) {
let value, type, text;
if (path === '/Serial') {
value = `SK${this.vrmInstanceId}`;
type = 's';
text = 'Serial number';
} else if (path === '/DeviceInstance') {
value = this.vrmInstanceId;
type = 'i';
text = 'Device instance';
}
items.push([path, [
["Value", this._wrapValue(type, value)],
["Text", this._wrapValue('s', text)],
]]);
}
});
// Note: We no longer add default values for /Soc, /Dc/0/Voltage, /Dc/0/Current
// These will only be exported when real Signal K data arrives via updateProperty
}
// Only log GetItems if it's the first time or if there's a significant change in properties count
if (!this._lastItemsCount || Math.abs(this._lastItemsCount - items.length) > 5) {
this._lastItemsCount = items.length;
}
return items;
},
GetValue: () => {
// Reduce GetValue logging noise - only log occasionally
if (!this._lastGetValueLog || Date.now() - this._lastGetValueLog > 10000) {
this._lastGetValueLog = Date.now();
}
const items = [];
// Add management properties
Object.entries(this.managementProperties).forEach(([path, config]) => {
items.push([path.slice(1), this._wrapValue(config.type, config.immutable ? config.value : this.deviceData[path])]);
});
// Add device data properties
Object.entries(this.deviceData).forEach(([path, value]) => {
if (this.managementProperties[path]) return;
const type = this.deviceConfig.pathTypes?.[path] || 'd';
items.push([path.slice(1), this._wrapValue(type, value)]);
});
return this._wrapValue('a{sv}', items);
},
SetValue: () => {
return -1; // Error
},
GetText: () => {
return this.deviceConfig.serviceDescription || `SignalK Virtual ${this.deviceConfig.serviceType} Service`;
},
emit: (signalName, ...signalOutputParams) => {
// Emit signals properly through D-Bus without causing "Missing or invalid serial" errors
try {
if (this.bus && this.isConnected && signalName === 'ItemsChanged') {
// Use the bus invoke method for reliable signal emission
this.bus.invoke({
destination: null, // Broadcast signal
path: '/',
interface: 'com.victronenergy.BusItem',
member: signalName,
signature: 'a{sa{sv}}',
body: signalOutputParams
}, () => {
// Signal sent successfully - no callback needed
});
}
} catch (err) {
// Silently handle signal emission errors to prevent service disruption
console.warn(`Signal emission warning for root interface:`, err.message);
}
}
};
// dbus-native has a bug related to Introspection, which means
// exporting the root interface will break introspection with
// 'dbus -y' CLI.
// https://github.com/sidorares/dbus-native/pull/140
this.bus.exportInterface(rootInterface, "/", busItemInterface);
// Store reference to root interface for signal emission
this.exportedInterfaces['/'] = rootInterface;
// Export standard D-Bus Properties interface for PropertiesChanged signals
const dbusPropertiesInterface = {
name: "org.freedesktop.DBus.Properties",
methods: {
Get: ["ss", "v", ["interface_name", "property_name"], ["value"]],
GetAll: ["s", "a{sv}", ["interface_name"], ["properties"]],
Set: ["ssv", "", ["interface_name", "property_name", "value"], []]
},
signals: {
PropertiesChanged: ["sa{sv}as", ["interface_name", "changed_properties", "invalidated_properties"]]
}
};
const dbusPropertiesImpl = {
Get: (interfaceName, propertyName) => {
// Log only critical property requests to reduce noise
if (propertyName === 'Serial' || propertyName === 'DeviceInstance' || propertyName === 'Soc') {
// Reduced logging for critical properties
}
// Handle requests for properties with or without leading slash
const pathWithSlash = propertyName.startsWith('/') ? propertyName : `/${propertyName}`;
const pathWithoutSlash = propertyName.startsWith('/') ? propertyName.substring(1) : propertyName;
// CRITICAL: Always handle Serial requests first - Venus OS validation depends on this
if (propertyName === 'Serial' || propertyName === '/Serial') {
const serialValue = `SK${this.vrmInstanceId}`;
return this._wrapValue('s', serialValue);
}
// Check management properties first (they take precedence)
if (this.managementProperties[pathWithSlash]) {
const config = this.managementProperties[pathWithSlash];
const value = config.immutable ? config.value : this.deviceData[pathWithSlash];
return this._wrapValue(config.type, value);
}
// Check device data
if (this.deviceData[pathWithSlash] !== undefined) {
const type = this.deviceConfig.pathTypes?.[pathWithSlash] || 'd';
return this._wrapValue(type, this.deviceData[pathWithSlash]);
}
if (this.deviceData[pathWithoutSlash] !== undefined) {
const type = this.deviceConfig.pathTypes?.[pathWithoutSlash] || 'd';
return this._wrapValue(type, this.deviceData[pathWithoutSlash]);
}
// CRITICAL: Special handling for other key properties Venus OS looks for
if (propertyName === 'DeviceInstance' || propertyName === '/DeviceInstance') {
return this._wrapValue('i', this.vrmInstanceId);
}
// Don't return fake defaults for battery properties - let them be undefined until real data arrives
return this._wrapValue('s', '');
},
GetAll: (interfaceName) => {
// Return all properties for the interface
const properties = {};
// CRITICAL: Always include Serial first for Venus OS validation
properties['Serial'] = this._wrapValue('s', `SK${this.vrmInstanceId}`);
properties['DeviceInstance'] = this._wrapValue('i', this.vrmInstanceId);
// Add management properties
Object.entries(this.managementProperties).forEach(([path, config]) => {
const propName = path.startsWith('/') ? path.substring(1) : path;
if (propName && propName !== 'Serial' && propName !== 'DeviceInstance') { // Avoid duplicates
const value = config.immutable ? config.value : this.deviceData[path];
properties[propName] = this._wrapValue(config.type, value);
}
});
// Add device data properties
Object.entries(this.deviceData).forEach(([path, value]) => {
if (this.managementProperties[path]) return; // Skip management properties
const type = this.deviceConfig.pathTypes?.[path] || 'd';
const propName = path.startsWith('/') ? path.substring(1) : path;
if (propName && propName !== 'Serial' && propName !== 'DeviceInstance') { // Avoid duplicates
properties[propName] = this._wrapValue(type, value);
}
});
return properties;
},
Set: (interfaceName, propertyName, value) => {
// Allow setting properties via D-Bus (only non-immutable ones)
const pathWithSlash = propertyName.startsWith('/') ? propertyName : `/${propertyName}`;
if (this.managementProperties[pathWithSlash]?.immutable) {
return; // Don't allow setting immutable properties
}
const actualValue = Array.isArray(value) ? value[1] : value;
this.deviceData[pathWithSlash] = actualValue;
}
};
// Export the standard D-Bus Properties interface on root path
this.bus.exportInterface(dbusPropertiesImpl, "/", dbusPropertiesInterface);
// Export individual property interfaces
Object.entries(this.managementProperties).forEach(([path, config]) => {
this._exportProperty(path, config);
});
}
_exportProperty(path, config) {
// Set/update the value in deviceData for both mutable and immutable properties
this.deviceData[path] = config.value;
// If already exported, done.
if (path in this.exportedInterfaces) {
const changes = [];
changes.push([path, [
["Value", this._wrapValue(config.type, config.value)],
["Text", this._wrapValue('s', config.text)],
]]);
this.exportedInterfaces[path].emit('ItemsChanged', changes);
return;
}
const busItemInterface = {
name: "com.victronenergy.BusItem",
methods: {
GetValue: ["", "v", [], ["value"]],
SetValue: ["v", "i", ["value"], ["result"]],
GetText: ["", "s", [], ["text"]],
},
signals: {
ItemsChanged: ["a{sa{sv}}", ["changes"]],
}
};
const propertyInterface = {
GetValue: () => {
// Always get the current value from deviceData, whether immutable or not
const currentValue = this.deviceData[path];
if (currentValue !== undefined) {
return this._wrapValue(config.type, currentValue);
}
// Fallback to config value if not in deviceData
return this._wrapValue(config.type, config.value);
},
SetValue: (val) => {
if (this.managementProperties[path]?.immutable) {
return 1; // NOT OK - management properties are read-only (vedbus.py pattern)
}
const actualValue = Array.isArray(val) ? val[1] : val;
// Check if value actually changed (vedbus.py pattern)
if (this.deviceData[path] === actualValue) {
return 0; // OK - no change needed
}
this._exportProperty(path, {value: actualValue, type: config.type, text: config.text});
return 0; // OK - value set successfully
},
GetText: () => {
// Always return the configured text for the property
return config.text || `${path} property`;
},
emit: (signalName, ...signalOutputParams) => {
// Emit signals properly through D-Bus without causing "Missing or invalid serial" errors
try {
if (this.bus && this.isConnected && signalName === 'ItemsChanged') {
// Use the bus invoke method for reliable signal emission
this.bus.invoke({
destination: null, // Broadcast signal
path: path,
interface: 'com.victronenergy.BusItem',
member: signalName,
signature: 'a{sa{sv}}',
body: signalOutputParams
}, () => {
// Signal sent successfully - no callback needed
});
}
} catch (err) {
// Silently handle signal emission errors to prevent service disruption
console.warn(`Signal emission warning for ${path}:`, err.message);
}
},
};
this.exportedInterfaces[path] = propertyInterface;
this.bus.exportInterface(propertyInterface, path, busItemInterface);
// Also export the standard D-Bus Properties interface for this property
const dbusPropertiesInterface = {
name: "org.freedesktop.DBus.Properties",
methods: {
Get: ["ss", "v", ["interface_name", "property_name"], ["value"]],
GetAll: ["s", "a{sv}", ["interface_name"], ["properties"]],
Set: ["ssv", "", ["interface_name", "property_name", "value"], []]
},
signals: {
PropertiesChanged: ["sa{sv}as", ["interface_name", "changed_properties", "invalidated_properties"]]
}
};
const dbusPropertiesImpl = {
Get: (interfaceName, propertyName) => {
// Log only critical individual property requests to reduce noise
if (propertyName === 'Value' && (path.includes('Serial') || path.includes('Soc') || path.includes('Voltage'))) {
// Reduced logging for critical individual properties
}
if (interfaceName === 'com.victronenergy.BusItem' && propertyName === 'Value') {
return propertyInterface.GetValue();
} else if (interfaceName === 'com.victronenergy.BusItem' && propertyName === 'Text') {
return this._wrapValue('s', config.text);
}
// CRITICAL: Handle direct Serial property requests on individual property paths
else if (propertyName === 'Serial' && path === '/Serial') {
return this._wrapValue('s', `SK${this.vrmInstanceId}`);
}
return this._wrapValue('s', '');
},
GetAll: (interfaceName) => {
if (interfaceName === 'com.victronenergy.BusItem') {
const result = {
'Value': propertyInterface.GetValue(),
'Text': this._wrapValue('s', config.text)
};
// Special handling for Serial property path
if (path === '/Serial') {
result['Serial'] = this._wrapValue('s', `SK${this.vrmInstanceId}`);
}
return result;
}
return {};
},
Set: (interfaceName, propertyName, value) => {
if (interfaceName === 'com.victronenergy.BusItem' && propertyName === 'Value') {
return propertyInterface.SetValue(value);
}
}
};
// Export Properties interface for this property path
this.bus.exportInterface(dbusPropertiesImpl, path, dbusPropertiesInterface);
}
// Public method to update device properties
updateProperty(path, value, type = 'd', text = `${this.deviceConfig.serviceType} property`) {
if (!this.isConnected) {
console.warn(`⚠️ RACE CONDITION: Cannot update property ${path} - D-Bus not connected for ${this.dbusServiceName} (value: ${value})`);
return;
}
// Check if value actually changed to avoid unnecessary signals
const oldValue = this.deviceData[path];
// Special preprocessing for critical BMV properties
if (this.deviceConfig.serviceType === 'battery' && path === '/TimeToGo') {
// Handle TimeToGo values - convert null/undefined/invalid to -1 (Victron standard for "no TTG available")
if (value === null || value === undefined || (typeof value === 'number' && (!isFinite(value) || isNaN(value)))) {
value = -1;
type = 'i'; // Ensure we use integer type for -1
this.logger.debug(`Converting TTG to -1 (unavailable) for ${this.dbusServiceName}`);
}
}
const valueChanged = oldValue !== value;
// Update device data first
this.deviceData[path] = value;
// Ensure the interface is exported
if (!this.exportedInterfaces[path]) {
this._exportProperty(path, { value, type, text });
} else {
// Update existing exported property
this._exportProperty(path, { value, type, text });
}
// Emit D-Bus signals only if value changed and bus is ready
if (this.bus && this.isConnected && valueChanged) {
try {
// CRITICAL: Enhanced validation to prevent "Missing or invalid serial" errors
if (value === null || value === undefined) {
return; // Don't emit signals for invalid values
}
// For numeric values, ensure they're valid numbers
if (type === 'd' || type === 'i') {
if (typeof value !== 'number' || !isFinite(value) || isNaN(value)) {
// Special handling for /TimeToGo - use -1 for "unknown" like Victron does
if (path === '/TimeToGo') {
value = -1; // Use Victron's standard "unknown" value
} else {
console.warn(`⚠️ Skipping signal emission for ${path} with invalid numeric value: ${value} (type: ${typeof value})`);
return;
}
}
}
// For string values, ensure they're non-empty
if (type === 's' && (typeof value !== 'string' || value.length === 0)) {
console.warn(`⚠️ Skipping signal emission for ${path} with invalid string value: ${value}`);
return;
}
// Emit signals for battery services using the enhanced methods
const isBatteryService = this.deviceConfig.serviceType === 'battery';
if (isBatteryService) {
// Identify critical battery paths that Venus OS system service monitors
const isCriticalBatteryPath = path === '/Soc' || path === '/Dc/0/Current' || path === '/Dc/0/Voltage' ||
path === '/ConsumedAmphours' || path === '/TimeToGo' || path === '/Dc/0/Power' ||
path === '/Serial' || path === '/DeviceInstance';
if (isCriticalBatteryPath) {
// Only log the most critical updates to reduce noise
if (path === '/Soc' || path === '/Dc/0/Voltage' || path === '/Serial') {
// Reduced logging for critical BMV paths
}
// Emit enhanced signals for BMV integration
this.emitPropertiesChanged(path, {
Value: value,
Text: text
});
this.emitValueChanged(path, value);
}
} else {
// For non-battery services, emit basic signals
this.emitPropertiesChanged(path, {
Value: value,
Text: text
});
}
} catch (err) {
// Handle connection errors gracefully
if (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' || err.code === 'EPIPE') {
this.isConnected = false;
this._scheduleReconnect();
} else {
console.error(`❌ Error emitting signals for ${path} on ${this.dbusServiceName}:`, err.message || err);
}
}
} else if (!valueChanged) {
// Value hasn't changed, no signal needed
} else {
// D-Bus connection not available - this is expected during initialization
}
}
emitPropertiesChanged(path, props) {
if (!this.bus || !this.isConnected) {
// Connection not ready - this is normal during initialization
return;
}
try {
// Validate the serial number is available before emitting any signals
const deviceDataSerial = this.deviceData['/Serial'];
const managementSerial = this.managementProperties['/Serial']?.value;
const serialNumber = deviceDataSerial || managementSerial;
if (!serialNumber) {
console.warn(`❌ Cannot emit signals for ${path}: No serial number available for ${this.dbusServiceName}`);
return;
}
// Use the bus.invoke method to emit signals properly instead of direct message creation
// This avoids the "Missing or invalid serial" errors from direct message handling
if (this.exportedInterfaces['/'] && typeof this.exportedInterfaces['/'].emit === 'function') {
// Emit through the exported interface if available
const changes = [];
changes.push([path, [
["Value", this._wrapValue(typeof props.Value === 'number' ? 'd' : 's', props.Value)],
["Text", this._wrapValue('s', props.Text || path)],
]]);
this.exportedInterfaces['/'].emit('ItemsChanged', changes);
} else {
// Fallback to simple property update without complex signal emission
}
} catch (err) {
console.error(`❌ Error emitting PropertiesChanged for ${path}:`, err.message || err);
}
}
emitValueChanged(path, value) {
if (!this.bus || !this.isConnected) {
// Connection not ready - this is normal during initialization
return;
}
// Validate value before emitting
if (value === null || value === undefined) {
return;
}
// Additional validation for numeric values
if (typeof value === 'number' && (!isFinite(value) || isNaN(value))) {
console.warn(`⚠️ Skipping ValueChanged signal for ${path}, invalid number:`, value);
return;
}
// Ensure serial number is available
const deviceDataSerial = this.deviceData['/Serial'];
const managementSerial = this.managementProperties['/Serial']?.value;
const serialNumber = deviceDataSerial || managementSerial;
if (!serialNumber) {
console.warn(`❌ Cannot emit ValueChanged for ${path}: No serial number available for ${this.dbusServiceName}`);
return;
}
// Use the same approach as PropertiesChanged - avoid direct message creation
try {
if (this.exportedInterfaces[path] && typeof this.exportedInterfaces[path].emit === 'function') {
// Emit through the exported interface for this specific path
this.exportedInterfaces[path].emit('ItemsChanged', [[path, [
["Value", this._wrapValue('d', value)],
["Text", this._wrapValue('s', `${this.deviceConfig.serviceType} property`)],
]]]);
} else {
// Just log the value change without complex signal emission
}
} catch (err) {
console.error(`❌ Error emitting ValueChanged for ${path}:`, err.message || err);
}
}
_wrapValue(type, value) {
// Handle null/undefined values like vedbus.py (invalid values)
if (value === null || value === undefined) {
return ["ai", []]; // Invalid value as empty array (vedbus.py pattern)
}
return [type, value];
}
d