UNPKG

signalk-weatherflow

Version:

SignalK plugin for WeatherFlow (Tempest) weather station data ingestion

1,104 lines (1,103 loc) 73.4 kB
"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