signalk-weatherflow
Version:
SignalK plugin for WeatherFlow weather station data ingestion
1,254 lines (1,111 loc) • 39.3 kB
text/typescript
import * as dgram from 'dgram';
import * as WebSocket from 'ws';
const fetch = require('node-fetch');
import { WindCalculations } from './windCalculations';
import {
SignalKApp,
SignalKPlugin,
PluginConfig,
PluginState,
WeatherFlowMessage,
RapidWindData,
TempestObservationData,
AirObservationData,
RainEventData,
LightningEventData,
WebSocketMessage,
ForecastData,
ProcessedWindData,
ProcessedTempestData,
ProcessedAirData,
ProcessedRainData,
ProcessedLightningData,
ConvertedValue,
SignalKDelta,
SignalKUpdate,
SubscriptionRequest,
SubscriptionValue,
WindInput,
PutHandler,
} from './types';
export = function (app: SignalKApp): SignalKPlugin {
const plugin: SignalKPlugin = {
id: 'signalk-weatherflow',
name: 'SignalK WeatherFlow Ingester',
description:
'Ingests data from WeatherFlow weather stations via UDP, WebSocket, and API',
schema: {},
start: () => {},
stop: () => {},
};
const state: PluginState = {
udpServer: null,
wsConnection: null,
forecastInterval: null,
windyInterval: null,
windCalculations: null,
navigationSubscriptions: [],
currentConfig: undefined,
webSocketEnabled: true,
forecastEnabled: true,
windCalculationsEnabled: true,
putHandlers: new Map(),
};
// Configuration schema
plugin.schema = {
type: 'object',
required: ['stationId', 'apiToken'],
properties: {
stationId: {
type: 'number',
title: 'WeatherFlow Station ID',
description: 'Your WeatherFlow station ID',
default: 118081,
},
vesselName: {
type: 'string',
title: 'Vessel Name',
description:
'Vessel name for source identification (defaults to "weatherflow" if not specified)',
default: '',
},
apiToken: {
type: 'string',
title: 'WeatherFlow API Token',
description: 'Your WeatherFlow API token',
default: '',
},
udpPort: {
type: 'number',
title: 'UDP Listen Port',
description: 'Port to listen for WeatherFlow UDP broadcasts',
default: 50222,
},
enableWebSocket: {
type: 'boolean',
title: 'Enable WebSocket Connection',
description: 'Connect to WeatherFlow WebSocket for real-time data',
default: true,
},
enableForecast: {
type: 'boolean',
title: 'Enable Forecast Data',
description: 'Fetch forecast data from WeatherFlow API',
default: true,
},
forecastInterval: {
type: 'number',
title: 'Forecast Update Interval (minutes)',
description: 'How often to fetch forecast data',
default: 30,
},
enableWindCalculations: {
type: 'boolean',
title: 'Enable Wind Calculations',
description: 'Calculate true wind from apparent wind',
default: true,
},
deviceId: {
type: 'number',
title: 'WeatherFlow Device ID',
description: 'Your WeatherFlow device ID for WebSocket connection',
default: 405588,
},
enablePutControl: {
type: 'boolean',
title: 'Enable PUT Control',
description:
'Allow external control of individual plugin services via PUT requests',
default: false,
},
webSocketControlPath: {
type: 'string',
title: 'WebSocket Control Path',
description: 'SignalK path for WebSocket control',
default: 'network.weatherflow.webSocket.state',
},
forecastControlPath: {
type: 'string',
title: 'Forecast Control Path',
description: 'SignalK path for forecast control',
default: 'network.weatherflow.forecast.state',
},
windCalculationsControlPath: {
type: 'string',
title: 'Wind Calculations Control Path',
description: 'SignalK path for wind calculations control',
default: 'network.weatherflow.windCalculations.state',
},
},
};
// Utility function to format name according to source naming rules
function formatSourceName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
}
// Utility function to get formatted vessel name for source
function getVesselBasedSource(
configuredPrefix: string | undefined,
suffix: string
): string {
// Use configured prefix if provided, otherwise default to "signalk" for now
const vesselPrefix =
configuredPrefix && configuredPrefix.trim()
? configuredPrefix
: 'signalk';
const formattedName = formatSourceName(vesselPrefix);
return `${formattedName}-weatherflow-${suffix}`;
}
// Utility function to convert underscore_case to camelCase
function toCamelCase(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
// Persistent state management
function getStateFilePath(): string {
return require('path').join(
app.getDataDirPath(),
'signalk-weatherflow-state.json'
);
}
function loadPersistedState(): Partial<{
webSocketEnabled: boolean;
forecastEnabled: boolean;
windCalculationsEnabled: boolean;
}> {
try {
const fs = require('fs');
const stateFile = getStateFilePath();
if (fs.existsSync(stateFile)) {
const data = fs.readFileSync(stateFile, 'utf8');
return JSON.parse(data);
}
} catch (error) {
app.debug('Could not load persisted state: ' + (error as Error).message);
}
return {};
}
function savePersistedState(): void {
try {
const fs = require('fs');
const stateToSave = {
webSocketEnabled: state.webSocketEnabled,
forecastEnabled: state.forecastEnabled,
windCalculationsEnabled: state.windCalculationsEnabled,
};
fs.writeFileSync(
getStateFilePath(),
JSON.stringify(stateToSave, null, 2)
);
} catch (error) {
app.error('Could not save persisted state: ' + (error as Error).message);
}
}
// Update plugin configuration to match current state
function updatePluginConfig(): void {
if (!state.currentConfig) return;
const updatedConfig = {
...state.currentConfig,
enableWebSocket: state.webSocketEnabled,
enableForecast: state.forecastEnabled,
enableWindCalculations: state.windCalculationsEnabled,
};
app.savePluginOptions(updatedConfig, (err?: any) => {
if (err) {
app.error('Could not save plugin configuration: ' + err.message);
} else {
app.debug('Plugin configuration updated to match PUT state changes');
state.currentConfig = updatedConfig;
}
});
}
// Setup PUT control for individual service control
function setupPutControl(config: PluginConfig): void {
const controlPaths = [
{ path: config.webSocketControlPath, service: 'webSocket' },
{ path: config.forecastControlPath, service: 'forecast' },
{ path: config.windCalculationsControlPath, service: 'windCalculations' },
];
controlPaths.forEach(({ path, service }) => {
// Create PUT handler
const putHandler: PutHandler = (
context: string,
requestPath: string,
value: any,
callback?: (result: { state: string; statusCode?: number }) => void
): { state: string; statusCode?: number } => {
app.debug(
`PUT request received for ${requestPath} with value: ${JSON.stringify(value)}`
);
if (requestPath === path) {
const newState = Boolean(value);
handleServiceControl(service, newState, config);
// Save the new state to persist across restarts
savePersistedState();
// Update plugin configuration so checkboxes reflect the change
updatePluginConfig();
// Publish updated state
const updatedDelta = createSignalKDelta(
path,
newState,
getVesselBasedSource(config.vesselName, 'control')
);
app.handleMessage(plugin.id, updatedDelta);
const result = { state: 'COMPLETED' };
if (callback) callback(result);
return result;
} else {
const result = { state: 'COMPLETED', statusCode: 405 };
if (callback) callback(result);
return result;
}
};
// Register PUT handler with SignalK
app.registerPutHandler(
'vessels.self',
path,
putHandler,
'signalk-weatherflow'
);
// Store handler for cleanup
state.putHandlers.set(path, putHandler);
// Publish current state (which reflects config checkboxes)
const currentState = getServiceState(service);
const initialDelta = createSignalKDelta(
path,
currentState,
getVesselBasedSource(config.vesselName, 'control')
);
app.handleMessage(plugin.id, initialDelta);
app.debug(`PUT control enabled for ${service} on path: ${path}`);
});
}
// Handle individual service control
function handleServiceControl(
service: string,
newState: boolean,
config: PluginConfig
): void {
const currentState = getServiceState(service);
if (newState !== currentState) {
app.debug(
`${newState ? 'Enabling' : 'Disabling'} ${service} via PUT control`
);
if (service === 'webSocket') {
state.webSocketEnabled = newState;
if (newState && config.enableWebSocket && config.apiToken) {
startWebSocketConnection(
config.apiToken,
config.deviceId,
config.vesselName
);
} else if (!newState && state.wsConnection) {
state.wsConnection.close();
state.wsConnection = null;
}
} else if (service === 'forecast') {
state.forecastEnabled = newState;
if (
newState &&
config.enableForecast &&
config.apiToken &&
config.stationId
) {
startForecastFetching(config);
} else if (!newState && state.forecastInterval) {
clearInterval(state.forecastInterval);
state.forecastInterval = null;
}
} else if (service === 'windCalculations') {
state.windCalculationsEnabled = newState;
if (newState && config.enableWindCalculations) {
state.windCalculations = new WindCalculations(app, config.vesselName);
setupNavigationSubscriptions();
} else if (!newState) {
state.navigationSubscriptions.forEach(unsub => unsub());
state.navigationSubscriptions = [];
state.windCalculations = null;
}
}
app.setProviderStatus(
`WeatherFlow ${service} ${newState ? 'enabled' : 'disabled'} via external control`
);
}
}
// Get current state of a service
function getServiceState(service: string): boolean {
switch (service) {
case 'webSocket':
return state.webSocketEnabled;
case 'forecast':
return state.forecastEnabled;
case 'windCalculations':
return state.windCalculationsEnabled;
default:
return false;
}
}
// Start plugin services (factored out for PUT control)
function startPluginServices(config: PluginConfig): void {
// Initialize wind calculations if enabled and not controlled externally
if (config.enableWindCalculations && state.windCalculationsEnabled) {
state.windCalculations = new WindCalculations(app, config.vesselName);
setupNavigationSubscriptions();
}
// Initialize UDP listener (always enabled - not controlled separately)
startUdpServer(config.udpPort, config);
// Initialize WebSocket connection if enabled and not controlled externally
if (config.enableWebSocket && config.apiToken && state.webSocketEnabled) {
startWebSocketConnection(
config.apiToken,
config.deviceId,
config.vesselName
);
}
// Initialize forecast data fetching if enabled and not controlled externally
if (
config.enableForecast &&
config.apiToken &&
config.stationId &&
state.forecastEnabled
) {
startForecastFetching(config);
}
}
// Stop plugin services (factored out for PUT control)
function stopPluginServices(): void {
// Stop UDP server
if (state.udpServer) {
state.udpServer.close();
state.udpServer = null;
}
// Close WebSocket connection
if (state.wsConnection) {
state.wsConnection.close();
state.wsConnection = null;
}
// Clear forecast interval
if (state.forecastInterval) {
clearInterval(state.forecastInterval);
state.forecastInterval = null;
}
// Unsubscribe from navigation data
state.navigationSubscriptions.forEach(unsub => unsub());
state.navigationSubscriptions = [];
// Clear wind calculations
state.windCalculations = null;
}
// Plugin start function
plugin.start = function (
options: Partial<PluginConfig>,
restartPlugin?: () => void
): void {
app.debug(
'Starting WeatherFlow plugin with options: ' + JSON.stringify(options)
);
app.setProviderStatus('Initializing WeatherFlow plugin...');
const config: PluginConfig = {
stationId: options.stationId || 118081,
vesselName: options.vesselName,
apiToken: options.apiToken || '',
udpPort: options.udpPort || 50222,
enableWebSocket: options.enableWebSocket !== false,
enableForecast: options.enableForecast !== false,
forecastInterval: options.forecastInterval || 30,
enableWindCalculations: options.enableWindCalculations !== false,
deviceId: options.deviceId || 405588,
enablePutControl: options.enablePutControl === true,
webSocketControlPath:
options.webSocketControlPath || 'network.weatherflow.webSocket.state',
forecastControlPath:
options.forecastControlPath || 'network.weatherflow.forecast.state',
windCalculationsControlPath:
options.windCalculationsControlPath ||
'network.weatherflow.windCalculations.state',
};
state.currentConfig = config;
plugin.config = config;
// Initialize service states from configuration
// Config is now the primary source of truth, kept in sync by PUT handlers
state.webSocketEnabled = config.enableWebSocket;
state.forecastEnabled = config.enableForecast;
state.windCalculationsEnabled = config.enableWindCalculations;
// Start plugin services
startPluginServices(config);
// Initialize PUT control if enabled
if (config.enablePutControl) {
setupPutControl(config);
}
app.debug('WeatherFlow plugin started successfully');
app.setProviderStatus('WeatherFlow plugin running');
};
// Plugin stop function
plugin.stop = function (): void {
app.debug('Stopping WeatherFlow plugin');
// Stop plugin services
stopPluginServices();
// Clean up PUT handlers
state.putHandlers.clear();
if (state.windyInterval) {
clearInterval(state.windyInterval);
state.windyInterval = null;
}
app.debug('WeatherFlow plugin stopped');
app.setProviderStatus('WeatherFlow plugin stopped');
};
// Setup navigation data subscriptions for wind calculations
function setupNavigationSubscriptions(): void {
if (!state.windCalculations) return;
const subscriptionPaths = [
'navigation.headingTrue',
'navigation.headingMagnetic',
'navigation.courseOverGroundMagnetic',
'navigation.speedOverGround',
'environment.outside.tempest.observations.airTemperature',
'environment.outside.tempest.observations.relativeHumidity',
];
const subscription: SubscriptionRequest = {
context: 'vessels.self',
subscribe: subscriptionPaths.map(path => ({
path,
policy: 'fixed' as const,
period: 1000,
format: 'delta' as const,
})),
};
app.subscriptionmanager.subscribe(
subscription,
state.navigationSubscriptions,
(subscriptionError: unknown) => {
app.debug('Navigation subscription error: ' + subscriptionError);
},
(delta: any) => {
handleNavigationData(delta);
}
);
}
// Handle navigation data from subscriptions
function handleNavigationData(delta: any): void {
if (!delta.updates || !state.windCalculations) return;
delta.updates.forEach((update: any) => {
if (!(update as any).values) return;
(update as any).values.forEach((valueUpdate: any) => {
if (valueUpdate.path && typeof valueUpdate.value === 'number') {
state.windCalculations.updateNavigationData(
valueUpdate.path,
valueUpdate.value
);
}
});
});
}
// Start UDP server for WeatherFlow broadcasts
function startUdpServer(port: number, config: PluginConfig): void {
state.udpServer = dgram.createSocket('udp4');
state.udpServer.on('message', (msg: Buffer, rinfo: dgram.RemoteInfo) => {
try {
const data: WeatherFlowMessage = JSON.parse(msg.toString());
processWeatherFlowMessage(data, config);
} catch (error) {
app.debug('Error parsing UDP message: ' + (error as Error).message);
}
});
state.udpServer.on('error', (err: Error) => {
app.error('UDP server error: ' + err.message);
});
state.udpServer.bind(port, () => {
app.debug('WeatherFlow UDP server listening on port ' + port);
});
}
// Start WebSocket connection to WeatherFlow
function startWebSocketConnection(
token: string,
deviceId: number,
vesselName?: string
): void {
const wsUrl = `wss://ws.weatherflow.com/swd/data?token=${token}`;
state.wsConnection = new WebSocket.WebSocket(wsUrl);
state.wsConnection.on('open', () => {
app.debug('WeatherFlow WebSocket connected');
// Request data for device
const request = {
type: 'listen_start',
device_id: deviceId || 405588,
id: Date.now().toString(),
};
state.wsConnection!.send(JSON.stringify(request));
});
state.wsConnection.on('message', (data: WebSocket.Data) => {
try {
const message: WebSocketMessage = JSON.parse(data.toString());
processWebSocketMessage(message, vesselName);
} catch (error) {
app.debug(
'Error parsing WebSocket message: ' + (error as Error).message
);
}
});
state.wsConnection.on('error', (error: Error) => {
app.error('WebSocket error: ' + error.message);
});
state.wsConnection.on('close', () => {
app.debug('WebSocket connection closed');
// Implement reconnection logic here if needed
});
}
// Start forecast data fetching
function startForecastFetching(config: PluginConfig): void {
const fetchForecast = async (): Promise<void> => {
try {
const url = `https://swd.weatherflow.com/swd/rest/better_forecast?station_id=${config.stationId}&token=${config.apiToken}`;
const response = await fetch(url);
const data: ForecastData = await response.json();
processForecastData(data, config.vesselName);
} catch (error) {
app.error('Error fetching forecast data: ' + (error as Error).message);
}
};
// Fetch immediately
fetchForecast();
// Set up interval
const intervalMs = (config.forecastInterval || 30) * 60 * 1000;
state.forecastInterval = setInterval(fetchForecast, intervalMs);
}
// Process WeatherFlow UDP messages
function processWeatherFlowMessage(
data: WeatherFlowMessage,
config: PluginConfig
): void {
if (!data.type) return;
switch (data.type) {
case 'rapid_wind':
processRapidWind(data as RapidWindData, config);
break;
case 'obs_st':
processTempestObservation(data as TempestObservationData, config);
break;
case 'obs_air':
processAirObservation(data as AirObservationData, config);
break;
case 'evt_precip':
processRainEvent(data as RainEventData, config);
break;
case 'evt_strike':
processLightningEvent(data as LightningEventData, config);
break;
default:
app.debug('Unknown WeatherFlow message type: ' + data.type);
}
}
// Helper function to convert snake_case to camelCase
function snakeToCamel(str: string): string {
return str.replace(/_([a-z0-9])/g, (match, letter) => letter.toUpperCase());
}
// Helper function to send individual SignalK deltas with units metadata
function sendSignalKDelta(
basePath: string,
key: string,
value: any,
source: string,
timestamp: string
): void {
const converted = convertToSignalKUnits(key, value);
const camelKey = snakeToCamel(key);
const path = `${basePath}.${camelKey}`;
const delta: SignalKDelta = {
context: 'vessels.self',
updates: [
{
$source: source,
timestamp: timestamp,
values: [
{
path: path,
value: converted.value,
},
],
},
],
};
// Add units metadata if available
if (converted.units) {
delta.updates[0].meta = [
{
path: path,
value: {
units: converted.units,
},
},
];
}
app.handleMessage(plugin.id, delta);
}
// Convert WeatherFlow values to SignalK standard units and get units metadata
function convertToSignalKUnits(key: string, value: any): ConvertedValue {
if (value === null || value === undefined) return { value, units: null };
// Normalize key to camelCase for consistent matching
const normalizedKey = snakeToCamel(key);
switch (normalizedKey) {
// Temperature conversions: °C to K
case 'airTemperature':
case 'feelsLike':
case 'heatIndex':
case 'windChill':
case 'dewPoint':
case 'wetBulbTemperature':
case 'wetBulbGlobeTemperature':
return { value: value + 273.15, units: 'K' };
// Pressure conversions: MB to Pa
case 'stationPressure':
case 'pressure':
return { value: value * 100, units: 'Pa' };
// Direction conversions: degrees to radians
case 'windDirection':
return { value: value * (Math.PI / 180), units: 'rad' };
// Distance conversions: km to m
case 'lightningStrikeAvgDistance':
case 'strikeLastDist':
return { value: value * 1000, units: 'm' };
// Time conversions: minutes to seconds
case 'reportInterval':
return { value: value * 60, units: 's' };
// Rain conversions: mm to m
case 'rainAccumulated':
case 'rainAccumulatedFinal':
case 'localDailyRainAccumulation':
case 'localDailyRainAccumulationFinal':
case 'precipTotal1h':
case 'precipAccumLocalYesterday':
case 'precipAccumLocalYesterdayFinal':
return { value: value / 1000, units: 'm' };
// Relative humidity: % to ratio (0-1)
case 'relativeHumidity':
return { value: value / 100, units: 'ratio' };
// Wind speeds (already in m/s)
case 'windLull':
case 'windAvg':
case 'windGust':
case 'windSpeed':
return { value: value, units: 'm/s' };
// Time values (already in seconds)
case 'windSampleInterval':
case 'timeEpoch':
case 'strikeLastEpoch':
case 'precipMinutesLocalDay':
case 'precipMinutesLocalYesterday':
return { value: value, units: 's' };
// Illuminance (lux)
case 'illuminance':
return { value: value, units: 'lux' };
// Solar radiation (W/m²)
case 'solarRadiation':
return { value: value, units: 'W/m2' };
// Battery voltage
case 'battery':
return { value: value, units: 'V' };
// Air density (kg/m³)
case 'airDensity':
return { value: value, units: 'kg/m3' };
// Temperature difference (already in K)
case 'deltaT':
return { value: value, units: 'K' };
// Counts and indices (dimensionless)
case 'uvIndex':
case 'precipitationType':
case 'precipType':
case 'lightningStrikeCount':
case 'strikeCount1h':
case 'strikeCount3h':
case 'precipitationAnalysisType':
case 'deviceId':
case 'firmwareRevision':
case 'precipAnalysisTypeYesterday':
case 'type':
case 'source':
case 'statusCode':
case 'statusMessage':
case 'id':
return { value: value, units: null };
// String values (no units)
case 'serialNumber':
case 'hubSn':
case 'pressureTrend':
return { value: value, units: null };
default:
return { value: value, units: null };
}
}
// Process WebSocket messages
function processWebSocketMessage(
data: WebSocketMessage,
vesselName?: string
): void {
// Check if WebSocket processing is enabled
if (!state.webSocketEnabled) {
return;
}
// Flatten summary and status properties
if (data.summary && typeof data.summary === 'object') {
Object.assign(data, data.summary);
delete data.summary;
}
if (data.status && typeof data.status === 'object') {
Object.assign(data, data.status);
delete data.status;
}
// Process observation array if present
if (data.obs && Array.isArray(data.obs) && data.obs.length > 0) {
const obsArray = data.obs[0];
const parsedObs = {
timeEpoch: obsArray[0],
windLull: obsArray[1],
windAvg: obsArray[2],
windGust: obsArray[3],
windDirection: obsArray[4], // Will be converted to radians by convertToSignalKUnits
windSampleInterval: obsArray[5],
stationPressure: obsArray[6], // Will be converted to Pa by convertToSignalKUnits
airTemperature: obsArray[7], // Will be converted to K by convertToSignalKUnits
relativeHumidity: obsArray[8], // Will be converted to ratio by convertToSignalKUnits
illuminance: obsArray[9],
uvIndex: obsArray[10],
solarRadiation: obsArray[11],
rainAccumulated: obsArray[12], // Will be converted to m by convertToSignalKUnits
precipitationType: obsArray[13],
lightningStrikeAvgDistance: obsArray[14], // Will be converted to m by convertToSignalKUnits
lightningStrikeCount: obsArray[15],
battery: obsArray[16],
reportInterval: obsArray[17], // Will be converted to sec by convertToSignalKUnits
localDailyRainAccumulation: obsArray[18], // Will be converted to m by convertToSignalKUnits
rainAccumulatedFinal: obsArray[19], // Will be converted to m by convertToSignalKUnits
localDailyRainAccumulationFinal: obsArray[20], // Will be converted to m by convertToSignalKUnits
precipitationAnalysisType: obsArray[21],
};
Object.assign(data, parsedObs);
delete data.obs;
}
// Send individual deltas for each observation value
const timestamp = data.utcDate || new Date().toISOString();
const source = getVesselBasedSource(vesselName, 'ws');
// Create individual deltas for each observation property
Object.entries(data).forEach(([key, value]) => {
if (key === 'utcDate') return; // Skip timestamp, use it for deltas
sendSignalKDelta(
'environment.outside.tempest.observations',
key,
value,
source,
timestamp
);
});
}
// Process rapid wind observations
function processRapidWind(data: RapidWindData, config: PluginConfig): void {
if (!data.ob) return;
const [timeEpoch, windSpeed, windDirection] = data.ob;
const windData: ProcessedWindData = {
timeEpoch,
windSpeed,
windDirection, // Will be converted to radians by convertToSignalKUnits
utcDate: new Date(timeEpoch * 1000).toISOString(),
};
// Send individual deltas for each wind observation
const timestamp = windData.utcDate;
const source = getVesselBasedSource(config.vesselName, 'udp');
Object.entries(windData).forEach(([key, value]) => {
if (key === 'utcDate') return; // Skip timestamp
sendSignalKDelta(
'environment.outside.rapidWind',
key,
value,
source,
timestamp
);
});
// Calculate wind values if enabled
if (config.enableWindCalculations && state.windCalculations) {
calculateAndPublishWind({
windSpeed,
windDirection,
airTemperature: state.windCalculations.airTemp,
});
}
}
// Calculate and publish wind values
function calculateAndPublishWind(windData: WindInput): void {
if (!state.windCalculations) return;
try {
const apparentWind =
state.windCalculations.calculateApparentWind(windData);
const derivedWind =
state.windCalculations.calculateDerivedWindValues(apparentWind);
const windDeltas = state.windCalculations.createWindDeltas(derivedWind);
// Send all wind deltas to SignalK
windDeltas.forEach((delta: any) => {
app.handleMessage(plugin.id, delta);
});
} catch (error) {
app.debug('Error calculating wind values: ' + (error as Error).message);
}
}
// Process Tempest station observations
function processTempestObservation(
data: TempestObservationData,
config: PluginConfig
): void {
if (!data.obs || !data.obs[0]) return;
const obs = data.obs[0];
const observationData: ProcessedTempestData = {
timeEpoch: obs[0],
windLull: obs[1],
windAvg: obs[2],
windGust: obs[3],
windDirection: obs[4], // Will be converted to radians by convertToSignalKUnits
windSampleInterval: obs[5],
stationPressure: obs[6], // Will be converted to Pa by convertToSignalKUnits
airTemperature: obs[7], // Will be converted to K by convertToSignalKUnits
relativeHumidity: obs[8], // Will be converted to ratio by convertToSignalKUnits
illuminance: obs[9],
uvIndex: obs[10],
solarRadiation: obs[11],
rainAccumulated: obs[12], // Will be converted to m by convertToSignalKUnits
precipitationType: obs[13],
lightningStrikeAvgDistance: obs[14], // Will be converted to m by convertToSignalKUnits
lightningStrikeCount: obs[15],
battery: obs[16],
reportInterval: obs[17], // Will be converted to sec by convertToSignalKUnits
localDailyRainAccumulation: obs[18], // Will be converted to m by convertToSignalKUnits
rainAccumulatedFinal: obs[19], // Will be converted to m by convertToSignalKUnits
localDailyRainAccumulationFinal: obs[20], // Will be converted to m by convertToSignalKUnits
precipitationAnalysisType: obs[21],
utcDate: new Date(obs[0] * 1000).toISOString(),
};
// Send individual deltas for each tempest observation
const timestamp = observationData.utcDate;
const source = getVesselBasedSource(config.vesselName, 'udp');
Object.entries(observationData).forEach(([key, value]) => {
if (key === 'utcDate') return; // Skip timestamp
sendSignalKDelta(
'environment.outside.tempest.observations',
key,
value,
source,
timestamp
);
});
// Calculate wind values if enabled
if (config.enableWindCalculations && state.windCalculations) {
calculateAndPublishWind({
windSpeed: obs[2], // windAvg
windDirection: obs[4], // windDirection in degrees
airTemperature: obs[7], // airTemperature in °C (will be converted in wind calculations)
});
}
}
// Process Air station observations
function processAirObservation(
data: AirObservationData,
config: PluginConfig
): void {
if (!data.obs || !data.obs[0]) return;
const obs = data.obs[0];
const observationData: ProcessedAirData = {
timeEpoch: obs[0],
stationPressure: obs[1], // Will be converted to Pa by convertToSignalKUnits
airTemperature: obs[2], // Will be converted to K by convertToSignalKUnits
relativeHumidity: obs[3], // Will be converted to ratio by convertToSignalKUnits
lightningStrikeCount: obs[4],
lightningStrikeAvgDistance: obs[5], // Will be converted to m by convertToSignalKUnits
battery: obs[6],
reportInterval: obs[7], // Will be converted to sec by convertToSignalKUnits
utcDate: new Date(obs[0] * 1000).toISOString(),
};
// Send individual deltas for each air observation
const timestamp = observationData.utcDate;
const source = getVesselBasedSource(config.vesselName, 'udp');
Object.entries(observationData).forEach(([key, value]) => {
if (key === 'utcDate') return; // Skip timestamp
sendSignalKDelta(
'environment.inside.air.observations',
key,
value,
source,
timestamp
);
});
}
// Process rain events
function processRainEvent(data: RainEventData, config: PluginConfig): void {
if (!data.evt) return;
const [timeEpoch] = data.evt;
const rainData: ProcessedRainData = {
timeEpoch,
utcDate: new Date(timeEpoch * 1000).toISOString(),
};
// Send individual deltas for each rain observation
const timestamp = rainData.utcDate;
const source = getVesselBasedSource(config.vesselName, 'udp');
Object.entries(rainData).forEach(([key, value]) => {
if (key === 'utcDate') return; // Skip timestamp
sendSignalKDelta(
'environment.outside.rain.observations',
key,
value,
source,
timestamp
);
});
}
// Process lightning events
function processLightningEvent(
data: LightningEventData,
config: PluginConfig
): void {
if (!data.evt) return;
const [timeEpoch, distance, energy] = data.evt;
const lightningData: ProcessedLightningData = {
timeEpoch,
lightningStrikeAvgDistance: distance, // Will be converted to m by convertToSignalKUnits
energy,
utcDate: new Date(timeEpoch * 1000).toISOString(),
};
// Send individual deltas for each lightning observation
const timestamp = lightningData.utcDate;
const source = getVesselBasedSource(config.vesselName, 'udp');
Object.entries(lightningData).forEach(([key, value]) => {
if (key === 'utcDate') return; // Skip timestamp
sendSignalKDelta(
'environment.outside.lightning.observations',
key,
value,
source,
timestamp
);
});
}
// Process forecast data
function processForecastData(data: ForecastData, vesselName?: string): void {
// Check if forecast processing is enabled
if (!state.forecastEnabled) {
return;
}
// Process current conditions
if (data.current_conditions) {
const delta = createSignalKDelta(
'environment.outside.tempest.observations',
data.current_conditions,
getVesselBasedSource(vesselName, 'api')
);
app.handleMessage(plugin.id, delta);
}
// Process hourly forecast (first 72 hours)
if (data.forecast && data.forecast.hourly) {
data.forecast.hourly.slice(0, 72).forEach((forecast, index) => {
const source = getVesselBasedSource(vesselName, 'api');
// Create individual deltas for each data point
Object.entries(forecast).forEach(([key, value]) => {
if (value !== undefined) {
let processedValue = value;
const camelKey = toCamelCase(key);
// Apply unit conversions
if (key === 'air_temperature' || key === 'feels_like') {
processedValue = (value as number) + 273.15; // °C to K
} else if (
key === 'sea_level_pressure' ||
key === 'station_pressure'
) {
processedValue = (value as number) * 100; // MB to Pa
} else if (key === 'wind_direction') {
processedValue = ((value as number) * Math.PI) / 180; // degrees to radians
}
// Add datetime field for time
if (key === 'time') {
processedValue = value;
// Also create datetime version
const datetimeValue = new Date(
(value as number) * 1000
).toISOString();
const datetimeDelta = createSignalKDelta(
`environment.outside.tempest.forecast.hourly.datetime.${index}`,
datetimeValue,
source
);
app.handleMessage(plugin.id, datetimeDelta);
}
const delta = createSignalKDelta(
`environment.outside.tempest.forecast.hourly.${camelKey}.${index}`,
processedValue,
source
);
app.handleMessage(plugin.id, delta);
}
});
});
}
// Process daily forecast (first 10 days)
if (data.forecast && data.forecast.daily) {
data.forecast.daily.slice(0, 10).forEach((forecast, index) => {
const source = getVesselBasedSource(vesselName, 'api');
// Create individual deltas for each data point
Object.entries(forecast).forEach(([key, value]) => {
if (value !== undefined) {
let processedValue = value;
const camelKey = toCamelCase(key);
// Apply unit conversions
if (key === 'air_temp_high' || key === 'air_temp_low') {
processedValue = (value as number) + 273.15; // °C to K
}
// Add ISO datetime fields
if (
key === 'day_start_local' ||
key === 'sunrise' ||
key === 'sunset'
) {
processedValue = value;
// Also create ISO version
const isoKey = `${toCamelCase(key)}Iso`;
const isoValue = new Date((value as number) * 1000).toISOString();
const isoDelta = createSignalKDelta(
`environment.outside.tempest.forecast.daily.${isoKey}.${index}`,
isoValue,
source
);
app.handleMessage(plugin.id, isoDelta);
}
const delta = createSignalKDelta(
`environment.outside.tempest.forecast.daily.${camelKey}.${index}`,
processedValue,
source
);
app.handleMessage(plugin.id, delta);
}
});
});
}
}
// Create SignalK delta message
function createSignalKDelta(
path: string,
value: any,
source: string
): SignalKDelta {
const timestamp = new Date().toISOString();
return {
context: 'vessels.self',
updates: [
{
$source: source,
timestamp: timestamp,
values: [
{
path: path,
value: value,
},
],
},
],
};
}
return plugin;
};