signalk-parquet
Version:
SignalK plugin to save marine data directly to Parquet files with regimen-based control
629 lines (550 loc) • 17.9 kB
text/typescript
import {
CommandConfig,
CommandRegistrationState,
CommandExecutionResult,
CommandPutHandler,
CommandHistoryEntry,
PathConfig,
WebAppPathConfig,
PluginConfig,
} from './types';
import {
Context,
Delta,
Path,
ServerAPI,
SourceRef,
Timestamp,
} from '@signalk/server-api';
import * as fs from 'fs-extra';
import * as path from 'path';
// Global variables for command management
let currentCommands: CommandConfig[] = [];
let commandHistory: CommandHistoryEntry[] = [];
let appInstance: ServerAPI;
// Command state management
const commandState: CommandRegistrationState = {
registeredCommands: new Map<string, CommandConfig>(),
putHandlers: new Map<string, CommandPutHandler>(),
};
// 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,
})
);
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
);
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: CommandConfig) => {
const commandPath = `commands.${commandConfig.command}`;
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;
}
});
// 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[]
): CommandExecutionResult {
try {
// Validate command name
if (!isValidCommandName(commandName)) {
return {
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 {
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,
};
// 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)}`
);
return executeCommand(commandName, Boolean(value));
};
// 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 false
initializeCommandValue(commandName, false);
// 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 {
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 {
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[]
): CommandExecutionResult {
try {
// Check if command exists
if (!commandState.registeredCommands.has(commandName)) {
return {
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,
};
// Update the command in the registry
commandState.registeredCommands.set(commandName, updatedCommand);
// Update current commands array
currentCommands = Array.from(commandState.registeredCommands.values());
// Log the update
addCommandHistoryEntry(commandName, 'UPDATE', undefined, true);
appInstance.debug(`✅ Updated command: ${commandName}`);
return {
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 {
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 {
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 {
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 {
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 {
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', 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 {
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 {
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', 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;
}