signalk-weatherflow
Version:
SignalK plugin for WeatherFlow (Tempest) weather station data ingestion
1,104 lines (1,103 loc) • 73.4 kB
JavaScript
"use strict";
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;
};
})();
const dgram = __importStar(require("dgram"));
const WebSocket = __importStar(require("ws"));
const fetch = require('node-fetch');
const windCalculations_1 = require("./windCalculations");
module.exports = function (app) {
const plugin = {
id: 'signalk-weatherflow',
name: 'SignalK WeatherFlow Ingester',
description: 'Ingests data from WeatherFlow weather stations via UDP, WebSocket, and API',
schema: {},
start: () => { },
stop: () => { },
};
const state = {
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) {
return name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
}
// Utility function to get formatted vessel name for source
function getVesselBasedSource(configuredPrefix, suffix) {
// 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) {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
// Persistent state management
function getStateFilePath() {
return require('path').join(app.getDataDirPath(), 'signalk-weatherflow-state.json');
}
function savePersistedState() {
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.message);
}
}
// Update plugin configuration to match current state
function updatePluginConfig() {
if (!state.currentConfig)
return;
const updatedConfig = {
...state.currentConfig,
enableWebSocket: state.webSocketEnabled,
enableForecast: state.forecastEnabled,
enableWindCalculations: state.windCalculationsEnabled,
};
app.savePluginOptions(updatedConfig, (err) => {
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) {
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 = (context, requestPath, value, callback) => {
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, newState, config) {
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_1.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) {
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) {
// 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_1.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() {
// 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) {
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) => {
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 = {
name: 'WeatherFlow Station Weather Provider',
methods: {
pluginId: 'signalk-weatherflow',
getObservations: async (_position, options) => {
const observations = [];
// 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, type, options) => {
const forecasts = [];
// 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) => {
const warnings = [];
// 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) {
app.debug('Starting WeatherFlow plugin with options: ' + JSON.stringify(options));
app.setProviderStatus('Initializing WeatherFlow plugin...');
const config = {
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 () {
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() {
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 = {
context: 'vessels.self',
subscribe: subscriptionPaths.map(path => ({
path,
policy: 'fixed',
period: 1000,
format: 'delta',
})),
};
app.subscriptionmanager.subscribe(subscription, state.navigationSubscriptions, (subscriptionError) => {
app.debug('Navigation subscription error: ' + subscriptionError);
}, (delta) => {
handleNavigationData(delta);
});
}
// Handle navigation data from subscriptions
function handleNavigationData(delta) {
if (!delta.updates || !state.windCalculations)
return;
delta.updates.forEach((update) => {
if (!update.values)
return;
update.values.forEach((valueUpdate) => {
if (valueUpdate.path && typeof valueUpdate.value === 'number') {
state.windCalculations.updateNavigationData(valueUpdate.path, valueUpdate.value);
}
});
});
}
// Set up position subscription for Weather API
function setupPositionSubscription() {
const positionSubscription = {
context: 'vessels.self',
subscribe: [
{
path: 'navigation.position',
policy: 'fixed',
period: 5000, // Update every 5 seconds
format: 'delta',
},
],
};
app.subscriptionmanager.subscribe(positionSubscription, state.navigationSubscriptions, (subscriptionError) => {
app.debug('Position subscription error: ' + subscriptionError);
}, (delta) => {
handlePositionData(delta);
});
}
// Handle position data updates
function handlePositionData(delta) {
if (!delta.updates)
return;
delta.updates.forEach((update) => {
if (!update.values)
return;
update.values.forEach((valueUpdate) => {
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, config) {
state.udpServer = dgram.createSocket('udp4');
state.udpServer.on('message', (msg, _rinfo) => {
try {
const data = JSON.parse(msg.toString());
processWeatherFlowMessage(data, config);
}
catch (error) {
app.debug('Error parsing UDP message: ' + error.message);
}
});
state.udpServer.on('error', (err) => {
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, deviceId, vesselName) {
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) => {
try {
const message = JSON.parse(data.toString());
processWebSocketMessage(message, vesselName);
}
catch (error) {
app.debug('Error parsing WebSocket message: ' + error.message);
}
});
state.wsConnection.on('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) {
const fetchForecast = async () => {
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 = await response.json();
processForecastData(data, config.vesselName);
}
catch (error) {
app.error('Error fetching forecast data: ' + 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, config) {
if (!data.type)
return;
switch (data.type) {
case 'rapid_wind':
processRapidWind(data, config);
break;
case 'obs_st':
processTempestObservation(data, config);
break;
case 'obs_air':
processAirObservation(data, config);
break;
case 'evt_precip':
processRainEvent(data, config);
break;
case 'evt_strike':
processLightningEvent(data, config);
break;
case 'hub_status':
processHubStatus(data, config);
break;
case 'device_status':
processDeviceStatus(data, config);
break;
default:
app.debug('Unknown WeatherFlow message type: ' + data.type);
}
}
// Helper function to convert snake_case to camelCase
function snakeToCamel(str) {
return str.replace(/_([a-z0-9])/g, (_match, letter) => letter.toUpperCase());
}
// Helper function to send individual SignalK deltas with units metadata
function sendSignalKDelta(basePath, key, value, source, timestamp) {
const converted = convertToSignalKUnits(key, value);
const camelKey = snakeToCamel(key);
const path = `${basePath}.${camelKey}`;
const delta = {
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, value) {
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, vesselName) {
// 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, data) {
state.latestObservations.set(type, {
...data,
timestamp: new Date().toISOString(),
});
}
// Cache forecast data for Weather API
function cacheForecastData(data) {
state.latestForecastData = data;
}
// Convert WeatherFlow observation to Weather API format
function convertObservationToWeatherAPI(observationType, data) {
const baseWeatherData = {
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, type) {
const baseWeatherData = {
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