signalk-weatherflow
Version:
SignalK plugin for WeatherFlow (Tempest) weather station data ingestion
1,536 lines (1,372 loc) • 66.8 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,
HubStatusData,
DeviceStatusData,
WebSocketMessage,
ForecastData,
ProcessedWindData,
ProcessedTempestData,
ProcessedAirData,
ProcessedRainData,
ProcessedLightningData,
ProcessedHubStatusData,
ProcessedDeviceStatusData,
ConvertedValue,
SignalKDelta,
SubscriptionRequest,
WindInput,
PutHandler,
WeatherProvider,
WeatherData,
WeatherReqParams,
WeatherForecastType,
WeatherWarning,
Position,
TendencyKind,
PrecipitationKind,
} 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(),
latestObservations: new Map(),
latestForecastData: null,
stationLocation: null,
currentVesselPosition: null,
};
// 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',
},
stationLatitude: {
type: 'number',
title: 'Station Latitude (Optional)',
description:
'Weather station latitude for Weather API position matching. If not set (0), will use vessel position from navigation.position',
default: 0,
},
stationLongitude: {
type: 'number',
title: 'Station Longitude (Optional)',
description:
'Weather station longitude for Weather API position matching. If not set (0), will use vessel position from navigation.position',
default: 0,
},
setCurrentLocationAction: {
type: 'object',
title: 'Home Port Location Actions',
description: 'Actions for setting the home port location',
properties: {
setCurrentLocation: {
type: 'boolean',
title: 'Set Current Location as Home Port',
description:
"Check this box and save to use the vessel's current position as the home port coordinates",
default: false,
},
},
},
},
};
// 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 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 {
// Set up position subscription for Weather API
setupPositionSubscription();
// 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;
}
// Handle "Set Current Location" action
async function handleSetCurrentLocationAction(
config: PluginConfig
): Promise<void> {
app.debug(
`handleSetCurrentLocationAction called with setCurrentLocation: ${config.setCurrentLocationAction?.setCurrentLocation}`
);
if (config.setCurrentLocationAction?.setCurrentLocation) {
// First try cached position
let currentPosition = getCurrentVesselPosition();
app.debug(
`Cached position: ${currentPosition ? `${currentPosition.latitude}, ${currentPosition.longitude}` : 'null'}`
);
// If no cached position, try to fetch from SignalK API directly
if (!currentPosition) {
app.debug('No cached position, trying to fetch from SignalK API...');
try {
const response = await fetch(
'http://localhost:3000/signalk/v1/api/vessels/self/navigation/position'
);
if (response.ok) {
const positionData = await response.json();
if (
positionData.value &&
positionData.value.latitude &&
positionData.value.longitude
) {
currentPosition = {
latitude: positionData.value.latitude,
longitude: positionData.value.longitude,
timestamp: new Date(positionData.timestamp || Date.now()),
};
app.debug(
`Fetched position from API: ${currentPosition.latitude}, ${currentPosition.longitude}`
);
}
}
} catch (error) {
app.debug(`Failed to fetch position from API: ${error}`);
}
}
if (currentPosition) {
// Update the configuration with current position
const updatedConfig = {
...config,
stationLatitude: currentPosition.latitude,
stationLongitude: currentPosition.longitude,
setCurrentLocationAction: {
setCurrentLocation: false, // Reset the checkbox
},
};
// Save the updated configuration
app.savePluginOptions(updatedConfig, (err?: unknown) => {
if (err) {
app.error(`Failed to save current location as home port: ${err}`);
} else {
app.debug(
`Set home port location to: ${currentPosition!.latitude}, ${currentPosition!.longitude}`
);
// Update the state with new station location
state.stationLocation = {
latitude: currentPosition!.latitude,
longitude: currentPosition!.longitude,
timestamp: new Date(),
};
// Update current config
state.currentConfig = updatedConfig;
plugin.config = updatedConfig;
}
});
} else {
app.error(
'No current vessel position available. Ensure navigation.position is being published to SignalK.'
);
}
}
}
// Weather API Provider Implementation
const weatherProvider: WeatherProvider = {
name: 'WeatherFlow Station Weather Provider',
methods: {
pluginId: 'signalk-weatherflow',
getObservations: async (
_position: Position,
options?: WeatherReqParams
): Promise<WeatherData[]> => {
const observations: WeatherData[] = [];
// WeatherFlow station is ON THE BOAT - always return current observations
// regardless of requested position since the station moves with the vessel
for (const [type, data] of state.latestObservations) {
if (data && data.timestamp) {
const weatherData = convertObservationToWeatherAPI(type, data);
observations.push(weatherData);
}
}
// Apply maxCount limit if specified
if (options?.maxCount && observations.length > options.maxCount) {
return observations.slice(0, options.maxCount);
}
return observations;
},
getForecasts: async (
position: Position,
type: WeatherForecastType,
options?: WeatherReqParams
): Promise<WeatherData[]> => {
const forecasts: WeatherData[] = [];
// For forecasts, check if requested position is near the station's registered location
// since forecasts are location-specific and tied to the station's API registration
const stationPos = getStationLocation();
const distance = calculateDistance(position, stationPos);
const maxDistance = 100000; // 100km radius for forecasts
if (distance > maxDistance) {
app.debug(
`Requested position too far from station's registered location for forecasts: ${distance}m`
);
return forecasts;
}
if (!state.latestForecastData) {
app.debug('No forecast data available');
return forecasts;
}
try {
if (type === 'point' && state.latestForecastData.forecast?.hourly) {
// Convert hourly forecasts
const hourlyForecasts =
state.latestForecastData.forecast.hourly.slice(0, 72); // 72 hours
for (const forecast of hourlyForecasts) {
const weatherData = convertForecastToWeatherAPI(
forecast,
'point'
);
forecasts.push(weatherData);
}
} else if (
type === 'daily' &&
state.latestForecastData.forecast?.daily
) {
// Convert daily forecasts
const dailyForecasts =
state.latestForecastData.forecast.daily.slice(0, 10); // 10 days
for (const forecast of dailyForecasts) {
const weatherData = convertForecastToWeatherAPI(
forecast,
'daily'
);
forecasts.push(weatherData);
}
}
// Apply date filtering if startDate specified
if (options?.startDate) {
const startTime = new Date(options.startDate).getTime();
return forecasts.filter(
f => new Date(f.date).getTime() >= startTime
);
}
// Apply maxCount limit if specified
if (options?.maxCount && forecasts.length > options.maxCount) {
return forecasts.slice(0, options.maxCount);
}
} catch (error) {
app.error(
`Error processing forecast data: ${error instanceof Error ? error.message : String(error)}`
);
}
return forecasts;
},
getWarnings: async (_position: Position): Promise<WeatherWarning[]> => {
const warnings: WeatherWarning[] = [];
// Check for lightning warnings based on recent strikes
const lightningData = state.latestObservations.get('tempest');
if (lightningData && lightningData.lightningStrikeCount > 0) {
const lastStrikeTime = new Date(lightningData.timeEpoch * 1000);
const warningEndTime = new Date(
lastStrikeTime.getTime() + 30 * 60 * 1000
); // 30 minutes after last strike
if (new Date() < warningEndTime) {
warnings.push({
startTime: lastStrikeTime.toISOString(),
endTime: warningEndTime.toISOString(),
details: `Lightning activity detected. ${lightningData.lightningStrikeCount} strikes recorded. Last strike at average distance of ${Math.round(lightningData.lightningStrikeAvgDistance)}m.`,
source: 'WeatherFlow Station',
type: 'lightning',
});
}
}
return warnings;
},
},
};
// Plugin start function
plugin.start = function (options: Partial<PluginConfig>): 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',
stationLatitude: options.stationLatitude || 0,
stationLongitude: options.stationLongitude || 0,
setCurrentLocationAction: options.setCurrentLocationAction || {
setCurrentLocation: false,
},
};
state.currentConfig = config;
plugin.config = config;
// Set station location for Weather API
if (config.stationLatitude !== 0 && config.stationLongitude !== 0) {
state.stationLocation = {
latitude: config.stationLatitude!,
longitude: config.stationLongitude!,
timestamp: new Date(),
};
}
// 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);
// Handle "Set Current Location" action
handleSetCurrentLocationAction(config).catch(err => {
app.error(`Error handling set current location action: ${err}`);
});
// Register as Weather API provider
try {
app.registerWeatherProvider(weatherProvider);
app.debug('Successfully registered WeatherFlow as Weather API provider');
} catch (error) {
app.error(
`Failed to register Weather API provider: ${error instanceof Error ? error.message : String(error)}`
);
}
// 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
);
}
});
});
}
// Set up position subscription for Weather API
function setupPositionSubscription(): void {
const positionSubscription: SubscriptionRequest = {
context: 'vessels.self',
subscribe: [
{
path: 'navigation.position',
policy: 'fixed',
period: 5000, // Update every 5 seconds
format: 'delta',
},
],
};
app.subscriptionmanager.subscribe(
positionSubscription,
state.navigationSubscriptions,
(subscriptionError: unknown) => {
app.debug('Position subscription error: ' + subscriptionError);
},
(delta: any) => {
handlePositionData(delta);
}
);
}
// Handle position data updates
function handlePositionData(delta: any): void {
if (!delta.updates) return;
delta.updates.forEach((update: any) => {
if (!update.values) return;
update.values.forEach((valueUpdate: any) => {
if (valueUpdate.path === 'navigation.position' && valueUpdate.value) {
const position = valueUpdate.value;
if (
position.latitude !== undefined &&
position.longitude !== undefined
) {
state.currentVesselPosition = {
latitude: position.latitude,
longitude: position.longitude,
timestamp: new Date(update.timestamp || Date.now()),
};
app.debug(
`Updated vessel position: ${position.latitude}, ${position.longitude}`
);
}
}
});
});
}
// 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;
case 'hub_status':
processHubStatus(data as HubStatusData, config);
break;
case 'device_status':
processDeviceStatus(data as DeviceStatusData, 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
);
});
}
// Cache latest observation data for Weather API
function cacheObservationData(type: string, data: any): void {
state.latestObservations.set(type, {
...data,
timestamp: new Date().toISOString(),
});
}
// Cache forecast data for Weather API
function cacheForecastData(data: ForecastData): void {
state.latestForecastData = data;
}
// Convert WeatherFlow observation to Weather API format
function convertObservationToWeatherAPI(
observationType: string,
data: any
): WeatherData {
const baseWeatherData: WeatherData = {
date:
data.utcDate ||
new Date(data.time ? data.time * 1000 : Date.now()).toISOString(),
type: 'observation',
description:
data.conditions || `WeatherFlow ${observationType} observation`,
};
// Prioritize currentConditions (REST API) data - much richer than UDP
if (observationType === 'currentConditions') {
baseWeatherData.outside = {
temperature: data.air_temperature + 273.15, // Convert °C to K
pressure: data.sea_level_pressure
? data.sea_level_pressure * 100
: data.station_pressure * 100, // Convert MB to Pa
relativeHumidity: data.relative_humidity / 100, // Convert % to ratio 0-1
feelsLikeTemperature: data.feels_like + 273.15, // Convert °C to K
dewPointTemperature: data.dew_point + 273.15, // Convert °C to K
uvIndex: data.uv,
precipitationVolume: 0, // Current conditions doesn't have accumulation
pressureTendency: mapPressureTendency(data.pressure_trend),
// Extended WeatherFlow fields
solarRadiation: data.solar_radiation, // W/m²
airDensity: data.air_density, // kg/m³
wetBulbTemperature: data.wet_bulb_temperature
? data.wet_bulb_temperature + 273.15
: undefined, // Convert °C to K
wetBulbGlobeTemperature: data.wet_bulb_globe_temperature
? data.wet_bulb_globe_temperature + 273.15
: undefined, // Convert °C to K
deltaT: data.delta_t, // °C (fire weather index)
};
baseWeatherData.wind = {
speedTrue: data.wind_avg, // Already in m/s
directionTrue: (data.wind_direction * Math.PI) / 180, // Convert degrees to radians
gust: data.wind_gust, // Already in m/s
averageSpeed: data.wind_avg, // Already in m/s
directionCardinal: data.wind_direction_cardinal, // E, W, NE, etc.
};
}
// Convert Tempest observation data (UDP fallback)
else if (observationType === 'tempest') {
baseWeatherData.outside = {
temperature: data.airTemperature, // Already in Kelvin
pressure: data.stationPressure, // Already in Pascal
relativeHumidity: data.relativeHumidity, // Already as ratio 0-1
uvIndex: data.uvIndex,
precipitationVolume: data.rainAccumulated, // Already in meters
precipitationType: mapPrecipitationType(data.precipitationType),
// Extended WeatherFlow fields from UDP
solarRadiation: data.solarRadiation, // W/m²
illuminance: data.illuminance, // lux
};
baseWeatherData.wind = {
speedTrue: data.windAvg, // Already in m/s
directionTrue: data.windDirection, // Already in radians
gust: data.windGust, // Already in m/s
averageSpeed: data.windAvg, // Already in m/s
};
}
// Convert rapid wind data
if (observationType === 'rapidWind') {
baseWeatherData.wind = {
speedTrue: data.windSpeed, // Already in m/s
directionTrue: data.windDirection, // Already in radians
};
}
// Convert air station data (UDP)
else if (observationType === 'air') {
baseWeatherData.outside = {
temperature: data.airTemperature, // Already in Kelvin from UDP processing
pressure: data.stationPressure, // Already in Pascal from UDP processing
relativeHumidity: data.relativeHumidity, // Already as ratio 0-1 from UDP processing
};
}
return baseWeatherData;
}
// Convert WeatherFlow forecast to Weather API format
function convertForecastToWeatherAPI(
forecast: any,
type: WeatherForecastType
): WeatherData {
const baseWeatherData: WeatherData = {
date: forecast.datetime || new Date(forecast.time * 1000).toISOString(),
type: type,
description: `WeatherFlow ${type} forecast`,
};
if (type === 'point') {
// Hourly forecast
baseWeatherData.outside = {
temperature: forecast.air_temperature
? forecast.air_temperature + 273.15
: undefined, // Convert °C to K
feelsLikeTemperature: forecast.feels_like
? forecast.feels_like + 273.15
: undefined,
relativeHumidity: forecast.relative_humidity
? forecast.relative_humidity / 100
: undefined, // Convert % to ratio
precipitationVolume: forecast.precip
? forecast.precip / 1000
: undefined, // Convert mm to m
pressure: forecast.sea_level_pressure
? forecast.sea_level_pressure * 100
: forecast.station_pressure
? forecast.station_pressure * 100
: undefined, // Prefer sea level, fallback to station pressure (MB to Pa)
uvIndex: forecast.uv,
// Extended WeatherFlow forecast fields
wetBulbTemperature: calculateWetBulbTemperature(
forecast.air_temperature,
forecast.relative_humidity
),
precipitationProbability: forecast.precip_probability
? forecast.precip_probability / 100
: undefined, // Convert % to ratio 0-1
};
baseWeatherData.wind = {
speedTrue: forecast.wind_avg, // Already in m/s
directionTrue: forecast.wind_direction
? forecast.wind_direction * (Math.PI / 180)
: undefined, // Convert deg to rad
gust: forecast.wind_gust,
averageSpeed: forecast.wind_avg, // Same as speedTrue for consistency
};
} else if (type === 'daily') {
// Daily forecast
baseWeatherData.outside = {
maxTemperature: forecast.air_temp_high
? forecast.air_temp_high + 273.15
: undefined,
minTemperature: forecast.air_temp_low
? forecast.air_temp_low + 273.15
: undefined,
precipitationType: mapPrecipitationType(forecast.precip_type),
precipitationProbability: forecast.precip_probability
? forecast.precip_probability / 100
: undefined, // Convert % to ratio 0-1
};
baseWeatherData.wind = {
speedTrue: forecast.wind_avg,
directionTrue: forecast.wind_direction
? forecast.wind_direction * (Math.PI / 180)
: undefined,
averageSpeed: forecast.wind_avg, // Same as speedTrue for consistency
};
if (forecast.sunrise_iso && forecast.sunset_iso) {
baseWeatherData.sun = {
sunrise: forecast.sunrise_iso,
sunset: forecast.sunset_iso,
};
}
}
return baseWeatherData;
}
// Map WeatherFlow precipitation type to Weather API format
function mapPrecipitationType(
precipType: number | string
): PrecipitationKind {
if (typeof precipType === 'string') {
switch (precipType.toLowerCase()) {
case 'rain':
return 'rain';
case 'snow':
return 'snow';
case 'thunderstorm':
return 'thunderstorm';
default:
return 'not available';
}
}
// WeatherFlow numeric precipitation types
switch (precipType) {
case 0:
return 'not available';
case 1:
return 'rain';
case 2:
return 'snow';
case 3:
return 'mixed/ice';
default:
return 'not available';
}
}
// Map pressure trend string to SignalK format
function mapPressureTendency(pressureTrend: string): TendencyKind {
switch (pressureTrend) {
case 'falling':
return 'decreasing';
case 'rising':
return 'increasing';
case 'steady':
return 'steady';
default:
return 'steady';
}
}
// Calculate wet bulb temperature from air temperature and relative humidity
function calculateWetBulbTemperature(
tempC: number,
relativeHumidity: number
): number | undefined {
if (tempC == null || relativeHumidity == null) return undefined;
// Simple approximation of wet bulb temperature (Stull formula)
// More accurate calculation would require iterative approach
const rh = relativeHumidity / 100; // Convert % to ratio if needed
const tw =
tempC * Math.atan(0.151977 * Math.sqrt(rh + 8.313659)) +
Math.atan(tempC + rh) -
Math.atan(rh - 1.676331) +
0.00391838 * Math.pow(rh, 1.5) * Math.atan(0.023101 * rh) -
4.686035;
return tw + 273.15; // Convert to Kelvin
}
// Get station location from vessel's current position or fallback to configured coordinates
function getStationLocation(): Position {
// First try to get current vessel position
const vesselPosition = getCurrentVesselPosition();
if (vesselPosition) {
return vesselPosition;
}
// Fallback to manually configured station location
return (
state.stationLocation || {
latitude: 0, // Default coordinates if nothing is configured
longitude: 0, // Default coordinates if nothing is configured
timestamp: new Date(),
}
);
}
// Get current vessel position from cached state
function getCurrentVesselPosition(): Position | null {
return state.currentVesselPosition;
}
// Calculate distance between two positions (haversine formula)
function calculateDistance(pos1: Position, pos2: Position): number {
const R = 6371000; // Earth's radius in meters
const φ1 = (pos1.latitude * Math.PI) / 180;
const φ2 = (pos2.latitude * Math.PI) / 180;
const Δφ = ((pos2.latitude - pos1.latitude) * Math.PI) / 180;
const Δλ = ((pos2.longitude - pos1.longitude) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// 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: n