signalk-to-venus
Version:
Injects batteries, tanks, environment sensors, and switches as virtual devices and battery monitor into the Victron Cerbo GX Venus OS.
894 lines (773 loc) • 35 kB
JavaScript
import { VenusClientFactory } from './venusClientFactory.js';
import settings from './settings.js';
import dbusNative from 'dbus-native';
// Signal K plugin entry point
export default function(app) {
// Dynamic tracking of discovered Signal K paths
let discoveredPaths = {
batteries: new Map(),
tanks: new Map(),
environment: new Map(),
switches: new Map()
};
let lastSchemaUpdate = 0;
const plugin = {
id: 'signalk-to-venus',
name: 'Signal K to Venus OS Bridge',
description: 'Bridges Signal K data to Victron Venus OS via D-Bus',
venusConnected: false,
unsubscribe: null,
clients: {},
connectivityInterval: null,
// Function to generate dynamic schema based on discovered paths
schema: function() {
const baseSchema = {
type: 'object',
properties: {
venusHost: {
type: 'string',
title: 'Venus OS Host',
default: 'venus.local'
},
interval: {
type: 'number',
title: 'Update Interval (ms)',
default: 1000
},
batteryCapacity: {
type: 'number',
title: 'Battery Capacity (Ah)',
description: 'Total battery capacity in Amp-hours for time-to-charge calculation',
default: 800
}
}
};
// Add discovered devices grouped by type if any have been found
if (hasDiscoveredPaths()) {
// Add each device type with discovered paths
Object.entries(discoveredPaths).forEach(([deviceType, pathMap]) => {
if (pathMap.size > 0) {
const deviceTitles = {
'batteries': 'Batteries',
'tanks': 'Tanks',
'environment': 'Environment',
'switches': 'Switches & Dimmers'
};
baseSchema.properties[deviceType] = {
type: 'object',
title: deviceTitles[deviceType],
properties: {}
};
// Add each discovered path as a simple checkbox
pathMap.forEach((pathInfo, devicePath) => {
const safePathKey = devicePath.replace(/[^a-zA-Z0-9]/g, '_');
baseSchema.properties[deviceType].properties[safePathKey] = {
type: 'boolean',
title: `${pathInfo.displayName} (${devicePath})`,
default: false // Disable devices by default to prevent unwanted connections
};
});
}
});
}
return baseSchema;
},
// UI Schema to enhance the configuration interface
uiSchema: function() {
const uiSchema = {};
// Make each device type collapsible if devices have been discovered
if (hasDiscoveredPaths()) {
Object.keys(discoveredPaths).forEach(deviceType => {
if (discoveredPaths[deviceType].size > 0) {
uiSchema[deviceType] = {
'ui:field': 'collapsible',
collapse: {
field: 'ObjectField',
wrapClassName: 'panel-group'
}
};
}
});
}
return uiSchema;
},
start: function(options) {
app.setPluginStatus('Waiting 20 seconds for system readiness');
app.debug('Signal K to Venus OS bridge waiting 20 seconds for system readiness');
// Simple 20-second delay to ensure Signal K and Venus OS are fully ready
setTimeout(() => {
plugin.actualStart(options);
}, 20000);
},
actualStart: function(options) {
app.setPluginStatus('Starting Signal K to Venus OS bridge');
app.debug('Starting Signal K to Venus OS bridge');
const config = { ...settings, ...options };
plugin.clients = {};
plugin.venusConnected = false; // Track Venus connection status
const activeClientTypes = new Set();
// Queue for paths that arrive before Venus OS is ready
const pendingPaths = [];
let isProcessingQueue = false;
const deviceTypeNames = {
'batteries': 'Batteries',
'tanks': 'Tanks',
'environment': 'Environment',
'switches': 'Switches'
};
// Test Venus OS connectivity before processing any data
async function testVenusConnectivity() {
let testBus = null;
try {
// Create D-Bus connection with anonymous authentication for Venus OS using dbus-native
try {
testBus = dbusNative.createClient({
host: config.venusHost,
port: 78,
authMethods: ['ANONYMOUS'] // Try anonymous auth first for Venus OS
});
} catch (createErr) {
throw createErr;
}
// Test the connection by trying to list names (simple D-Bus operation)
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'));
}, 3000);
// Try a simple D-Bus operation to test connectivity
try {
testBus.listNames((err, names) => {
clearTimeout(timeout);
if (err) {
reject(err);
} else {
resolve();
}
});
} catch (syncErr) {
clearTimeout(timeout);
reject(syncErr);
}
});
const wasReachable = plugin.venusConnected;
plugin.venusConnected = true;
// Only update status if Venus OS was previously unreachable
if (!wasReachable) {
app.setPluginStatus(`Venus OS ready at ${config.venusHost}`);
}
return true;
} catch (err) {
plugin.venusConnected = false;
let errorMsg = `Venus OS not reachable at ${config.venusHost}`;
if (err.code === 'ENOTFOUND') {
errorMsg += ' (DNS resolution failed)';
} else if (err.code === 'ECONNREFUSED') {
errorMsg += ' (connection refused - check D-Bus TCP setting)';
} else if (err.message.includes('timeout')) {
errorMsg += ' (connection timeout)';
} else if (err.message.includes('dbus-keyrings') || err.message.includes('ENOENT')) {
errorMsg += ' (D-Bus authentication failed - this is a known issue)';
}
app.setPluginError(errorMsg);
// Clear all existing clients when Venus becomes unreachable
Object.keys(plugin.clients).forEach(key => {
if (plugin.clients[key] && typeof plugin.clients[key] === 'object') {
// Disconnect existing clients gracefully
if (plugin.clients[key].disconnect) {
plugin.clients[key].disconnect().catch(() => {});
}
}
delete plugin.clients[key];
});
return false;
} finally {
// Always disconnect the test bus
if (testBus) {
try {
testBus.end();
} catch (disconnectErr) {
// Silent disconnect error handling
}
}
}
}
// Main startup sequence - systems should be ready after 20 second delay
async function startBridge() {
try {
// Test Venus OS connectivity
await runConnectivityTest();
// Set up Signal K subscription
setupSignalKSubscription();
// Start periodic Venus connectivity tests
plugin.connectivityInterval = setInterval(runConnectivityTest, 120000); // Check every 2 minutes
} catch (err) {
app.error('Error during bridge startup:', err);
app.setPluginError(`Bridge startup failed: ${err.message}`);
}
}
// Function to set up Signal K subscription
function setupSignalKSubscription() {
app.setPluginStatus('Setting up Signal K subscription');
let deltaCount = 0;
let unsubscribes = [];
// Use the proper subscriptionmanager API as documented
if (app.subscriptionmanager && app.subscriptionmanager.subscribe) {
try {
// Get our own MMSI for vessel context filtering
const selfMMSI = app.getSelfPath('mmsi');
const selfContext = selfMMSI ? `vessels.urn:mrn:imo:mmsi:${selfMMSI}` : 'vessels.self';
app.debug(`Setting up subscription for context: ${selfContext} (MMSI: ${selfMMSI})`);
const localSubscription = {
context: selfContext, // Subscribe only to our vessel's data
subscribe: [
{
path: '*', // Get all paths
period: config.interval || 1000 // Every 1000ms by default
}
]
};
app.debug(`Subscription config:`, JSON.stringify(localSubscription, null, 2));
app.subscriptionmanager.subscribe(
localSubscription,
unsubscribes,
(subscriptionError) => {
app.error('Subscription error: ' + subscriptionError);
app.setPluginError('Signal K subscription failed: ' + subscriptionError);
},
(delta) => {
processDelta(delta);
}
);
app.debug(`Subscription setup completed, unsubscribe functions: ${unsubscribes.length}`);
// Store unsubscribe functions for cleanup
plugin.unsubscribe = () => {
app.debug(`Unsubscribing from ${unsubscribes.length} subscriptions`);
unsubscribes.forEach((f) => f());
unsubscribes = [];
};
} catch (err) {
app.error('subscriptionmanager.subscribe method failed:', err);
app.setPluginError('Signal K subscription setup failed: ' + err.message);
return;
}
} else {
app.setPluginError('Signal K subscriptionmanager not available');
return;
}
// Function to process delta messages
function processDelta(delta) {
try {
deltaCount++;
if (delta.updates) {
delta.updates.forEach((update) => {
// Check if update and update.values are valid
if (!update || !Array.isArray(update.values)) {
return;
}
// Check for Venus OS sources to prevent feedback loops
const sourceLabel = update.source?.label || update.$source || 'unknown';
if (sourceLabel.includes('venus.com.victronenergy')) {
return;
}
update.values.forEach(async (pathValue) => {
try {
// Check if pathValue exists and has required properties
if (!pathValue || typeof pathValue !== 'object') {
return;
}
if (!pathValue.path) {
return;
}
// Skip null/undefined values
if (pathValue.value == null) {
return;
}
// Check if this is a device type we care about
const deviceType = identifyDeviceType(pathValue.path);
if (!deviceType) {
return;
}
// Process this path value using the unified processing function
await processPathValue(pathValue.path, pathValue.value, config);
} catch (err) {
// Only log unexpected errors, suppress common connection errors
if (!err.message || (!err.message.includes('ENOTFOUND') && !err.message.includes('ECONNREFUSED'))) {
const pathInfo = pathValue?.path || 'unknown path';
app.error(`Unexpected error processing ${pathInfo}: ${err.message}`);
}
}
});
});
}
} catch (err) {
app.error('Error processing delta:', err);
}
}
// Monitor subscription health
setTimeout(() => {
if (deltaCount === 0) {
app.setPluginStatus(`No Signal K data received, check server configuration`);
}
}, 5000);
// Set initial status if no data comes in
setTimeout(() => {
if (activeClientTypes.size === 0) {
// Check if any devices are enabled
const hasEnabledDevices = ['batteries', 'tanks', 'environment', 'switches'].some(deviceType => {
if (config[deviceType]) {
return Object.values(config[deviceType]).some(enabled => enabled === true);
}
return false;
});
if (!hasEnabledDevices) {
const deviceCountText = generateDeviceCountText();
if (deviceCountText.includes('0 devices')) {
app.setPluginStatus('Discovering Signal K devices');
} else {
app.setPluginStatus(`Select devices, available ${deviceCountText}`);
}
} else if (!plugin.venusConnected) {
const deviceCountText = generateDeviceCountText();
app.setPluginStatus(`Not connected to ${config.venusHost}, available ${deviceCountText}`);
} else {
app.setPluginStatus(`Connected to ${config.venusHost}, waiting for Signal K data`);
}
}
}, 2000);
}
// Test Venus OS connectivity initially and periodically
async function runConnectivityTest() {
try {
const wasReachable = plugin.venusConnected;
const isReachable = await testVenusConnectivity();
// If Venus just became reachable, process any queued paths after a longer delay
if (!wasReachable && isReachable && pendingPaths.length > 0) {
app.debug(`Venus OS became reachable, waiting 20 seconds before processing ${pendingPaths.length} queued paths`);
setTimeout(() => {
processPendingPaths();
}, 20000);
}
} catch (err) {
app.error('Connectivity test error:', err);
}
}
// Process paths that were queued while Venus OS was not reachable
async function processPendingPaths() {
if (isProcessingQueue || pendingPaths.length === 0) {
return;
}
isProcessingQueue = true;
const pathsToProcess = [...pendingPaths];
pendingPaths.length = 0; // Clear the queue
for (const queuedPath of pathsToProcess) {
try {
await processPathValue(queuedPath.path, queuedPath.value, config);
} catch (err) {
app.error(`Error processing queued path ${queuedPath.path}:`, err);
}
}
// After processing all queued paths, give devices more time to initialize
// then trigger a forced update to ensure VRM sees the devices
setTimeout(async () => {
// Get the latest values for all enabled devices and send them
Object.entries(plugin.clients).forEach(([deviceType, client]) => {
if (client && client !== null && discoveredPaths[deviceType]) {
const pathMap = discoveredPaths[deviceType];
pathMap.forEach((pathInfo, devicePath) => {
const safePathKey = devicePath.replace(/[^a-zA-Z0-9]/g, '_');
if (config[deviceType] && config[deviceType][safePathKey] === true) {
// Force update with the last known value
pathInfo.properties.forEach(async (fullPath) => {
try {
const currentValue = app.getSelfPath(fullPath);
if (currentValue != null) {
await client.handleSignalKUpdate(fullPath, currentValue);
}
} catch (err) {
app.debug(`Could not force update for ${fullPath}: ${err.message}`);
}
});
}
});
}
});
}, 5000); // Wait 5 seconds after device creation to force updates
isProcessingQueue = false;
}
// Extract path processing logic into a separate function
async function processPathValue(path, value, config) {
const deviceType = identifyDeviceType(path);
if (!deviceType) {
return;
}
// Always do discovery
addDiscoveredPath(deviceType, path, value, config);
// Only proceed with Venus OS operations if Venus is reachable and path is enabled
if (!plugin.venusConnected) {
// Venus OS not reachable, add to queue for later processing
const existingIndex = pendingPaths.findIndex(p => p.path === path);
if (existingIndex >= 0) {
// Update existing queued path with new value
pendingPaths[existingIndex].value = value;
} else {
// Add new path to queue
pendingPaths.push({ path, value, timestamp: Date.now() });
}
return;
}
// Check if this specific path is enabled
const isEnabled = isPathEnabled(deviceType, path, config);
if (!isEnabled) {
return; // Skip disabled paths
}
// Create Venus client for this device type if it doesn't exist yet or has failed
if (!plugin.clients[deviceType] || plugin.clients[deviceType] === null) {
app.setPluginStatus(`Creating Venus OS service for ${deviceTypeNames[deviceType]}`);
try {
plugin.clients[deviceType] = VenusClientFactory(config, deviceType, app);
activeClientTypes.add(deviceTypeNames[deviceType]);
const deviceCountText = generateEnabledDeviceCountText(config);
app.setPluginStatus(`Connected to ${config.venusHost}, injecting ${deviceCountText}`);
// After creating a new client, force immediate updates with ALL current data
// for this device type to ensure the device appears in VRM with valid data
setTimeout(async () => {
try {
// Send data for all discovered devices of this type that are enabled
if (discoveredPaths[deviceType]) {
const pathMap = discoveredPaths[deviceType];
for (const [devicePath, pathInfo] of pathMap) {
const safePathKey = devicePath.replace(/[^a-zA-Z0-9]/g, '_');
if (config[deviceType] && config[deviceType][safePathKey] === true) {
// Send all properties for this enabled device
for (const fullPath of pathInfo.properties) {
try {
const currentValue = app.getSelfPath(fullPath);
if (currentValue != null) {
await plugin.clients[deviceType].handleSignalKUpdate(fullPath, currentValue);
// Longer delay between updates to avoid overwhelming
await new Promise(resolve => setTimeout(resolve, 100));
}
} catch (pathErr) {
app.debug(`Could not send initial data for ${fullPath}: ${pathErr.message}`);
}
}
}
}
}
} catch (err) {
app.debug(`Could not send initial data batch to new ${deviceType} client: ${err.message}`);
}
}, 2000); // Wait 2 seconds for client to fully initialize
} catch (err) {
// Clean up connection error messages for better user experience
let cleanMessage = err.message || err.toString();
if (cleanMessage.includes('ENOTFOUND')) {
cleanMessage = `Venus OS device not found at ${config.venusHost} (DNS resolution failed)`;
} else if (cleanMessage.includes('ECONNREFUSED')) {
cleanMessage = `Venus OS device refused connection at ${config.venusHost}:78 (check D-Bus TCP setting)`;
} else if (cleanMessage.includes('timeout')) {
cleanMessage = `Venus OS connection timeout (${config.venusHost}:78)`;
}
app.setPluginError(`Venus OS not reachable: ${cleanMessage}`);
// Mark this client as failed to prevent retries
plugin.clients[deviceType] = null;
app.error(`Cannot connect to Venus OS for ${deviceTypeNames[deviceType]}: ${cleanMessage}`);
return;
}
}
// Update the Venus client with the new data (whether client is new or existing)
if (plugin.clients[deviceType] && plugin.clients[deviceType] !== null) {
try {
await plugin.clients[deviceType].handleSignalKUpdate(path, value);
} catch (err) {
// Handle client update errors
if (err.message && (err.message.includes('ENOTFOUND') || err.message.includes('ECONNREFUSED'))) {
// Connection lost - mark client as failed and update connection status
plugin.clients[deviceType] = null;
plugin.venusConnected = false;
activeClientTypes.delete(deviceTypeNames[deviceType]);
} else {
app.error(`Error updating ${deviceType} client for ${path}: ${err.message}`);
}
}
}
}
// Start the bridge immediately since we've already waited
startBridge();
},
stop: function() {
app.setPluginStatus('Stopping Signal K to Venus OS bridge');
app.debug('Stopping Signal K to Venus OS bridge');
// Clear connectivity interval
if (plugin.connectivityInterval) {
clearInterval(plugin.connectivityInterval);
}
// Use proper unsubscribe method
if (plugin.unsubscribe) {
plugin.unsubscribe();
plugin.unsubscribe = null;
}
if (plugin.clients) {
Object.values(plugin.clients).forEach(async client => {
if (client && client.disconnect) {
await client.disconnect();
}
});
}
app.setPluginStatus('Stopped');
}
};
// Helper function to identify device type from Signal K path
function identifyDeviceType(path) {
if (settings.batteryRegex.test(path)) return 'batteries';
if (settings.tankRegex.test(path)) return 'tanks';
if (settings.temperatureRegex.test(path) || settings.humidityRegex.test(path)) return 'environment';
if (settings.switchRegex.test(path) || settings.dimmerRegex.test(path)) return 'switches';
return null;
}
// Helper function to generate device count text by type (all discovered devices)
function generateDeviceCountText() {
const deviceCounts = {
batteries: discoveredPaths.batteries.size,
tanks: discoveredPaths.tanks.size,
environment: discoveredPaths.environment.size,
switches: discoveredPaths.switches.size
};
const deviceCountParts = [];
if (deviceCounts.batteries > 0) {
deviceCountParts.push(`${deviceCounts.batteries} ${deviceCounts.batteries === 1 ? 'battery' : 'batteries'}`);
}
if (deviceCounts.tanks > 0) {
deviceCountParts.push(`${deviceCounts.tanks} ${deviceCounts.tanks === 1 ? 'tank' : 'tanks'}`);
}
if (deviceCounts.environment > 0) {
deviceCountParts.push(`${deviceCounts.environment} environment ${deviceCounts.environment === 1 ? 'sensor' : 'sensors'}`);
}
if (deviceCounts.switches > 0) {
deviceCountParts.push(`${deviceCounts.switches} ${deviceCounts.switches === 1 ? 'switch' : 'switches'}`);
}
if (deviceCountParts.length > 0) {
return deviceCountParts.join(', ');
} else {
const totalPaths = Object.values(discoveredPaths).reduce((sum, map) => sum + map.size, 0);
return `${totalPaths} devices`;
}
}
// Helper function to generate device count text for enabled devices only
function generateEnabledDeviceCountText(config) {
const enabledCounts = {
batteries: 0,
tanks: 0,
environment: 0,
switches: 0
};
// Count enabled devices by checking configuration
Object.entries(discoveredPaths).forEach(([deviceType, pathMap]) => {
if (config[deviceType]) {
pathMap.forEach((pathInfo, devicePath) => {
const safePathKey = devicePath.replace(/[^a-zA-Z0-9]/g, '_');
if (config[deviceType][safePathKey] === true) {
enabledCounts[deviceType]++;
}
});
}
});
const deviceCountParts = [];
if (enabledCounts.batteries > 0) {
deviceCountParts.push(`${enabledCounts.batteries} ${enabledCounts.batteries === 1 ? 'battery' : 'batteries'}`);
}
if (enabledCounts.tanks > 0) {
deviceCountParts.push(`${enabledCounts.tanks} ${enabledCounts.tanks === 1 ? 'tank' : 'tanks'}`);
}
if (enabledCounts.environment > 0) {
deviceCountParts.push(`${enabledCounts.environment} environment ${enabledCounts.environment === 1 ? 'sensor' : 'sensors'}`);
}
if (enabledCounts.switches > 0) {
deviceCountParts.push(`${enabledCounts.switches} ${enabledCounts.switches === 1 ? 'switch' : 'switches'}`);
}
if (deviceCountParts.length > 0) {
return deviceCountParts.join(', ');
} else {
return '0 devices';
}
}
// Helper function to check if any paths have been discovered
function hasDiscoveredPaths() {
return Object.values(discoveredPaths).some(pathMap => pathMap.size > 0);
}
// Function to add a discovered path to tracking
function addDiscoveredPath(deviceType, path, value, config) {
try {
const pathMap = discoveredPaths[deviceType];
if (!pathMap) return;
// Extract the device/sensor path (one level up from the property)
const devicePath = getDevicePath(deviceType, path);
if (!devicePath) return;
const isNewDevice = !pathMap.has(devicePath);
if (isNewDevice) {
// Generate a human-readable display name
let displayName = generateDisplayName(deviceType, devicePath);
pathMap.set(devicePath, {
displayName: displayName,
firstSeen: new Date().toISOString(),
lastValue: value,
sampleValue: value,
properties: new Set([path]) // Track which properties we've seen
});
// Trigger schema update if enough time has passed
const now = Date.now();
if (now - lastSchemaUpdate > 2000) { // Throttle updates to every 2 seconds
lastSchemaUpdate = now;
// Notify Signal K that the schema has changed (if supported)
if (app.handleMessage && typeof app.handleMessage === 'function') {
try {
app.handleMessage(plugin.id, {
type: 'schema-update',
timestamp: new Date().toISOString()
});
} catch (err) {
// Schema update notification not supported - ignore silently
}
}
}
app.debug(`Discovered new ${deviceType} device: ${displayName} (${devicePath})`);
} else {
// Update last seen value and add this property to the set
const deviceInfo = pathMap.get(devicePath);
deviceInfo.lastValue = value;
deviceInfo.properties.add(path);
}
// Note: Status updates are handled by the client creation process
// to avoid overriding more specific connection status messages
} catch (err) {
app.error(`Error in addDiscoveredPath: ${err.message}`);
}
}
// Function to extract device path from full property path
function getDevicePath(deviceType, fullPath) {
switch (deviceType) {
case 'batteries':
// electrical.batteries.0.voltage -> electrical.batteries.0
const batteryMatch = fullPath.match(/^(electrical\.batteries\.[^.]+)/);
return batteryMatch ? batteryMatch[1] : null;
case 'tanks':
// tanks.blackWater.0.currentLevel -> tanks.blackWater.0
const tankMatch = fullPath.match(/^(tanks\.[^.]+\.[^.]+)/);
return tankMatch ? tankMatch[1] : null;
case 'environment':
// environment.water.temperature -> environment.water
// propulsion.main.temperature -> propulsion.main
// Group by sensor location, not individual property
const envMatch = fullPath.match(/^(environment\.[^.]+|propulsion\.[^.]+)/);
return envMatch ? envMatch[1] : null;
case 'switches':
// electrical.switches.nav.state -> electrical.switches.nav
const switchMatch = fullPath.match(/^(electrical\.switches\.[^.]+)/);
return switchMatch ? switchMatch[1] : null;
}
return null;
}
// Function to generate human-readable display names
function generateDisplayName(deviceType, devicePath) {
switch (deviceType) {
case 'batteries':
// electrical.batteries.0 -> Battery (if only one) or Battery 1, Battery 2 (if multiple)
const batteryMatch = devicePath.match(/electrical\.batteries\.(\d+|[^.]+)/);
if (batteryMatch) {
const batteryId = batteryMatch[1];
// Count total batteries to decide if we need numbers
const totalBatteries = discoveredPaths.batteries ? discoveredPaths.batteries.size : 0;
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();
}
return `Battery ${displayId}`;
}
break;
case 'tanks':
// tanks.freshWater.starboard -> Freshwater starboard (if specific name)
const tankMatch = devicePath.match(/tanks\.([^.]+)\.([^.]+)/);
if (tankMatch) {
let tankType = tankMatch[1];
const tankId = tankMatch[2]; // Can be any alphanumeric string per Signal K spec
// Remove camel case and capitalize first letter
tankType = tankType.replace(/([A-Z])/g, ' $1').trim();
tankType = tankType.charAt(0).toUpperCase() + tankType.slice(1).toLowerCase();
// Remove spaces for consistency (Fresh Water -> Freshwater)
tankType = tankType.replace(/\s+/g, '');
// Check if we have multiple tanks of this type
const tanksOfThisType = Array.from(discoveredPaths.tanks?.keys() || [])
.filter(path => path.includes(`tanks.${tankMatch[1]}.`)).length;
// Use generic ID detection instead of assuming only '0' is generic
const isGenericId = ['0', 'main', 'primary', 'default'].includes(tankId.toLowerCase());
// If single tank with generic ID, omit the ID
if (tanksOfThisType <= 1 && isGenericId) {
return tankType;
} else {
// Multiple tanks or specific ID - include the ID
// Convert numeric IDs to start from 1 instead of 0
let displayId = tankId;
if (/^\d+$/.test(tankId)) {
displayId = (parseInt(tankId) + 1).toString();
}
return `${tankType} ${displayId}`;
}
}
break;
case 'environment':
// environment.water -> Water
// propulsion.main -> Main
// environment.outside -> Outside
const envMatch = devicePath.match(/environment\.([^.]+)|propulsion\.([^.]+)/);
if (envMatch) {
let sensor = envMatch[1] || envMatch[2];
// Remove camel case and capitalize first letter
sensor = sensor.replace(/([A-Z])/g, ' $1').trim();
sensor = sensor.charAt(0).toUpperCase() + sensor.slice(1).toLowerCase();
return sensor;
}
break;
case 'switches':
// electrical.switches.nav -> Nav (if functional name)
const switchMatch = devicePath.match(/electrical\.switches\.([^.]+)/);
if (switchMatch) {
let switchId = switchMatch[1];
// If it's a functional name (not just a number), use it directly
if (!/^\d+$/.test(switchId)) {
// Remove camel case and capitalize first letter
switchId = switchId.replace(/([A-Z])/g, ' $1').trim();
return switchId.charAt(0).toUpperCase() + switchId.slice(1).toLowerCase();
} else {
// It's a numeric ID - convert to start from 1 instead of 0
const displayId = (parseInt(switchId) + 1).toString();
return `Switch ${displayId}`;
}
}
break;
}
// Fallback to path-based name with camel case removed
const fallback = devicePath.split('.').pop() || devicePath;
return fallback.replace(/([A-Z])/g, ' $1').trim()
.charAt(0).toUpperCase() + fallback.slice(1).toLowerCase();
}
// Function to check if a path is enabled in configuration
function isPathEnabled(deviceType, fullPath, config) {
// Get the device path for checking individual configuration
const devicePath = getDevicePath(deviceType, fullPath);
if (!devicePath) return false; // Default to disabled if we can't parse the path
// Check if this specific device is enabled in the new configuration structure
if (config[deviceType]) {
const safePathKey = devicePath.replace(/[^a-zA-Z0-9]/g, '_');
return config[deviceType][safePathKey] === true;
}
// Default to disabled for newly discovered devices
// Devices will be discovered and appear in the UI but won't be processed until explicitly enabled
return false;
}
return plugin;
}