UNPKG

signalk-noaa-weather-report

Version:

Sends Voluntary Ship Observations to NOAA (and Windy) Requires an official ships radio callsign

407 lines (360 loc) 16.2 kB
const axios = require('axios'); const winston = require('winston'); const DailyRotateFile = require('winston-daily-rotate-file'); const { collectSignalkData, displaySignalkSummary, sendSignalkData } = require('./signalkReader'); const { humanReadableReport } = require('./weatherReport'); module.exports = function(app) { let logger; let intervalId = null; let lastReport = null; let pluginStarted = false; let lastError = null; let lastErrorTime = null; const plugin = { id: 'signalk-noaa-weather-report', name: 'NOAA / Windy Ship Reporting Plugin', description: 'Collects marine weather data from SignalK and generates BBXX weather reports for NOAA and Windy', schema: { type: 'object', required: ['stationId'], properties: { stationId: { type: 'string', title: 'Ship Station Callsign', description: 'You must register with the FCC or ITU and provide a valid Ship\'s Station Callsign.', minLength: 3, maxLength: 10 }, betaKey: { type: 'string', title: 'Beta Key', description: 'Beta access key required to send reports to NOAA. Without this key, only test mode is available. Email kevin@connells.net for access.', minLength: 0, maxLength: 50 }, testMode: { type: 'boolean', title: 'Test Mode', default: true }, samples: { type: 'number', title: 'Number of Samples', description: 'Number of data samples to average for each report (1-10).', minimum: 1, maximum: 10, default: 3 }, interval: { type: 'number', title: 'Sampling Interval (seconds)', description: 'Time between data samples in seconds (1-60).', minimum: 1, maximum: 60, default: 5 }, reportInterval: { type: 'number', title: 'Automatic Report Interval (hours)', description: 'Hours between automatic reports (0 = manual only, 1-24).', minimum: 0, maximum: 24, default: 0 }, logLevel: { type: 'string', title: 'Log Level', description: 'Logging detail level.', enum: ['error', 'warn', 'info', 'debug'], default: 'info' } } }, uiSchema: { stationId: { 'ui:widget': 'text', 'ui:placeholder': 'Enter your ship station callsign (e.g., 9RM2K7C)' }, betaKey: { 'ui:widget': 'password', 'ui:placeholder': 'Email kevin@connells.net for beta key access' }, testMode: { 'ui:widget': 'checkbox', 'ui:help': 'Reports are logged locally in test mode and not sent to NOAA.' }, samples: { 'ui:widget': 'updown' }, interval: { 'ui:widget': 'updown' }, reportInterval: { 'ui:widget': 'updown' }, logLevel: { 'ui:widget': 'select' } }, statusMessage: function() { if (!pluginStarted) { return 'Plugin not started'; } if (lastReport) { const timestamp = lastReport.utc_time.toISOString().slice(11, 19); // Just HH:MM:SS const tws = lastReport.true_wind_speed ? `${lastReport.true_wind_speed.toFixed(1)}` : 'N/A'; const twd = lastReport.true_wind_dir ? `${lastReport.true_wind_dir.toFixed(0)}°` : 'N/A'; return `${timestamp} ${tws}kts ${twd}`; } // Show recent error if within last 5 minutes if (lastError && lastErrorTime) { const fiveMinutesAgo = Date.now() - (5 * 60 * 1000); if (lastErrorTime > fiveMinutesAgo) { const errorTime = new Date(lastErrorTime).toISOString().slice(11, 19); // Use "ERROR" prefix to trigger red status in SignalK dashboard return `ERROR: ${lastError}`; } } return 'Waiting for data...'; }, // Add status property for SignalK dashboard color indication get status() { if (!pluginStarted) { return 'stopped'; } if (lastError && lastErrorTime) { const fiveMinutesAgo = Date.now() - (5 * 60 * 1000); if (lastErrorTime > fiveMinutesAgo) { return 'error'; } } if (lastReport) { return 'active'; } return 'waiting'; }, start: function(options) { if (pluginStarted) { return; } // Setup logging const logTransports = [ new winston.transports.Console({ level: 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.printf(({ timestamp, level, message }) => { return `${timestamp} [noaa-vos] ${level}: ${message}`; }) ) }), new DailyRotateFile({ filename: 'logs/noaa-vos-%DATE%.log', datePattern: 'YYYY-MM-DD', maxFiles: '7d', level: 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.printf(({ timestamp, level, message }) => { return `${timestamp} [noaa-vos] ${level}: ${message}`; }) ) }) ]; logger = winston.createLogger({ transports: logTransports }); logger.info('NOAA / Windy Ship Reporting Plugin starting...'); logger.info(`Test Mode: ${options.testMode ? 'ON (reports logged only)' : 'OFF (reports sent to NOAA)'}`); pluginStarted = true; app.setPluginStatus('Plugin started - waiting for data...'); // Handle automatic reporting if interval is set if (options.reportInterval && options.reportInterval > 0) { logger.info(`Automatic reporting enabled - interval: ${options.reportInterval} hours`); // Send initial report after 2 minutes logger.info('Setting up initial report timer (2 minutes)...'); setTimeout(async () => { logger.info('Initial report timer fired - checking plugin state...'); if (pluginStarted) { logger.info('Plugin is still running - sending initial weather report...'); try { await generateReport(options); logger.info('Initial weather report sent successfully'); // Clear any previous errors on success lastError = null; lastErrorTime = null; app.setPluginStatus('Initial weather report sent successfully'); } catch (error) { const errorMsg = `Failed to send initial weather report: ${error.message}`; logger.error(errorMsg); lastError = errorMsg; lastErrorTime = Date.now(); app.setPluginError(errorMsg); } } else { logger.warn('Plugin is no longer running - skipping initial report'); } }, 2 * 60 * 1000); // 2 minutes // Set up recurring reports const intervalMs = options.reportInterval * 60 * 60 * 1000; // Convert hours to milliseconds intervalId = setInterval(async () => { if (pluginStarted) { logger.info(`Sending scheduled weather report (every ${options.reportInterval} hours)...`); try { await generateReport(options); logger.info('Scheduled weather report sent successfully'); // Clear any previous errors on success lastError = null; lastErrorTime = null; app.setPluginStatus('Scheduled weather report sent successfully'); } catch (error) { const errorMsg = `Failed to send scheduled weather report: ${error.message}`; logger.error(errorMsg); lastError = errorMsg; lastErrorTime = Date.now(); app.setPluginError(errorMsg); } } }, intervalMs); logger.info(`Next scheduled report in ${options.reportInterval} hours`); } else { logger.info('Automatic reporting disabled - manual reports only'); } logger.info('NOAA / Windy Ship Reporting Plugin started successfully'); }, stop: function() { if (!pluginStarted) { return; } if (logger) { logger.info('NOAA / Windy Ship Reporting Plugin stopping...'); } if (intervalId) { clearInterval(intervalId); intervalId = null; } pluginStarted = false; lastReport = null; if (logger) { logger.info('NOAA / Windy Ship Reporting Plugin stopped'); logger.close(); logger = null; } }, registerWithRouter: function(router) { // Status endpoint router.get('/status', (req, res) => { const status = pluginStatus; let detailedStatus = status; if (lastReport) { const timestamp = lastReport.utc_time.toISOString().slice(0, 19).replace('T', ' '); const tws = lastReport.true_wind_speed ? `${lastReport.true_wind_speed.toFixed(1)} knots` : 'N/A'; detailedStatus = `${status} - Last report sent: ${timestamp}, TWS average: ${tws}`; } res.json({ pluginStarted, status: detailedStatus, lastReport: lastReport ? { timestamp: lastReport.utc_time, position: { lat: lastReport.lat, lon: lastReport.lon }, trueWind: { direction: lastReport.true_wind_dir, speed: lastReport.true_wind_speed }, bbxx: lastReport.bbxx, humanReadable: lastReport.humanReadable } : null }); }); // Manual report generation endpoint router.post('/generate-report', async (req, res) => { if (!pluginStarted) { return res.status(400).json({ error: 'Plugin not started' }); } try { const result = await generateReport(); app.setPluginStatus('Manual weather report generated successfully'); res.json({ success: true, report: { timestamp: result.utc_time, position: { lat: result.lat, lon: result.lon }, trueWind: { direction: result.true_wind_dir, speed: result.true_wind_speed }, bbxx: result.bbxx, humanReadable: result.humanReadable } }); } catch (error) { const errorMsg = `Error generating manual report: ${error.message}`; logger.error(errorMsg); app.setPluginError(errorMsg); res.status(500).json({ error: error.message }); } }); } }; async function generateReport(options = {}) { if (!logger) { console.error('Logger not initialized'); throw new Error('Logger not initialized'); } logger.info('Generating weather report...'); // Use configuration options with fallback defaults const samples = options.samples || 3; const interval = options.interval || 5; const stationId = options.stationId; const betaKey = options.betaKey; const logLevel = options.logLevel || 'info'; // Hardcoded beta key for production access const VALID_BETA_KEY = 'NOAA-WINDY-BETA-2025-WXR7K9'; // Validate beta key and determine test mode const validBetaKey = betaKey === VALID_BETA_KEY; const testMode = !validBetaKey || options.testMode !== false; if (!betaKey) { logger.warn('Beta key not provided - forcing test mode (reports will not be sent to NOAA)'); } else if (!validBetaKey) { logger.warn('Invalid beta key provided - forcing test mode (reports will not be sent to NOAA)'); } else if (validBetaKey && !options.testMode) { logger.info('Valid beta key provided - production mode enabled (reports will be sent to NOAA)'); } if (!stationId) { const errorMsg = 'Station ID is required but not configured'; logger.error(errorMsg); throw new Error(errorMsg); } // Collect data const data = await collectSignalkData(app, samples, interval, stationId); if (!data) { const errorMsg = 'Failed to collect weather data'; logger.error(errorMsg); throw new Error(errorMsg); } // Add human readable format data.humanReadable = humanReadableReport( data.lat, data.lon, data.true_wind_dir, data.true_wind_speed, data.utc_time ); // Log the summary displaySignalkSummary(data); logger.info(`Generated BBXX report: ${data.bbxx}`); // Send to NOAA (always attempt, testMode controls actual sending) await sendSignalkData(data, testMode, stationId); logger.info(testMode ? 'Report logged (test mode - not sent to NOAA)' : 'Report sent to NOAA'); // Store last report lastReport = data; return data; } return plugin; };