signalk-parquet
Version:
SignalK plugin and webapp that archives SK data to Parquet files with a regimen control system, advanced querying, Claude integrated AI analysis, spatial capabilities, and REST API.
1,118 lines âĸ 48.7 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadWebAppConfig = loadWebAppConfig;
exports.saveWebAppConfig = saveWebAppConfig;
exports.initializeCommandState = initializeCommandState;
exports.registerCommand = registerCommand;
exports.updateCommand = updateCommand;
exports.unregisterCommand = unregisterCommand;
exports.executeCommand = executeCommand;
exports.extractCommandName = extractCommandName;
exports.getCurrentCommands = getCurrentCommands;
exports.getCommandHistory = getCommandHistory;
exports.getCommandState = getCommandState;
exports.setCurrentCommands = setCurrentCommands;
exports.startThresholdMonitoring = startThresholdMonitoring;
exports.stopThresholdMonitoring = stopThresholdMonitoring;
exports.updateCommandThreshold = updateCommandThreshold;
exports.setManualOverride = setManualOverride;
const fs = __importStar(require("fs-extra"));
const path = __importStar(require("path"));
const geo_calculator_1 = require("./utils/geo-calculator");
// Global variables for command management
let currentCommands = [];
let commandHistory = [];
let appInstance;
let pluginConfig = null;
// Threshold processing lock system for first-in rule
const thresholdProcessingLocks = new Map();
const THRESHOLD_PROCESSING_TIMEOUT = 5000; // 5 seconds
// Command state management
const commandState = {
registeredCommands: new Map(),
putHandlers: new Map(),
};
// Threshold monitoring state
const thresholdState = new Map();
// Configuration management functions
function loadWebAppConfig(app) {
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) => ({
path: path.path,
name: path.name,
enabled: path.enabled,
regimen: path.regimen,
source: path.source || undefined,
context: path.context,
excludeMMSI: path.excludeMMSI,
}));
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() {
const defaultCommands = [
{
command: 'captureMoored',
path: 'commands.captureMoored',
registered: 'COMPLETED',
description: 'Capture data when moored (position and wind)',
active: false,
},
];
const defaultPaths = [
{
path: 'commands.captureMoored',
name: 'Command: captureMoored',
enabled: true,
regimen: 'commands',
source: undefined,
context: 'vessels.self',
},
{
path: 'navigation.position',
name: 'Navigation Position',
enabled: true,
regimen: 'captureMoored',
source: undefined,
context: 'vessels.self',
},
{
path: 'environment.wind.speedApparent',
name: 'Apparent Wind Speed',
enabled: true,
regimen: 'captureMoored',
source: undefined,
context: 'vessels.self',
},
];
return {
commands: defaultCommands,
paths: defaultPaths,
};
}
function saveWebAppConfig(paths, commands, app) {
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 = { 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
function initializeCommandState(currentPaths, app) {
appInstance = app;
// Clear existing command state
commandState.registeredCommands.clear();
commandState.putHandlers.clear();
// Re-register commands from configuration
currentCommands.forEach((commandConfig) => {
const result = registerCommand(commandConfig.command, commandConfig.description, commandConfig.keywords, commandConfig.defaultState, commandConfig.thresholds);
if (result.state === 'COMPLETED') {
}
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) => {
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 = {
path: commandPath,
name: `Command: ${commandConfig.command}`,
enabled: true,
regimen: undefined,
source: undefined,
context: 'vessels.self',
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 = {
path: autoPath,
name: `Command Auto: ${commandConfig.command}`,
enabled: true,
regimen: 'commands',
source: undefined,
context: 'vessels.self',
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) => {
initializeCommandValue(commandConfig.command, false);
});
}
// Command registration with full type safety
function registerCommand(commandName, description, keywords, defaultState, thresholds) {
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 = {
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 = (context, path,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value, _callback) => {
appInstance.debug(`Handling PUT for commands.${commandName} with value: ${JSON.stringify(value)}`);
return executeCommand(commandName, Boolean(value));
};
// Register PUT handler with SignalK
appInstance.registerPutHandler('vessels.self', commandPath, putHandler, //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, _path, value, _callback) => {
appInstance.debug(`Handling PUT for ${autoPath} with value: ${JSON.stringify(value)}`);
// Update the .auto value
const autoValue = Boolean(value);
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);
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('zennora-parquet-commands', {
context: 'vessels.self',
updates: [
{
timestamp: timestamp,
values: [
{
path: autoPath,
value: autoValue,
},
],
},
],
});
return {
success: true,
state: 'COMPLETED',
message: `Automation ${autoValue ? 'enabled' : 'disabled'} for ${commandName}`,
timestamp
};
};
// Register .auto PUT handler with SignalK
appInstance.registerPutHandler('vessels.self', autoPath, autoPutHandler, 'zennora-parquet-commands');
// Start threshold monitoring for this command if thresholds are defined
if (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)
function updateCommand(commandName, description, keywords, defaultState, thresholds) {
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 = {
...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.message}`);
return {
success: false,
state: 'FAILED',
statusCode: 500,
message: `Failed to update command: ${error.message}`,
timestamp: new Date().toISOString(),
};
}
}
// Command unregistration with type safety
function unregisterCommand(commandName) {
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
function executeCommand(commandName, value) {
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 = {
context: 'vessels.self',
updates: [
{
$source: 'signalk-parquet-commands',
timestamp: timestamp,
values: [
{
path: `commands.${commandName}`,
value: value,
},
],
},
],
};
// Send delta message
//FIXME see if delta can be Delta from the beginning
appInstance.handleMessage('signalk-parquet-commands', 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) {
const validPattern = /^[a-zA-Z0-9_]+$/;
return (validPattern.test(commandName) &&
commandName.length > 0 &&
commandName.length <= 50);
}
function initializeCommandValue(commandName, value) {
const timestamp = new Date().toISOString();
const delta = {
context: 'vessels.self',
updates: [
{
$source: 'signalk-parquet-commands',
timestamp,
values: [
{
path: `commands.${commandName}`,
value,
},
],
},
],
};
//FIXME
appInstance.handleMessage('signalk-parquet-commands', delta);
}
function addCommandHistoryEntry(command, action, value, success = true, error) {
const entry = {
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
function extractCommandName(signalkPath) {
// Extract command name from "commands.captureWeather"
const parts = signalkPath.split('.');
return parts[parts.length - 1];
}
// Getters for external access
function getCurrentCommands() {
return currentCommands;
}
function getCommandHistory() {
return commandHistory;
}
function getCommandState() {
return commandState;
}
function setCurrentCommands(commands) {
currentCommands = commands;
}
// Threshold monitoring system
function startThresholdMonitoring(app, config) {
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) {
// 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}`);
}
}
}
function stopThresholdMonitoring() {
appInstance?.debug('âšī¸ Stopping threshold monitoring system');
// Unsubscribe from all threshold monitors
thresholdState.forEach(state => {
if (state.unsubscribe) {
state.unsubscribe();
}
});
thresholdState.clear();
}
function setupThresholdMonitoring(command, threshold) {
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)?.onValue((value) => {
try {
processThresholdValue(command, threshold, value, monitorKey);
}
catch (error) {
appInstance?.error(`â Error processing threshold for ${command.command}: ${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);
if (currentValue !== undefined) {
processThresholdValue(command, threshold, currentValue, monitorKey);
}
}
catch (error) {
appInstance?.debug(`âšī¸ Unable to read current value for ${threshold.watchPath}: ${error.message}`);
}
}
function processThresholdValue(command, threshold, value, monitorKey) {
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);
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`);
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}`;
}
appInstance?.debug(`đ Threshold evaluation for ${commandName}:${threshold.watchPath} operator=${threshold.operator} ${thresholdDesc} conditionMet=${conditionMet}`);
if (!conditionMet) {
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}`);
const actualValue = (currentCommandValue && typeof currentCommandValue === 'object' && 'value' in currentCommandValue)
? currentCommandValue.value
: currentCommandValue;
const currentlyActive = Boolean(actualValue);
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 {
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.message}`);
}
finally {
// Always release lock and clear timeout
clearTimeout(timeoutId);
thresholdProcessingLocks.set(commandName, false);
}
}
function evaluateThreshold(threshold, currentValue, homePort) {
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 = (0, geo_calculator_1.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;
// 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 = (0, geo_calculator_1.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 = (0, geo_calculator_1.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, threshold) {
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, threshold) {
const parts = [
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('|');
}
function updateCommandThreshold(commandName, threshold) {
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() };
}
function setManualOverride(commandName, override, expiryMinutes) {
const command = commandState.registeredCommands.get(commandName);
if (!command) {
return { success: false, state: 'FAILED', message: `Command ${commandName} not found`, timestamp: new Date().toISOString() };
}
command.manualOverride = override;
if (override && expiryMinutes) {
const expiry = new Date(Date.now() + expiryMinutes * 60 * 1000);
command.manualOverrideUntil = expiry.toISOString();
appInstance?.debug(`đ Manual override set for ${commandName} until ${expiry.toISOString()}`);
}
else if (override) {
command.manualOverrideUntil = undefined;
appInstance?.debug(`đ Permanent manual override set for ${commandName}`);
}
else {
// Enabling automation mode: set to OFF then evaluate thresholds
command.manualOverrideUntil = undefined;
appInstance?.debug(`đ Manual override cleared for ${commandName}, enabling automation`);
// Set command to OFF (defaultState = false)
const offResult = executeCommand(commandName, false);
if (offResult.success) {
appInstance?.debug(`đ§ Command ${commandName} set to OFF (default state) before threshold evaluation`);
}
else {
appInstance?.error(`â Failed to set ${commandName} to OFF: ${offResult.message}`);
}
// Immediately evaluate all thresholds for this command
if (command.thresholds && command.thresholds.length > 0) {
command.thresholds.forEach(threshold => {
const currentValue = appInstance?.getSelfPath(threshold.watchPath);
if (currentValue !== undefined) {
const monitorKey = buildThresholdMonitorKey(commandName, threshold);
const state = thresholdState.get(monitorKey);
if (state) {
// Threshold monitoring is active, evaluate immediately
processThresholdValue(command, threshold, currentValue, monitorKey);
appInstance?.debug(`đ¯ Immediately evaluated threshold for ${commandName}:${threshold.watchPath}`);
}
else {
appInstance?.debug(`â ī¸ Threshold state not found for ${commandName}:${threshold.watchPath}, will evaluate on next update`);
}
}
});
}
}
// Save configuration
const config = loadWebAppConfig();
saveWebAppConfig(config.paths, config.commands, appInstance);
return { success: true, state: 'COMPLETED', message: `Manual override ${override ? 'enabled' : 'disabled'} for ${commandName}`, timestamp: new Date().toISOString() };
}
//# sourceMappingURL=commands.js.map