UNPKG

signalk-parquet

Version:

Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.

1,667 lines (1,471 loc) â€ĸ 52.5 kB
import { CommandConfig, CommandRegistrationState, CommandExecutionResult, CommandPutHandler, CommandHistoryEntry, PathConfig, WebAppPathConfig, PluginConfig, ThresholdConfig, } from './types'; import { Context, Delta, Path, ServerAPI, SourceRef, Timestamp, } from '@signalk/server-api'; import * as fs from 'fs-extra'; import * as path from 'path'; import { calculateDistance, isPointInBoundingBox, calculateBoundingBoxFromHomePort, } from './utils/geo-calculator'; import { BoundingBox } from './types'; // Global variables for command management let currentCommands: CommandConfig[] = []; let commandHistory: CommandHistoryEntry[] = []; let appInstance: ServerAPI; let pluginConfig: PluginConfig | null = null; // Threshold processing lock system for first-in rule const thresholdProcessingLocks = new Map<string, boolean>(); const THRESHOLD_PROCESSING_TIMEOUT = 5000; // 5 seconds // Command state management const commandState: CommandRegistrationState = { registeredCommands: new Map<string, CommandConfig>(), putHandlers: new Map<string, CommandPutHandler>(), }; // Threshold monitoring state const thresholdState = new Map< string, { lastValue: any; lastTriggered: number; lastConditionMet?: boolean; unsubscribe?: () => void; } >(); // Configuration management functions export function loadWebAppConfig(app?: ServerAPI): WebAppPathConfig { const appToUse = app || appInstance; if (!appToUse) { throw new Error('App instance not provided and not initialized'); } const webAppConfigPath = path.join( appToUse.getDataDirPath(), 'signalk-parquet', 'webapp-config.json' ); try { if (fs.existsSync(webAppConfigPath)) { const configData = fs.readFileSync(webAppConfigPath, 'utf8'); const rawConfig = JSON.parse(configData); // Migrate old config format to new format with backward compatibility const migratedPaths = (rawConfig.paths || []).map( (path: Partial<PathConfig>) => ({ path: path.path, name: path.name, enabled: path.enabled, regimen: path.regimen, source: path.source || undefined, context: path.context, excludeMMSI: path.excludeMMSI, autoDiscovered: path.autoDiscovered, }) ); const migratedCommands = rawConfig.commands || []; const migratedConfig = { paths: migratedPaths, commands: migratedCommands, }; appToUse.debug( `Loaded and migrated ${migratedPaths.length} paths and ${migratedCommands.length} commands from existing config` ); // Save the migrated config back to preserve the new format try { fs.writeFileSync( webAppConfigPath, JSON.stringify(migratedConfig, null, 2) ); appToUse.debug( 'Saved migrated configuration with source field compatibility' ); } catch (saveError) { appToUse.debug(`Warning: Could not save migrated config: ${saveError}`); } return migratedConfig; } } catch (error) { appToUse.error(`Failed to load webapp configuration: ${error}`); // BACKUP the broken file instead of destroying it try { const backupPath = webAppConfigPath + '.backup.' + Date.now(); if (fs.existsSync(webAppConfigPath)) { fs.copyFileSync(webAppConfigPath, backupPath); appToUse.debug(`Backed up broken config to: ${backupPath}`); } } catch (backupError) { appToUse.debug(`Could not backup broken config: ${backupError}`); } } // Only create defaults if NO config file exists if (!fs.existsSync(webAppConfigPath)) { const defaultConfig = getDefaultWebAppConfig(); appToUse.debug( 'No existing configuration found, using default installation' ); // Save the default configuration for future use try { const configDir = path.dirname(webAppConfigPath); if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }); } fs.writeFileSync( webAppConfigPath, JSON.stringify(defaultConfig, null, 2) ); appToUse.debug('Saved default installation configuration'); } catch (error) { appToUse.debug(`Failed to save default configuration: ${error}`); } return defaultConfig; } // If file exists but couldn't be parsed, return empty config to avoid data loss appInstance.debug( 'Config file exists but could not be parsed, returning empty config to avoid data loss' ); return { paths: [], commands: [], }; } function getDefaultWebAppConfig(): WebAppPathConfig { const defaultCommands: CommandConfig[] = [ { command: 'captureMoored', path: 'commands.captureMoored', registered: 'COMPLETED', description: 'Capture data when moored (position and wind)', active: false, }, ]; const defaultPaths: PathConfig[] = [ { path: 'commands.captureMoored' as Path, name: 'Command: captureMoored', enabled: true, regimen: 'commands', source: undefined, context: 'vessels.self' as Context, }, { path: 'navigation.position' as Path, name: 'Navigation Position', enabled: true, regimen: 'captureMoored', source: undefined, context: 'vessels.self' as Context, }, { path: 'environment.wind.speedApparent' as Path, name: 'Apparent Wind Speed', enabled: true, regimen: 'captureMoored', source: undefined, context: 'vessels.self' as Context, }, ]; return { commands: defaultCommands, paths: defaultPaths, }; } export function saveWebAppConfig( paths: PathConfig[], commands: CommandConfig[], app?: ServerAPI ): void { const appToUse = app || appInstance; if (!appToUse) { throw new Error('App instance not provided and not initialized'); } const webAppConfigPath = path.join( appToUse.getDataDirPath(), 'signalk-parquet', 'webapp-config.json' ); try { const configDir = path.dirname(webAppConfigPath); if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }); } const webAppConfig: WebAppPathConfig = { paths, commands }; fs.writeFileSync(webAppConfigPath, JSON.stringify(webAppConfig, null, 2)); appToUse.debug( `Saved webapp configuration: ${paths.length} paths, ${commands.length} commands` ); } catch (error) { appToUse.error(`Failed to save webapp configuration: ${error}`); } } // Initialize command state on plugin start export function initializeCommandState( currentPaths: PathConfig[], app: ServerAPI ): void { appInstance = app; // Clear existing command state commandState.registeredCommands.clear(); commandState.putHandlers.clear(); // Re-register commands from configuration currentCommands.forEach((commandConfig: CommandConfig) => { const result = registerCommand( commandConfig.command, commandConfig.description, commandConfig.keywords, commandConfig.defaultState, commandConfig.thresholds, true // skipThresholdSetup - let startThresholdMonitoring handle it ); if (result.state === 'COMPLETED') { /* no-op: restored successfully, only the failure case needs logging */ } else { app.error( `❌ Failed to restore command: ${commandConfig.command} - ${result.message}` ); } }); // Ensure all commands have path configurations (for backwards compatibility) let addedMissingPaths = false; currentCommands.forEach((commandConfig: CommandConfig) => { const commandPath = `commands.${commandConfig.command}`; const autoPath = `commands.${commandConfig.command}.auto`; // Add main command path if missing const existingCommandPath = currentPaths.find(p => p.path === commandPath); if (!existingCommandPath) { const commandPathConfig: PathConfig = { path: commandPath as Path, name: `Command: ${commandConfig.command}`, enabled: true, regimen: undefined, source: undefined, context: 'vessels.self' as Context, excludeMMSI: undefined, }; currentPaths.push(commandPathConfig); addedMissingPaths = true; } // Add .auto path if command has thresholds and path is missing if (commandConfig.thresholds && commandConfig.thresholds.length > 0) { const existingAutoPath = currentPaths.find(p => p.path === autoPath); if (!existingAutoPath) { const autoPathConfig: PathConfig = { path: autoPath as Path, name: `Command Auto: ${commandConfig.command}`, enabled: true, regimen: 'commands', source: undefined, context: 'vessels.self' as Context, excludeMMSI: undefined, }; currentPaths.push(autoPathConfig); addedMissingPaths = true; } } }); // Save the updated configuration if we added missing paths if (addedMissingPaths) { saveWebAppConfig(currentPaths, currentCommands, app); } // Reset all commands to false on startup currentCommands.forEach((commandConfig: CommandConfig) => { initializeCommandValue(commandConfig.command, false); }); } // Command registration with full type safety export function registerCommand( commandName: string, description?: string, keywords?: string[], defaultState?: boolean, thresholds?: ThresholdConfig[], skipThresholdSetup: boolean = false ): CommandExecutionResult { try { // Validate command name if (!isValidCommandName(commandName)) { return { success: false, state: 'FAILED', statusCode: 400, message: 'Invalid command name. Use alphanumeric characters and underscores only.', timestamp: new Date().toISOString(), }; } // Check if command already exists if (commandState.registeredCommands.has(commandName)) { return { success: false, state: 'FAILED', statusCode: 409, message: `Command '${commandName}' already registered`, timestamp: new Date().toISOString(), }; } const commandPath = `commands.${commandName}`; const fullPath = `vessels.self.${commandPath}`; // Create command configuration const commandConfig: CommandConfig = { command: commandName, path: fullPath, registered: new Date().toISOString(), description: description || `Command: ${commandName}`, keywords: keywords || [], active: false, lastExecuted: undefined, defaultState: defaultState, thresholds: thresholds, }; // Create PUT handler with proper typing const putHandler: CommandPutHandler = ( context: string, path: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any, callback?: (result: CommandExecutionResult) => void ): CommandExecutionResult => { appInstance.debug( `Handling PUT for commands.${commandName} with value: ${JSON.stringify(value)}` ); // Extract value from object if needed (PUT body may be {value: true/false, source: "..."}) const rawValue = value !== null && typeof value === 'object' && 'value' in value ? value.value : value; const result = executeCommand(commandName, Boolean(rawValue)); // Call callback to signal completion (returns 200 instead of 202) if (callback) callback(result); return result; }; // Register PUT handler with SignalK appInstance.registerPutHandler( 'vessels.self', commandPath, putHandler as any, //FIXME server api registerPutHandler is incorrectly typed https://github.com/SignalK/signalk-server/pull/2043 'zennora-parquet-commands' ); // Store command and handler commandState.registeredCommands.set(commandName, commandConfig); commandState.putHandlers.set(commandName, putHandler); // Initialize command value to defaultState or false initializeCommandValue(commandName, defaultState || false); // Initialize .auto path for threshold automation control const autoPath = `commands.${commandName}.auto`; const autoEnabled = !!( commandConfig.thresholds && commandConfig.thresholds.length > 0 ); initializeCommandValue(`${commandName}.auto`, autoEnabled); // Register PUT handler for .auto control const autoPutHandler = ( _context: string, _path: string, value: any, _callback?: (result: CommandExecutionResult) => void ): CommandExecutionResult => { appInstance.debug( `Handling PUT for ${autoPath} with value: ${JSON.stringify(value)}` ); // Update the .auto value - extract from object if needed const rawValue = value !== null && typeof value === 'object' && 'value' in value ? value.value : value; const autoValue = Boolean(rawValue); const timestamp = new Date().toISOString(); // One-time transition operations when .auto toggles if (autoValue) { // Enabling automation: 1) Set to OFF, 2) Evaluate thresholds immediately, 3) Thresholds take control (continuous) appInstance.debug(`🤖 Enabling automation for ${commandName}`); // 1. Set command state to OFF/false const offResult = executeCommand(commandName, false); if (offResult.success) { appInstance.debug( `🔧 Command ${commandName} set to OFF before threshold evaluation` ); } else { appInstance.error( `❌ Failed to set ${commandName} to OFF: ${offResult.message}` ); } // 2. Immediately evaluate all thresholds if (commandConfig.thresholds && commandConfig.thresholds.length > 0) { commandConfig.thresholds.forEach(threshold => { const currentValue = appInstance.getSelfPath( threshold.watchPath as Path ); if (currentValue !== undefined) { const monitorKey = buildThresholdMonitorKey( commandName, threshold ); const state = thresholdState.get(monitorKey); if (state) { processThresholdValue( commandConfig, threshold, currentValue, monitorKey ); appInstance.debug( `đŸŽ¯ Evaluated threshold for ${commandName}:${threshold.watchPath}` ); } } }); } } else { // Disabling automation: 1) Stop monitoring (handled by threshold checks), 2) Leave state as-is, 3) Enable manual control appInstance.debug( `👤 Disabling automation for ${commandName}, state unchanged` ); } appInstance.handleMessage('signalk-parquet-commands', { context: 'vessels.self' as Context, updates: [ { $source: 'signalk-parquet-commands' as SourceRef, timestamp: timestamp as Timestamp, values: [ { path: autoPath as Path, value: autoValue, }, ], }, ], }); const result: CommandExecutionResult = { success: true, state: 'COMPLETED', statusCode: 200, message: `Automation ${autoValue ? 'enabled' : 'disabled'} for ${commandName}`, timestamp, }; // Call callback to signal completion (returns 200 instead of 202) if (_callback) _callback(result); return result; }; // Register .auto PUT handler with SignalK appInstance.registerPutHandler( 'vessels.self', autoPath, autoPutHandler as any, 'signalk-parquet-commands' ); // Start threshold monitoring for this command if thresholds are defined // Skip during initialization - startThresholdMonitoring() will set them up if ( !skipThresholdSetup && commandConfig.thresholds && commandConfig.thresholds.length > 0 ) { commandConfig.thresholds.forEach(threshold => { if (threshold.enabled) { setupThresholdMonitoring(commandConfig, threshold); } }); } // Update current commands currentCommands = Array.from(commandState.registeredCommands.values()); // Log command registration addCommandHistoryEntry(commandName, 'REGISTER', undefined, true); appInstance.debug(`✅ Registered command: ${commandName} at ${fullPath}`); return { success: true, state: 'COMPLETED', statusCode: 200, message: `Command '${commandName}' registered successfully with automatic path configuration`, timestamp: new Date().toISOString(), }; } catch (error) { const errorMessage = `Failed to register command '${commandName}': ${error}`; appInstance.error(errorMessage); return { success: false, state: 'FAILED', statusCode: 500, message: errorMessage, timestamp: new Date().toISOString(), }; } } // Command update (description and keywords only) export function updateCommand( commandName: string, description?: string, keywords?: string[], defaultState?: boolean, thresholds?: any[] ): CommandExecutionResult { try { // Check if command exists if (!commandState.registeredCommands.has(commandName)) { return { success: false, state: 'FAILED', statusCode: 404, message: `Command '${commandName}' not found`, timestamp: new Date().toISOString(), }; } // Get existing command config const existingCommand = commandState.registeredCommands.get(commandName)!; // Update only the fields that were provided const updatedCommand: CommandConfig = { ...existingCommand, description: description !== undefined ? description : existingCommand.description, keywords: keywords !== undefined ? keywords : existingCommand.keywords, defaultState: defaultState !== undefined ? defaultState : existingCommand.defaultState, thresholds: thresholds !== undefined ? thresholds : existingCommand.thresholds, }; // Update the command in the registry commandState.registeredCommands.set(commandName, updatedCommand); // Update current commands array currentCommands = Array.from(commandState.registeredCommands.values()); // If thresholds were updated, restart threshold monitoring for this command if (thresholds !== undefined) { // Stop existing threshold monitoring for this command const existingThresholds = existingCommand.thresholds || []; existingThresholds.forEach(threshold => { const monitorKey = `${commandName}_${threshold.watchPath}`; const state = thresholdState.get(monitorKey); if (state?.unsubscribe) { state.unsubscribe(); thresholdState.delete(monitorKey); } }); // Start new threshold monitoring if (updatedCommand.thresholds && updatedCommand.thresholds.length > 0) { updatedCommand.thresholds.forEach(threshold => { if (threshold.enabled) { setupThresholdMonitoring(updatedCommand, threshold); } }); } } // Log the update addCommandHistoryEntry(commandName, 'UPDATE', undefined, true); appInstance.debug(`✅ Updated command: ${commandName}`); return { success: true, state: 'COMPLETED', statusCode: 200, message: `Command '${commandName}' updated successfully`, timestamp: new Date().toISOString(), }; } catch (error) { appInstance.error( `Failed to update command ${commandName}: ${(error as Error).message}` ); return { success: false, state: 'FAILED', statusCode: 500, message: `Failed to update command: ${(error as Error).message}`, timestamp: new Date().toISOString(), }; } } // Command unregistration with type safety export function unregisterCommand(commandName: string): CommandExecutionResult { try { const commandConfig = commandState.registeredCommands.get(commandName); if (!commandConfig) { return { success: false, state: 'FAILED', statusCode: 404, message: `Command '${commandName}' not found`, timestamp: new Date().toISOString(), }; } // Remove PUT handler (SignalK API doesn't have unregister, but we can track it) commandState.putHandlers.delete(commandName); commandState.registeredCommands.delete(commandName); // Update current commands currentCommands = Array.from(commandState.registeredCommands.values()); // Log command unregistration addCommandHistoryEntry(commandName, 'UNREGISTER', undefined, true); appInstance.debug(`đŸ—‘ī¸ Unregistered command: ${commandName}`); return { success: true, state: 'COMPLETED', statusCode: 200, message: `Command '${commandName}' unregistered successfully with path cleanup`, timestamp: new Date().toISOString(), }; } catch (error) { const errorMessage = `Failed to unregister command '${commandName}': ${error}`; appInstance.error(errorMessage); return { success: false, state: 'FAILED', statusCode: 500, message: errorMessage, timestamp: new Date().toISOString(), }; } } // Command execution with full type safety export function executeCommand( commandName: string, value: boolean ): CommandExecutionResult { try { const commandConfig = commandState.registeredCommands.get(commandName); if (!commandConfig) { return { success: false, state: 'FAILED', statusCode: 404, message: `Command '${commandName}' not found`, timestamp: new Date().toISOString(), }; } // Execute the command by sending delta const timestamp = new Date().toISOString(); const delta: Delta = { context: 'vessels.self' as Context, updates: [ { $source: 'signalk-parquet-commands' as SourceRef, timestamp: timestamp as Timestamp, values: [ { path: `commands.${commandName}` as Path, value: value, }, ], }, ], }; // Send delta message //FIXME see if delta can be Delta from the beginning appInstance.handleMessage('signalk-parquet-commands', delta as Delta); // Update command state commandConfig.active = value; commandConfig.lastExecuted = timestamp; commandState.registeredCommands.set(commandName, commandConfig); // Update current commands currentCommands = Array.from(commandState.registeredCommands.values()); // Log command execution addCommandHistoryEntry(commandName, 'EXECUTE', value, true); appInstance.debug(`🎮 Executed command: ${commandName} = ${value}`); return { success: true, state: 'COMPLETED', statusCode: 200, message: `Command '${commandName}' executed: ${value}`, timestamp: timestamp, }; } catch (error) { const errorMessage = `Failed to execute command '${commandName}': ${error}`; appInstance.error(errorMessage); addCommandHistoryEntry(commandName, 'EXECUTE', value, false, errorMessage); return { success: false, state: 'FAILED', statusCode: 500, message: errorMessage, timestamp: new Date().toISOString(), }; } } // Helper functions with type safety function isValidCommandName(commandName: string): boolean { const validPattern = /^[a-zA-Z0-9_]+$/; return ( validPattern.test(commandName) && commandName.length > 0 && commandName.length <= 50 ); } function initializeCommandValue(commandName: string, value: boolean): void { const timestamp = new Date().toISOString() as Timestamp; const delta: Delta = { context: 'vessels.self' as Context, updates: [ { $source: 'signalk-parquet-commands' as SourceRef, timestamp, values: [ { path: `commands.${commandName}` as Path, value, }, ], }, ], }; //FIXME appInstance.handleMessage('signalk-parquet-commands', delta as Delta); } function addCommandHistoryEntry( command: string, action: 'EXECUTE' | 'STOP' | 'REGISTER' | 'UNREGISTER' | 'UPDATE', value?: boolean, success: boolean = true, error?: string ): void { const entry: CommandHistoryEntry = { command, action, value, timestamp: new Date().toISOString(), success, error, }; commandHistory.push(entry); // Keep only last 100 entries if (commandHistory.length > 100) { commandHistory = commandHistory.slice(-100); } } // Helper function to extract command name from SignalK path export function extractCommandName(signalkPath: string): string { // Extract command name from "commands.captureWeather" const parts = signalkPath.split('.'); return parts[parts.length - 1]; } // Getters for external access export function getCurrentCommands(): CommandConfig[] { return currentCommands; } export function getCommandHistory(): CommandHistoryEntry[] { return commandHistory; } export function getCommandState(): CommandRegistrationState { return commandState; } export function setCurrentCommands(commands: CommandConfig[]): void { currentCommands = commands; } export function updatePluginConfig(config: PluginConfig): void { pluginConfig = config; appInstance?.debug( `📍 Plugin config updated (homePort: ${config.homePortLatitude}, ${config.homePortLongitude})` ); // Re-evaluate all position-based thresholds with new home port // This uses level-triggered logic (not edge-triggered like processThresholdValue) // because config changes require immediate state reconciliation currentCommands.forEach(command => { if (command.thresholds) { command.thresholds.forEach(threshold => { if (threshold.enabled && isPositionOperator(threshold.operator)) { // Check if automation is enabled for this command const autoEnabledRaw = appInstance?.getSelfPath( `commands.${command.command}.auto` as Path ); const autoEnabled = autoEnabledRaw && typeof autoEnabledRaw === 'object' && 'value' in autoEnabledRaw ? autoEnabledRaw.value : autoEnabledRaw; if (!autoEnabled) { appInstance?.debug( `đŸšĢ Skipping threshold re-evaluation for ${command.command} (automation disabled)` ); return; } const currentValue = appInstance?.getSelfPath( threshold.watchPath as Path ); if (currentValue !== undefined) { const normalizedValue = normalizeThresholdValue( currentValue, threshold ); const homePort = pluginConfig && pluginConfig.homePortLatitude && pluginConfig.homePortLongitude ? { latitude: pluginConfig.homePortLatitude, longitude: pluginConfig.homePortLongitude, } : undefined; const conditionMet = evaluateThreshold( threshold, normalizedValue, homePort ); // Level-triggered: set to activateOnMatch when condition met, opposite when not met const desiredState = conditionMet ? Boolean(threshold.activateOnMatch) : !threshold.activateOnMatch; // Get current command state const currentCommandValue = appInstance?.getSelfPath( `commands.${command.command}` as Path ); const actualValue = currentCommandValue && typeof currentCommandValue === 'object' && 'value' in currentCommandValue ? currentCommandValue.value : currentCommandValue; const currentlyActive = Boolean(actualValue); appInstance?.debug( `🔄 Config change re-eval: ${command.command} conditionMet=${conditionMet}, current=${currentlyActive}, desired=${desiredState}` ); if (currentlyActive !== desiredState) { appInstance?.debug( `📍 Config change triggers state change: ${command.command} → ${desiredState ? 'ON' : 'OFF'}` ); executeCommand(command.command, desiredState); // Update command state command.active = desiredState; commandState.registeredCommands.set(command.command, command); } } } }); } }); } function isPositionOperator(operator: string): boolean { return [ 'withinRadius', 'outsideRadius', 'inBoundingBox', 'outsideBoundingBox', ].includes(operator); } // Threshold monitoring system export function startThresholdMonitoring( app: ServerAPI, config?: PluginConfig ): void { appInstance = app; if (config) { pluginConfig = config; } app.debug('🔄 Starting threshold monitoring system'); // Process all commands currentCommands.forEach(command => { if (command.thresholds && command.thresholds.length > 0) { // Set up monitoring for all enabled thresholds command.thresholds.forEach(threshold => { if (threshold.enabled) { setupThresholdMonitoring(command, threshold); } }); } else { // Apply default state for commands without thresholds or manual override applyDefaultState(command); } }); } function applyDefaultState(command: CommandConfig): void { // Skip if manual override is active if (command.manualOverride) { return; } // Apply default state if specified and command is not already active if ( command.defaultState !== undefined && command.active !== command.defaultState ) { appInstance?.debug( `🔧 Applying default state for ${command.command}: ${command.defaultState ? 'ON' : 'OFF'}` ); const result = executeCommand(command.command, command.defaultState); if (result.success) { appInstance?.debug( `✅ Default state applied: ${command.command} = ${command.defaultState}` ); } else { appInstance?.error( `❌ Failed to apply default state: ${command.command} - ${result.message}` ); } } } export function stopThresholdMonitoring(): void { appInstance?.debug('âšī¸ Stopping threshold monitoring system'); // Unsubscribe from all threshold monitors thresholdState.forEach(state => { if (state.unsubscribe) { state.unsubscribe(); } }); thresholdState.clear(); } function setupThresholdMonitoring( command: CommandConfig, threshold: ThresholdConfig ): void { if (threshold?.enabled === false || !threshold.watchPath) { return; } const monitorKey = buildThresholdMonitorKey(command.command, threshold); appInstance?.debug( `đŸŽ¯ Setting up threshold monitoring for ${command.command} watching ${threshold.watchPath}` ); // Clean up existing monitoring for this command const existingState = thresholdState.get(monitorKey); if (existingState?.unsubscribe) { existingState.unsubscribe(); } // Subscribe to the watch path const unsubscribe = appInstance?.streambundle ?.getSelfStream(threshold.watchPath as Path) ?.onValue((value: any) => { try { processThresholdValue(command, threshold, value, monitorKey); } catch (error) { appInstance?.error( `❌ Error processing threshold for ${command.command}: ${(error as Error).message}` ); } }); // Store the monitoring state thresholdState.set(monitorKey, { lastValue: undefined, lastTriggered: 0, lastConditionMet: undefined, unsubscribe, }); // Evaluate immediately with current value if available try { const currentValue = appInstance?.getSelfPath(threshold.watchPath as Path); if (currentValue !== undefined) { processThresholdValue(command, threshold, currentValue, monitorKey); } } catch (error) { appInstance?.debug( `â„šī¸ Unable to read current value for ${threshold.watchPath}: ${(error as Error).message}` ); } } function processThresholdValue( command: CommandConfig, threshold: ThresholdConfig, value: any, monitorKey: string ): void { const state = thresholdState.get(monitorKey); if (!state) return; const commandName = command.command; // Check if threshold processing is already running for this command (first-in rule) if (thresholdProcessingLocks.get(commandName)) { appInstance?.debug( `🔒 Threshold processing already running for ${commandName}, skipping` ); return; } // Acquire lock thresholdProcessingLocks.set(commandName, true); // Set timeout to prevent deadlocks const timeoutId = setTimeout(() => { if (thresholdProcessingLocks.get(commandName)) { appInstance?.error( `âš ī¸ Threshold processing timeout for ${commandName}, releasing lock` ); thresholdProcessingLocks.set(commandName, false); } }, THRESHOLD_PROCESSING_TIMEOUT); try { const normalizedValue = normalizeThresholdValue(value, threshold); // Verbose: logs on every value update // appInstance?.debug( // `📈 Threshold monitor ${commandName}:${threshold.watchPath} received value: ${JSON.stringify(normalizedValue)}` // ); // Check if automation is enabled for this command const autoEnabledRaw = appInstance?.getSelfPath( `commands.${commandName}.auto` as Path ); const autoEnabled = autoEnabledRaw && typeof autoEnabledRaw === 'object' && 'value' in autoEnabledRaw ? autoEnabledRaw.value : autoEnabledRaw; if (!autoEnabled) { appInstance?.debug( `đŸšĢ Threshold automation disabled for ${commandName} (auto=${autoEnabled})` ); return; } // Skip processing if manual override is active (backward compatibility) if (command.manualOverride) { // Check if manual override has expired if (command.manualOverrideUntil) { const expiry = new Date(command.manualOverrideUntil); if (new Date() > expiry) { // Override expired, clear it command.manualOverride = false; command.manualOverrideUntil = undefined; appInstance?.debug(`⏰ Manual override expired for ${commandName}`); } else { // Override still active, skip threshold processing return; } } else { // Permanent manual override, skip threshold processing return; } } const now = Date.now(); // Get homePort from config for position-based thresholds const homePort = pluginConfig && pluginConfig.homePortLatitude && pluginConfig.homePortLongitude ? { latitude: pluginConfig.homePortLatitude, longitude: pluginConfig.homePortLongitude, } : undefined; const conditionMet = evaluateThreshold( threshold, normalizedValue, homePort ); // Better debug logging for different threshold types let _thresholdDesc = ''; if ( threshold.operator === 'inBoundingBox' || threshold.operator === 'outsideBoundingBox' ) { if (threshold.useHomePort && threshold.boxSize && threshold.boxAnchor) { _thresholdDesc = `homePort-based box: size=${threshold.boxSize}m anchor=${threshold.boxAnchor} homePort=${JSON.stringify(homePort)}`; } else if (threshold.boundingBox) { _thresholdDesc = `manual box: ${JSON.stringify(threshold.boundingBox)}`; } else { _thresholdDesc = 'NO BOX CONFIGURED'; } } else if ( threshold.operator === 'withinRadius' || threshold.operator === 'outsideRadius' ) { _thresholdDesc = `radius=${threshold.radius}m useHomePort=${threshold.useHomePort} lat=${threshold.latitude} lon=${threshold.longitude}`; } else { _thresholdDesc = `value=${threshold.value}`; } // Verbose: logs on every value update // appInstance?.debug( // `📉 Threshold evaluation for ${commandName}:${threshold.watchPath} operator=${threshold.operator} ${_thresholdDesc} conditionMet=${conditionMet}` // ); if (!conditionMet) { // For position-based operators, we need bidirectional triggering: // When condition becomes FALSE, set command to opposite of activateOnMatch if (isPositionOperator(threshold.operator)) { const desiredState = !threshold.activateOnMatch; // Get current command state const currentCommandValue = appInstance?.getSelfPath( `commands.${commandName}` as Path ); const actualValue = currentCommandValue && typeof currentCommandValue === 'object' && 'value' in currentCommandValue ? currentCommandValue.value : currentCommandValue; const currentlyActive = Boolean(actualValue); if (currentlyActive !== desiredState) { appInstance?.debug( `📍 Position threshold condition FALSE: ${commandName} → ${desiredState ? 'ON' : 'OFF'}` ); const result = executeCommand(commandName, desiredState); if (result.success) { command.active = desiredState; commandState.registeredCommands.set(commandName, command); currentCommands = Array.from( commandState.registeredCommands.values() ); } } } state.lastConditionMet = false; state.lastValue = normalizedValue; return; } // Apply hysteresis window only when condition remains true if (threshold.hysteresis && state.lastTriggered) { const timeSinceLastTrigger = now - state.lastTriggered; if (timeSinceLastTrigger < threshold.hysteresis * 1000) { appInstance?.debug( `âąī¸ Hysteresis active for ${commandName}, skipping trigger (${timeSinceLastTrigger}ms < ${threshold.hysteresis * 1000}ms)` ); state.lastConditionMet = true; state.lastValue = normalizedValue; return; } } const desiredState = Boolean(threshold.activateOnMatch); // Determine current command state const currentCommandValue = appInstance?.getSelfPath( `commands.${commandName}` as Path ); const actualValue = currentCommandValue && typeof currentCommandValue === 'object' && 'value' in currentCommandValue ? currentCommandValue.value : currentCommandValue; const currentlyActive = Boolean(actualValue); // Verbose: logs on every value update // appInstance?.debug( // `🔍 Command state check for ${commandName}: current=${actualValue} (bool=${currentlyActive}), desired=${desiredState}` // ); if (currentlyActive !== desiredState) { appInstance?.debug( `đŸŽ¯ Threshold condition met for ${commandName}: ${threshold.watchPath} = ${normalizedValue}, setting to ${desiredState ? 'ON' : 'OFF'}` ); const result = executeCommand(commandName, desiredState); if (result.success) { state.lastTriggered = now; command.active = desiredState; commandState.registeredCommands.set(commandName, command); currentCommands = Array.from(commandState.registeredCommands.values()); appInstance?.debug( `✅ Command updated by threshold: ${commandName} = ${desiredState}` ); } else { appInstance?.error( `❌ Threshold-triggered command failed: ${commandName} - ${result.message}` ); } } else { // Verbose: logs on every value update when state unchanged // appInstance?.debug( // `â„šī¸ Command ${commandName} already ${desiredState ? 'ON' : 'OFF'}, no action taken` // ); } state.lastConditionMet = true; state.lastValue = normalizedValue; } catch (error) { appInstance?.error( `❌ Error processing threshold for ${commandName}: ${(error as Error).message}` ); } finally { // Always release lock and clear timeout clearTimeout(timeoutId); thresholdProcessingLocks.set(commandName, false); } } function evaluateThreshold( threshold: ThresholdConfig, currentValue: any, homePort?: { latitude: number; longitude: number } ): boolean { switch (threshold.operator) { // Numeric operators case 'gt': return ( typeof currentValue === 'number' && typeof threshold.value === 'number' && currentValue > threshold.value ); case 'lt': return ( typeof currentValue === 'number' && typeof threshold.value === 'number' && currentValue < threshold.value ); case 'eq': return currentValue === threshold.value; case 'ne': return currentValue !== threshold.value; case 'range': if ( typeof currentValue !== 'number' || typeof threshold.valueMin !== 'number' || typeof threshold.valueMax !== 'number' ) { return false; } return ( currentValue >= threshold.valueMin && currentValue <= threshold.valueMax ); // String operators case 'contains': return ( typeof currentValue === 'string' && typeof threshold.value === 'string' && currentValue.includes(threshold.value) ); case 'startsWith': return ( typeof currentValue === 'string' && typeof threshold.value === 'string' && currentValue.startsWith(threshold.value) ); case 'endsWith': return ( typeof currentValue === 'string' && typeof threshold.value === 'string' && currentValue.endsWith(threshold.value) ); case 'stringEquals': return ( typeof currentValue === 'string' && typeof threshold.value === 'string' && currentValue === threshold.value ); // Boolean operators case 'true': return ( currentValue === true || currentValue === 'true' || currentValue === 1 ); case 'false': return ( currentValue === false || currentValue === 'false' || currentValue === 0 ); // Position operators case 'withinRadius': case 'outsideRadius': { if ( !currentValue || typeof currentValue.latitude !== 'number' || typeof currentValue.longitude !== 'number' ) { appInstance?.error( `❌ Threshold ${threshold.watchPath}: Current value is not a valid position` ); return false; } const targetLat = threshold.useHomePort && homePort ? homePort.latitude : threshold.latitude; const targetLon = threshold.useHomePort && homePort ? homePort.longitude : threshold.longitude; if (typeof targetLat !== 'number' || typeof targetLon !== 'number') { appInstance?.error( `❌ Threshold ${threshold.watchPath}: Target position not configured` ); return false; } if (typeof threshold.radius !== 'number') { appInstance?.error( `❌ Threshold ${threshold.watchPath}: Radius not configured` ); return false; } const distance = calculateDistance( currentValue.latitude, currentValue.longitude, targetLat, targetLon ); return threshold.operator === 'withinRadius' ? distance <= threshold.radius : distance > threshold.radius; } case 'inBoundingBox': case 'outsideBoundingBox': { if ( !currentValue || typeof currentValue.latitude !== 'number' || typeof currentValue.longitude !== 'number' ) { appInstance?.error( `❌ Threshold ${threshold.watchPath}: Current value is not a valid position` ); return false; } let boundingBox: BoundingBox; // Check if using home port-based bounding box if (threshold.useHomePort && threshold.boxSize && threshold.boxAnchor) { if (!homePort) { appInstance?.error( `❌ Threshold ${threshold.watchPath}: Home port not configured` ); return false; } // Calculate bounding box from home port, box size, and anchor // Add buffer for GPS accuracy (default 5m) const buffer = threshold.boxBuffer !== undefined ? threshold.boxBuffer : 5; const effectiveBoxSize = threshold.boxSize + buffer; boundingBox = calculateBoundingBoxFromHomePort( homePort.latitude, homePort.longitude, effectiveBoxSize, threshold.boxAnchor ); } else if (threshold.boundingBox) { // Use manual bounding box boundingBox = threshold.boundingBox; } else { appInstance?.error( `❌ Threshold ${threshold.watchPath}: Bounding box not configured` ); return false; } const inBox = isPointInBoundingBox( currentValue.latitude, currentValue.longitude, boundingBox ); return threshold.operator === 'inBoundingBox' ? inBox : !inBox; } default: appInstance?.error( `❌ Unknown threshold operator: ${threshold.operator}` ); return false; } } function normalizeThresholdValue(value: any, threshold: ThresholdConfig): any { if (value === null || value === undefined) { return value; } if (typeof value === 'object') { if ('value' in value) { return value.value; } if ('values' in value && value.values && typeof value.values === 'object') { if ('value' in value.values) { return value.values.value; } } } if (typeof value === 'string') { const trimmed = value.trim(); if (trimmed === '') { return value; } const lower = trimmed.toLowerCase(); if (lower === 'true' || lower === 'false') { return lower === 'true'; } const num = Number(trimmed); if (!Number.isNaN(num)) { return num; } } if (typeof value === 'number') { return value; } if (typeof value === 'boolean') { return value; } // If threshold expects numeric and value is object with number if (typeof threshold.value === 'number') { const extracted = Number(value); if (!Number.isNaN(extracted)) { return extracted; } } return value; } function buildThresholdMonitorKey( commandName: string, threshold: ThresholdConfig ): string { const parts: string[] = [ commandName, threshold.watchPath, threshold.operator, ]; if (threshold.value !== undefined) { parts.push(`value=${JSON.stringify(threshold.value)}`); } if (threshold.valueMin !== undefined) { parts.push(`min=${threshold.valueMin}`); } if (threshold.valueMax !== undefined) { parts.push(`max=${threshold.valueMax}`); } if (threshold.latitude !== undefined) { parts.push(`lat=${threshold.latitude}`); } if (threshold.longitude !== undefined) { parts.push(`lon=${threshold.longitude}`); } if (threshold.radius !== undefined) { parts.push(`radius=${threshold.radius}`); } if (threshold.useHomePort) { parts.push('useHomePort=true'); } if (threshold.boxSize !== undefined) { parts.push(`boxSize=${threshold.boxSize}`); } if (threshold.boxAnchor) { parts.push(`boxAnchor=${threshold.boxAnchor}`); } if (threshold.boundingBox) { const { north, south, east, west } = threshold.boundingBox; parts.push(`boundingBox=${north},${south},${east},${west}`); } parts.push(`activateOnMatch=${threshold.activateOnMatch}`); if (threshold.hysteresis !== undefined) { parts.push(`hysteresis=${threshold.hysteresis}`); } return parts.join('|'); } export function updateCommandThreshold( commandName: string, threshold: ThresholdConfig ): CommandExecutionResult { const command = commandState.registeredCommands.get(commandName); if (!command) { return { success: false, state: 'FAILED', message: `Command ${commandName} not found`, timestamp: new Date().toISOString(), }; } // Update the thresholds configuration (replace single threshold with array) command.thresholds = [threshold]; // Restart monitoring for this command if (threshold.enabled) { setupThresholdMonitoring(command, threshold); } else { // Stop monitoring if disabled const monitorKey = buildThresholdMonitorKey(commandName, threshold); const state = thresholdState.get(monitorKey); if (state?.unsubscribe) { state.unsubscribe(); thresholdState.delete(monitorKey); } } // Save configuration const config = loadWebAppConfig(); saveWebAppConfig(config.paths, config.commands, appInstance); appInstance?.debug(`đŸŽ¯ Updated threshold configuration for ${commandName}`); return { success: true, state: 'COMPLETED', message: `Threshold updated for ${commandName}`, timestamp: new Date().toISOString(), }; } export function setManualOverride( commandName: string, override: boolean, expiryMinutes?: number ): CommandExecutionResult { const command = commandState.registeredCommands.get(commandName); if (!command) { return { success: false, state: 'FAILED', message: `Command ${commandName} not found`, timestamp: new