From 0bfbee452e19b59a8903c06e70a8a2378b0f00d5 Mon Sep 17 00:00:00 2001 From: s53zo <44831766+s53zo@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:48:22 +0100 Subject: [PATCH 1/6] Auto-follow DX target from newest contest QSO --- src/components/PluginLayer.jsx | 4 ++-- src/components/WorldMap.jsx | 3 +++ src/plugins/layers/useContestQsos.js | 29 +++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/components/PluginLayer.jsx b/src/components/PluginLayer.jsx index 79ea1740..6136c095 100644 --- a/src/components/PluginLayer.jsx +++ b/src/components/PluginLayer.jsx @@ -5,12 +5,12 @@ */ import React from 'react'; -export const PluginLayer = ({ plugin, enabled, opacity, map, callsign, locator, lowMemoryMode }) => { +export const PluginLayer = ({ plugin, enabled, opacity, map, callsign, locator, lowMemoryMode, onDXChange, dxLocked, dxLocation }) => { const layerFunc = plugin.useLayer || plugin.hook; if (typeof layerFunc === 'function') { - layerFunc({ map, enabled, opacity, callsign, locator, lowMemoryMode }); + layerFunc({ map, enabled, opacity, callsign, locator, lowMemoryMode, onDXChange, dxLocked, dxLocation }); } return null; diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index aafbab7b..53959d21 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -910,6 +910,9 @@ export const WorldMap = ({ callsign={callsign} locator={deLocator} lowMemoryMode={lowMemoryMode} + onDXChange={onDXChange} + dxLocked={dxLocked} + dxLocation={dxLocation} /> ))} // MODIS SLIDER CODE HERE diff --git a/src/plugins/layers/useContestQsos.js b/src/plugins/layers/useContestQsos.js index 01c8ba7c..c69e0e3d 100644 --- a/src/plugins/layers/useContestQsos.js +++ b/src/plugins/layers/useContestQsos.js @@ -13,13 +13,14 @@ export const metadata = { version: '1.0.0' }; -export function useLayer({ enabled = false, opacity = 0.7, map = null }) { +export function useLayer({ enabled = false, opacity = 0.7, map = null, onDXChange = null }) { const [qsos, setQsos] = useState([]); const [deLocation, setDeLocation] = useState(null); const markersRef = useRef([]); const linesRef = useRef([]); const pollRef = useRef(null); const configLoadedRef = useRef(false); + const lastAutoDxQsoIdRef = useRef(null); useEffect(() => { if (!enabled || configLoadedRef.current) return; @@ -129,5 +130,31 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { }); }, [qsos, enabled, opacity, map, deLocation]); + useEffect(() => { + if (!enabled || typeof onDXChange !== 'function' || !Array.isArray(qsos) || qsos.length === 0) return; + + const latestWithCoords = [...qsos] + .reverse() + .find((qso) => Number.isFinite(parseFloat(qso?.lat)) && Number.isFinite(parseFloat(qso?.lon))); + + if (!latestWithCoords) return; + + const qsoId = latestWithCoords.id || + `${latestWithCoords.dxCall || ''}-${latestWithCoords.timestamp || ''}-${latestWithCoords.lat}-${latestWithCoords.lon}`; + + if (!qsoId || qsoId === lastAutoDxQsoIdRef.current) return; + + const lat = parseFloat(latestWithCoords.lat); + let lon = parseFloat(latestWithCoords.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + + while (lon > 180) lon -= 360; + while (lon < -180) lon += 360; + + // Auto-follow newest contest QSO so propagation/DX panel refresh from logger activity. + onDXChange({ lat, lon }); + lastAutoDxQsoIdRef.current = qsoId; + }, [enabled, qsos, onDXChange]); + return { layer: markersRef.current }; } From c88b630d5d3a690b18ecd83c337c318ba61b70b6 Mon Sep 17 00:00:00 2001 From: s53zo <44831766+s53zo@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:57:36 +0100 Subject: [PATCH 2/6] Add DXLog UDP support in contest QSO parser --- server.js | 43 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/server.js b/server.js index c5236d5e..72db9e70 100644 --- a/server.js +++ b/server.js @@ -8197,6 +8197,23 @@ function normalizeCallsign(value) { return (value || '').trim().toUpperCase(); } +function parseXmlBoolean(value) { + if (value == null) return null; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return null; + if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + return null; +} + +function detectContestSource(xml) { + const app = getXmlTag(xml, 'app').toLowerCase(); + const logger = getXmlTag(xml, 'logger').toLowerCase(); + if (app.includes('dxlog') || logger.includes('dxlog')) return 'dxlog'; + if (app.includes('n1mm') || logger.includes('n1mm')) return 'n1mm'; + return 'contest'; +} + function n1mmFreqToMHz(value, bandMHz) { const v = parseFloat(value); if (!v || Number.isNaN(v)) return bandMHz || null; @@ -8287,10 +8304,11 @@ function addContestQso(qso) { return true; } -function parseN1MMContactInfo(xml) { +function parseContestContactInfo(xml) { const dxCall = normalizeCallsign(getXmlTag(xml, 'call')); if (!dxCall) return null; + const source = detectContestSource(xml); const myCall = normalizeCallsign(getXmlTag(xml, 'mycall')) || normalizeCallsign(getXmlTag(xml, 'stationprefix')) || CONFIG.callsign; @@ -8307,13 +8325,22 @@ function parseN1MMContactInfo(xml) { const contestName = getXmlTag(xml, 'contestname') || ''; const timestampStr = getXmlTag(xml, 'timestamp') || ''; const timestamp = parseN1MMTimestamp(timestampStr) || Date.now(); - const id = getXmlTag(xml, 'ID') || ''; + const id = getXmlTag(xml, 'ID') || getXmlTag(xml, 'guid') || getXmlTag(xml, 'qsoid') || ''; + const isNewQso = parseXmlBoolean(getXmlTag(xml, 'newqso')); + const isDuplicate = parseXmlBoolean(getXmlTag(xml, 'duplicate')); + const isInvalid = parseXmlBoolean(getXmlTag(xml, 'invalid')); + const isDeleted = parseXmlBoolean(getXmlTag(xml, 'xqso')); + + // DXLog may send non-new / duplicate / invalid/deleted updates on the same stream. + // Only ingest genuine new, valid QSOs so the map and auto-DX-follow stay clean. + if (isNewQso === false) return null; + if (isDuplicate === true || isInvalid === true || isDeleted === true) return null; const loc = resolveQsoLocation(dxCall, grid, comment); const qso = { id, - source: 'n1mm', + source, timestamp, time: timestampStr, myCall, @@ -8398,17 +8425,17 @@ if (N1MM_ENABLED) { const text = buf.toString('utf8'); const xml = extractContactInfoXml(text); if (!xml) return; - const qso = parseN1MMContactInfo(xml); + const qso = parseContestContactInfo(xml); if (qso) addContestQso(qso); }); n1mmSocket.on('error', (err) => { - logErrorOnce('N1MM UDP', err.message); + logErrorOnce('Contest UDP', err.message); }); n1mmSocket.on('listening', () => { const addr = n1mmSocket.address(); - console.log(`[N1MM] UDP listener on ${addr.address}:${addr.port}`); + console.log(`[Contest UDP] listener on ${addr.address}:${addr.port}`); }); n1mmSocket.bind(N1MM_UDP_PORT, '0.0.0.0'); @@ -8507,8 +8534,8 @@ app.listen(PORT, '0.0.0.0', () => { if (WSJTX_RELAY_KEY) { console.log(` 🔁 WSJT-X relay endpoint enabled (POST /api/wsjtx/relay)`); } -if (N1MM_ENABLED) { - console.log(` 📥 N1MM UDP listener on port ${N1MM_UDP_PORT}`); + if (N1MM_ENABLED) { + console.log(` 📥 Contest logger UDP listener (N1MM/DXLog) on port ${N1MM_UDP_PORT}`); } if (AUTO_UPDATE_ENABLED) { console.log(` 🔄 Auto-update enabled every ${AUTO_UPDATE_INTERVAL_MINUTES || 60} minutes`); From 3e80379c4b7e6bc90c9375b8651e5a5a0acb5662 Mon Sep 17 00:00:00 2001 From: s53zo <44831766+s53zo@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:26:27 +0100 Subject: [PATCH 3/6] Fix PluginLayer merge artifact and wire DX follow props --- src/components/PluginLayer.jsx | 44 ++++++++++++++++++---------------- src/components/WorldMap.jsx | 5 +++- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/components/PluginLayer.jsx b/src/components/PluginLayer.jsx index a227d9d9..a6a719c1 100644 --- a/src/components/PluginLayer.jsx +++ b/src/components/PluginLayer.jsx @@ -4,38 +4,42 @@ */ import React from 'react'; -export const PluginLayer = ({ plugin, enabled, opacity, map, callsign, locator, lowMemoryMode, onDXChange, dxLocked, dxLocation }) => { -export const PluginLayer = ({ - plugin, - enabled, - opacity, - map, - callsign, - locator, +export const PluginLayer = ({ + plugin, + enabled, + opacity, + map, + callsign, + locator, lowMemoryMode, satellites, units, - config + config, + onDXChange, + dxLocked, + dxLocation }) => { const layerFunc = plugin.useLayer || plugin.hook; if (typeof layerFunc === 'function') { - layerFunc({ map, enabled, opacity, callsign, locator, lowMemoryMode, onDXChange, dxLocked, dxLocation }); - layerFunc({ - map, - enabled, - opacity, - callsign, - locator, - lowMemoryMode, - satellites, + layerFunc({ + map, + enabled, + opacity, + callsign, + locator, + lowMemoryMode, + satellites, units, - config + config, + onDXChange, + dxLocked, + dxLocation }); } return null; }; -export default PluginLayer; \ No newline at end of file +export default PluginLayer; diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 77667957..178b0716 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -1366,6 +1366,9 @@ export const WorldMap = ({ callsign={callsign} locator={deLocator} lowMemoryMode={lowMemoryMode} + onDXChange={onDXChange} + dxLocked={dxLocked} + dxLocation={dxLocation} /> ))} @@ -1612,4 +1615,4 @@ export const WorldMap = ({ ); }; -export default WorldMap; \ No newline at end of file +export default WorldMap; From 72a8ce244d8185f021d63df8fa0912453e9904f7 Mon Sep 17 00:00:00 2001 From: s53zo <44831766+s53zo@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:29:47 +0100 Subject: [PATCH 4/6] style: format PR 371 files with Prettier --- server.js | 3213 ++++++++++---------------- src/components/PluginLayer.jsx | 5 +- src/components/WorldMap.jsx | 957 ++++---- src/plugins/layers/useContestQsos.js | 35 +- 4 files changed, 1606 insertions(+), 2604 deletions(-) diff --git a/server.js b/server.js index 906886e2..fc6fca0a 100644 --- a/server.js +++ b/server.js @@ -28,18 +28,12 @@ const dgram = require('dgram'); const fs = require('fs'); const { execFile, spawn } = require('child_process'); const mqttLib = require('mqtt'); -const { - initCtyData, - getCtyData, - lookupCall, -} = require('./src/server/ctydat.js'); +const { initCtyData, getCtyData, lookupCall } = require('./src/server/ctydat.js'); // Read version from package.json as single source of truth const APP_VERSION = (() => { try { - const pkg = JSON.parse( - fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'), - ); + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); return pkg.version || '0.0.0'; } catch { return '0.0.0'; @@ -49,10 +43,7 @@ const APP_VERSION = (() => { // Global safety nets — log but don't crash on stray errors (e.g. MQTT connack timeout) process.on('uncaughtException', (err) => { // BadRequestError: request aborted — benign, just a client disconnecting mid-request - if ( - err.type === 'request.aborted' || - (err.name === 'BadRequestError' && err.message === 'request aborted') - ) { + if (err.type === 'request.aborted' || (err.name === 'BadRequestError' && err.message === 'request aborted')) { return; // Silently ignore — not a real crash } // PayloadTooLargeError — client sent oversized body, already handled by Express middleware @@ -66,11 +57,7 @@ process.on('uncaughtException', (err) => { }); process.on('unhandledRejection', (reason) => { // AbortErrors are benign — just fetch timeouts firing after the request context ended - if ( - reason && - (reason.name === 'AbortError' || - (typeof reason === 'string' && reason.includes('AbortError'))) - ) { + if (reason && (reason.name === 'AbortError' || (typeof reason === 'string' && reason.includes('AbortError')))) { return; // Silently ignore — these are expected during upstream slowdowns } console.error(`[WARN] Unhandled rejection: ${reason}`); @@ -83,9 +70,7 @@ const envExamplePath = path.join(__dirname, '.env.example'); if (!fs.existsSync(envPath) && fs.existsSync(envExamplePath)) { fs.copyFileSync(envExamplePath, envPath); console.log('[Config] Created .env from .env.example'); - console.log( - '[Config] ⚠️ Please edit .env with your callsign and locator, then restart', - ); + console.log('[Config] ⚠️ Please edit .env with your callsign and locator, then restart'); } // Load .env file if it exists @@ -120,8 +105,7 @@ const API_WRITE_KEY = process.env.API_WRITE_KEY || ''; // Helper: check write auth on POST endpoints that modify server state function requireWriteAuth(req, res, next) { if (!API_WRITE_KEY) return next(); // No key configured = open (local installs) - const token = - req.headers.authorization?.replace('Bearer ', '') || req.query.key || ''; + const token = req.headers.authorization?.replace('Bearer ', '') || req.query.key || ''; if (token === API_WRITE_KEY) return next(); return res.status(401).json({ error: 'Unauthorized — set Authorization: Bearer ', @@ -169,17 +153,13 @@ class UpstreamManager { const consecutive = prev.consecutive + 1; // Base delays by status: 429=aggressive, 503=moderate, other=short - const baseDelay = - statusCode === 429 ? 60000 : statusCode === 503 ? 30000 : 15000; + const baseDelay = statusCode === 429 ? 60000 : statusCode === 503 ? 30000 : 15000; // Per-service max backoff caps const maxBackoff = 30 * 60 * 1000; // 30 minutes // Exponential: base * 2^(n-1), capped per service - const delay = Math.min( - maxBackoff, - baseDelay * Math.pow(2, Math.min(consecutive - 1, 8)), - ); + const delay = Math.min(maxBackoff, baseDelay * Math.pow(2, Math.min(consecutive - 1, 8))); // Add 0-15s jitter to prevent synchronized retries across instances const jitter = Math.random() * 15000; @@ -328,10 +308,8 @@ if ((!stationLat || !stationLon) && locator) { } // Fallback to config.json location if no env -if (!stationLat && jsonConfig.location?.lat) - stationLat = jsonConfig.location.lat; -if (!stationLon && jsonConfig.location?.lon) - stationLon = jsonConfig.location.lon; +if (!stationLat && jsonConfig.location?.lat) stationLat = jsonConfig.location.lat; +if (!stationLon && jsonConfig.location?.lon) stationLon = jsonConfig.location.lon; const CONFIG = { // Station info (env takes precedence over config.json) @@ -347,46 +325,26 @@ const CONFIG = { layout: process.env.LAYOUT || jsonConfig.layout || 'modern', // DX target - dxLatitude: - parseFloat(process.env.DX_LATITUDE) || jsonConfig.defaultDX?.lat || 51.5074, - dxLongitude: - parseFloat(process.env.DX_LONGITUDE) || - jsonConfig.defaultDX?.lon || - -0.1278, + dxLatitude: parseFloat(process.env.DX_LATITUDE) || jsonConfig.defaultDX?.lat || 51.5074, + dxLongitude: parseFloat(process.env.DX_LONGITUDE) || jsonConfig.defaultDX?.lon || -0.1278, // Feature toggles - showSatellites: - process.env.SHOW_SATELLITES !== 'false' && - jsonConfig.features?.showSatellites !== false, - showPota: - process.env.SHOW_POTA !== 'false' && - jsonConfig.features?.showPOTA !== false, - showDxPaths: - process.env.SHOW_DX_PATHS !== 'false' && - jsonConfig.features?.showDXPaths !== false, - showDxWeather: - process.env.SHOW_DX_WEATHER !== 'false' && - jsonConfig.features?.showDXWeather !== false, - classicAnalogClock: - process.env.CLASSIC_ANALOG_CLOCK === 'true' || - jsonConfig.features?.classicAnalogClock === true, + showSatellites: process.env.SHOW_SATELLITES !== 'false' && jsonConfig.features?.showSatellites !== false, + showPota: process.env.SHOW_POTA !== 'false' && jsonConfig.features?.showPOTA !== false, + showDxPaths: process.env.SHOW_DX_PATHS !== 'false' && jsonConfig.features?.showDXPaths !== false, + showDxWeather: process.env.SHOW_DX_WEATHER !== 'false' && jsonConfig.features?.showDXWeather !== false, + classicAnalogClock: process.env.CLASSIC_ANALOG_CLOCK === 'true' || jsonConfig.features?.classicAnalogClock === true, showContests: jsonConfig.features?.showContests !== false, showDXpeditions: jsonConfig.features?.showDXpeditions !== false, // DX Cluster settings spotRetentionMinutes: - parseInt(process.env.SPOT_RETENTION_MINUTES) || - jsonConfig.dxCluster?.spotRetentionMinutes || - 30, - dxClusterSource: - process.env.DX_CLUSTER_SOURCE || jsonConfig.dxCluster?.source || 'auto', - dxClusterHost: - process.env.DX_CLUSTER_HOST || jsonConfig.dxCluster?.host || '', - dxClusterPort: - parseInt(process.env.DX_CLUSTER_PORT) || jsonConfig.dxCluster?.port || 7300, + parseInt(process.env.SPOT_RETENTION_MINUTES) || jsonConfig.dxCluster?.spotRetentionMinutes || 30, + dxClusterSource: process.env.DX_CLUSTER_SOURCE || jsonConfig.dxCluster?.source || 'auto', + dxClusterHost: process.env.DX_CLUSTER_HOST || jsonConfig.dxCluster?.host || '', + dxClusterPort: parseInt(process.env.DX_CLUSTER_PORT) || jsonConfig.dxCluster?.port || 7300, // Login callsign for DX cluster telnet. If unset, falls back to CALLSIGN-56. - dxClusterCallsign: - process.env.DX_CLUSTER_CALLSIGN || jsonConfig.dxCluster?.callsign || '', + dxClusterCallsign: process.env.DX_CLUSTER_CALLSIGN || jsonConfig.dxCluster?.callsign || '', // API keys (don't expose to frontend) _openWeatherApiKey: process.env.OPENWEATHER_API_KEY || '', @@ -398,9 +356,7 @@ const CONFIG = { const configMissing = CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare; if (configMissing) { console.log('[Config] ⚠️ Station configuration incomplete!'); - console.log( - '[Config] Copy .env.example to .env OR config.example.json to config.json', - ); + console.log('[Config] Copy .env.example to .env OR config.example.json to config.json'); console.log('[Config] Set your CALLSIGN and LOCATOR/grid square'); console.log('[Config] Settings popup will appear in browser'); } @@ -409,18 +365,13 @@ if (configMissing) { // Defaults to the public OpenHamClock prediction service; override in .env if self-hosting const ITURHFPROP_DEFAULT = 'https://proppy-production.up.railway.app'; const ITURHFPROP_URL = - process.env.ITURHFPROP_URL && - process.env.ITURHFPROP_URL.trim().startsWith('http') + process.env.ITURHFPROP_URL && process.env.ITURHFPROP_URL.trim().startsWith('http') ? process.env.ITURHFPROP_URL.trim() : ITURHFPROP_DEFAULT; // Log configuration -console.log( - `[Config] Station: ${CONFIG.callsign} @ ${CONFIG.gridSquare || 'No grid'}`, -); -console.log( - `[Config] Location: ${CONFIG.latitude.toFixed(4)}, ${CONFIG.longitude.toFixed(4)}`, -); +console.log(`[Config] Station: ${CONFIG.callsign} @ ${CONFIG.gridSquare || 'No grid'}`); +console.log(`[Config] Location: ${CONFIG.latitude.toFixed(4)}, ${CONFIG.longitude.toFixed(4)}`); console.log(`[Config] Units: ${CONFIG.units}, Time: ${CONFIG.timeFormat}h`); if (ITURHFPROP_URL) { const isDefault = ITURHFPROP_URL === ITURHFPROP_DEFAULT; @@ -440,9 +391,7 @@ app.use( ); // CORS — restrict to same origin by default; allow override via env -const CORS_ORIGINS = process.env.CORS_ORIGINS - ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim()) - : true; // true = reflect request origin (same as before for local installs) +const CORS_ORIGINS = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim()) : true; // true = reflect request origin (same as before for local installs) app.use( cors({ origin: CORS_ORIGINS, @@ -569,11 +518,7 @@ const ERROR_LOG_INTERVAL = 5 * 60 * 1000; // Only log same error once per 5 minu function logErrorOnce(category, message) { // Suppress AbortError messages — these are just fetch timeouts, not real errors - if ( - message && - (message.includes('aborted') || message.includes('AbortError')) - ) - return false; + if (message && (message.includes('aborted') || message.includes('AbortError'))) return false; const key = `${category}:${message}`; const now = Date.now(); @@ -645,16 +590,10 @@ const endpointStats = { .map((s) => ({ ...s, avgBytes: s.requests > 0 ? Math.round(s.totalBytes / s.requests) : 0, - avgDuration: - s.requests > 0 ? Math.round(s.totalDuration / s.requests) : 0, - requestsPerHour: - uptimeHours > 0 ? (s.requests / uptimeHours).toFixed(1) : s.requests, - bytesPerHour: - uptimeHours > 0 - ? Math.round(s.totalBytes / uptimeHours) - : s.totalBytes, - errorRate: - s.requests > 0 ? ((s.errors / s.requests) * 100).toFixed(1) : 0, + avgDuration: s.requests > 0 ? Math.round(s.totalDuration / s.requests) : 0, + requestsPerHour: uptimeHours > 0 ? (s.requests / uptimeHours).toFixed(1) : s.requests, + bytesPerHour: uptimeHours > 0 ? Math.round(s.totalBytes / uptimeHours) : s.totalBytes, + errorRate: s.requests > 0 ? ((s.errors / s.requests) * 100).toFixed(1) : 0, })) .sort((a, b) => b.totalBytes - a.totalBytes); // Sort by bandwidth usage @@ -722,10 +661,7 @@ app.use('/api', (req, res, next) => { // Self-hosted users must explicitly set ROTATOR_PROVIDER=pstrotator_udp. const ROTATOR_PROVIDER = (process.env.ROTATOR_PROVIDER || 'none').toLowerCase(); const PSTROTATOR_HOST = process.env.PSTROTATOR_HOST || '192.168.1.43'; -const PSTROTATOR_UDP_PORT = parseInt( - process.env.PSTROTATOR_UDP_PORT || '12000', - 10, -); +const PSTROTATOR_UDP_PORT = parseInt(process.env.PSTROTATOR_UDP_PORT || '12000', 10); const ROTATOR_STALE_MS = parseInt(process.env.ROTATOR_STALE_MS || '5000', 10); const ROTATOR_POLL_MS = parseInt(process.env.ROTATOR_POLL_MS || '1000', 10); @@ -781,9 +717,7 @@ function ensureRotatorSocket() { try { sock.setRecvBufferSize?.(1024 * 1024); } catch {} - console.log( - `[Rotator] UDP listening on ${PSTROTATOR_REPLY_PORT} (provider=${ROTATOR_PROVIDER})`, - ); + console.log(`[Rotator] UDP listening on ${PSTROTATOR_REPLY_PORT} (provider=${ROTATOR_PROVIDER})`); }); rotatorSocket = sock; @@ -794,17 +728,10 @@ function udpSend(message) { const sock = ensureRotatorSocket(); const buf = Buffer.from(message, 'utf8'); return new Promise((resolve, reject) => { - sock.send( - buf, - 0, - buf.length, - PSTROTATOR_UDP_PORT, - PSTROTATOR_HOST, - (err) => { - if (err) return reject(err); - resolve(); - }, - ); + sock.send(buf, 0, buf.length, PSTROTATOR_UDP_PORT, PSTROTATOR_HOST, (err) => { + if (err) return reject(err); + resolve(); + }); }); } @@ -871,9 +798,7 @@ app.get('/api/rotator/status', (req, res) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); const now = Date.now(); - const isLive = - rotatorState.azimuth !== null && - now - rotatorState.lastSeen <= ROTATOR_STALE_MS; + const isLive = rotatorState.azimuth !== null && now - rotatorState.lastSeen <= ROTATOR_STALE_MS; res.json({ source: ROTATOR_PROVIDER, @@ -898,9 +823,7 @@ app.post('/api/rotator/turn', async (req, res) => { ok: result.ok, target: result.target, azimuth: rotatorState.azimuth, - live: - rotatorState.azimuth !== null && - Date.now() - rotatorState.lastSeen <= ROTATOR_STALE_MS, + live: rotatorState.azimuth !== null && Date.now() - rotatorState.lastSeen <= ROTATOR_STALE_MS, error: result.ok ? null : result.reason, }); } catch (e) { @@ -998,34 +921,20 @@ function loadVisitorStats() { console.log( `[Stats] 📊 All-time: ${data.allTimeVisitors || 0} unique visitors, ${data.allTimeRequests || 0} requests`, ); - console.log( - `[Stats] 📅 History: ${(data.history || []).length} days tracked`, - ); + console.log(`[Stats] 📅 History: ${(data.history || []).length} days tracked`); console.log( `[Stats] 🚀 Deployment #${(data.deploymentCount || 0) + 1} (first: ${data.serverFirstStarted || 'unknown'})`, ); return { today: new Date().toISOString().slice(0, 10), - uniqueIPsToday: - data.today === new Date().toISOString().slice(0, 10) - ? data.uniqueIPsToday || [] - : [], - totalRequestsToday: - data.today === new Date().toISOString().slice(0, 10) - ? data.totalRequestsToday || 0 - : 0, + uniqueIPsToday: data.today === new Date().toISOString().slice(0, 10) ? data.uniqueIPsToday || [] : [], + totalRequestsToday: data.today === new Date().toISOString().slice(0, 10) ? data.totalRequestsToday || 0 : 0, allTimeVisitors: data.allTimeVisitors || 0, allTimeRequests: data.allTimeRequests || 0, // Reconstruct from geoIPCache keys (covers ~99% of IPs) + any legacy array - allTimeUniqueIPs: [ - ...new Set([ - ...(data.allTimeUniqueIPs || []), - ...Object.keys(data.geoIPCache || {}), - ]), - ], - serverFirstStarted: - data.serverFirstStarted || defaults.serverFirstStarted, + allTimeUniqueIPs: [...new Set([...(data.allTimeUniqueIPs || []), ...Object.keys(data.geoIPCache || {})])], + serverFirstStarted: data.serverFirstStarted || defaults.serverFirstStarted, lastDeployment: new Date().toISOString(), deploymentCount: (data.deploymentCount || 0) + 1, history: data.history || [], @@ -1079,14 +988,9 @@ function saveVisitorStats(includeGeoCache = false) { saveErrorCount++; // Only log first error and then every 10th to avoid spam if (saveErrorCount === 1 || saveErrorCount % 10 === 0) { - console.error( - `[Stats] Failed to save (attempt #${saveErrorCount}):`, - err.message, - ); + console.error(`[Stats] Failed to save (attempt #${saveErrorCount}):`, err.message); if (saveErrorCount === 1) { - console.error( - "[Stats] Stats will be kept in memory but won't persist across restarts", - ); + console.error("[Stats] Stats will be kept in memory but won't persist across restarts"); } } } @@ -1127,33 +1031,19 @@ const GEOIP_BATCH_SIZE = 100; // ip-api.com batch limit // Queue any existing IPs that haven't been resolved yet for (const ip of allTimeIPSet) { - if ( - !geoIPCache.has(ip) && - ip !== 'unknown' && - !ip.startsWith('127.') && - !ip.startsWith('::') - ) { + if (!geoIPCache.has(ip) && ip !== 'unknown' && !ip.startsWith('127.') && !ip.startsWith('::')) { geoIPQueue.add(ip); } } if (geoIPQueue.size > 0) { - logInfo( - `[GeoIP] Queued ${geoIPQueue.size} unresolved IPs from history for batch lookup`, - ); + logInfo(`[GeoIP] Queued ${geoIPQueue.size} unresolved IPs from history for batch lookup`); } /** * Queue an IP for GeoIP resolution */ function queueGeoIPLookup(ip) { - if ( - !ip || - ip === 'unknown' || - ip.startsWith('127.') || - ip.startsWith('::1') || - ip === '0.0.0.0' - ) - return; + if (!ip || ip === 'unknown' || ip.startsWith('127.') || ip.startsWith('::1') || ip === '0.0.0.0') return; if (geoIPCache.has(ip)) return; geoIPQueue.add(ip); } @@ -1169,13 +1059,11 @@ function recordCountry(ip, countryCode) { } // All-time stats - visitorStats.countryStats[countryCode] = - (visitorStats.countryStats[countryCode] || 0) + 1; + visitorStats.countryStats[countryCode] = (visitorStats.countryStats[countryCode] || 0) + 1; // Today stats (only if IP is in today's set) if (todayIPSet.has(ip)) { - visitorStats.countryStatsToday[countryCode] = - (visitorStats.countryStatsToday[countryCode] || 0) + 1; + visitorStats.countryStatsToday[countryCode] = (visitorStats.countryStatsToday[countryCode] || 0) + 1; } } @@ -1205,20 +1093,17 @@ async function resolveGeoIPBatch() { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); - const response = await fetch( - 'http://ip-api.com/batch?fields=query,countryCode,status', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( - batch.map((ip) => ({ - query: ip, - fields: 'query,countryCode,status', - })), - ), - signal: controller.signal, - }, - ); + const response = await fetch('http://ip-api.com/batch?fields=query,countryCode,status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + batch.map((ip) => ({ + query: ip, + fields: 'query,countryCode,status', + })), + ), + signal: controller.signal, + }); clearTimeout(timeout); if (response.status === 429) { @@ -1247,9 +1132,7 @@ async function resolveGeoIPBatch() { } if (resolved > 0) { - logDebug( - `[GeoIP] Resolved ${resolved}/${batch.length} IPs (${geoIPQueue.size} remaining)`, - ); + logDebug(`[GeoIP] Resolved ${resolved}/${batch.length} IPs (${geoIPQueue.size} remaining)`); } } catch (err) { // Re-queue on network errors @@ -1287,10 +1170,7 @@ function rolloverVisitorStats() { const now = new Date().toISOString().slice(0, 10); if (now !== visitorStats.today) { // Save yesterday's stats to history - if ( - visitorStats.uniqueIPsToday.length > 0 || - visitorStats.totalRequestsToday > 0 - ) { + if (visitorStats.uniqueIPsToday.length > 0 || visitorStats.totalRequestsToday > 0) { visitorStats.history.push({ date: visitorStats.today, uniqueVisitors: visitorStats.uniqueIPsToday.length, @@ -1304,10 +1184,7 @@ function rolloverVisitorStats() { } const avg = visitorStats.history.length > 0 - ? Math.round( - visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / - visitorStats.history.length, - ) + ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) : 0; console.log( `[Stats] Daily rollover for ${visitorStats.today}: ${visitorStats.uniqueIPsToday.length} unique, ${visitorStats.totalRequestsToday} requests | All-time: ${visitorStats.allTimeVisitors} visitors | ${visitorStats.history.length}-day avg: ${avg}/day`, @@ -1407,12 +1284,12 @@ const sessionTracker = { p90Duration: 0, maxDuration: 0, durationBuckets: { - 'under1m': 0, + under1m: 0, '1to5m': 0, '5to15m': 0, '15to30m': 0, '30to60m': 0, - 'over1h': 0, + over1h: 0, }, recentTrend: [], activeSessions: [], @@ -1420,21 +1297,19 @@ const sessionTracker = { } const durations = sessions.map((s) => s.duration).sort((a, b) => a - b); - const avg = Math.round( - durations.reduce((s, d) => s + d, 0) / durations.length, - ); + const avg = Math.round(durations.reduce((s, d) => s + d, 0) / durations.length); const median = durations[Math.floor(durations.length / 2)]; const p90 = durations[Math.floor(durations.length * 0.9)]; const max = durations[durations.length - 1]; // Duration distribution buckets const buckets = { - 'under1m': 0, + under1m: 0, '1to5m': 0, '5to15m': 0, '15to30m': 0, '30to60m': 0, - 'over1h': 0, + over1h: 0, }; for (const d of durations) { if (d < 60000) buckets.under1m++; @@ -1461,19 +1336,11 @@ const sessionTracker = { sessions: hourSessions.length, avgDuration: hourSessions.length > 0 - ? Math.round( - hourSessions.reduce((s, x) => s + x.duration, 0) / - hourSessions.length, - ) + ? Math.round(hourSessions.reduce((s, x) => s + x.duration, 0) / hourSessions.length) : 0, avgDurationFormatted: hourSessions.length > 0 - ? formatDuration( - Math.round( - hourSessions.reduce((s, x) => s + x.duration, 0) / - hourSessions.length, - ), - ) + ? formatDuration(Math.round(hourSessions.reduce((s, x) => s + x.duration, 0) / hourSessions.length)) : '--', }); } @@ -1512,8 +1379,7 @@ const sessionTracker = { function formatDuration(ms) { if (ms < 60000) return `${Math.round(ms / 1000)}s`; - if (ms < 3600000) - return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`; return `${Math.floor(ms / 3600000)}h ${Math.floor((ms % 3600000) / 60000)}m`; } @@ -1526,10 +1392,7 @@ app.use((req, res, next) => { // Track concurrent sessions for ALL requests (not just countable routes) const sessionIp = - req.headers['x-forwarded-for']?.split(',')[0]?.trim() || - req.ip || - req.connection?.remoteAddress || - 'unknown'; + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.connection?.remoteAddress || 'unknown'; if (req.path !== '/api/health' && !req.path.startsWith('/assets/')) { sessionTracker.touch(sessionIp, req.headers['user-agent']); } @@ -1538,10 +1401,7 @@ app.use((req, res, next) => { const countableRoutes = ['/', '/index.html', '/api/config']; if (countableRoutes.includes(req.path)) { const ip = - req.headers['x-forwarded-for']?.split(',')[0]?.trim() || - req.ip || - req.connection?.remoteAddress || - 'unknown'; + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.connection?.remoteAddress || 'unknown'; // Track today's visitors const isNewToday = !todayIPSet.has(ip); @@ -1577,18 +1437,10 @@ app.use((req, res, next) => { setInterval( () => { rolloverVisitorStats(); - if ( - visitorStats.uniqueIPsToday.length > 0 || - visitorStats.allTimeVisitors > 0 - ) { + if (visitorStats.uniqueIPsToday.length > 0 || visitorStats.allTimeVisitors > 0) { const avg = visitorStats.history.length > 0 - ? Math.round( - visitorStats.history.reduce( - (sum, d) => sum + d.uniqueVisitors, - 0, - ) / visitorStats.history.length, - ) + ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) : visitorStats.uniqueIPsToday.length; console.log( `[Stats] Hourly: ${visitorStats.uniqueIPsToday.length} unique today, ${visitorStats.totalRequestsToday} requests | All-time: ${visitorStats.allTimeVisitors} visitors | Avg: ${avg}/day`, @@ -1601,20 +1453,25 @@ setInterval( ); // Log memory usage every 15 minutes for leak detection -setInterval(() => { - const mem = process.memoryUsage(); - const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1); - const mqttStats = { - subscribers: pskMqtt.subscribers.size, - subscribedCalls: pskMqtt.subscribedCalls.size, - sseClients: [...pskMqtt.subscribers.values()].reduce((n, s) => n + s.size, 0), - recentSpotsEntries: pskMqtt.recentSpots.size, - recentSpotsTotal: [...pskMqtt.recentSpots.values()].reduce((n, s) => n + s.length, 0), - spotBufferEntries: pskMqtt.spotBuffer.size, - spotBufferTotal: [...pskMqtt.spotBuffer.values()].reduce((n, b) => n + b.length, 0), - }; - console.log(`[Memory] RSS=${mb(mem.rss)}MB Heap=${mb(mem.heapUsed)}/${mb(mem.heapTotal)}MB External=${mb(mem.external)}MB | MQTT: ${mqttStats.sseClients} SSE clients, ${mqttStats.subscribedCalls} calls, ${mqttStats.recentSpotsTotal} recent spots (${mqttStats.recentSpotsEntries} entries), ${mqttStats.spotBufferTotal} buffered | GeoIP=${geoIPCache.size} CallLookup=${callsignLookupCache?.size || 0} LocCache=${callsignLocationCache?.size || 0} MySpots=${mySpotsCache.size} AllTimeIPs=${allTimeIPSet.size} RBN=${rbnSpotsByDX?.size || 0} RBNapi=${rbnApiCaches?.size || 0}`); -}, 15 * 60 * 1000); +setInterval( + () => { + const mem = process.memoryUsage(); + const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1); + const mqttStats = { + subscribers: pskMqtt.subscribers.size, + subscribedCalls: pskMqtt.subscribedCalls.size, + sseClients: [...pskMqtt.subscribers.values()].reduce((n, s) => n + s.size, 0), + recentSpotsEntries: pskMqtt.recentSpots.size, + recentSpotsTotal: [...pskMqtt.recentSpots.values()].reduce((n, s) => n + s.length, 0), + spotBufferEntries: pskMqtt.spotBuffer.size, + spotBufferTotal: [...pskMqtt.spotBuffer.values()].reduce((n, b) => n + b.length, 0), + }; + console.log( + `[Memory] RSS=${mb(mem.rss)}MB Heap=${mb(mem.heapUsed)}/${mb(mem.heapTotal)}MB External=${mb(mem.external)}MB | MQTT: ${mqttStats.sseClients} SSE clients, ${mqttStats.subscribedCalls} calls, ${mqttStats.recentSpotsTotal} recent spots (${mqttStats.recentSpotsEntries} entries), ${mqttStats.spotBufferTotal} buffered | GeoIP=${geoIPCache.size} CallLookup=${callsignLookupCache?.size || 0} LocCache=${callsignLocationCache?.size || 0} MySpots=${mySpotsCache.size} AllTimeIPs=${allTimeIPSet.size} RBN=${rbnSpotsByDX?.size || 0} RBNapi=${rbnApiCaches?.size || 0}`, + ); + }, + 15 * 60 * 1000, +); // Periodic GC compaction — helps V8 release fragmented old-space memory // Without this, long-running processes slowly accumulate unreclaimable heap @@ -1624,24 +1481,16 @@ setInterval( const memBefore = process.memoryUsage(); global.gc(); const memAfter = process.memoryUsage(); - const heapFreed = ( - (memBefore.heapUsed - memAfter.heapUsed) / - 1024 / - 1024 - ).toFixed(1); + const heapFreed = ((memBefore.heapUsed - memAfter.heapUsed) / 1024 / 1024).toFixed(1); const rssNow = (memAfter.rss / 1024 / 1024).toFixed(0); if (heapFreed > 5) { - console.log( - `[GC] Compaction freed ${heapFreed}MB heap (RSS=${rssNow}MB)`, - ); + console.log(`[GC] Compaction freed ${heapFreed}MB heap (RSS=${rssNow}MB)`); } // If RSS is still high after normal GC, do a second pass to release old-space pages if (memAfter.rss > 400 * 1024 * 1024) { global.gc(); const rssAfter2 = (process.memoryUsage().rss / 1024 / 1024).toFixed(0); - console.log( - `[GC] High RSS — double compaction (${rssNow}MB -> ${rssAfter2}MB)`, - ); + console.log(`[GC] High RSS — double compaction (${rssNow}MB -> ${rssAfter2}MB)`); } } }, @@ -1652,9 +1501,7 @@ setInterval( // AUTO UPDATE (GIT) // ============================================ const AUTO_UPDATE_ENABLED = process.env.AUTO_UPDATE_ENABLED === 'true'; -const AUTO_UPDATE_INTERVAL_MINUTES = parseInt( - process.env.AUTO_UPDATE_INTERVAL_MINUTES || '60', -); +const AUTO_UPDATE_INTERVAL_MINUTES = parseInt(process.env.AUTO_UPDATE_INTERVAL_MINUTES || '60'); const AUTO_UPDATE_ON_START = process.env.AUTO_UPDATE_ON_START === 'true'; const AUTO_UPDATE_EXIT_AFTER = process.env.AUTO_UPDATE_EXIT_AFTER !== 'false'; @@ -1695,35 +1542,17 @@ async function getDefaultBranch() { async function hasGitUpdates() { // Ensure remote URL is correct try { - const { stdout: url } = await execFilePromise( - 'git', - ['remote', 'get-url', 'origin'], - { cwd: __dirname }, - ); + const { stdout: url } = await execFilePromise('git', ['remote', 'get-url', 'origin'], { cwd: __dirname }); if (!url.trim()) { - await execFilePromise( - 'git', - [ - 'remote', - 'add', - 'origin', - 'https://github.com/accius/openhamclock.git', - ], - { cwd: __dirname }, - ); + await execFilePromise('git', ['remote', 'add', 'origin', 'https://github.com/accius/openhamclock.git'], { + cwd: __dirname, + }); } } catch { try { - await execFilePromise( - 'git', - [ - 'remote', - 'add', - 'origin', - 'https://github.com/accius/openhamclock.git', - ], - { cwd: __dirname }, - ); + await execFilePromise('git', ['remote', 'add', 'origin', 'https://github.com/accius/openhamclock.git'], { + cwd: __dirname, + }); } catch {} // already exists } @@ -1735,9 +1564,7 @@ async function hasGitUpdates() { // Reset branch cache after fetch (refs may have changed) _defaultBranch = null; const branch = await getDefaultBranch(); - const local = ( - await execFilePromise('git', ['rev-parse', 'HEAD'], { cwd: __dirname }) - ).stdout.trim(); + const local = (await execFilePromise('git', ['rev-parse', 'HEAD'], { cwd: __dirname })).stdout.trim(); const remote = ( await execFilePromise('git', ['rev-parse', `origin/${branch}`], { cwd: __dirname, @@ -1749,12 +1576,7 @@ async function hasGitUpdates() { // Prevent chmod changes from showing as dirty (common on Pi, Mac, Windows/WSL) if (fs.existsSync(path.join(__dirname, '.git'))) { try { - execFile( - 'git', - ['config', 'core.fileMode', 'false'], - { cwd: __dirname }, - () => {}, - ); + execFile('git', ['config', 'core.fileMode', 'false'], { cwd: __dirname }, () => {}); } catch {} } @@ -1845,8 +1667,7 @@ async function autoUpdateTick(trigger = 'interval', force = false) { function startAutoUpdateScheduler() { if (!AUTO_UPDATE_ENABLED) return; const intervalMinutes = - Number.isFinite(AUTO_UPDATE_INTERVAL_MINUTES) && - AUTO_UPDATE_INTERVAL_MINUTES > 0 + Number.isFinite(AUTO_UPDATE_INTERVAL_MINUTES) && AUTO_UPDATE_INTERVAL_MINUTES > 0 ? AUTO_UPDATE_INTERVAL_MINUTES : 60; const intervalMs = Math.max(5, intervalMinutes) * 60 * 1000; @@ -1895,10 +1716,8 @@ const assetOptions = { // falls back to CDN redirect when vendor files haven't been downloaded yet. // Run: bash scripts/vendor-download.sh to eliminate all external requests. const VENDOR_CDN_MAP = { - '/vendor/leaflet/leaflet.js': - 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', - '/vendor/leaflet/leaflet.css': - 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', + '/vendor/leaflet/leaflet.js': 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', + '/vendor/leaflet/leaflet.css': 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', '/vendor/fonts/fonts.css': 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Space+Grotesk:wght@300;400;500;600;700&display=swap', }; @@ -1914,10 +1733,7 @@ app.use('/vendor', (req, res, next) => { if (distExists) { // Serve built React app from dist/ // Hashed assets (with content hash in filename) can be cached forever - app.use( - '/assets', - express.static(path.join(distDir, 'assets'), assetOptions), - ); + app.use('/assets', express.static(path.join(distDir, 'assets'), assetOptions)); app.use(express.static(distDir, staticOptions)); console.log('[Server] Serving React app from dist/'); } else { @@ -1946,15 +1762,10 @@ const NOAA_CACHE_TTL = 5 * 60 * 1000; // 5 minutes // NOAA Space Weather - Solar Flux app.get('/api/noaa/flux', async (req, res) => { try { - if ( - noaaCache.flux.data && - Date.now() - noaaCache.flux.timestamp < NOAA_CACHE_TTL - ) { + if (noaaCache.flux.data && Date.now() - noaaCache.flux.timestamp < NOAA_CACHE_TTL) { return res.json(noaaCache.flux.data); } - const response = await fetch( - 'https://services.swpc.noaa.gov/json/f107_cm_flux.json', - ); + const response = await fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'); const data = await response.json(); noaaCache.flux = { data, timestamp: Date.now() }; res.json(data); @@ -1968,15 +1779,10 @@ app.get('/api/noaa/flux', async (req, res) => { // NOAA Space Weather - K-Index app.get('/api/noaa/kindex', async (req, res) => { try { - if ( - noaaCache.kindex.data && - Date.now() - noaaCache.kindex.timestamp < NOAA_CACHE_TTL - ) { + if (noaaCache.kindex.data && Date.now() - noaaCache.kindex.timestamp < NOAA_CACHE_TTL) { return res.json(noaaCache.kindex.data); } - const response = await fetch( - 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json', - ); + const response = await fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'); const data = await response.json(); noaaCache.kindex = { data, timestamp: Date.now() }; res.json(data); @@ -1990,15 +1796,10 @@ app.get('/api/noaa/kindex', async (req, res) => { // NOAA Space Weather - Sunspots app.get('/api/noaa/sunspots', async (req, res) => { try { - if ( - noaaCache.sunspots.data && - Date.now() - noaaCache.sunspots.timestamp < NOAA_CACHE_TTL - ) { + if (noaaCache.sunspots.data && Date.now() - noaaCache.sunspots.timestamp < NOAA_CACHE_TTL) { return res.json(noaaCache.sunspots.data); } - const response = await fetch( - 'https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json', - ); + const response = await fetch('https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json'); const data = await response.json(); noaaCache.sunspots = { data, timestamp: Date.now() }; res.json(data); @@ -2017,27 +1818,17 @@ app.get('/api/noaa/sunspots', async (req, res) => { app.get('/api/solar-indices', async (req, res) => { try { // Check cache first - if ( - noaaCache.solarIndices.data && - Date.now() - noaaCache.solarIndices.timestamp < NOAA_CACHE_TTL - ) { + if (noaaCache.solarIndices.data && Date.now() - noaaCache.solarIndices.timestamp < NOAA_CACHE_TTL) { return res.json(noaaCache.solarIndices.data); } - const [fluxRes, kIndexRes, kForecastRes, sunspotRes, sfiSummaryRes] = - await Promise.allSettled([ - fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'), - fetch( - 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json', - ), - fetch( - 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json', - ), - fetch( - 'https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json', - ), - fetch('https://services.swpc.noaa.gov/products/summary/10cm-flux.json'), - ]); + const [fluxRes, kIndexRes, kForecastRes, sunspotRes, sfiSummaryRes] = await Promise.allSettled([ + fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'), + fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'), + fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json'), + fetch('https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json'), + fetch('https://services.swpc.noaa.gov/products/summary/10cm-flux.json'), + ]); const result = { sfi: { current: null, history: [] }, @@ -2073,8 +1864,7 @@ app.get('/api/solar-indices', async (req, res) => { })); // Only use archive for current if we still don't have one if (!result.sfi.current) { - result.sfi.current = - result.sfi.history[result.sfi.history.length - 1]?.value || null; + result.sfi.current = result.sfi.history[result.sfi.history.length - 1]?.value || null; } } } @@ -2088,8 +1878,7 @@ app.get('/api/solar-indices', async (req, res) => { time: d[0], value: parseFloat(d[1]) || 0, })); - result.kp.current = - result.kp.history[result.kp.history.length - 1]?.value || null; + result.kp.current = result.kp.history[result.kp.history.length - 1]?.value || null; } } @@ -2121,8 +1910,7 @@ app.get('/api/solar-indices', async (req, res) => { })); // Only use monthly archive for current if we still don't have one if (result.ssn.current == null) { - result.ssn.current = - result.ssn.history[result.ssn.history.length - 1]?.value || null; + result.ssn.current = result.ssn.history[result.ssn.history.length - 1]?.value || null; } } } @@ -2134,8 +1922,7 @@ app.get('/api/solar-indices', async (req, res) => { } catch (error) { logErrorOnce('Solar Indices', error.message); // Return stale cache on error - if (noaaCache.solarIndices.data) - return res.json(noaaCache.solarIndices.data); + if (noaaCache.solarIndices.data) return res.json(noaaCache.solarIndices.data); res.status(500).json({ error: 'Failed to fetch solar indices' }); } }); @@ -2149,15 +1936,8 @@ app.get('/api/dxpeditions', async (req, res) => { logDebug('[DXpeditions] API called'); // Return cached data if fresh - if ( - dxpeditionCache.data && - now - dxpeditionCache.timestamp < dxpeditionCache.maxAge - ) { - logDebug( - '[DXpeditions] Returning cached data:', - dxpeditionCache.data.dxpeditions?.length, - 'entries', - ); + if (dxpeditionCache.data && now - dxpeditionCache.timestamp < dxpeditionCache.maxAge) { + logDebug('[DXpeditions] Returning cached data:', dxpeditionCache.data.dxpeditions?.length, 'entries'); return res.json(dxpeditionCache.data); } @@ -2235,9 +2015,7 @@ app.get('/api/dxpeditions', async (req, res) => { let dateStr = null; // Strategy 1: "DXCC: xxx Callsign: xxx" format - const dxccMatch = entry.match( - /DXCC:\s*([^C\n]+?)(?=Callsign:|QSL:|Source:|Info:|$)/i, - ); + const dxccMatch = entry.match(/DXCC:\s*([^C\n]+?)(?=Callsign:|QSL:|Source:|Info:|$)/i); const callMatch = entry.match(/Callsign:\s*([A-Z0-9\/]+)/i); if (callMatch && dxccMatch) { @@ -2247,9 +2025,7 @@ app.get('/api/dxpeditions', async (req, res) => { // Strategy 2: Look for callsign patterns directly (like "3Y0K" or "VP8/G3ABC") if (!callsign) { - const directCallMatch = entry.match( - /\b([A-Z]{1,2}\d[A-Z0-9]*[A-Z](?:\/[A-Z0-9]+)?)\b/, - ); + const directCallMatch = entry.match(/\b([A-Z]{1,2}\d[A-Z0-9]*[A-Z](?:\/[A-Z0-9]+)?)\b/); if (directCallMatch) { callsign = directCallMatch[1]; } @@ -2257,9 +2033,7 @@ app.get('/api/dxpeditions', async (req, res) => { // Strategy 3: Parse "Entity - Callsign" or similar patterns if (!callsign) { - const altMatch = entry.match( - /([A-Za-z\s&]+?)\s*[-–:]\s*([A-Z]{1,2}\d[A-Z0-9]*)/, - ); + const altMatch = entry.match(/([A-Za-z\s&]+?)\s*[-–:]\s*([A-Z]{1,2}\d[A-Z0-9]*)/); if (altMatch) { entity = altMatch[1].trim(); callsign = altMatch[2].trim(); @@ -2301,20 +2075,7 @@ app.get('/api/dxpeditions', async (req, res) => { let isUpcoming = false; if (dateStr) { - const monthNames = [ - 'jan', - 'feb', - 'mar', - 'apr', - 'may', - 'jun', - 'jul', - 'aug', - 'sep', - 'oct', - 'nov', - 'dec', - ]; + const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const datePattern = /([A-Za-z]{3})\s+(\d{1,2})(?:,?\s*(\d{4}))?(?:\s*[-–]\s*([A-Za-z]{3})?\s*(\d{1,2})(?:,?\s*(\d{4}))?)?/i; const dateParsed = dateStr.match(datePattern); @@ -2323,9 +2084,7 @@ app.get('/api/dxpeditions', async (req, res) => { const currentYear = new Date().getFullYear(); const startMonth = monthNames.indexOf(dateParsed[1].toLowerCase()); const startDay = parseInt(dateParsed[2]); - const startYear = dateParsed[3] - ? parseInt(dateParsed[3]) - : currentYear; + const startYear = dateParsed[3] ? parseInt(dateParsed[3]) : currentYear; const endMonthStr = dateParsed[4] || dateParsed[1]; const endMonth = monthNames.indexOf(endMonthStr.toLowerCase()); @@ -2334,11 +2093,7 @@ app.get('/api/dxpeditions', async (req, res) => { if (startMonth >= 0) { startDate = new Date(startYear, startMonth, startDay); - endDate = new Date( - endYear, - endMonth >= 0 ? endMonth : startMonth, - endDay, - ); + endDate = new Date(endYear, endMonth >= 0 ? endMonth : startMonth, endDay); if (endDate < startDate && !dateParsed[6]) { endDate.setFullYear(endYear + 1); @@ -2357,12 +2112,8 @@ app.get('/api/dxpeditions', async (req, res) => { const bandsMatch = entry.match(/(\d+(?:-\d+)?m)/g); const bands = bandsMatch ? [...new Set(bandsMatch)].join(' ') : ''; - const modesMatch = entry.match( - /\b(CW|SSB|FT8|FT4|RTTY|PSK|FM|AM|DIGI)\b/gi, - ); - const modes = modesMatch - ? [...new Set(modesMatch.map((m) => m.toUpperCase()))].join(' ') - : ''; + const modesMatch = entry.match(/\b(CW|SSB|FT8|FT4|RTTY|PSK|FM|AM|DIGI)\b/gi); + const modes = modesMatch ? [...new Set(modesMatch.map((m) => m.toUpperCase()))].join(' ') : ''; dxpeditions.push({ callsign, @@ -2393,21 +2144,13 @@ app.get('/api/dxpeditions', async (req, res) => { if (!a.isActive && b.isActive) return 1; if (a.isUpcoming && !b.isUpcoming) return -1; if (!a.isUpcoming && b.isUpcoming) return 1; - if (a.startDate && b.startDate) - return new Date(a.startDate) - new Date(b.startDate); + if (a.startDate && b.startDate) return new Date(a.startDate) - new Date(b.startDate); return 0; }); - logDebug( - '[DXpeditions] Parsed', - uniqueDxpeditions.length, - 'unique entries', - ); + logDebug('[DXpeditions] Parsed', uniqueDxpeditions.length, 'unique entries'); if (uniqueDxpeditions.length > 0) { - logDebug( - '[DXpeditions] First entry:', - JSON.stringify(uniqueDxpeditions[0]), - ); + logDebug('[DXpeditions] First entry:', JSON.stringify(uniqueDxpeditions[0])); } const result = { @@ -2418,13 +2161,7 @@ app.get('/api/dxpeditions', async (req, res) => { timestamp: new Date().toISOString(), }; - logDebug( - '[DXpeditions] Result:', - result.active, - 'active,', - result.upcoming, - 'upcoming', - ); + logDebug('[DXpeditions] Result:', result.active, 'active,', result.upcoming, 'upcoming'); dxpeditionCache.data = result; dxpeditionCache.timestamp = now; @@ -2445,15 +2182,10 @@ app.get('/api/dxpeditions', async (req, res) => { // NOAA Space Weather - X-Ray Flux app.get('/api/noaa/xray', async (req, res) => { try { - if ( - noaaCache.xray.data && - Date.now() - noaaCache.xray.timestamp < NOAA_CACHE_TTL - ) { + if (noaaCache.xray.data && Date.now() - noaaCache.xray.timestamp < NOAA_CACHE_TTL) { return res.json(noaaCache.xray.data); } - const response = await fetch( - 'https://services.swpc.noaa.gov/json/goes/primary/xrays-6-hour.json', - ); + const response = await fetch('https://services.swpc.noaa.gov/json/goes/primary/xrays-6-hour.json'); const data = await response.json(); noaaCache.xray = { data, timestamp: Date.now() }; res.json(data); @@ -2468,15 +2200,10 @@ app.get('/api/noaa/xray', async (req, res) => { const AURORA_CACHE_TTL = 30 * 60 * 1000; // 30 minutes (matches NOAA update frequency) app.get('/api/noaa/aurora', async (req, res) => { try { - if ( - noaaCache.aurora.data && - Date.now() - noaaCache.aurora.timestamp < AURORA_CACHE_TTL - ) { + if (noaaCache.aurora.data && Date.now() - noaaCache.aurora.timestamp < AURORA_CACHE_TTL) { return res.json(noaaCache.aurora.data); } - const response = await fetch( - 'https://services.swpc.noaa.gov/json/ovation_aurora_latest.json', - ); + const response = await fetch('https://services.swpc.noaa.gov/json/ovation_aurora_latest.json'); const data = await response.json(); noaaCache.aurora = { data, timestamp: Date.now() }; res.json(data); @@ -2493,10 +2220,7 @@ const DXNEWS_CACHE_TTL = 30 * 60 * 1000; // 30 minutes app.get('/api/dxnews', async (req, res) => { try { - if ( - dxNewsCache.data && - Date.now() - dxNewsCache.timestamp < DXNEWS_CACHE_TTL - ) { + if (dxNewsCache.data && Date.now() - dxNewsCache.timestamp < DXNEWS_CACHE_TTL) { return res.json(dxNewsCache.data); } @@ -2523,9 +2247,7 @@ app.get('/api/dxnews', async (req, res) => { // Extract title const titleMatch = block.match(/title="([^"]+)"/); // Extract date - const dateMatch = block.match( - /(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/, - ); + const dateMatch = block.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/); // Extract description - text after the date, before stats const descParts = block.split(/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/); let desc = ''; @@ -2573,7 +2295,7 @@ const POTA_CACHE_TTL = 90 * 1000; // 90 seconds (longer than 60s frontend poll t app.get('/api/pota/spots', async (req, res) => { try { // Return cached data if fresh - if (potaCache.data && (Date.now() - potaCache.timestamp) < POTA_CACHE_TTL) { + if (potaCache.data && Date.now() - potaCache.timestamp < POTA_CACHE_TTL) { res.set('Cache-Control', 'no-store'); return res.json(potaCache.data); } @@ -2584,23 +2306,14 @@ app.get('/api/pota/spots', async (req, res) => { // Log diagnostic info about the response if (Array.isArray(data) && data.length > 0) { const sample = data[0]; - logDebug( - '[POTA] API returned', - data.length, - 'spots. Sample fields:', - Object.keys(sample).join(', '), - ); + logDebug('[POTA] API returned', data.length, 'spots. Sample fields:', Object.keys(sample).join(', ')); // Count coordinate coverage const withLatLon = data.filter((s) => s.latitude && s.longitude).length; const withGrid6 = data.filter((s) => s.grid6).length; const withGrid4 = data.filter((s) => s.grid4).length; - const noCoords = data.filter( - (s) => !s.latitude && !s.longitude && !s.grid6 && !s.grid4, - ).length; - logDebug( - `[POTA] Coords: ${withLatLon} lat/lon, ${withGrid6} grid6, ${withGrid4} grid4, ${noCoords} no coords`, - ); + const noCoords = data.filter((s) => !s.latitude && !s.longitude && !s.grid6 && !s.grid4).length; + logDebug(`[POTA] Coords: ${withLatLon} lat/lon, ${withGrid6} grid6, ${withGrid4} grid4, ${noCoords} no coords`); } // Cache the response @@ -2610,7 +2323,7 @@ app.get('/api/pota/spots', async (req, res) => { } catch (error) { logErrorOnce('POTA', error.message); // Return stale cache on error, but only if less than 10 minutes old - if (potaCache.data && (Date.now() - potaCache.timestamp) < 10 * 60 * 1000) return res.json(potaCache.data); + if (potaCache.data && Date.now() - potaCache.timestamp < 10 * 60 * 1000) return res.json(potaCache.data); res.status(500).json({ error: 'Failed to fetch POTA spots' }); } }); @@ -2622,7 +2335,7 @@ const WWFF_CACHE_TTL = 90 * 1000; // 90 seconds (longer than 60s frontend poll t app.get('/api/wwff/spots', async (req, res) => { try { // Return cached data if fresh - if (wwffCache.data && (Date.now() - wwffCache.timestamp) < WWFF_CACHE_TTL) { + if (wwffCache.data && Date.now() - wwffCache.timestamp < WWFF_CACHE_TTL) { res.set('Cache-Control', 'no-store'); return res.json(wwffCache.data); } @@ -2633,12 +2346,7 @@ app.get('/api/wwff/spots', async (req, res) => { // Log diagnostic info about the response if (Array.isArray(data) && data.length > 0) { const sample = data[0]; - logDebug( - '[WWFF] API returned', - data.length, - 'spots. Sample fields:', - Object.keys(sample).join(', '), - ); + logDebug('[WWFF] API returned', data.length, 'spots. Sample fields:', Object.keys(sample).join(', ')); } // Cache the response @@ -2648,7 +2356,7 @@ app.get('/api/wwff/spots', async (req, res) => { } catch (error) { logErrorOnce('WWFF', error.message); // Return stale cache on error, but only if less than 10 minutes old - if (wwffCache.data && (Date.now() - wwffCache.timestamp) < 10 * 60 * 1000) return res.json(wwffCache.data); + if (wwffCache.data && Date.now() - wwffCache.timestamp < 10 * 60 * 1000) return res.json(wwffCache.data); res.status(500).json({ error: 'Failed to fetch WWFF spots' }); } }); @@ -2666,10 +2374,7 @@ const SOTASUMMITS_CACHE_TTL = 24 * 60 * 60 * 1000; // 1 day async function checkSummitCache() { const now = Date.now(); try { - if ( - sotaSummits.data && - now - sotaSummits.timestamp < SOTASUMMITS_CACHE_TTL - ) { + if (sotaSummits.data && now - sotaSummits.timestamp < SOTASUMMITS_CACHE_TTL) { return; } logDebug('[SOTA] Refreshing sotaSummits'); @@ -2677,14 +2382,13 @@ async function checkSummitCache() { const data = await response.text(); const Papa = require('papaparse'); const csvresults = Papa.parse(data, { - skipFirstNLines: 1, - header: true - } - ); + skipFirstNLines: 1, + header: true, + }); let summit = {}; - csvresults.data.forEach( obj => { + csvresults.data.forEach((obj) => { summit[obj['SummitCode']] = { latitude: obj['Latitude'], longitude: obj['Longitude'], @@ -2708,7 +2412,7 @@ checkSummitCache(); // Prime the sotaSummits cache app.get('/api/sota/spots', async (req, res) => { try { // Return cached data if fresh - if (sotaCache.data && (Date.now() - sotaCache.timestamp) < SOTA_CACHE_TTL) { + if (sotaCache.data && Date.now() - sotaCache.timestamp < SOTA_CACHE_TTL) { res.set('Cache-Control', 'no-store'); return res.json(sotaCache.data); } @@ -2727,12 +2431,7 @@ app.get('/api/sota/spots', async (req, res) => { } if (Array.isArray(data) && data.length > 0) { const sample = data[0]; - logDebug( - '[SOTA] API returned', - data.length, - 'spots. Sample fields:', - Object.keys(sample).join(', '), - ); + logDebug('[SOTA] API returned', data.length, 'spots. Sample fields:', Object.keys(sample).join(', ')); } // Cache the response @@ -2742,7 +2441,7 @@ app.get('/api/sota/spots', async (req, res) => { } catch (error) { logErrorOnce('SOTA', error.message); // Return stale cache on error, but only if less than 10 minutes old - if (sotaCache.data && (Date.now() - sotaCache.timestamp) < 10 * 60 * 1000) return res.json(sotaCache.data); + if (sotaCache.data && Date.now() - sotaCache.timestamp < 10 * 60 * 1000) return res.json(sotaCache.data); res.status(500).json({ error: 'Failed to fetch SOTA spots' }); } }); @@ -2775,8 +2474,7 @@ function parseN0NBHxml(xml) { // Parse VHF conditions const vhfConditions = []; - const vhfRegex = - /([^<]+)<\/phenomenon>/g; + const vhfRegex = /([^<]+)<\/phenomenon>/g; while ((match = vhfRegex.exec(xml)) !== null) { vhfConditions.push({ name: match[1], @@ -2817,10 +2515,7 @@ function parseN0NBHxml(xml) { // N0NBH Parsed Band Conditions + Solar Data app.get('/api/n0nbh', async (req, res) => { try { - if ( - n0nbhCache.data && - Date.now() - n0nbhCache.timestamp < N0NBH_CACHE_TTL - ) { + if (n0nbhCache.data && Date.now() - n0nbhCache.timestamp < N0NBH_CACHE_TTL) { return res.json(n0nbhCache.data); } @@ -2841,10 +2536,7 @@ app.get('/api/n0nbh', async (req, res) => { app.get('/api/hamqsl/conditions', async (req, res) => { try { // Use N0NBH cache if fresh, otherwise fetch - if ( - n0nbhCache.data && - Date.now() - n0nbhCache.timestamp < N0NBH_CACHE_TTL - ) { + if (n0nbhCache.data && Date.now() - n0nbhCache.timestamp < N0NBH_CACHE_TTL) { // Re-fetch raw XML from cache won't work since we only store parsed, // so just fetch fresh if needed } @@ -2864,9 +2556,7 @@ app.get('/api/hamqsl/conditions', async (req, res) => { // The 'proxy' source uses our DX Spider Proxy microservice // DX Spider Proxy URL (sibling service on Railway or external) -const DXSPIDER_PROXY_URL = - process.env.DXSPIDER_PROXY_URL || - 'https://dxspider-proxy-production-1ec7.up.railway.app'; +const DXSPIDER_PROXY_URL = process.env.DXSPIDER_PROXY_URL || 'https://dxspider-proxy-production-1ec7.up.railway.app'; // Cache for DX Spider telnet spots (to avoid excessive connections) let dxSpiderCache = { spots: [], timestamp: 0 }; @@ -2883,11 +2573,7 @@ const DXSPIDER_NODES = [ const DXSPIDER_SSID = '-56'; // OpenHamClock SSID function getDxClusterLoginCallsign(preferredCallsign = null) { - const candidate = ( - preferredCallsign || - CONFIG.dxClusterCallsign || - '' - ).trim(); + const candidate = (preferredCallsign || CONFIG.dxClusterCallsign || '').trim(); if (candidate && candidate.toUpperCase() !== 'N0CALL') { return candidate.toUpperCase(); } @@ -2909,17 +2595,7 @@ function parseDXSpiderSpotLine(line) { if (!/^\d{4}$/.test(hhmm)) return Date.now(); const hh = parseInt(hhmm.substring(0, 2), 10); const mm = parseInt(hhmm.substring(2, 4), 10); - const dt = new Date( - Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - now.getUTCDate(), - hh, - mm, - 0, - 0, - ), - ); + const dt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hh, mm, 0, 0)); // If parsed time is too far in the future, assume spot was yesterday. if (dt.getTime() - Date.now() > 5 * 60 * 1000) { dt.setUTCDate(dt.getUTCDate() - 1); @@ -2930,9 +2606,7 @@ function parseDXSpiderSpotLine(line) { // Format 1: classic stream line // DX de SPOTTER: 14074.0 DXCALL comment 1234Z if (line.includes('DX de ')) { - const match = line.match( - /DX de ([A-Z0-9\/\-]+):\s+(\d+\.?\d*)\s+([A-Z0-9\/\-]+)\s+(.+?)\s+(\d{4})Z/i, - ); + const match = line.match(/DX de ([A-Z0-9\/\-]+):\s+(\d+\.?\d*)\s+([A-Z0-9\/\-]+)\s+(.+?)\s+(\d{4})Z/i); if (match) { const freqKhz = parseFloat(match[2]); if (isNaN(freqKhz) || freqKhz <= 0) return null; @@ -2956,9 +2630,7 @@ function parseDXSpiderSpotLine(line) { if (tableMatch) { const freqKhz = parseFloat(tableMatch[1]); if (isNaN(freqKhz) || freqKhz <= 0) return null; - const fullDateMatch = line.match( - /^\s*\d+\.?\d*\s+[A-Z0-9\/\-]+\s+(\d{1,2})-([A-Za-z]{3})-(\d{4})\s+(\d{4})Z/i, - ); + const fullDateMatch = line.match(/^\s*\d+\.?\d*\s+[A-Z0-9\/\-]+\s+(\d{1,2})-([A-Za-z]{3})-(\d{4})\s+(\d{4})Z/i); let timestampMs = Date.now(); if (fullDateMatch) { const day = parseInt(fullDateMatch[1], 10); @@ -3156,10 +2828,7 @@ function connectCustomSession(session) { !err.message.includes('ENOTFOUND') && !err.message.includes('ECONNREFUSED') ) { - logErrorOnce( - 'DX Cluster', - `Custom DX Spider ${session.node.host}: ${err.message}`, - ); + logErrorOnce('DX Cluster', `Custom DX Spider ${session.node.host}: ${err.message}`); } handleCustomSessionDisconnect(session); }); @@ -3237,9 +2906,7 @@ function tryDXSpiderNode(node, userCallsign = null) { // Try connecting to DX Spider node client.connect(node.port, node.host, () => { - logDebug( - `[DX Cluster] DX Spider: connected to ${node.host}:${node.port} as ${loginCallsign}`, - ); + logDebug(`[DX Cluster] DX Spider: connected to ${node.host}:${node.port} as ${loginCallsign}`); }); client.on('data', (data) => { @@ -3270,9 +2937,7 @@ function tryDXSpiderNode(node, userCallsign = null) { commandSent = true; setTimeout(() => { if (!finished) { - logInfo( - `[DX Cluster] Sending command: sh/dx 25 to ${node.host}:${node.port} as ${loginCallsign}`, - ); + logInfo(`[DX Cluster] Sending command: sh/dx 25 to ${node.host}:${node.port} as ${loginCallsign}`); client.write('sh/dx 25\r\n'); } }, 1000); @@ -3285,14 +2950,7 @@ function tryDXSpiderNode(node, userCallsign = null) { const parsed = parseDXSpiderSpotLine(line); if (!parsed) continue; // Avoid duplicates - if ( - !spots.find( - (s) => - s.call === parsed.call && - s.freq === parsed.freq && - s.spotter === parsed.spotter, - ) - ) { + if (!spots.find((s) => s.call === parsed.call && s.freq === parsed.freq && s.spotter === parsed.spotter)) { spots.push(parsed); } } @@ -3323,12 +2981,7 @@ function tryDXSpiderNode(node, userCallsign = null) { client.on('close', () => { if (!finished && spots.length > 0) { - logDebug( - '[DX Cluster] DX Spider:', - spots.length, - 'spots from', - node.host, - ); + logDebug('[DX Cluster] DX Spider:', spots.length, 'spots from', node.host); dxSpiderCache = { spots: spots, timestamp: Date.now() }; } finalize(spots.length > 0 ? spots : null); @@ -3338,12 +2991,7 @@ function tryDXSpiderNode(node, userCallsign = null) { setTimeout(() => { if (!finished) { if (spots.length > 0) { - logDebug( - '[DX Cluster] DX Spider:', - spots.length, - 'spots from', - node.host, - ); + logDebug('[DX Cluster] DX Spider:', spots.length, 'spots from', node.host); dxSpiderCache = { spots: spots, timestamp: Date.now() }; } finalize(spots.length > 0 ? spots : null); @@ -3353,11 +3001,7 @@ function tryDXSpiderNode(node, userCallsign = null) { } app.get('/api/dxcluster/spots', async (req, res) => { - const source = ( - req.query.source || - CONFIG.dxClusterSource || - 'auto' - ).toLowerCase(); + const source = (req.query.source || CONFIG.dxClusterSource || 'auto').toLowerCase(); // Helper function for HamQTH (HTTP-based, works everywhere) async function fetchHamQTH() { @@ -3365,13 +3009,10 @@ app.get('/api/dxcluster/spots', async (req, res) => { const timeout = setTimeout(() => controller.abort(), 10000); try { - const response = await fetch( - 'https://www.hamqth.com/dxc_csv.php?limit=25', - { - headers: { 'User-Agent': 'OpenHamClock/3.13.1' }, - signal: controller.signal, - }, - ); + const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=25', { + headers: { 'User-Agent': 'OpenHamClock/3.13.1' }, + signal: controller.signal, + }); clearTimeout(timeout); if (response.ok) { @@ -3393,15 +3034,13 @@ app.get('/api/dxcluster/spots', async (req, res) => { const timeDate = parts[4] || ''; // Frequency: convert from kHz to MHz - const freqMhz = - freqKhz > 1000 ? (freqKhz / 1000).toFixed(3) : String(freqKhz); + const freqMhz = freqKhz > 1000 ? (freqKhz / 1000).toFixed(3) : String(freqKhz); // Time: extract HHMM from "2149 2025-05-27" format let time = ''; if (timeDate && timeDate.length >= 4) { const timeStr = timeDate.substring(0, 4); - time = - timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4) + 'z'; + time = timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4) + 'z'; } return { @@ -3432,13 +3071,10 @@ app.get('/api/dxcluster/spots', async (req, res) => { const timeout = setTimeout(() => controller.abort(), 10000); try { - const response = await fetch( - `${DXSPIDER_PROXY_URL}/api/dxcluster/spots?limit=50`, - { - headers: { 'User-Agent': 'OpenHamClock/3.13.1' }, - signal: controller.signal, - }, - ); + const response = await fetch(`${DXSPIDER_PROXY_URL}/api/dxcluster/spots?limit=50`, { + headers: { 'User-Agent': 'OpenHamClock/3.13.1' }, + signal: controller.signal, + }); clearTimeout(timeout); if (response.ok) { @@ -3461,15 +3097,8 @@ app.get('/api/dxcluster/spots', async (req, res) => { // Multiple nodes for failover - uses module-level constants and tryDXSpiderNode async function fetchDXSpider() { // Check cache first (use longer cache to reduce connection attempts) - if ( - Date.now() - dxSpiderCache.timestamp < DXSPIDER_CACHE_TTL && - dxSpiderCache.spots.length > 0 - ) { - logDebug( - '[DX Cluster] DX Spider: returning', - dxSpiderCache.spots.length, - 'cached spots', - ); + if (Date.now() - dxSpiderCache.timestamp < DXSPIDER_CACHE_TTL && dxSpiderCache.spots.length > 0) { + logDebug('[DX Cluster] DX Spider: returning', dxSpiderCache.spots.length, 'cached spots'); return dxSpiderCache.spots; } @@ -3529,8 +3158,7 @@ app.get('/api/dxcluster/sources', (req, res) => { { id: 'proxy', name: 'DX Spider Proxy ⭐', - description: - 'Our dedicated proxy service - real-time telnet feed via HTTP', + description: 'Our dedicated proxy service - real-time telnet feed via HTTP', }, { id: 'hamqth', @@ -3573,19 +3201,10 @@ function parseSpotHHMMzToTimestamp(timeStr, fallbackTs = Date.now()) { if (!m) return fallbackTs; const hh = parseInt(m[1], 10); const mm = parseInt(m[2], 10); - if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh > 23 || mm > 59) - return fallbackTs; + if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh > 23 || mm > 59) return fallbackTs; const now = new Date(); - const ts = Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - now.getUTCDate(), - hh, - mm, - 0, - 0, - ); + const ts = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hh, mm, 0, 0); // If parsed time is slightly ahead of now, assume it belongs to previous UTC day. if (ts - Date.now() > 5 * 60 * 1000) { return ts - 24 * 60 * 60 * 1000; @@ -3598,14 +3217,8 @@ app.get('/api/dxcluster/paths', async (req, res) => { const source = req.query.source || 'auto'; const customHost = (req.query.host || CONFIG.dxClusterHost || '').trim(); const parsedPort = parseInt(req.query.port, 10); - const customPort = Number.isFinite(parsedPort) - ? parsedPort - : CONFIG.dxClusterPort; - const userCallsign = ( - req.query.callsign || - CONFIG.dxClusterCallsign || - '' - ).trim(); + const customPort = Number.isFinite(parsedPort) ? parsedPort : CONFIG.dxClusterPort; + const userCallsign = (req.query.callsign || CONFIG.dxClusterCallsign || '').trim(); // SECURITY: Validate custom host to prevent SSRF (internal network scanning) if (source === 'custom' && customHost) { @@ -3613,9 +3226,7 @@ app.get('/api/dxcluster/paths', async (req, res) => { const blockedPatterns = /^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.|0:|\[::1\]|::1|fe80:|fc00:|fd00:|ff00:)/i; if (blockedPatterns.test(customHost)) { - return res - .status(400) - .json({ error: 'Custom host cannot be a private/reserved address' }); + return res.status(400).json({ error: 'Custom host cannot be a private/reserved address' }); } // Block numeric-only hosts (raw IPs) that could be encoded to bypass above // Only allow hostnames that look like legitimate DX Spider nodes @@ -3629,16 +3240,12 @@ app.get('/api/dxcluster/paths', async (req, res) => { (octets[0] === 192 && octets[1] === 168) || (octets[0] === 169 && octets[1] === 254) ) { - return res - .status(400) - .json({ error: 'Custom host cannot be a private/reserved address' }); + return res.status(400).json({ error: 'Custom host cannot be a private/reserved address' }); } } // Restrict port range to common DX Spider/telnet ports if (customPort < 1024 || customPort > 49151) { - return res - .status(400) - .json({ error: 'Port must be between 1024 and 49151' }); + return res.status(400).json({ error: 'Port must be between 1024 and 49151' }); } } @@ -3650,11 +3257,7 @@ app.get('/api/dxcluster/paths', async (req, res) => { const pathsCache = getDxPathsCache(cacheKey); // Check cache first (but not for custom sources - they might have different data) - if ( - source !== 'custom' && - Date.now() - pathsCache.timestamp < DXPATHS_CACHE_TTL && - pathsCache.paths.length > 0 - ) { + if (source !== 'custom' && Date.now() - pathsCache.timestamp < DXPATHS_CACHE_TTL && pathsCache.paths.length > 0) { logDebug('[DX Paths] Returning', pathsCache.paths.length, 'cached paths'); return res.json(pathsCache.paths); } @@ -3706,13 +3309,10 @@ app.get('/api/dxcluster/paths', async (req, res) => { // Try proxy if not using custom or custom failed if (newSpots.length === 0 && source !== 'custom') { try { - const proxyResponse = await fetch( - `${DXSPIDER_PROXY_URL}/api/spots?limit=100`, - { - headers: { 'User-Agent': 'OpenHamClock/3.14.11' }, - signal: controller.signal, - }, - ); + const proxyResponse = await fetch(`${DXSPIDER_PROXY_URL}/api/spots?limit=100`, { + headers: { 'User-Agent': 'OpenHamClock/3.14.11' }, + signal: controller.signal, + }); if (proxyResponse.ok) { const proxyData = await proxyResponse.json(); @@ -3740,13 +3340,10 @@ app.get('/api/dxcluster/paths', async (req, res) => { // Fallback to HamQTH if proxy failed (never for explicit custom source) if (newSpots.length === 0 && source !== 'custom') { try { - const response = await fetch( - 'https://www.hamqth.com/dxc_csv.php?limit=50', - { - headers: { 'User-Agent': 'OpenHamClock/3.13.1' }, - signal: controller.signal, - }, - ); + const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=50', { + headers: { 'User-Agent': 'OpenHamClock/3.13.1' }, + signal: controller.signal, + }); if (response.ok) { const text = await response.text(); @@ -3788,13 +3385,7 @@ app.get('/api/dxcluster/paths', async (req, res) => { dxGrid: grids.dxGrid, freq: (freqKhz / 1000).toFixed(3), comment, - time: - timeDate.length >= 4 - ? timeDate.substring(0, 2) + - ':' + - timeDate.substring(2, 4) + - 'z' - : '', + time: timeDate.length >= 4 ? timeDate.substring(0, 2) + ':' + timeDate.substring(2, 4) + 'z' : '', timestamp: spotTimestamp, id: `${dxCall}-${freqKhz}-${spotter}`, }); @@ -3810,9 +3401,7 @@ app.get('/api/dxcluster/paths', async (req, res) => { if (newSpots.length === 0) { // Return existing paths if fetch failed - const validPaths = pathsCache.allPaths.filter( - (p) => now - p.timestamp < DXPATHS_RETENTION, - ); + const validPaths = pathsCache.allPaths.filter((p) => now - p.timestamp < DXPATHS_RETENTION); return res.json(validPaths.slice(0, 50)); } @@ -3858,11 +3447,7 @@ app.get('/api/dxcluster/paths', async (req, res) => { const hamqthMisses = []; // Callsigns to look up in background for (const call of callsToLookup) { const cached = callsignLookupCache.get(call); - if ( - cached && - now - cached.timestamp < CALLSIGN_CACHE_TTL && - cached.data?.lat != null - ) { + if (cached && now - cached.timestamp < CALLSIGN_CACHE_TTL && cached.data?.lat != null) { hamqthLocations[call] = { lat: cached.data.lat, lon: cached.data.lon, @@ -3880,24 +3465,17 @@ app.get('/api/dxcluster/paths', async (req, res) => { // Limit to 10 per cycle to avoid hammering HamQTH if (hamqthMisses.length > 0) { const batch = hamqthMisses.slice(0, 10); - logDebug( - '[DX Paths] Background HamQTH lookup for', - batch.length, - 'callsigns', - ); + logDebug('[DX Paths] Background HamQTH lookup for', batch.length, 'callsigns'); for (const rawCall of batch) { // Sanitize and validate before hitting external API const call = rawCall.replace(/[<>]/g, '').trim(); if (!call || !/^[A-Z0-9\/\-]{1,20}$/.test(call)) continue; // Fire-and-forget — results land in callsignLookupCache for next poll - fetch( - `https://www.hamqth.com/dxcc.php?callsign=${encodeURIComponent(call)}`, - { - headers: { 'User-Agent': 'OpenHamClock/' + APP_VERSION }, - signal: AbortSignal.timeout(5000), - }, - ) + fetch(`https://www.hamqth.com/dxcc.php?callsign=${encodeURIComponent(call)}`, { + headers: { 'User-Agent': 'OpenHamClock/' + APP_VERSION }, + signal: AbortSignal.timeout(5000), + }) .then(async (resp) => { if (!resp.ok) return; const text = await resp.text(); @@ -3959,10 +3537,7 @@ app.get('/api/dxcluster/paths', async (req, res) => { } // Fall back to HamQTH cached location (more accurate than prefix) - if ( - !dxLoc && - hamqthLocations[baseCallMap[spot.dxCall] || spot.dxCall] - ) { + if (!dxLoc && hamqthLocations[baseCallMap[spot.dxCall] || spot.dxCall]) { dxLoc = hamqthLocations[baseCallMap[spot.dxCall] || spot.dxCall]; } @@ -4010,18 +3585,13 @@ app.get('/api/dxcluster/paths', async (req, res) => { } // Fall back to HamQTH cached location for spotter - if ( - !spotterLoc && - hamqthLocations[baseCallMap[spot.spotter] || spot.spotter] - ) { - spotterLoc = - hamqthLocations[baseCallMap[spot.spotter] || spot.spotter]; + if (!spotterLoc && hamqthLocations[baseCallMap[spot.spotter] || spot.spotter]) { + spotterLoc = hamqthLocations[baseCallMap[spot.spotter] || spot.spotter]; } // Fall back to prefix location for spotter (now includes grid-based coordinates!) if (!spotterLoc) { - spotterLoc = - prefixLocations[baseCallMap[spot.spotter] || spot.spotter]; + spotterLoc = prefixLocations[baseCallMap[spot.spotter] || spot.spotter]; if (spotterLoc && spotterLoc.grid) { spotterGridSquare = spotterLoc.grid; } @@ -4047,27 +3617,20 @@ app.get('/api/dxcluster/paths', async (req, res) => { time: spot.time, id: spot.id, // Sorting is driven by spot-provided HHMMz time when available. - timestamp: parseSpotHHMMzToTimestamp( - spot.time, - Number.isFinite(spot.timestamp) ? spot.timestamp : now, - ), + timestamp: parseSpotHHMMzToTimestamp(spot.time, Number.isFinite(spot.timestamp) ? spot.timestamp : now), }; }) .filter(Boolean); // Merge with existing paths, removing expired and duplicates - const existingValidPaths = pathsCache.allPaths.filter( - (p) => now - p.timestamp < DXPATHS_RETENTION, - ); + const existingValidPaths = pathsCache.allPaths.filter((p) => now - p.timestamp < DXPATHS_RETENTION); // Add new paths, avoiding duplicates (same dxCall+freq within 2 minutes) const mergedPaths = [...existingValidPaths]; for (const newPath of newPaths) { const isDuplicate = mergedPaths.some( (existing) => - existing.dxCall === newPath.dxCall && - existing.freq === newPath.freq && - now - existing.timestamp < 120000, // 2 minute dedup window + existing.dxCall === newPath.dxCall && existing.freq === newPath.freq && now - existing.timestamp < 120000, // 2 minute dedup window ); if (!isDuplicate) { mergedPaths.push(newPath); @@ -4075,19 +3638,9 @@ app.get('/api/dxcluster/paths', async (req, res) => { } // Sort by timestamp (newest first) and limit - const sortedPaths = mergedPaths - .sort((a, b) => b.timestamp - a.timestamp) - .slice(0, 100); + const sortedPaths = mergedPaths.sort((a, b) => b.timestamp - a.timestamp).slice(0, 100); - logDebug( - '[DX Paths]', - sortedPaths.length, - 'total paths (', - newPaths.length, - 'new from', - newSpots.length, - 'spots)', - ); + logDebug('[DX Paths]', sortedPaths.length, 'total paths (', newPaths.length, 'new from', newSpots.length, 'spots)'); // Update cache dxSpotPathsCacheByKey.set(cacheKey, { @@ -4126,13 +3679,8 @@ setInterval( } // If still over cap after TTL purge, evict oldest entries if (callsignLookupCache.size > CALLSIGN_CACHE_MAX) { - const sorted = [...callsignLookupCache.entries()].sort( - (a, b) => a[1].timestamp - b[1].timestamp, - ); - const toRemove = sorted.slice( - 0, - callsignLookupCache.size - CALLSIGN_CACHE_MAX, - ); + const sorted = [...callsignLookupCache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp); + const toRemove = sorted.slice(0, callsignLookupCache.size - CALLSIGN_CACHE_MAX); for (const [call] of toRemove) { callsignLookupCache.delete(call); purged++; @@ -4148,10 +3696,7 @@ setInterval( // Helper: add to cache with size enforcement — prevents unbounded growth between cleanups function cacheCallsignLookup(call, data) { - if ( - callsignLookupCache.size >= CALLSIGN_CACHE_MAX && - !callsignLookupCache.has(call) - ) { + if (callsignLookupCache.size >= CALLSIGN_CACHE_MAX && !callsignLookupCache.has(call)) { // Evict oldest entry to make room const oldest = callsignLookupCache.keys().next().value; if (oldest) callsignLookupCache.delete(oldest); @@ -4179,24 +3724,7 @@ function extractBaseCallsign(raw) { const parts = call.split('/'); // Known suffixes that are always modifiers (not callsigns) - const MODIFIERS = new Set([ - 'M', - 'P', - 'QRP', - 'MM', - 'AM', - 'R', - 'T', - 'B', - 'BCN', - 'LH', - 'A', - 'E', - 'J', - 'AG', - 'AE', - 'KT', - ]); + const MODIFIERS = new Set(['M', 'P', 'QRP', 'MM', 'AM', 'R', 'T', 'B', 'BCN', 'LH', 'A', 'E', 'J', 'AG', 'AE', 'KT']); // Filter out known modifiers and single digits (call area overrides like /6) const candidates = parts.filter((p) => { @@ -4308,9 +3836,7 @@ async function qrzLogin() { errorMatch[1].includes('denied') ) { qrzSession.authFailedUntil = Date.now() + qrzSession.authFailCooldown; - console.error( - `[QRZ] Login failed: ${errorMatch[1]} — suppressing retries for 1 hour`, - ); + console.error(`[QRZ] Login failed: ${errorMatch[1]} — suppressing retries for 1 hour`); } else { console.error(`[QRZ] Login failed: ${errorMatch[1]}`); } @@ -4417,9 +3943,7 @@ async function qrzLookup(callsign) { source: 'qrz', }; - logDebug( - `[QRZ] ${callsign}: ${result.lat.toFixed(4)}, ${result.lon.toFixed(4)} (${result.geoloc})`, - ); + logDebug(`[QRZ] ${callsign}: ${result.lat.toFixed(4)}, ${result.lon.toFixed(4)} (${result.geoloc})`); return result; } catch (err) { if (err.name !== 'AbortError') { @@ -4432,12 +3956,9 @@ async function qrzLookup(callsign) { // Look up via HamQTH DXCC API (no auth, but only DXCC-level accuracy) async function hamqthLookup(callsign) { try { - const response = await fetch( - `https://www.hamqth.com/dxcc.php?callsign=${encodeURIComponent(callsign)}`, - { - signal: AbortSignal.timeout(8000), - }, - ); + const response = await fetch(`https://www.hamqth.com/dxcc.php?callsign=${encodeURIComponent(callsign)}`, { + signal: AbortSignal.timeout(8000), + }); if (!response.ok) return null; @@ -4477,14 +3998,8 @@ app.get('/api/qrz/status', (req, res) => { lookupCount: qrzSession.lookupCount, lastError: qrzSession.lastError, authCooldownRemaining: - qrzSession.authFailedUntil > Date.now() - ? Math.round((qrzSession.authFailedUntil - Date.now()) / 60000) - : 0, - source: CONFIG._qrzUsername - ? 'env' - : qrzSession.username - ? 'settings' - : 'none', + qrzSession.authFailedUntil > Date.now() ? Math.round((qrzSession.authFailedUntil - Date.now()) / 60000) : 0, + source: CONFIG._qrzUsername ? 'env' : qrzSession.username ? 'settings' : 'none', }); }); @@ -4514,10 +4029,10 @@ app.post('/api/qrz/configure', writeLimiter, async (req, res) => { qrzSession.password = oldPassword; return res.status(429).json({ success: false, - error: 'QRZ login recently failed with these credentials. Try again later or use different credentials.' + error: 'QRZ login recently failed with these credentials. Try again later or use different credentials.', }); } - + const key = await qrzLogin(); if (key) { @@ -4587,8 +4102,7 @@ app.get('/api/callsign/:call', async (req, res) => { const callsign = extractBaseCallsign(rawCallsign); // Check cache first (check both raw and base forms) - const cached = - callsignLookupCache.get(callsign) || callsignLookupCache.get(rawCallsign); + const cached = callsignLookupCache.get(callsign) || callsignLookupCache.get(rawCallsign); if (cached && now - cached.timestamp < CALLSIGN_CACHE_TTL) { logDebug('[Callsign Lookup] Cache hit for:', callsign); return res.json(cached.data); @@ -4706,8 +4220,7 @@ function maidenheadToLatLon(grid) { // Try to extract grid locators from a comment string // Returns { spotterGrid, dxGrid } - may have one, both, or neither function extractGridsFromComment(comment) { - if (!comment || typeof comment !== 'string') - return { spotterGrid: null, dxGrid: null }; + if (!comment || typeof comment !== 'string') return { spotterGrid: null, dxGrid: null }; // Check for dual grid format: FN20<>EM79 or FN20->EM79 or FN20/EM79 const dualGridMatch = comment.match( @@ -4752,9 +4265,7 @@ function isValidGrid(grid) { const firstChar = grid.charCodeAt(0); const secondChar = grid.charCodeAt(1); // First char should be A-R, second char should be A-R - return ( - firstChar >= 65 && firstChar <= 82 && secondChar >= 65 && secondChar <= 82 - ); + return firstChar >= 65 && firstChar <= 82 && secondChar >= 65 && secondChar <= 82; } // Legacy single-grid extraction (kept for compatibility) @@ -4777,135 +4288,135 @@ function estimateLocationFromPrefix(callsign) { // ============================================ // USA - by call district // ============================================ - 'W1': 'FN41', - 'K1': 'FN41', - 'N1': 'FN41', - 'AA1': 'FN41', - 'W2': 'FN20', - 'K2': 'FN20', - 'N2': 'FN20', - 'AA2': 'FN20', - 'W3': 'FM19', - 'K3': 'FM19', - 'N3': 'FM19', - 'AA3': 'FM19', - 'W4': 'EM73', - 'K4': 'EM73', - 'N4': 'EM73', - 'AA4': 'EM73', - 'W5': 'EM12', - 'K5': 'EM12', - 'N5': 'EM12', - 'AA5': 'EM12', - 'W6': 'CM97', - 'K6': 'CM97', - 'N6': 'CM97', - 'AA6': 'CM97', - 'W7': 'DN31', - 'K7': 'DN31', - 'N7': 'DN31', - 'AA7': 'DN31', - 'W8': 'EN81', - 'K8': 'EN81', - 'N8': 'EN81', - 'AA8': 'EN81', - 'W9': 'EN52', - 'K9': 'EN52', - 'N9': 'EN52', - 'AA9': 'EN52', - 'W0': 'EN31', - 'K0': 'EN31', - 'N0': 'EN31', - 'AA0': 'EN31', - 'W': 'EM79', - 'K': 'EM79', - 'N': 'EM79', + W1: 'FN41', + K1: 'FN41', + N1: 'FN41', + AA1: 'FN41', + W2: 'FN20', + K2: 'FN20', + N2: 'FN20', + AA2: 'FN20', + W3: 'FM19', + K3: 'FM19', + N3: 'FM19', + AA3: 'FM19', + W4: 'EM73', + K4: 'EM73', + N4: 'EM73', + AA4: 'EM73', + W5: 'EM12', + K5: 'EM12', + N5: 'EM12', + AA5: 'EM12', + W6: 'CM97', + K6: 'CM97', + N6: 'CM97', + AA6: 'CM97', + W7: 'DN31', + K7: 'DN31', + N7: 'DN31', + AA7: 'DN31', + W8: 'EN81', + K8: 'EN81', + N8: 'EN81', + AA8: 'EN81', + W9: 'EN52', + K9: 'EN52', + N9: 'EN52', + AA9: 'EN52', + W0: 'EN31', + K0: 'EN31', + N0: 'EN31', + AA0: 'EN31', + W: 'EM79', + K: 'EM79', + N: 'EM79', // ============================================ // US Territories // ============================================ - 'KP4': 'FK68', - 'NP4': 'FK68', - 'WP4': 'FK68', - 'KP3': 'FK68', - 'NP3': 'FK68', - 'WP3': 'FK68', - 'KP2': 'FK77', - 'NP2': 'FK77', - 'WP2': 'FK77', - 'KP1': 'FK28', - 'NP1': 'FK28', - 'WP1': 'FK28', - 'KP5': 'FK68', - 'KH0': 'QK25', - 'NH0': 'QK25', - 'WH0': 'QK25', - 'KH1': 'BL01', - 'KH2': 'QK24', - 'NH2': 'QK24', - 'WH2': 'QK24', - 'KH3': 'BK29', - 'KH4': 'AL07', - 'KH5': 'BK29', - 'KH5K': 'BL01', - 'KH6': 'BL10', - 'NH6': 'BL10', - 'WH6': 'BL10', - 'KH7': 'BL10', - 'NH7': 'BL10', - 'WH7': 'BL10', - 'KH8': 'AH38', - 'NH8': 'AH38', - 'WH8': 'AH38', - 'KH9': 'AK19', - 'KL7': 'BP51', - 'NL7': 'BP51', - 'WL7': 'BP51', - 'AL7': 'BP51', - 'KG4': 'FK29', + KP4: 'FK68', + NP4: 'FK68', + WP4: 'FK68', + KP3: 'FK68', + NP3: 'FK68', + WP3: 'FK68', + KP2: 'FK77', + NP2: 'FK77', + WP2: 'FK77', + KP1: 'FK28', + NP1: 'FK28', + WP1: 'FK28', + KP5: 'FK68', + KH0: 'QK25', + NH0: 'QK25', + WH0: 'QK25', + KH1: 'BL01', + KH2: 'QK24', + NH2: 'QK24', + WH2: 'QK24', + KH3: 'BK29', + KH4: 'AL07', + KH5: 'BK29', + KH5K: 'BL01', + KH6: 'BL10', + NH6: 'BL10', + WH6: 'BL10', + KH7: 'BL10', + NH7: 'BL10', + WH7: 'BL10', + KH8: 'AH38', + NH8: 'AH38', + WH8: 'AH38', + KH9: 'AK19', + KL7: 'BP51', + NL7: 'BP51', + WL7: 'BP51', + AL7: 'BP51', + KG4: 'FK29', // ============================================ // Canada // ============================================ - 'VE1': 'FN74', - 'VA1': 'FN74', - 'VE2': 'FN35', - 'VA2': 'FN35', - 'VE3': 'FN03', - 'VA3': 'FN03', - 'VE4': 'EN19', - 'VA4': 'EN19', - 'VE5': 'DO51', - 'VA5': 'DO51', - 'VE6': 'DO33', - 'VA6': 'DO33', - 'VE7': 'CN89', - 'VA7': 'CN89', - 'VE8': 'DP31', - 'VE9': 'FN65', - 'VA9': 'FN65', - 'VO1': 'GN37', - 'VO2': 'GO17', - 'VY0': 'EQ79', - 'VY1': 'CP28', - 'VY2': 'FN86', - 'CY0': 'GN76', - 'CY9': 'FN97', - 'VE': 'FN03', - 'VA': 'FN03', + VE1: 'FN74', + VA1: 'FN74', + VE2: 'FN35', + VA2: 'FN35', + VE3: 'FN03', + VA3: 'FN03', + VE4: 'EN19', + VA4: 'EN19', + VE5: 'DO51', + VA5: 'DO51', + VE6: 'DO33', + VA6: 'DO33', + VE7: 'CN89', + VA7: 'CN89', + VE8: 'DP31', + VE9: 'FN65', + VA9: 'FN65', + VO1: 'GN37', + VO2: 'GO17', + VY0: 'EQ79', + VY1: 'CP28', + VY2: 'FN86', + CY0: 'GN76', + CY9: 'FN97', + VE: 'FN03', + VA: 'FN03', // ============================================ // Mexico & Central America // ============================================ - 'XE': 'EK09', - 'XE1': 'EK09', - 'XE2': 'DL84', - 'XE3': 'EK57', - 'XA': 'EK09', - 'XB': 'EK09', - 'XC': 'EK09', - 'XD': 'EK09', - 'XF': 'DK48', + XE: 'EK09', + XE1: 'EK09', + XE2: 'DL84', + XE3: 'EK57', + XA: 'EK09', + XB: 'EK09', + XC: 'EK09', + XD: 'EK09', + XF: 'DK48', '4A': 'EK09', '4B': 'EK09', '4C': 'EK09', @@ -4916,416 +4427,416 @@ function estimateLocationFromPrefix(callsign) { '6H': 'EK09', '6I': 'EK09', '6J': 'EK09', - 'TI': 'EJ79', - 'TE': 'EJ79', - 'TG': 'EK44', - 'TD': 'EK44', - 'HR': 'EK55', - 'HQ': 'EK55', - 'YN': 'EK62', - 'HT': 'EK62', - 'H6': 'EK62', - 'H7': 'EK62', - 'HP': 'FJ08', - 'HO': 'FJ08', - 'H3': 'FJ08', - 'H8': 'FJ08', - 'H9': 'FJ08', + TI: 'EJ79', + TE: 'EJ79', + TG: 'EK44', + TD: 'EK44', + HR: 'EK55', + HQ: 'EK55', + YN: 'EK62', + HT: 'EK62', + H6: 'EK62', + H7: 'EK62', + HP: 'FJ08', + HO: 'FJ08', + H3: 'FJ08', + H8: 'FJ08', + H9: 'FJ08', '3E': 'FJ08', '3F': 'FJ08', - 'YS': 'EK53', - 'HU': 'EK53', - 'V3': 'EK56', + YS: 'EK53', + HU: 'EK53', + V3: 'EK56', // ============================================ // Caribbean // ============================================ - 'HI': 'FK49', - 'CO': 'FL10', - 'CM': 'FL10', - 'CL': 'FL10', - 'T4': 'FL10', + HI: 'FK49', + CO: 'FL10', + CM: 'FL10', + CL: 'FL10', + T4: 'FL10', '6Y': 'FK17', - 'VP5': 'FL31', - 'C6': 'FL06', - 'ZF': 'EK99', - 'V2': 'FK97', - 'J3': 'FK92', - 'J6': 'FK93', - 'J7': 'FK95', - 'J8': 'FK93', + VP5: 'FL31', + C6: 'FL06', + ZF: 'EK99', + V2: 'FK97', + J3: 'FK92', + J6: 'FK93', + J7: 'FK95', + J8: 'FK93', '8P': 'GK03', '9Y': 'FK90', - 'PJ2': 'FK52', - 'PJ4': 'FK52', - 'PJ5': 'FK87', - 'PJ6': 'FK87', - 'PJ7': 'FK88', - 'P4': 'FK52', - 'VP2E': 'FK88', - 'VP2M': 'FK96', - 'VP2V': 'FK77', - 'V4': 'FK87', - 'FG': 'FK96', - 'FM': 'FK94', - 'TO': 'FK94', - 'FS': 'FK88', - 'FJ': 'GK08', - 'HH': 'FK38', + PJ2: 'FK52', + PJ4: 'FK52', + PJ5: 'FK87', + PJ6: 'FK87', + PJ7: 'FK88', + P4: 'FK52', + VP2E: 'FK88', + VP2M: 'FK96', + VP2V: 'FK77', + V4: 'FK87', + FG: 'FK96', + FM: 'FK94', + TO: 'FK94', + FS: 'FK88', + FJ: 'GK08', + HH: 'FK38', // ============================================ // South America // ============================================ - 'LU': 'GF05', - 'LW': 'GF05', - 'LO': 'GF05', - 'LR': 'GF05', - 'LT': 'GF05', - 'AY': 'GF05', - 'AZ': 'GF05', - 'L1': 'GF05', - 'L2': 'GF05', - 'L3': 'GF05', - 'L4': 'GF05', - 'L5': 'GF05', - 'L6': 'GF05', - 'L7': 'GF05', - 'L8': 'GF05', - 'L9': 'GF05', - 'PY': 'GG87', - 'PP': 'GG87', - 'PQ': 'GG87', - 'PR': 'GG87', - 'PS': 'GG87', - 'PT': 'GG87', - 'PU': 'GG87', - 'PV': 'GG87', - 'PW': 'GG87', - 'PX': 'GG87', - 'ZV': 'GG87', - 'ZW': 'GG87', - 'ZX': 'GG87', - 'ZY': 'GG87', - 'ZZ': 'GG87', - 'CE': 'FF46', - 'CA': 'FF46', - 'CB': 'FF46', - 'CC': 'FF46', - 'CD': 'FF46', - 'XQ': 'FF46', - 'XR': 'FF46', + LU: 'GF05', + LW: 'GF05', + LO: 'GF05', + LR: 'GF05', + LT: 'GF05', + AY: 'GF05', + AZ: 'GF05', + L1: 'GF05', + L2: 'GF05', + L3: 'GF05', + L4: 'GF05', + L5: 'GF05', + L6: 'GF05', + L7: 'GF05', + L8: 'GF05', + L9: 'GF05', + PY: 'GG87', + PP: 'GG87', + PQ: 'GG87', + PR: 'GG87', + PS: 'GG87', + PT: 'GG87', + PU: 'GG87', + PV: 'GG87', + PW: 'GG87', + PX: 'GG87', + ZV: 'GG87', + ZW: 'GG87', + ZX: 'GG87', + ZY: 'GG87', + ZZ: 'GG87', + CE: 'FF46', + CA: 'FF46', + CB: 'FF46', + CC: 'FF46', + CD: 'FF46', + XQ: 'FF46', + XR: 'FF46', '3G': 'FF46', - 'CE0Y': 'DG52', - 'CE0Z': 'FE49', - 'CE0X': 'FG14', - 'CX': 'GF15', - 'CV': 'GF15', - 'HC': 'FI09', - 'HD': 'FI09', - 'HC8': 'EI49', - 'OA': 'FH17', - 'OB': 'FH17', - 'OC': 'FH17', + CE0Y: 'DG52', + CE0Z: 'FE49', + CE0X: 'FG14', + CX: 'GF15', + CV: 'GF15', + HC: 'FI09', + HD: 'FI09', + HC8: 'EI49', + OA: 'FH17', + OB: 'FH17', + OC: 'FH17', '4T': 'FH17', - 'HK': 'FJ35', - 'HJ': 'FJ35', + HK: 'FJ35', + HJ: 'FJ35', '5J': 'FJ35', '5K': 'FJ35', - 'HK0': 'FJ55', - 'HK0M': 'EJ96', - 'YV': 'FK60', - 'YW': 'FK60', - 'YX': 'FK60', - 'YY': 'FK60', + HK0: 'FJ55', + HK0M: 'EJ96', + YV: 'FK60', + YW: 'FK60', + YX: 'FK60', + YY: 'FK60', '4M': 'FK60', - 'YV0': 'FK53', - 'CP': 'FH64', + YV0: 'FK53', + CP: 'FH64', '8R': 'GJ24', - 'PZ': 'GJ25', - 'FY': 'GJ34', - 'VP8': 'GD18', - 'VP8F': 'GD18', - 'VP8G': 'IC16', - 'VP8H': 'GC17', - 'VP8O': 'GC06', - 'VP8S': 'GC06', + PZ: 'GJ25', + FY: 'GJ34', + VP8: 'GD18', + VP8F: 'GD18', + VP8G: 'IC16', + VP8H: 'GC17', + VP8O: 'GC06', + VP8S: 'GC06', // ============================================ // Europe - UK & Ireland // ============================================ - 'G': 'IO91', - 'M': 'IO91', + G: 'IO91', + M: 'IO91', '2E': 'IO91', - 'GW': 'IO81', - 'MW': 'IO81', + GW: 'IO81', + MW: 'IO81', '2W': 'IO81', - 'GM': 'IO85', - 'MM': 'IO85', + GM: 'IO85', + MM: 'IO85', '2M': 'IO85', - 'GI': 'IO64', - 'MI': 'IO64', + GI: 'IO64', + MI: 'IO64', '2I': 'IO64', - 'GD': 'IO74', - 'MD': 'IO74', + GD: 'IO74', + MD: 'IO74', '2D': 'IO74', - 'GJ': 'IN89', - 'MJ': 'IN89', + GJ: 'IN89', + MJ: 'IN89', '2J': 'IN89', - 'GU': 'IN89', - 'MU': 'IN89', + GU: 'IN89', + MU: 'IN89', '2U': 'IN89', - 'EI': 'IO63', - 'EJ': 'IO63', + EI: 'IO63', + EJ: 'IO63', // ============================================ // Europe - Germany // ============================================ - 'DL': 'JO51', - 'DJ': 'JO51', - 'DK': 'JO51', - 'DA': 'JO51', - 'DB': 'JO51', - 'DC': 'JO51', - 'DD': 'JO51', - 'DF': 'JO51', - 'DG': 'JO51', - 'DH': 'JO51', - 'DM': 'JO51', - 'DO': 'JO51', - 'DP': 'JO51', - 'DQ': 'JO51', - 'DR': 'JO51', + DL: 'JO51', + DJ: 'JO51', + DK: 'JO51', + DA: 'JO51', + DB: 'JO51', + DC: 'JO51', + DD: 'JO51', + DF: 'JO51', + DG: 'JO51', + DH: 'JO51', + DM: 'JO51', + DO: 'JO51', + DP: 'JO51', + DQ: 'JO51', + DR: 'JO51', // ============================================ // Europe - France & territories // ============================================ - 'F': 'JN18', - 'TM': 'JN18', + F: 'JN18', + TM: 'JN18', // ============================================ // Europe - Italy // ============================================ - 'I': 'JN61', - 'IK': 'JN45', - 'IZ': 'JN61', - 'IW': 'JN61', - 'IU': 'JN61', + I: 'JN61', + IK: 'JN45', + IZ: 'JN61', + IW: 'JN61', + IU: 'JN61', // ============================================ // Europe - Spain & Portugal // ============================================ - 'EA': 'IN80', - 'EC': 'IN80', - 'EB': 'IN80', - 'ED': 'IN80', - 'EE': 'IN80', - 'EF': 'IN80', - 'EG': 'IN80', - 'EH': 'IN80', - 'EA6': 'JM19', - 'EC6': 'JM19', - 'EA8': 'IL18', - 'EC8': 'IL18', - 'EA9': 'IM75', - 'EC9': 'IM75', - 'CT': 'IM58', - 'CQ': 'IM58', - 'CS': 'IM58', - 'CT3': 'IM12', - 'CQ3': 'IM12', - 'CU': 'HM68', + EA: 'IN80', + EC: 'IN80', + EB: 'IN80', + ED: 'IN80', + EE: 'IN80', + EF: 'IN80', + EG: 'IN80', + EH: 'IN80', + EA6: 'JM19', + EC6: 'JM19', + EA8: 'IL18', + EC8: 'IL18', + EA9: 'IM75', + EC9: 'IM75', + CT: 'IM58', + CQ: 'IM58', + CS: 'IM58', + CT3: 'IM12', + CQ3: 'IM12', + CU: 'HM68', // ============================================ // Europe - Benelux // ============================================ - 'PA': 'JO21', - 'PD': 'JO21', - 'PE': 'JO21', - 'PF': 'JO21', - 'PG': 'JO21', - 'PH': 'JO21', - 'PI': 'JO21', - 'ON': 'JO20', - 'OO': 'JO20', - 'OP': 'JO20', - 'OQ': 'JO20', - 'OR': 'JO20', - 'OS': 'JO20', - 'OT': 'JO20', - 'LX': 'JN39', + PA: 'JO21', + PD: 'JO21', + PE: 'JO21', + PF: 'JO21', + PG: 'JO21', + PH: 'JO21', + PI: 'JO21', + ON: 'JO20', + OO: 'JO20', + OP: 'JO20', + OQ: 'JO20', + OR: 'JO20', + OS: 'JO20', + OT: 'JO20', + LX: 'JN39', // ============================================ // Europe - Alpine // ============================================ - 'HB': 'JN47', - 'HB9': 'JN47', - 'HE': 'JN47', - 'HB0': 'JN47', - 'OE': 'JN78', + HB: 'JN47', + HB9: 'JN47', + HE: 'JN47', + HB0: 'JN47', + OE: 'JN78', // ============================================ // Europe - Scandinavia // ============================================ - 'OZ': 'JO55', - 'OU': 'JO55', - 'OV': 'JO55', + OZ: 'JO55', + OU: 'JO55', + OV: 'JO55', '5P': 'JO55', '5Q': 'JO55', - 'OX': 'GP47', - 'XP': 'GP47', - 'SM': 'JO89', - 'SA': 'JO89', - 'SB': 'JO89', - 'SC': 'JO89', - 'SD': 'JO89', - 'SE': 'JO89', - 'SF': 'JO89', - 'SG': 'JO89', - 'SH': 'JO89', - 'SI': 'JO89', - 'SJ': 'JO89', - 'SK': 'JO89', - 'SL': 'JO89', + OX: 'GP47', + XP: 'GP47', + SM: 'JO89', + SA: 'JO89', + SB: 'JO89', + SC: 'JO89', + SD: 'JO89', + SE: 'JO89', + SF: 'JO89', + SG: 'JO89', + SH: 'JO89', + SI: 'JO89', + SJ: 'JO89', + SK: 'JO89', + SL: 'JO89', '7S': 'JO89', '8S': 'JO89', - 'LA': 'JO59', - 'LB': 'JO59', - 'LC': 'JO59', - 'LD': 'JO59', - 'LE': 'JO59', - 'LF': 'JO59', - 'LG': 'JO59', - 'LH': 'JO59', - 'LI': 'JO59', - 'LJ': 'JO59', - 'LK': 'JO59', - 'LL': 'JO59', - 'LM': 'JO59', - 'LN': 'JO59', - 'JW': 'JQ68', - 'JX': 'IQ50', - 'OH': 'KP20', - 'OF': 'KP20', - 'OG': 'KP20', - 'OI': 'KP20', - 'OH0': 'JP90', - 'OJ0': 'KP03', - 'TF': 'HP94', + LA: 'JO59', + LB: 'JO59', + LC: 'JO59', + LD: 'JO59', + LE: 'JO59', + LF: 'JO59', + LG: 'JO59', + LH: 'JO59', + LI: 'JO59', + LJ: 'JO59', + LK: 'JO59', + LL: 'JO59', + LM: 'JO59', + LN: 'JO59', + JW: 'JQ68', + JX: 'IQ50', + OH: 'KP20', + OF: 'KP20', + OG: 'KP20', + OI: 'KP20', + OH0: 'JP90', + OJ0: 'KP03', + TF: 'HP94', // ============================================ // Europe - Eastern // ============================================ - 'SP': 'JO91', - 'SQ': 'JO91', - 'SO': 'JO91', - 'SN': 'JO91', + SP: 'JO91', + SQ: 'JO91', + SO: 'JO91', + SN: 'JO91', '3Z': 'JO91', - 'HF': 'JO91', - 'OK': 'JN79', - 'OL': 'JN79', - 'OM': 'JN88', - 'HA': 'JN97', - 'HG': 'JN97', - 'YO': 'KN34', - 'YP': 'KN34', - 'YQ': 'KN34', - 'YR': 'KN34', - 'LZ': 'KN22', - 'SV': 'KM17', - 'SX': 'KM17', - 'SY': 'KM17', - 'SZ': 'KM17', - 'J4': 'KM17', - 'SV5': 'KM46', - 'SV9': 'KM25', + HF: 'JO91', + OK: 'JN79', + OL: 'JN79', + OM: 'JN88', + HA: 'JN97', + HG: 'JN97', + YO: 'KN34', + YP: 'KN34', + YQ: 'KN34', + YR: 'KN34', + LZ: 'KN22', + SV: 'KM17', + SX: 'KM17', + SY: 'KM17', + SZ: 'KM17', + J4: 'KM17', + SV5: 'KM46', + SV9: 'KM25', 'SV/A': 'KN10', '9H': 'JM75', - 'YU': 'KN04', - 'YT': 'KN04', - 'YZ': 'KN04', + YU: 'KN04', + YT: 'KN04', + YZ: 'KN04', '9A': 'JN75', - 'S5': 'JN76', - 'E7': 'JN84', - 'Z3': 'KN01', + S5: 'JN76', + E7: 'JN84', + Z3: 'KN01', '4O': 'JN92', - 'ZA': 'JN91', - 'T7': 'JN63', - 'HV': 'JN61', + ZA: 'JN91', + T7: 'JN63', + HV: 'JN61', '1A': 'JM64', // ============================================ // Europe - Baltic // ============================================ - 'LY': 'KO24', - 'ES': 'KO29', - 'YL': 'KO26', + LY: 'KO24', + ES: 'KO29', + YL: 'KO26', // ============================================ // Russia & Ukraine & Belarus // ============================================ - 'UA': 'KO85', - 'RA': 'KO85', - 'RU': 'KO85', - 'RV': 'KO85', - 'RW': 'KO85', - 'RX': 'KO85', - 'RZ': 'KO85', - 'R1': 'KO85', - 'R2': 'KO85', - 'R3': 'KO85', - 'R4': 'KO85', - 'R5': 'KO85', - 'R6': 'KO85', - 'U1': 'KO85', - 'U2': 'KO85', - 'U3': 'KO85', - 'U4': 'KO85', - 'U5': 'KO85', - 'U6': 'KO85', - 'UA9': 'MO06', - 'RA9': 'MO06', - 'R9': 'MO06', - 'U9': 'MO06', - 'UA0': 'OO33', - 'RA0': 'OO33', - 'R0': 'OO33', - 'U0': 'OO33', - 'UA2': 'KO04', - 'RA2': 'KO04', - 'R2F': 'KO04', - 'UR': 'KO50', - 'UT': 'KO50', - 'UX': 'KO50', - 'US': 'KO50', - 'UY': 'KO50', - 'UW': 'KO50', - 'UV': 'KO50', - 'UU': 'KO50', - 'EU': 'KO33', - 'EV': 'KO33', - 'EW': 'KO33', - 'ER': 'KN47', - 'C3': 'JN02', + UA: 'KO85', + RA: 'KO85', + RU: 'KO85', + RV: 'KO85', + RW: 'KO85', + RX: 'KO85', + RZ: 'KO85', + R1: 'KO85', + R2: 'KO85', + R3: 'KO85', + R4: 'KO85', + R5: 'KO85', + R6: 'KO85', + U1: 'KO85', + U2: 'KO85', + U3: 'KO85', + U4: 'KO85', + U5: 'KO85', + U6: 'KO85', + UA9: 'MO06', + RA9: 'MO06', + R9: 'MO06', + U9: 'MO06', + UA0: 'OO33', + RA0: 'OO33', + R0: 'OO33', + U0: 'OO33', + UA2: 'KO04', + RA2: 'KO04', + R2F: 'KO04', + UR: 'KO50', + UT: 'KO50', + UX: 'KO50', + US: 'KO50', + UY: 'KO50', + UW: 'KO50', + UV: 'KO50', + UU: 'KO50', + EU: 'KO33', + EV: 'KO33', + EW: 'KO33', + ER: 'KN47', + C3: 'JN02', // ============================================ // Asia - Japan // ============================================ - 'JA': 'PM95', - 'JH': 'PM95', - 'JR': 'PM95', - 'JE': 'PM95', - 'JF': 'PM95', - 'JG': 'PM95', - 'JI': 'PM95', - 'JJ': 'PM95', - 'JK': 'PM95', - 'JL': 'PM95', - 'JM': 'PM95', - 'JN': 'PM95', - 'JO': 'PM95', - 'JP': 'PM95', - 'JQ': 'PM95', - 'JS': 'PM95', + JA: 'PM95', + JH: 'PM95', + JR: 'PM95', + JE: 'PM95', + JF: 'PM95', + JG: 'PM95', + JI: 'PM95', + JJ: 'PM95', + JK: 'PM95', + JL: 'PM95', + JM: 'PM95', + JN: 'PM95', + JO: 'PM95', + JP: 'PM95', + JQ: 'PM95', + JS: 'PM95', '7J': 'PM95', '7K': 'PM95', '7L': 'PM95', @@ -5336,68 +4847,68 @@ function estimateLocationFromPrefix(callsign) { '8L': 'PM95', '8M': 'PM95', '8N': 'PM95', - 'JA1': 'PM95', - 'JA2': 'PM84', - 'JA3': 'PM74', - 'JA4': 'PM64', - 'JA5': 'PM63', - 'JA6': 'PM53', - 'JA7': 'QM07', - 'JA8': 'QN02', - 'JA9': 'PM86', - 'JA0': 'PM97', - 'JD1': 'QL07', + JA1: 'PM95', + JA2: 'PM84', + JA3: 'PM74', + JA4: 'PM64', + JA5: 'PM63', + JA6: 'PM53', + JA7: 'QM07', + JA8: 'QN02', + JA9: 'PM86', + JA0: 'PM97', + JD1: 'QL07', // ============================================ // Asia - China & Taiwan & Hong Kong // ============================================ - 'BY': 'OM92', - 'BT': 'OM92', - 'BA': 'OM92', - 'BD': 'OM92', - 'BG': 'OM92', - 'BH': 'OM92', - 'BI': 'OM92', - 'BJ': 'OM92', - 'BL': 'OM92', - 'BM': 'OM92', - 'BO': 'OM92', - 'BP': 'OM92', - 'BQ': 'OM92', - 'BR': 'OM92', - 'BS': 'OM92', - 'BU': 'OM92', - 'BV': 'PL04', - 'BW': 'PL04', - 'BX': 'PL04', - 'BN': 'PL04', - 'XX9': 'OL62', - 'VR': 'OL62', + BY: 'OM92', + BT: 'OM92', + BA: 'OM92', + BD: 'OM92', + BG: 'OM92', + BH: 'OM92', + BI: 'OM92', + BJ: 'OM92', + BL: 'OM92', + BM: 'OM92', + BO: 'OM92', + BP: 'OM92', + BQ: 'OM92', + BR: 'OM92', + BS: 'OM92', + BU: 'OM92', + BV: 'PL04', + BW: 'PL04', + BX: 'PL04', + BN: 'PL04', + XX9: 'OL62', + VR: 'OL62', // ============================================ // Asia - Korea // ============================================ - 'HL': 'PM37', - 'DS': 'PM37', + HL: 'PM37', + DS: 'PM37', '6K': 'PM37', '6L': 'PM37', '6M': 'PM37', '6N': 'PM37', - 'D7': 'PM37', - 'D8': 'PM37', - 'D9': 'PM37', - 'P5': 'PM38', + D7: 'PM37', + D8: 'PM37', + D9: 'PM37', + P5: 'PM38', // ============================================ // Asia - Southeast // ============================================ - 'HS': 'OK03', - 'E2': 'OK03', - 'XV': 'OK30', + HS: 'OK03', + E2: 'OK03', + XV: 'OK30', '3W': 'OK30', - 'XU': 'OK10', - 'XW': 'NK97', - 'XZ': 'NL99', + XU: 'OK10', + XW: 'NK97', + XZ: 'NL99', '1Z': 'NL99', '9V': 'OJ11', '9M': 'OJ05', @@ -5406,25 +4917,25 @@ function estimateLocationFromPrefix(callsign) { '9M8': 'OJ69', '9W6': 'OJ69', '9W8': 'OJ69', - 'DU': 'PK04', - 'DV': 'PK04', - 'DW': 'PK04', - 'DX': 'PK04', - 'DY': 'PK04', - 'DZ': 'PK04', + DU: 'PK04', + DV: 'PK04', + DW: 'PK04', + DX: 'PK04', + DY: 'PK04', + DZ: 'PK04', '4D': 'PK04', '4E': 'PK04', '4F': 'PK04', '4G': 'PK04', '4H': 'PK04', '4I': 'PK04', - 'YB': 'OI33', - 'YC': 'OI33', - 'YD': 'OI33', - 'YE': 'OI33', - 'YF': 'OI33', - 'YG': 'OI33', - 'YH': 'OI33', + YB: 'OI33', + YC: 'OI33', + YD: 'OI33', + YE: 'OI33', + YF: 'OI33', + YG: 'OI33', + YH: 'OI33', '7A': 'OI33', '7B': 'OI33', '7C': 'OI33', @@ -5443,170 +4954,170 @@ function estimateLocationFromPrefix(callsign) { '8G': 'OI33', '8H': 'OI33', '8I': 'OI33', - 'V8': 'OJ84', + V8: 'OJ84', // ============================================ // Asia - South // ============================================ - 'VU': 'MK82', - 'VU2': 'MK82', - 'VU3': 'MK82', - 'VU4': 'MJ97', - 'VU7': 'MJ58', + VU: 'MK82', + VU2: 'MK82', + VU3: 'MK82', + VU4: 'MJ97', + VU7: 'MJ58', '8T': 'MK82', '8U': 'MK82', '8V': 'MK82', '8W': 'MK82', '8X': 'MK82', '8Y': 'MK82', - 'AP': 'MM44', + AP: 'MM44', '4S': 'MJ96', - 'S2': 'NL93', + S2: 'NL93', '9N': 'NL27', - 'A5': 'NL49', + A5: 'NL49', '8Q': 'MJ63', // ============================================ // Asia - Middle East // ============================================ - 'A4': 'LL93', - 'A41': 'LL93', - 'A43': 'LL93', - 'A45': 'LL93', - 'A47': 'LL93', - 'A6': 'LL65', - 'A61': 'LL65', - 'A62': 'LL65', - 'A63': 'LL65', - 'A65': 'LL65', - 'A7': 'LL45', - 'A71': 'LL45', - 'A72': 'LL45', - 'A73': 'LL45', - 'A75': 'LL45', - 'A9': 'LL56', - 'A91': 'LL56', - 'A92': 'LL56', + A4: 'LL93', + A41: 'LL93', + A43: 'LL93', + A45: 'LL93', + A47: 'LL93', + A6: 'LL65', + A61: 'LL65', + A62: 'LL65', + A63: 'LL65', + A65: 'LL65', + A7: 'LL45', + A71: 'LL45', + A72: 'LL45', + A73: 'LL45', + A75: 'LL45', + A9: 'LL56', + A91: 'LL56', + A92: 'LL56', '9K': 'LL47', - 'HZ': 'LL24', + HZ: 'LL24', '7Z': 'LL24', '8Z': 'LL24', '4X': 'KM72', '4Z': 'KM72', - 'OD': 'KM73', - 'JY': 'KM71', - 'YK': 'KM74', - 'YI': 'LM30', - 'EP': 'LL58', - 'EQ': 'LL58', - 'EK': 'LN20', + OD: 'KM73', + JY: 'KM71', + YK: 'KM74', + YI: 'LM30', + EP: 'LL58', + EQ: 'LL58', + EK: 'LN20', '4J': 'LN40', '4K': 'LN40', '4L': 'LN21', - 'TA': 'KN41', - 'TB': 'KN41', - 'TC': 'KN41', - 'YM': 'KN41', - 'TA1': 'KN41', + TA: 'KN41', + TB: 'KN41', + TC: 'KN41', + YM: 'KN41', + TA1: 'KN41', '5B': 'KM64', - 'C4': 'KM64', - 'H2': 'KM64', - 'P3': 'KM64', - 'ZC4': 'KM64', + C4: 'KM64', + H2: 'KM64', + P3: 'KM64', + ZC4: 'KM64', // ============================================ // Asia - Central // ============================================ - 'EX': 'MM78', - 'EY': 'MM49', - 'EZ': 'LN71', - 'UK': 'MN41', - 'UN': 'MN53', - 'UP': 'MN53', - 'UQ': 'MN53', - 'YA': 'MM24', - 'T6': 'MM24', + EX: 'MM78', + EY: 'MM49', + EZ: 'LN71', + UK: 'MN41', + UN: 'MN53', + UP: 'MN53', + UQ: 'MN53', + YA: 'MM24', + T6: 'MM24', // ============================================ // Oceania - Australia // ============================================ - 'VK': 'QF56', - 'VK1': 'QF44', - 'VK2': 'QF56', - 'VK3': 'QF22', - 'VK4': 'QG62', - 'VK5': 'PF95', - 'VK6': 'OF86', - 'VK7': 'QE38', - 'VK8': 'PH57', - 'VK9': 'QF56', - 'VK9C': 'OH29', - 'VK9X': 'NH93', - 'VK9L': 'QF92', - 'VK9W': 'QG14', - 'VK9M': 'QG11', - 'VK9N': 'RF73', - 'VK0H': 'MC55', - 'VK0M': 'QE37', + VK: 'QF56', + VK1: 'QF44', + VK2: 'QF56', + VK3: 'QF22', + VK4: 'QG62', + VK5: 'PF95', + VK6: 'OF86', + VK7: 'QE38', + VK8: 'PH57', + VK9: 'QF56', + VK9C: 'OH29', + VK9X: 'NH93', + VK9L: 'QF92', + VK9W: 'QG14', + VK9M: 'QG11', + VK9N: 'RF73', + VK0H: 'MC55', + VK0M: 'QE37', // ============================================ // Oceania - New Zealand & Pacific // ============================================ - 'ZL': 'RF70', - 'ZL1': 'RF72', - 'ZL2': 'RF70', - 'ZL3': 'RE66', - 'ZL4': 'RE54', - 'ZM': 'RF70', - 'ZL7': 'AE67', - 'ZL8': 'AH36', - 'ZL9': 'RE44', - 'E5': 'BH83', - 'E51': 'BH83', - 'E52': 'AI38', - 'ZK3': 'AH89', - 'FK': 'RG37', - 'TX': 'RG37', + ZL: 'RF70', + ZL1: 'RF72', + ZL2: 'RF70', + ZL3: 'RE66', + ZL4: 'RE54', + ZM: 'RF70', + ZL7: 'AE67', + ZL8: 'AH36', + ZL9: 'RE44', + E5: 'BH83', + E51: 'BH83', + E52: 'AI38', + ZK3: 'AH89', + FK: 'RG37', + TX: 'RG37', 'FK/C': 'RH29', - 'FO': 'BH52', + FO: 'BH52', 'FO/A': 'CJ07', 'FO/C': 'CI06', 'FO/M': 'DI79', - 'FW': 'AH44', - 'A3': 'AG28', - 'A35': 'AG28', + FW: 'AH44', + A3: 'AG28', + A35: 'AG28', '5W': 'AH45', - 'YJ': 'RH31', - 'YJ0': 'RH31', - 'H4': 'RI07', - 'H44': 'RI07', - 'P2': 'QI24', - 'V6': 'QJ66', - 'V7': 'RJ48', - 'T8': 'PJ77', - 'T2': 'RI87', - 'T3': 'RI96', - 'T31': 'AI58', - 'T32': 'BI69', - 'T33': 'AJ25', - 'C2': 'QI32', + YJ: 'RH31', + YJ0: 'RH31', + H4: 'RI07', + H44: 'RI07', + P2: 'QI24', + V6: 'QJ66', + V7: 'RJ48', + T8: 'PJ77', + T2: 'RI87', + T3: 'RI96', + T31: 'AI58', + T32: 'BI69', + T33: 'AJ25', + C2: 'QI32', '3D2': 'RH91', '3D2C': 'QH38', '3D2R': 'RG26', - 'ZK2': 'AI48', - 'E6': 'AH28', + ZK2: 'AI48', + E6: 'AH28', // ============================================ // Africa - North // ============================================ - 'CN': 'IM63', + CN: 'IM63', '5C': 'IM63', '5D': 'IM63', '7X': 'JM16', '3V': 'JM54', - 'TS': 'JM54', + TS: 'JM54', '5A': 'JM73', - 'SU': 'KL30', + SU: 'KL30', '6A': 'KL30', // ============================================ @@ -5614,102 +5125,102 @@ function estimateLocationFromPrefix(callsign) { // ============================================ '5T': 'IL30', '6W': 'IK14', - 'C5': 'IK13', - 'J5': 'IK52', + C5: 'IK13', + J5: 'IK52', '3X': 'IJ75', '9L': 'IJ38', - 'EL': 'IJ56', - 'TU': 'IJ95', + EL: 'IJ56', + TU: 'IJ95', '9G': 'IJ95', '5V': 'JJ07', - 'TY': 'JJ16', + TY: 'JJ16', '5N': 'JJ55', '5U': 'JK16', - 'TZ': 'IK52', - 'XT': 'JJ00', - 'TJ': 'JJ55', - 'D4': 'HK76', + TZ: 'IK52', + XT: 'JJ00', + TJ: 'JJ55', + D4: 'HK76', // ============================================ // Africa - Central // ============================================ - 'TT': 'JK73', - 'TN': 'JI64', + TT: 'JK73', + TN: 'JI64', '9Q': 'JI76', - 'TL': 'JJ91', - 'TR': 'JI41', - 'S9': 'JJ40', + TL: 'JJ91', + TR: 'JI41', + S9: 'JJ40', '3C': 'JJ41', - 'D2': 'JH84', + D2: 'JH84', // ============================================ // Africa - East // ============================================ - 'ET': 'KJ49', - 'E3': 'KJ76', + ET: 'KJ49', + E3: 'KJ76', '6O': 'LJ07', - 'T5': 'LJ07', - 'J2': 'LK03', + T5: 'LJ07', + J2: 'LK03', '5Z': 'KI88', '5X': 'KI42', '5H': 'KI73', '9X': 'KI45', '9U': 'KI23', - 'C9': 'KH53', + C9: 'KH53', '7Q': 'KH54', '9J': 'KH35', - 'Z2': 'KH42', + Z2: 'KH42', '7P': 'KG30', '3DA': 'KG53', - 'A2': 'KG52', - 'V5': 'JG87', + A2: 'KG52', + V5: 'JG87', // ============================================ // Africa - South // ============================================ - 'ZS': 'KG33', - 'ZR': 'KG33', - 'ZT': 'KG33', - 'ZU': 'KG33', - 'ZS8': 'KG42', + ZS: 'KG33', + ZR: 'KG33', + ZT: 'KG33', + ZU: 'KG33', + ZS8: 'KG42', '3Y': 'JD45', // ============================================ // Africa - Islands // ============================================ - 'D6': 'LH47', + D6: 'LH47', '5R': 'LH45', '3B8': 'LG89', '3B9': 'LH14', '3B6': 'LH28', - 'S7': 'LI73', - 'FT5W': 'KG42', - 'FT5X': 'MC55', - 'FT5Z': 'ME47', - 'FR': 'LG79', - 'FH': 'LI15', - 'VQ9': 'MJ66', + S7: 'LI73', + FT5W: 'KG42', + FT5X: 'MC55', + FT5Z: 'ME47', + FR: 'LG79', + FH: 'LI15', + VQ9: 'MJ66', // ============================================ // Antarctica // ============================================ - 'CE9': 'FC56', - 'DP0': 'IB59', - 'DP1': 'IB59', - 'KC4': 'FC56', + CE9: 'FC56', + DP0: 'IB59', + DP1: 'IB59', + KC4: 'FC56', '8J1': 'LC97', - 'R1AN': 'KC29', - 'ZL5': 'RB32', + R1AN: 'KC29', + ZL5: 'RB32', // ============================================ // Other/Islands // ============================================ - 'ZB': 'IM76', - 'ZD7': 'IH74', - 'ZD8': 'II22', - 'ZD9': 'JE26', + ZB: 'IM76', + ZD7: 'IH74', + ZD8: 'II22', + ZD9: 'JE26', '9M0': 'NJ07', - 'BQ9': 'PJ29', + BQ9: 'PJ29', }; const upper = callsign.toUpperCase(); @@ -5761,9 +5272,7 @@ function estimateLocationFromPrefix(callsign) { lon: gridLoc.lon, grid: grid, country: - territoryPrefix3.startsWith('KP') || - territoryPrefix3.startsWith('NP') || - territoryPrefix3.startsWith('WP') + territoryPrefix3.startsWith('KP') || territoryPrefix3.startsWith('NP') || territoryPrefix3.startsWith('WP') ? 'Puerto Rico/USVI' : territoryPrefix3.startsWith('KH') || territoryPrefix3.startsWith('NH') || @@ -5801,10 +5310,7 @@ function estimateLocationFromPrefix(callsign) { 9: 'EN52', // IL, IN, WI }; - const grid = - district && usDistrictGrids[district] - ? usDistrictGrids[district] - : 'EM79'; + const grid = district && usDistrictGrids[district] ? usDistrictGrids[district] : 'EM79'; const gridLoc = maidenheadToLatLon(grid); if (gridLoc) { return { @@ -5903,101 +5409,101 @@ function estimateLocationFromPrefix(callsign) { // Helper to get country name from prefix function getCountryFromPrefix(prefix) { const prefixCountries = { - 'W': 'USA', - 'K': 'USA', - 'N': 'USA', - 'AA': 'USA', - 'KP4': 'Puerto Rico', - 'NP4': 'Puerto Rico', - 'WP4': 'Puerto Rico', - 'KP2': 'US Virgin Is', - 'NP2': 'US Virgin Is', - 'WP2': 'US Virgin Is', - 'KH6': 'Hawaii', - 'NH6': 'Hawaii', - 'WH6': 'Hawaii', - 'KH2': 'Guam', - 'KL7': 'Alaska', - 'NL7': 'Alaska', - 'WL7': 'Alaska', - 'VE': 'Canada', - 'VA': 'Canada', - 'VY': 'Canada', - 'VO': 'Canada', - 'G': 'England', - 'M': 'England', + W: 'USA', + K: 'USA', + N: 'USA', + AA: 'USA', + KP4: 'Puerto Rico', + NP4: 'Puerto Rico', + WP4: 'Puerto Rico', + KP2: 'US Virgin Is', + NP2: 'US Virgin Is', + WP2: 'US Virgin Is', + KH6: 'Hawaii', + NH6: 'Hawaii', + WH6: 'Hawaii', + KH2: 'Guam', + KL7: 'Alaska', + NL7: 'Alaska', + WL7: 'Alaska', + VE: 'Canada', + VA: 'Canada', + VY: 'Canada', + VO: 'Canada', + G: 'England', + M: 'England', '2E': 'England', - 'GM': 'Scotland', - 'GW': 'Wales', - 'GI': 'N. Ireland', - 'EI': 'Ireland', - 'F': 'France', - 'DL': 'Germany', - 'I': 'Italy', - 'EA': 'Spain', - 'CT': 'Portugal', - 'PA': 'Netherlands', - 'ON': 'Belgium', - 'HB': 'Switzerland', - 'OE': 'Austria', - 'OZ': 'Denmark', - 'SM': 'Sweden', - 'LA': 'Norway', - 'OH': 'Finland', - 'SP': 'Poland', - 'OK': 'Czech Rep', - 'HA': 'Hungary', - 'YO': 'Romania', - 'LZ': 'Bulgaria', - 'UA': 'Russia', - 'UR': 'Ukraine', - 'JA': 'Japan', - 'HL': 'S. Korea', - 'BV': 'Taiwan', - 'BY': 'China', - 'VU': 'India', - 'HS': 'Thailand', - 'VK': 'Australia', - 'ZL': 'New Zealand', - 'LU': 'Argentina', - 'PY': 'Brazil', - 'ZV': 'Brazil', - 'ZW': 'Brazil', - 'ZX': 'Brazil', - 'ZY': 'Brazil', - 'ZZ': 'Brazil', - 'CE': 'Chile', - 'HK': 'Colombia', - 'YV': 'Venezuela', - 'HC': 'Ecuador', - 'OA': 'Peru', - 'CX': 'Uruguay', - 'ZS': 'South Africa', - 'CN': 'Morocco', - 'SU': 'Egypt', + GM: 'Scotland', + GW: 'Wales', + GI: 'N. Ireland', + EI: 'Ireland', + F: 'France', + DL: 'Germany', + I: 'Italy', + EA: 'Spain', + CT: 'Portugal', + PA: 'Netherlands', + ON: 'Belgium', + HB: 'Switzerland', + OE: 'Austria', + OZ: 'Denmark', + SM: 'Sweden', + LA: 'Norway', + OH: 'Finland', + SP: 'Poland', + OK: 'Czech Rep', + HA: 'Hungary', + YO: 'Romania', + LZ: 'Bulgaria', + UA: 'Russia', + UR: 'Ukraine', + JA: 'Japan', + HL: 'S. Korea', + BV: 'Taiwan', + BY: 'China', + VU: 'India', + HS: 'Thailand', + VK: 'Australia', + ZL: 'New Zealand', + LU: 'Argentina', + PY: 'Brazil', + ZV: 'Brazil', + ZW: 'Brazil', + ZX: 'Brazil', + ZY: 'Brazil', + ZZ: 'Brazil', + CE: 'Chile', + HK: 'Colombia', + YV: 'Venezuela', + HC: 'Ecuador', + OA: 'Peru', + CX: 'Uruguay', + ZS: 'South Africa', + CN: 'Morocco', + SU: 'Egypt', '5N': 'Nigeria', '5Z': 'Kenya', - 'ET': 'Ethiopia', - 'TY': 'Benin', - 'TU': 'Ivory Coast', - 'TR': 'Gabon', - 'TZ': 'Mali', - 'V5': 'Namibia', - 'A2': 'Botswana', - 'JY': 'Jordan', - 'HZ': 'Saudi Arabia', - 'A6': 'UAE', - 'A7': 'Qatar', - 'A9': 'Bahrain', - 'A4': 'Oman', + ET: 'Ethiopia', + TY: 'Benin', + TU: 'Ivory Coast', + TR: 'Gabon', + TZ: 'Mali', + V5: 'Namibia', + A2: 'Botswana', + JY: 'Jordan', + HZ: 'Saudi Arabia', + A6: 'UAE', + A7: 'Qatar', + A9: 'Bahrain', + A4: 'Oman', '4X': 'Israel', - 'OD': 'Lebanon', - 'YK': 'Syria', - 'YI': 'Iraq', - 'EP': 'Iran', - 'TA': 'Turkey', + OD: 'Lebanon', + YK: 'Syria', + YI: 'Iraq', + EP: 'Iran', + TA: 'Turkey', '5B': 'Cyprus', - 'EK': 'Armenia', + EK: 'Armenia', '4J': 'Azerbaijan', }; @@ -6049,13 +5555,10 @@ app.get('/api/myspots/:callsign', async (req, res) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); - const response = await fetch( - `https://www.hamqth.com/dxc_csv.php?limit=100`, - { - headers: { 'User-Agent': 'OpenHamClock/3.13.1' }, - signal: controller.signal, - }, - ); + const response = await fetch(`https://www.hamqth.com/dxc_csv.php?limit=100`, { + headers: { 'User-Agent': 'OpenHamClock/3.13.1' }, + signal: controller.signal, + }); clearTimeout(timeout); if (response.ok) { @@ -6074,12 +5577,7 @@ app.get('/api/myspots/:callsign', async (req, res) => { const timeStr = parts[4]?.trim() || ''; // Check if our callsign is involved (as spotter or spotted) - if ( - spotter === callsign || - dxCall === callsign || - spotter.includes(callsign) || - dxCall.includes(callsign) - ) { + if (spotter === callsign || dxCall === callsign || spotter.includes(callsign) || dxCall.includes(callsign)) { mySpots.push({ spotter, dxCall, @@ -6096,9 +5594,7 @@ app.get('/api/myspots/:callsign', async (req, res) => { logDebug('[My Spots] Found', mySpots.length, 'spots involving', callsign); // Now try to get locations for each unique callsign - const uniqueCalls = [ - ...new Set(mySpots.map((s) => (s.isMySpot ? s.dxCall : s.spotter))), - ]; + const uniqueCalls = [...new Set(mySpots.map((s) => (s.isMySpot ? s.dxCall : s.spotter)))]; const locations = {}; for (const rawCall of uniqueCalls.slice(0, 10)) { @@ -6217,10 +5713,7 @@ app.get('/api/pskreporter/config', (req, res) => { mqtt: { status: pskMqtt.connected ? 'connected' : 'disconnected', activeCallsigns: pskMqtt.subscribedCalls.size, - sseClients: [...pskMqtt.subscribers.values()].reduce( - (n, s) => n + s.size, - 0, - ), + sseClients: [...pskMqtt.subscribers.values()].reduce((n, s) => n + s.size, 0), }, info: 'Connect to /api/pskreporter/stream/:callsign for real-time spots via Server-Sent Events', }); @@ -6240,10 +5733,7 @@ app.get('/api/pskreporter/:callsign', async (req, res) => { mqtt: { status: pskMqtt.connected ? 'connected' : 'disconnected', activeCallsigns: pskMqtt.subscribedCalls.size, - sseClients: Array.from(pskMqtt.subscribers.values()).reduce( - (s, c) => s + c.size, - 0, - ), + sseClients: Array.from(pskMqtt.subscribers.values()).reduce((s, c) => s + c.size, 0), }, }); }); @@ -6295,9 +5785,7 @@ function pskMqttConnect() { } const clientId = `ohc_svr_${Math.random().toString(16).substr(2, 8)}`; - console.log( - `[PSK-MQTT] Connecting to mqtt.pskreporter.info as ${clientId}...`, - ); + console.log(`[PSK-MQTT] Connecting to mqtt.pskreporter.info as ${clientId}...`); const client = mqttLib.connect('wss://mqtt.pskreporter.info:1886/mqtt', { clientId, @@ -6330,9 +5818,7 @@ function pskMqttConnect() { if (err.message && err.message.includes('onnection closed')) return; console.error(`[PSK-MQTT] Batch subscribe error:`, err.message); } else { - console.log( - `[PSK-MQTT] Subscribed ${count} callsigns (${topics.length} topics)`, - ); + console.log(`[PSK-MQTT] Subscribed ${count} callsigns (${topics.length} topics)`); } }); } else { @@ -6378,16 +5864,13 @@ function pskMqttConnect() { lon: receiverLoc?.lon, direction: 'tx', }; - if (!pskMqtt.spotBuffer.has(scUpper)) - pskMqtt.spotBuffer.set(scUpper, []); + if (!pskMqtt.spotBuffer.has(scUpper)) pskMqtt.spotBuffer.set(scUpper, []); pskMqtt.spotBuffer.get(scUpper).push(txSpot); // Also add to recent spots (capped at insert time to prevent unbounded growth) - if (!pskMqtt.recentSpots.has(scUpper)) - pskMqtt.recentSpots.set(scUpper, []); + if (!pskMqtt.recentSpots.has(scUpper)) pskMqtt.recentSpots.set(scUpper, []); const scRecent = pskMqtt.recentSpots.get(scUpper); scRecent.push(txSpot); - if (scRecent.length > 250) - pskMqtt.recentSpots.set(scUpper, scRecent.slice(-200)); + if (scRecent.length > 250) pskMqtt.recentSpots.set(scUpper, scRecent.slice(-200)); } // Buffer for RX subscribers (rc is the callsign being tracked) @@ -6399,15 +5882,12 @@ function pskMqttConnect() { lon: senderLoc?.lon, direction: 'rx', }; - if (!pskMqtt.spotBuffer.has(rcUpper)) - pskMqtt.spotBuffer.set(rcUpper, []); + if (!pskMqtt.spotBuffer.has(rcUpper)) pskMqtt.spotBuffer.set(rcUpper, []); pskMqtt.spotBuffer.get(rcUpper).push(rxSpot); - if (!pskMqtt.recentSpots.has(rcUpper)) - pskMqtt.recentSpots.set(rcUpper, []); + if (!pskMqtt.recentSpots.has(rcUpper)) pskMqtt.recentSpots.set(rcUpper, []); const rcRecent = pskMqtt.recentSpots.get(rcUpper); rcRecent.push(rxSpot); - if (rcRecent.length > 250) - pskMqtt.recentSpots.set(rcUpper, rcRecent.slice(-200)); + if (rcRecent.length > 250) pskMqtt.recentSpots.set(rcUpper, rcRecent.slice(-200)); } } catch { pskMqtt.stats.messagesDropped++; @@ -6450,9 +5930,7 @@ function scheduleMqttReconnect() { ); // Log first attempt and every 5th to avoid spam during extended outages if (pskMqtt.reconnectAttempts === 1 || pskMqtt.reconnectAttempts % 5 === 0) { - console.log( - `[PSK-MQTT] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${pskMqtt.reconnectAttempts})...`, - ); + console.log(`[PSK-MQTT] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${pskMqtt.reconnectAttempts})...`); } pskMqtt.reconnectTimer = setTimeout(() => { pskMqtt.reconnectTimer = null; @@ -6566,7 +6044,7 @@ app.get('/api/pskreporter/stream/:callsign', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'X-Accel-Buffering': 'no', 'Content-Encoding': 'identity', }); @@ -6597,10 +6075,7 @@ app.get('/api/pskreporter/stream/:callsign', (req, res) => { subscribeCallsign(callsign); } // Start MQTT connection if not already connected - if ( - !pskMqtt.client || - (!pskMqtt.connected && pskMqtt.reconnectAttempts === 0) - ) { + if (!pskMqtt.client || (!pskMqtt.connected && pskMqtt.reconnectAttempts === 0)) { pskMqttConnect(); } } @@ -6625,9 +6100,7 @@ app.get('/api/pskreporter/stream/:callsign', (req, res) => { const clients = pskMqtt.subscribers.get(callsign); if (clients) { clients.delete(res); - console.log( - `[PSK-MQTT] SSE client disconnected for ${callsign} (${clients.size} remaining)`, - ); + console.log(`[PSK-MQTT] SSE client disconnected for ${callsign} (${clients.size} remaining)`); // If no more clients for this callsign, unsubscribe after a grace period if (clients.size === 0) { @@ -6640,15 +6113,11 @@ app.get('/api/pskreporter/stream/:callsign', (req, res) => { pskMqtt.recentSpots.delete(callsign); pskMqtt.spotBuffer.delete(callsign); unsubscribeCallsign(callsign); - console.log( - `[PSK-MQTT] Unsubscribed ${callsign} (no more clients after grace period)`, - ); + console.log(`[PSK-MQTT] Unsubscribed ${callsign} (no more clients after grace period)`); // If no subscribers at all, disconnect MQTT entirely if (pskMqtt.subscribedCalls.size === 0 && pskMqtt.client) { - console.log( - '[PSK-MQTT] No more subscribers, disconnecting from broker', - ); + console.log('[PSK-MQTT] No more subscribers, disconnecting from broker'); // Cancel any pending reconnect if (pskMqtt.reconnectTimer) { clearTimeout(pskMqtt.reconnectTimer); @@ -6693,9 +6162,7 @@ function latLonToGrid(lat, lon) { // Subsquare (2 chars): 5' lon x 2.5' lat const subsq1 = String.fromCharCode(65 + Math.floor(((adjLon % 2) * 60) / 5)); - const subsq2 = String.fromCharCode( - 65 + Math.floor(((adjLat % 1) * 60) / 2.5), - ); + const subsq2 = String.fromCharCode(65 + Math.floor(((adjLat % 1) * 60) / 2.5)); return `${field1}${field2}${square1}${square2}${subsq1}${subsq2}`.toUpperCase(); } @@ -6713,10 +6180,7 @@ const callsignLocationCache = new Map(); // Cache for skimmer/station locations const LOCATION_CACHE_MAX = 2000; // ~1000 active RBN skimmers worldwide, 2x headroom function cacheCallsignLocation(call, data) { - if ( - callsignLocationCache.size >= LOCATION_CACHE_MAX && - !callsignLocationCache.has(call) - ) { + if (callsignLocationCache.size >= LOCATION_CACHE_MAX && !callsignLocationCache.has(call)) { const oldest = callsignLocationCache.keys().next().value; if (oldest) callsignLocationCache.delete(oldest); } @@ -6747,9 +6211,7 @@ function maintainRBNConnection(port = 7000) { return; // Already connected } - console.log( - `[RBN] Creating persistent connection to telnet.reversebeacon.net:${port}...`, - ); + console.log(`[RBN] Creating persistent connection to telnet.reversebeacon.net:${port}...`); let dataBuffer = ''; let authenticated = false; @@ -6796,9 +6258,7 @@ function maintainRBNConnection(port = 7000) { // CW: DX de W3LPL-#: 7003.0 K3LR CW 30 dB 23 WPM CQ 0123Z // FT8: DX de KM3T-#: 14074.0 K3LR FT8 -12 dB CQ 0123Z // RTTY: DX de W3LPL-#: 14080.0 K3LR RTTY 15 dB 45 BPS CQ 0123Z - const spotMatch = line.match( - /DX de\s+(\S+)\s*:\s*([\d.]+)\s+(\S+)\s+(\S+)\s+([-\d]+)\s+dB/, - ); + const spotMatch = line.match(/DX de\s+(\S+)\s*:\s*([\d.]+)\s+(\S+)\s+(\S+)\s+([-\d]+)\s+dB/); if (spotMatch) { const [, skimmer, freq, dx, mode, snr] = spotMatch; @@ -6887,9 +6347,7 @@ setInterval(() => { } } if (cleaned > 0) { - console.log( - `[RBN] Cleanup: removed ${cleaned} expired spots, tracking ${rbnSpotsByDX.size} DX stations`, - ); + console.log(`[RBN] Cleanup: removed ${cleaned} expired spots, tracking ${rbnSpotsByDX.size} DX stations`); } // Also purge expired rbnApiCaches entries (10s TTL, but entries never removed otherwise) const apiCutoff = Date.now() - 60000; // Keep entries under 1 minute (6x the 10s TTL) @@ -6917,12 +6375,10 @@ async function enrichSpotWithLocation(spot) { // Lookup location (don't block on failures) try { - const response = await fetch( - `http://localhost:${PORT}/api/callsign/${skimmerCall}`, - ); + const response = await fetch(`http://localhost:${PORT}/api/callsign/${skimmerCall}`); if (response.ok) { const locationData = await response.json(); - + // Verify the API returned data for the callsign we asked for // (guards against any response mix-up or redirect) const returnedCall = (locationData.callsign || '').toUpperCase(); @@ -6931,11 +6387,14 @@ async function enrichSpotWithLocation(spot) { console.warn(`[RBN] Callsign mismatch! Requested: ${skimmerCall}, Got: ${returnedCall} — discarding`); return spot; } - + // Validate coordinates are reasonable - if (typeof locationData.lat === 'number' && typeof locationData.lon === 'number' && - Math.abs(locationData.lat) <= 90 && Math.abs(locationData.lon) <= 180) { - + if ( + typeof locationData.lat === 'number' && + typeof locationData.lon === 'number' && + Math.abs(locationData.lat) <= 90 && + Math.abs(locationData.lon) <= 180 + ) { // Cross-validate: compare returned location against prefix estimate // If they're wildly different, the lookup data may be wrong const prefixLoc = estimateLocationFromPrefix(requestedBase); @@ -6946,14 +6405,16 @@ async function enrichSpotWithLocation(spot) { if (dist > 5000) { // Location is > 5000 km from where the callsign prefix says it should be // This is almost certainly wrong data — use prefix estimate instead - console.warn(`[RBN] Location sanity check FAILED for ${skimmerCall}: lookup=${locationData.lat.toFixed(1)},${locationData.lon.toFixed(1)} vs prefix=${prefixCoords.lat.toFixed(1)},${prefixCoords.lon.toFixed(1)} (${Math.round(dist)} km apart) — using prefix`); + console.warn( + `[RBN] Location sanity check FAILED for ${skimmerCall}: lookup=${locationData.lat.toFixed(1)},${locationData.lon.toFixed(1)} vs prefix=${prefixCoords.lat.toFixed(1)},${prefixCoords.lon.toFixed(1)} (${Math.round(dist)} km apart) — using prefix`, + ); const grid = latLonToGrid(prefixCoords.lat, prefixCoords.lon); const location = { callsign: skimmerCall, grid: grid, lat: prefixCoords.lat, lon: prefixCoords.lon, - country: prefixLoc.country || locationData.country + country: prefixLoc.country || locationData.country, }; cacheCallsignLocation(skimmerCall, location); return { @@ -6961,12 +6422,12 @@ async function enrichSpotWithLocation(spot) { grid: grid, skimmerLat: prefixCoords.lat, skimmerLon: prefixCoords.lon, - skimmerCountry: location.country + skimmerCountry: location.country, }; } } } - + const grid = latLonToGrid(locationData.lat, locationData.lon); const location = { @@ -7028,17 +6489,19 @@ app.get('/api/rbn/spots', async (req, res) => { // Direct O(1) lookup by DX callsign — no scanning the full firehose const dxSpots = rbnSpotsByDX.get(callsign) || []; - const recentSpots = dxSpots.filter(spot => spot.timestampMs > cutoff); - + const recentSpots = dxSpots.filter((spot) => spot.timestampMs > cutoff); + // Enrich with skimmer locations — process sequentially to avoid // concurrent lookup race conditions that can mix up locations const enrichedSpots = []; for (const spot of recentSpots) { enrichedSpots.push(await enrichSpotWithLocation(spot)); } - - console.log(`[RBN] Returning ${enrichedSpots.length} spots for ${callsign} (last ${minutes} min, ${rbnSpotsByDX.size} DX stations tracked)`); - + + console.log( + `[RBN] Returning ${enrichedSpots.length} spots for ${callsign} (last ${minutes} min, ${rbnSpotsByDX.size} DX stations tracked)`, + ); + const response = { count: enrichedSpots.length, spots: enrichedSpots, @@ -7064,9 +6527,7 @@ app.get('/api/rbn/location/:callsign', async (req, res) => { try { // Look up via HamQTH - const response = await fetch( - `http://localhost:${PORT}/api/callsign/${callsign}`, - ); + const response = await fetch(`http://localhost:${PORT}/api/callsign/${callsign}`); if (response.ok) { const locationData = await response.json(); const grid = latLonToGrid(locationData.lat, locationData.lon); @@ -7093,9 +6554,7 @@ app.get('/api/rbn/location/:callsign', async (req, res) => { // Legacy endpoint for compatibility (deprecated) app.get('/api/rbn', async (req, res) => { - console.log( - '[RBN] Warning: Using deprecated /api/rbn endpoint, use /api/rbn/spots instead', - ); + console.log('[RBN] Warning: Using deprecated /api/rbn endpoint, use /api/rbn/spots instead'); const callsign = (req.query.callsign || '').toUpperCase().trim(); const minutes = parseInt(req.query.minutes) || 30; @@ -7110,9 +6569,7 @@ app.get('/api/rbn', async (req, res) => { // Filter spots for this callsign const userSpots = rbnSpots - .filter( - (spot) => spot.timestampMs > cutoff && spot.dx.toUpperCase() === callsign, - ) + .filter((spot) => spot.timestampMs > cutoff && spot.dx.toUpperCase() === callsign) .slice(-limit); res.json(userSpots); @@ -7273,11 +6730,7 @@ app.get('/api/wspr/heatmap', async (req, res) => { const cacheKey = `wspr:${minutes}:${band}:${raw ? 'raw' : 'agg'}`; // 1. Fresh cache hit — serve immediately - if ( - wsprCache.data && - wsprCache.data.cacheKey === cacheKey && - now - wsprCache.timestamp < WSPR_CACHE_TTL - ) { + if (wsprCache.data && wsprCache.data.cacheKey === cacheKey && now - wsprCache.timestamp < WSPR_CACHE_TTL) { return res.json({ ...wsprCache.data.result, cached: true }); } @@ -7298,10 +6751,7 @@ app.get('/api/wspr/heatmap', async (req, res) => { } // 3. Stale-while-revalidate: if stale data exists, serve it and refresh in background - const hasStale = - wsprCache.data && - wsprCache.data.cacheKey === cacheKey && - now - wsprCache.timestamp < WSPR_STALE_TTL; + const hasStale = wsprCache.data && wsprCache.data.cacheKey === cacheKey && now - wsprCache.timestamp < WSPR_STALE_TTL; // 4. Deduplicated upstream fetch — WSPR is global data, so all users share ONE in-flight request const doFetch = () => @@ -7315,20 +6765,15 @@ app.get('/api/wspr/heatmap', async (req, res) => { const response = await fetch(url, { headers: { 'User-Agent': 'OpenHamClock/15.2.12 (Amateur Radio Dashboard)', - 'Accept': '*/*', + Accept: '*/*', }, signal: controller.signal, }); clearTimeout(timeout); if (!response.ok) { - const backoffSecs = upstream.recordFailure( - 'pskreporter', - response.status, - ); - throw new Error( - `HTTP ${response.status} — backing off for ${backoffSecs}s`, - ); + const backoffSecs = upstream.recordFailure('pskreporter', response.status); + throw new Error(`HTTP ${response.status} — backing off for ${backoffSecs}s`); } const xml = await response.text(); @@ -7357,12 +6802,7 @@ app.get('/api/wspr/heatmap', async (req, res) => { const receiverAz = getAttr('receiverAzimuth'); const drift = getAttr('drift'); - if ( - receiverCallsign && - senderCallsign && - senderLocator && - receiverLocator - ) { + if (receiverCallsign && senderCallsign && senderLocator && receiverLocator) { const freq = frequency ? parseInt(frequency) : null; const spotBand = freq ? getBandFromHz(freq) : 'Unknown'; @@ -7373,14 +6813,9 @@ app.get('/api/wspr/heatmap', async (req, res) => { if (senderLoc && receiverLoc) { const powerWatts = power ? parseFloat(power) : null; - const powerDbm = powerWatts - ? (10 * Math.log10(powerWatts * 1000)).toFixed(0) - : null; + const powerDbm = powerWatts ? (10 * Math.log10(powerWatts * 1000)).toFixed(0) : null; const dist = distance ? parseInt(distance) : null; - const kPerW = - dist && powerWatts && powerWatts > 0 - ? Math.round(dist / powerWatts) - : null; + const kPerW = dist && powerWatts && powerWatts > 0 ? Math.round(dist / powerWatts) : null; spots.push({ sender: senderCallsign, @@ -7402,12 +6837,8 @@ app.get('/api/wspr/heatmap', async (req, res) => { receiverAz: receiverAz ? parseInt(receiverAz) : null, drift: drift ? parseInt(drift) : null, kPerW: kPerW, - timestamp: flowStartSecs - ? parseInt(flowStartSecs) * 1000 - : Date.now(), - age: flowStartSecs - ? Math.floor((Date.now() / 1000 - parseInt(flowStartSecs)) / 60) - : 0, + timestamp: flowStartSecs ? parseInt(flowStartSecs) * 1000 : Date.now(), + age: flowStartSecs ? Math.floor((Date.now() / 1000 - parseInt(flowStartSecs)) / 60) : 0, }); } } @@ -7427,9 +6858,7 @@ app.get('/api/wspr/heatmap', async (req, res) => { source: 'pskreporter', format: 'raw', }; - console.log( - `[WSPR Heatmap] Returning ${spots.length} raw spots (${minutes}min, band: ${band})`, - ); + console.log(`[WSPR Heatmap] Returning ${spots.length} raw spots (${minutes}min, band: ${band})`); } else { const aggregated = aggregateWSPRByGrid(spots); result = { @@ -7485,7 +6914,7 @@ app.get('/api/wspr/heatmap', async (req, res) => { // Updated list of active amateur radio satellites and selected weather satellites const HAM_SATELLITES = { // High Priority - Popular FM satellites - 'ISS': { + ISS: { norad: 25544, name: 'ISS (ZARYA)', color: '#00ffff', @@ -7783,7 +7212,7 @@ const HAM_SATELLITES = { }, // APRS Digipeaters - 'ARISS': { + ARISS: { norad: 25544, name: 'ARISS (ISS)', color: '#00ffff', @@ -7799,7 +7228,7 @@ const HAM_SATELLITES = { priority: 4, mode: 'Telemetry', }, - 'MEZNSAT': { + MEZNSAT: { norad: 46489, name: 'MeznSat', color: '#66ff66', @@ -7824,13 +7253,13 @@ app.get('/api/satellites/tle', async (req, res) => { const now = Date.now(); // Return memory cache if fresh (6-hour window) - if (tleCache.data && (now - tleCache.timestamp) < TLE_CACHE_DURATION) { + if (tleCache.data && now - tleCache.timestamp < TLE_CACHE_DURATION) { return res.json(tleCache.data); } logDebug('[Satellites] Fetching fresh TLE data from multiple groups...'); const tleData = {}; - + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); @@ -7839,13 +7268,10 @@ app.get('/api/satellites/tle', async (req, res) => { for (const group of groups) { try { - const response = await fetch( - `https://celestrak.org/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`, - { - headers: { 'User-Agent': 'OpenHamClock/3.3' }, - signal: controller.signal, - }, - ); + const response = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`, { + headers: { 'User-Agent': 'OpenHamClock/3.3' }, + signal: controller.signal, + }); if (response.ok) { const text = await response.text(); @@ -7859,18 +7285,14 @@ app.get('/api/satellites/tle', async (req, res) => { const noradId = parseInt(line1.substring(2, 7)); // Skip if this NORAD ID already exists (prevent duplicates) - const alreadyExists = Object.values(tleData).some( - (sat) => sat.norad === noradId, - ); + const alreadyExists = Object.values(tleData).some((sat) => sat.norad === noradId); if (alreadyExists) continue; // Create a sanitized key from the satellite name const key = name.replace(/[^A-Z0-9\-]/g, '_').toUpperCase(); // Check if we have metadata in HAM_SATELLITES - const hamSat = Object.values(HAM_SATELLITES).find( - (s) => s.norad === noradId, - ); + const hamSat = Object.values(HAM_SATELLITES).find((s) => s.norad === noradId); if (hamSat) { // Use defined metadata from HAM_SATELLITES @@ -7902,9 +7324,7 @@ app.get('/api/satellites/tle', async (req, res) => { // Fallback for ISS if it wasn't found in the groups above if (!issExists) { try { - const issRes = await fetch( - 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle', - ); + const issRes = await fetch('https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle'); if (issRes.ok) { const issText = await issRes.text(); const issLines = issText.trim().split('\n'); @@ -7946,10 +7366,7 @@ async function fetchIonosondeData() { const now = Date.now(); // Return cached data if fresh - if ( - ionosondeCache.data && - now - ionosondeCache.timestamp < ionosondeCache.maxAge - ) { + if (ionosondeCache.data && now - ionosondeCache.timestamp < ionosondeCache.maxAge) { return ionosondeCache.data; } @@ -7992,9 +7409,7 @@ async function fetchIonosondeData() { timestamp: now, }; - logDebug( - `[Ionosonde] Fetched ${validStations.length} valid stations from KC2G`, - ); + logDebug(`[Ionosonde] Fetched ${validStations.length} valid stations from KC2G`); return validStations; } catch (error) { logErrorOnce('Ionosonde', `Fetch error: ${error.message}`); @@ -8024,10 +7439,7 @@ function haversineDistance(lat1, lon1, lat2, lon2) { const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos((lat1 * Math.PI) / 180) * - Math.cos((lat2 * Math.PI) / 180) * - Math.sin(dLon / 2) * - Math.sin(dLon / 2); + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } @@ -8071,9 +7483,7 @@ function interpolateFoF2(lat, lon, stations) { } // Filter to only stations within valid range - const validStations = stationsWithDist.filter( - (s) => s.distance <= MAX_VALID_DISTANCE, - ); + const validStations = stationsWithDist.filter((s) => s.distance <= MAX_VALID_DISTANCE); const nearest = validStations.slice(0, 5); // If very close to a station, use its value directly @@ -8134,25 +7544,14 @@ let iturhfpropCache = { /** * Fetch base prediction from ITURHFProp service */ -async function fetchITURHFPropPrediction( - txLat, - txLon, - rxLat, - rxLon, - ssn, - month, - hour, -) { +async function fetchITURHFPropPrediction(txLat, txLon, rxLat, rxLon, ssn, month, hour) { if (!ITURHFPROP_URL) return null; const cacheKey = `${txLat.toFixed(1)},${txLon.toFixed(1)}-${rxLat.toFixed(1)},${rxLon.toFixed(1)}-${ssn}-${month}-${hour}`; const now = Date.now(); // Check cache - if ( - iturhfpropCache.key === cacheKey && - now - iturhfpropCache.timestamp < iturhfpropCache.maxAge - ) { + if (iturhfpropCache.key === cacheKey && now - iturhfpropCache.timestamp < iturhfpropCache.maxAge) { return iturhfpropCache.data; } @@ -8286,12 +7685,7 @@ function applyHybridCorrection(iturhfpropData, ionoData, kIndex, sfi) { reliability: Math.round(correctedReliability), baseReliability: Math.round(baseReliability), correctionApplied: correction.factor !== 1.0, - status: - correctedReliability >= 70 - ? 'GOOD' - : correctedReliability >= 40 - ? 'FAIR' - : 'POOR', + status: correctedReliability >= 70 ? 'GOOD' : correctedReliability >= 40 ? 'FAIR' : 'POOR', }; } @@ -8359,9 +7753,7 @@ app.get('/api/propagation', async (req, res) => { // Prefer SWPC summary (updates every few hours) + N0NBH for SSN const [summaryRes, kRes] = await Promise.allSettled([ fetch('https://services.swpc.noaa.gov/products/summary/10cm-flux.json'), - fetch( - 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json', - ), + fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'), ]); if (summaryRes.status === 'fulfilled' && summaryRes.value.ok) { @@ -8410,11 +7802,7 @@ app.get('/api/propagation', async (req, res) => { // Get ionospheric data at path midpoint const ionoData = interpolateFoF2(midLat, midLon, ionosondeStations); - const hasValidIonoData = !!( - ionoData && - ionoData.method !== 'no-coverage' && - ionoData.foF2 - ); + const hasValidIonoData = !!(ionoData && ionoData.method !== 'no-coverage' && ionoData.foF2); const currentHour = new Date().getUTCHours(); const currentMonth = new Date().getMonth() + 1; @@ -8445,15 +7833,8 @@ app.get('/api/propagation', async (req, res) => { if (iturhfpropData && hasValidIonoData) { // Full hybrid: ITURHFProp + ionosonde correction - hybridResult = applyHybridCorrection( - iturhfpropData, - ionoData, - kIndex, - sfi, - ); - logDebug( - '[Propagation] Using HYBRID mode (ITURHFProp + ionosonde correction)', - ); + hybridResult = applyHybridCorrection(iturhfpropData, ionoData, kIndex, sfi); + logDebug('[Propagation] Using HYBRID mode (ITURHFProp + ionosonde correction)'); } else if (iturhfpropData) { // ITURHFProp only (no ionosonde coverage) hybridResult = { @@ -8466,17 +7847,7 @@ app.get('/api/propagation', async (req, res) => { } // ===== FALLBACK: Built-in calculations ===== - const bands = [ - '160m', - '80m', - '40m', - '30m', - '20m', - '17m', - '15m', - '12m', - '10m', - ]; + const bands = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m']; const bandFreqs = [1.8, 3.5, 7, 10, 14, 18, 21, 24, 28]; // Generate predictions (hybrid or fallback) @@ -8551,8 +7922,7 @@ app.get('/api/propagation', async (req, res) => { // Get hybrid reliability for this band (the accurate one) const hybridBand = hybridResult.bands?.[band]; - const hybridReliability = - hybridBand?.reliability || builtInCurrentReliability; + const hybridReliability = hybridBand?.reliability || builtInCurrentReliability; // Calculate correction ratio (how much to scale predictions) // Avoid division by zero, and cap the ratio to prevent extreme corrections @@ -8583,10 +7953,7 @@ app.get('/api/propagation', async (req, res) => { signalMarginDb, ); // Apply correction ratio and clamp to valid range - const correctedReliability = Math.min( - 99, - Math.max(0, Math.round(baseReliability * correctionRatio)), - ); + const correctedReliability = Math.min(99, Math.max(0, Math.round(baseReliability * correctionRatio))); predictions[band].push({ hour, reliability: correctedReliability, @@ -8638,16 +8005,7 @@ app.get('/api/propagation', async (req, res) => { // Calculate MUF and LUF const currentMuf = - hybridResult?.muf || - calculateMUF( - distance, - midLat, - midLon, - currentHour, - sfi, - ssn, - effectiveIonoData, - ); + hybridResult?.muf || calculateMUF(distance, midLat, midLon, currentHour, sfi, ssn, effectiveIonoData); const currentLuf = calculateLUF(distance, midLat, currentHour, sfi, kIndex); // Build ionospheric response @@ -8734,10 +8092,7 @@ app.get('/api/propagation/heatmap', async (req, res) => { const cacheKey = `${deLat.toFixed(0)}:${deLon.toFixed(0)}:${freq}:${gridSize}:${txMode}:${txPower}`; const now = Date.now(); - if ( - PROP_HEATMAP_CACHE[cacheKey] && - now - PROP_HEATMAP_CACHE[cacheKey].ts < PROP_HEATMAP_TTL - ) { + if (PROP_HEATMAP_CACHE[cacheKey] && now - PROP_HEATMAP_CACHE[cacheKey].ts < PROP_HEATMAP_TTL) { return res.json(PROP_HEATMAP_CACHE[cacheKey].data); } @@ -8749,9 +8104,7 @@ app.get('/api/propagation/heatmap', async (req, res) => { try { const [fluxRes, kRes] = await Promise.allSettled([ fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'), - fetch( - 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json', - ), + fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'), ]); if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) { const data = await fluxRes.value.json(); @@ -8970,8 +8323,7 @@ function calculateEnhancedReliability( if (ionoData && hour !== currentHour) { // Estimate foF2 change based on diurnal variation // foF2 typically varies by factor of 2-3 between day and night - const currentHourFactor = - 1 + 0.4 * Math.cos(((currentHour - 14) * Math.PI) / 12); + const currentHourFactor = 1 + 0.4 * Math.cos(((currentHour - 14) * Math.PI) / 12); const targetHourFactor = 1 + 0.4 * Math.cos(((hour - 14) * Math.PI) / 12); const scaleFactor = targetHourFactor / currentHourFactor; @@ -8982,15 +8334,7 @@ function calculateEnhancedReliability( }; } - const muf = calculateMUF( - distance, - midLat, - midLon, - hour, - sfi, - ssn, - hourIonoData, - ); + const muf = calculateMUF(distance, midLat, midLon, hour, sfi, ssn, hourIonoData); const luf = calculateLUF(distance, midLat, hour, sfi, kIndex); // Apply signal margin from mode + power to MUF/LUF boundaries. @@ -9009,15 +8353,13 @@ function calculateEnhancedReliability( reliability = Math.max(0, 30 - (freq - effectiveMuf) * 5); } else if (freq > effectiveMuf) { // Slightly above MUF - marginal (sometimes works due to scatter) - reliability = - 30 + ((effectiveMuf * 1.1 - freq) / (effectiveMuf * 0.1)) * 20; + reliability = 30 + ((effectiveMuf * 1.1 - freq) / (effectiveMuf * 0.1)) * 20; } else if (freq < effectiveLuf * 0.8) { // Well below LUF - absorbed reliability = Math.max(0, 20 - (effectiveLuf - freq) * 10); } else if (freq < effectiveLuf) { // Near LUF - marginal - reliability = - 20 + ((freq - effectiveLuf * 0.8) / (effectiveLuf * 0.2)) * 30; + reliability = 20 + ((freq - effectiveLuf * 0.8) / (effectiveLuf * 0.2)) * 30; } else { // In usable range - calculate optimum // Optimum Working Frequency (OWF) is typically 80-85% of MUF @@ -9036,8 +8378,7 @@ function calculateEnhancedReliability( reliability = 50 + (position / optimalPosition) * 45; } else { // Above OWF - reliability decreases as we approach MUF - reliability = - 95 - ((position - optimalPosition) / (1 - optimalPosition)) * 45; + reliability = 95 - ((position - optimalPosition) / (1 - optimalPosition)) * 45; } } } @@ -9139,16 +8480,13 @@ app.get('/api/contests', async (req, res) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); - const response = await fetch( - 'https://www.contestcalendar.com/calendar.rss', - { - headers: { - 'User-Agent': 'OpenHamClock/3.13.1', - 'Accept': 'application/rss+xml, application/xml, text/xml', - }, - signal: controller.signal, + const response = await fetch('https://www.contestcalendar.com/calendar.rss', { + headers: { + 'User-Agent': 'OpenHamClock/3.13.1', + Accept: 'application/rss+xml, application/xml, text/xml', }, - ); + signal: controller.signal, + }); clearTimeout(timeout); if (response.ok) { @@ -9207,29 +8545,17 @@ function parseContestRSS(xml) { const parsed = parseContestDateTime(desc, currentYear); if (parsed) { - const status = - now >= parsed.start && now <= parsed.end ? 'active' : 'upcoming'; + const status = now >= parsed.start && now <= parsed.end ? 'active' : 'upcoming'; // Try to detect mode from contest name let mode = 'Mixed'; const nameLower = name.toLowerCase(); - if (nameLower.includes('cw') || nameLower.includes('morse')) - mode = 'CW'; - else if ( - nameLower.includes('ssb') || - nameLower.includes('phone') || - nameLower.includes('sideband') - ) + if (nameLower.includes('cw') || nameLower.includes('morse')) mode = 'CW'; + else if (nameLower.includes('ssb') || nameLower.includes('phone') || nameLower.includes('sideband')) mode = 'SSB'; else if (nameLower.includes('rtty')) mode = 'RTTY'; - else if ( - nameLower.includes('ft4') || - nameLower.includes('ft8') || - nameLower.includes('digi') - ) - mode = 'Digital'; - else if (nameLower.includes('vhf') || nameLower.includes('uhf')) - mode = 'VHF'; + else if (nameLower.includes('ft4') || nameLower.includes('ft8') || nameLower.includes('digi')) mode = 'Digital'; + else if (nameLower.includes('vhf') || nameLower.includes('uhf')) mode = 'VHF'; contests.push({ name, @@ -9268,12 +8594,9 @@ function parseContestDateTime(desc, year) { }; // Pattern 1: "1300Z, Jan 31 to 1300Z, Feb 1" - const rangeMatch = desc.match( - /(\d{4})Z,\s*(\w+)\s+(\d+)\s+to\s+(\d{4})Z,\s*(\w+)\s+(\d+)/i, - ); + const rangeMatch = desc.match(/(\d{4})Z,\s*(\w+)\s+(\d+)\s+to\s+(\d{4})Z,\s*(\w+)\s+(\d+)/i); if (rangeMatch) { - const [, startTime, startMon, startDay, endTime, endMon, endDay] = - rangeMatch; + const [, startTime, startMon, startDay, endTime, endMon, endDay] = rangeMatch; const startMonth = months[startMon.toLowerCase()]; const endMonth = months[endMon.toLowerCase()]; @@ -9311,22 +8634,10 @@ function parseContestDateTime(desc, year) { const month = months[mon.toLowerCase()]; const start = new Date( - Date.UTC( - year, - month, - parseInt(day), - parseInt(startTime.substring(0, 2)), - parseInt(startTime.substring(2, 4)), - ), + Date.UTC(year, month, parseInt(day), parseInt(startTime.substring(0, 2)), parseInt(startTime.substring(2, 4))), ); const end = new Date( - Date.UTC( - year, - month, - parseInt(day), - parseInt(endTime.substring(0, 2)), - parseInt(endTime.substring(2, 4)), - ), + Date.UTC(year, month, parseInt(day), parseInt(endTime.substring(0, 2)), parseInt(endTime.substring(2, 4))), ); // Handle overnight contests (end time < start time means next day) @@ -9342,22 +8653,10 @@ function parseContestDateTime(desc, year) { const month = months[mon.toLowerCase()]; const start = new Date( - Date.UTC( - year, - month, - parseInt(day), - parseInt(startTime.substring(0, 2)), - parseInt(startTime.substring(2, 4)), - ), + Date.UTC(year, month, parseInt(day), parseInt(startTime.substring(0, 2)), parseInt(startTime.substring(2, 4))), ); const end = new Date( - Date.UTC( - year, - month, - parseInt(day), - parseInt(endTime.substring(0, 2)), - parseInt(endTime.substring(2, 4)), - ), + Date.UTC(year, month, parseInt(day), parseInt(endTime.substring(0, 2)), parseInt(endTime.substring(2, 4))), ); if (end <= start) end.setUTCDate(end.getUTCDate() + 1); @@ -9566,13 +8865,10 @@ function calculateUpcomingContests() { // Most contests start at 00:00 UTC Saturday startDate.setUTCHours(0, 0, 0, 0); - const endDate = new Date( - startDate.getTime() + contest.duration * 3600000, - ); + const endDate = new Date(startDate.getTime() + contest.duration * 3600000); if (endDate > now) { - const status = - now >= startDate && now <= endDate ? 'active' : 'upcoming'; + const status = now >= startDate && now <= endDate ? 'active' : 'upcoming'; contests.push({ name: contest.name, start: startDate.toISOString(), @@ -9635,16 +8931,11 @@ function generateStatusDashboard() { // Calculate time since first deployment const firstStart = new Date(visitorStats.serverFirstStarted); - const trackingDays = Math.floor( - (Date.now() - firstStart.getTime()) / 86400000, - ); + const trackingDays = Math.floor((Date.now() - firstStart.getTime()) / 86400000); const avg = visitorStats.history.length > 0 - ? Math.round( - visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / - visitorStats.history.length, - ) + ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) : visitorStats.uniqueIPsToday.length; // Get last 14 days for the chart @@ -9665,9 +8956,7 @@ function generateStatusDashboard() { .map((d) => { const height = Math.max((d.uniqueVisitors / maxVisitors) * 100, 2); const date = new Date(d.date); - const dayLabel = date - .toLocaleDateString('en-US', { weekday: 'short' }) - .slice(0, 2); + const dayLabel = date.toLocaleDateString('en-US', { weekday: 'short' }).slice(0, 2); const isToday = d.date === visitorStats.today; return `
@@ -9681,14 +8970,9 @@ function generateStatusDashboard() { .join(''); // Calculate week-over-week growth - const thisWeek = chartData - .slice(-7) - .reduce((sum, d) => sum + d.uniqueVisitors, 0); - const lastWeek = chartData - .slice(-14, -7) - .reduce((sum, d) => sum + d.uniqueVisitors, 0); - const growth = - lastWeek > 0 ? Math.round(((thisWeek - lastWeek) / lastWeek) * 100) : 0; + const thisWeek = chartData.slice(-7).reduce((sum, d) => sum + d.uniqueVisitors, 0); + const lastWeek = chartData.slice(-14, -7).reduce((sum, d) => sum + d.uniqueVisitors, 0); + const growth = lastWeek > 0 ? Math.round(((thisWeek - lastWeek) / lastWeek) * 100) : 0; const growthIcon = growth > 0 ? '📈' : growth < 0 ? '📉' : '➡️'; const growthColor = growth > 0 ? '#00ff88' : growth < 0 ? '#ff4466' : '#888'; @@ -9696,10 +8980,7 @@ function generateStatusDashboard() { const apiStats = endpointStats.getStats(); const estimatedMonthlyGB = apiStats.uptimeHours > 0 - ? ( - ((apiStats.totalBytes / parseFloat(apiStats.uptimeHours)) * 24 * 30) / - (1024 * 1024 * 1024) - ).toFixed(2) + ? (((apiStats.totalBytes / parseFloat(apiStats.uptimeHours)) * 24 * 30) / (1024 * 1024 * 1024)).toFixed(2) : '0.00'; // Get session stats @@ -9711,10 +8992,7 @@ function generateStatusDashboard() { .map((ep, i) => { const bytesFormatted = formatBytes(ep.totalBytes); const avgBytesFormatted = formatBytes(ep.avgBytes); - const bandwidthBar = Math.min( - (ep.totalBytes / (apiStats.totalBytes || 1)) * 100, - 100, - ); + const bandwidthBar = Math.min((ep.totalBytes / (apiStats.totalBytes || 1)) * 100, 100); return ` ${i + 1} @@ -10212,12 +9490,8 @@ function generateStatusDashboard() { ${(() => { // Country statistics section - const allTimeCountries = Object.entries( - visitorStats.countryStats || {}, - ).sort((a, b) => b[1] - a[1]); - const todayCountries = Object.entries( - visitorStats.countryStatsToday || {}, - ).sort((a, b) => b[1] - a[1]); + const allTimeCountries = Object.entries(visitorStats.countryStats || {}).sort((a, b) => b[1] - a[1]); + const todayCountries = Object.entries(visitorStats.countryStatsToday || {}).sort((a, b) => b[1] - a[1]); const totalResolved = allTimeCountries.reduce((s, [, v]) => s + v, 0); if (allTimeCountries.length === 0 && geoIPQueue.size === 0) return ''; @@ -10225,9 +9499,7 @@ function generateStatusDashboard() { // Country code to flag emoji const flag = (cc) => { try { - return String.fromCodePoint( - ...[...cc.toUpperCase()].map((c) => 0x1f1e5 + c.charCodeAt(0) - 64), - ); + return String.fromCodePoint(...[...cc.toUpperCase()].map((c) => 0x1f1e5 + c.charCodeAt(0) - 64)); } catch { return '🏳'; } @@ -10345,11 +9617,8 @@ function generateStatusDashboard() { const backedOff = upstream.isBackedOff(svc); const remaining = upstream.backoffRemaining(svc); const consecutive = upstream.backoffs.get(svc)?.consecutive || 0; - const prefix = - svc === 'pskreporter' ? ['psk:', 'wspr:'] : ['weather:']; - const inFlight = [...upstream.inFlight.keys()].filter((k) => - prefix.some((p) => k.startsWith(p)), - ).length; + const prefix = svc === 'pskreporter' ? ['psk:', 'wspr:'] : ['weather:']; + const inFlight = [...upstream.inFlight.keys()].filter((k) => prefix.some((p) => k.startsWith(p))).length; const label = 'PSKReporter (WSPR Heatmap)'; return ` ${label} @@ -10401,8 +9670,7 @@ app.get('/api/health', (req, res) => { rolloverVisitorStats(); // SECURITY: Check if request is authenticated for full details - const token = - req.headers.authorization?.replace('Bearer ', '') || req.query.key || ''; + const token = req.headers.authorization?.replace('Bearer ', '') || req.query.key || ''; const isAuthed = API_WRITE_KEY && token === API_WRITE_KEY; // Check if browser wants HTML or explicitly requesting JSON @@ -10415,10 +9683,7 @@ app.get('/api/health', (req, res) => { // JSON response for API consumers const avg = visitorStats.history.length > 0 - ? Math.round( - visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / - visitorStats.history.length, - ) + ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) : visitorStats.uniqueIPsToday.length; // Get endpoint monitoring stats @@ -10491,9 +9756,8 @@ app.get('/api/health', (req, res) => { status: upstream.isBackedOff('pskreporter') ? 'backoff' : 'ok', backoffRemaining: upstream.backoffRemaining('pskreporter'), consecutive: upstream.backoffs.get('pskreporter')?.consecutive || 0, - inFlightRequests: [...upstream.inFlight.keys()].filter( - (k) => k.startsWith('psk:') || k.startsWith('wspr:'), - ).length, + inFlightRequests: [...upstream.inFlight.keys()].filter((k) => k.startsWith('psk:') || k.startsWith('wspr:')) + .length, }, weather: { status: 'client-direct', @@ -10503,27 +9767,14 @@ app.get('/api/health', (req, res) => { pskMqttProxy: { connected: pskMqtt.connected, // SECURITY: Only expose active callsigns to authenticated requests - activeCallsigns: isAuthed - ? [...pskMqtt.subscribedCalls] - : pskMqtt.subscribedCalls.size, - sseClients: [...pskMqtt.subscribers.values()].reduce( - (n, s) => n + s.size, - 0, - ), + activeCallsigns: isAuthed ? [...pskMqtt.subscribedCalls] : pskMqtt.subscribedCalls.size, + sseClients: [...pskMqtt.subscribers.values()].reduce((n, s) => n + s.size, 0), spotsReceived: pskMqtt.stats.spotsReceived, spotsRelayed: pskMqtt.stats.spotsRelayed, messagesDropped: pskMqtt.stats.messagesDropped, - bufferedSpots: [...pskMqtt.spotBuffer.values()].reduce( - (n, b) => n + b.length, - 0, - ), - recentSpotsCache: [...pskMqtt.recentSpots.values()].reduce( - (n, s) => n + s.length, - 0, - ), - lastSpotTime: pskMqtt.stats.lastSpotTime - ? new Date(pskMqtt.stats.lastSpotTime).toISOString() - : null, + bufferedSpots: [...pskMqtt.spotBuffer.values()].reduce((n, b) => n + b.length, 0), + recentSpotsCache: [...pskMqtt.recentSpots.values()].reduce((n, s) => n + s.length, 0), + lastSpotTime: pskMqtt.stats.lastSpotTime ? new Date(pskMqtt.stats.lastSpotTime).toISOString() : null, }, }, }); @@ -10553,8 +9804,7 @@ app.get('/api/version', (req, res) => { // On multi-user hosted deployments (openhamclock.com), leave disabled — // settings stay in each user's browser localStorage. -const SETTINGS_SYNC_ENABLED = - (process.env.SETTINGS_SYNC || '').toLowerCase() === 'true'; +const SETTINGS_SYNC_ENABLED = (process.env.SETTINGS_SYNC || '').toLowerCase() === 'true'; function getSettingsFilePath() { if (!SETTINGS_SYNC_ENABLED) return null; @@ -10581,14 +9831,9 @@ function getSettingsFilePath() { } const SETTINGS_FILE = getSettingsFilePath(); -if (SETTINGS_SYNC_ENABLED && SETTINGS_FILE) - logInfo(`[Settings] ✓ Sync enabled, using: ${SETTINGS_FILE}`); -else if (SETTINGS_SYNC_ENABLED) - logWarn('[Settings] Sync enabled but no writable path found'); -else - logInfo( - '[Settings] Sync disabled (set SETTINGS_SYNC=true in .env to enable)', - ); +if (SETTINGS_SYNC_ENABLED && SETTINGS_FILE) logInfo(`[Settings] ✓ Sync enabled, using: ${SETTINGS_FILE}`); +else if (SETTINGS_SYNC_ENABLED) logWarn('[Settings] Sync enabled but no writable path found'); +else logInfo('[Settings] Sync disabled (set SETTINGS_SYNC=true in .env to enable)'); function loadServerSettings() { if (!SETTINGS_SYNC_ENABLED || !SETTINGS_FILE) return null; @@ -10637,10 +9882,7 @@ app.post('/api/settings', writeLimiter, requireWriteAuth, (req, res) => { // Only allow openhamclock_* and ohc_* keys (security: prevent arbitrary data injection) const filtered = {}; for (const [key, value] of Object.entries(settings)) { - if ( - (key.startsWith('openhamclock_') || key.startsWith('ohc_')) && - typeof value === 'string' - ) { + if ((key.startsWith('openhamclock_') || key.startsWith('ohc_')) && typeof value === 'string') { filtered[key] = value; } } @@ -10690,8 +9932,7 @@ app.get('/api/config', (req, res) => { configIncomplete: CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare, // Server timezone (from TZ env var or system) - timezone: - process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || '', + timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || '', // Feature availability features: { @@ -11092,17 +10333,7 @@ function parseDecodeMessage(text) { // FT8/FT4 protocol tokens that look like valid Maidenhead grids but aren't // RR73 matches [A-R]{2}\d{2} but is a QSO acknowledgment - const FT8_TOKENS = new Set([ - 'RR73', - 'RR53', - 'RR13', - 'RR23', - 'RR33', - 'RR43', - 'RR63', - 'RR83', - 'RR93', - ]); + const FT8_TOKENS = new Set(['RR73', 'RR53', 'RR13', 'RR23', 'RR33', 'RR43', 'RR63', 'RR83', 'RR93']); // Validate grid: must be valid Maidenhead AND not an FT8 protocol token function isGrid(s) { @@ -11133,8 +10364,7 @@ function parseDecodeMessage(text) { // Modifiers (DX, POTA, NA, EU, etc.) come before it if (tokens.length >= 1) { result.caller = tokens[tokens.length - 1]; - result.modifier = - tokens.length >= 2 ? tokens.slice(0, -1).join(' ') : null; + result.modifier = tokens.length >= 2 ? tokens.slice(0, -1).join(' ') : null; } result.grid = grid; @@ -11297,28 +10527,17 @@ function handleWSJTXMessage(msg, state) { const targetCall = extractBaseCallsign(rawCall); if (targetCall) { const cached = callsignLookupCache.get(targetCall); - if ( - cached && - Date.now() - cached.timestamp < CALLSIGN_CACHE_TTL && - cached.data?.lat != null - ) { + if (cached && Date.now() - cached.timestamp < CALLSIGN_CACHE_TTL && cached.data?.lat != null) { decode.lat = cached.data.lat; decode.lon = cached.data.lon; decode.gridSource = 'hamqth'; - } else if ( - targetCall.length >= 3 && - !wsjtxHamqthInflight.has(targetCall) && - wsjtxHamqthInflight.size < 5 - ) { + } else if (targetCall.length >= 3 && !wsjtxHamqthInflight.has(targetCall) && wsjtxHamqthInflight.size < 5) { // Background lookup for next cycle (fire-and-forget, max 5 concurrent) wsjtxHamqthInflight.add(targetCall); - fetch( - `https://www.hamqth.com/dxcc.php?callsign=${encodeURIComponent(targetCall)}`, - { - headers: { 'User-Agent': 'OpenHamClock/' + APP_VERSION }, - signal: AbortSignal.timeout(5000), - }, - ) + fetch(`https://www.hamqth.com/dxcc.php?callsign=${encodeURIComponent(targetCall)}`, { + headers: { 'User-Agent': 'OpenHamClock/' + APP_VERSION }, + signal: AbortSignal.timeout(5000), + }) .then(async (resp) => { if (!resp.ok) return; const text = await resp.text(); @@ -11440,7 +10659,7 @@ function handleWSJTXMessage(msg, state) { } // ---- N3FJP Logged QSO relay (in-memory) ---- -const N3FJP_QSO_RETENTION_MINUTES = parseInt(process.env.N3FJP_QSO_RETENTION_MINUTES || "1440", 10); +const N3FJP_QSO_RETENTION_MINUTES = parseInt(process.env.N3FJP_QSO_RETENTION_MINUTES || '1440', 10); let n3fjpQsos = []; function pruneN3fjpQsos() { @@ -11466,9 +10685,7 @@ async function lookupCallLatLon(callsign) { try { // Reuse your existing endpoint (keeps all HamQTH/grid logic in one place) - const resp = await fetch( - `http://localhost:${PORT}/api/callsign/${encodeURIComponent(call)}`, - ); + const resp = await fetch(`http://localhost:${PORT}/api/callsign/${encodeURIComponent(call)}`); if (!resp.ok) return null; const data = await resp.json(); @@ -11485,8 +10702,7 @@ async function lookupCallLatLon(callsign) { // POST one QSO from a bridge (your Python script) app.post('/api/n3fjp/qso', writeLimiter, requireWriteAuth, async (req, res) => { const qso = req.body || {}; - if (!qso.dx_call) - return res.status(400).json({ ok: false, error: 'dx_call required' }); + if (!qso.dx_call) return res.status(400).json({ ok: false, error: 'dx_call required' }); if (!qso.ts_utc) qso.ts_utc = new Date().toISOString(); if (!qso.source) qso.source = 'n3fjp_to_timemapper_udp'; @@ -11598,8 +10814,7 @@ app.get('/api/wsjtx', (req, res) => { } // Relay is "connected" if this session's relay was seen in last 60 seconds - const relayConnected = - state.relay && Date.now() - state.relay.lastSeen < 60000; + const relayConnected = state.relay && Date.now() - state.relay.lastSeen < 60000; res.json({ enabled: WSJTX_ENABLED, @@ -11622,15 +10837,10 @@ app.get('/api/wsjtx', (req, res) => { // API endpoint: get just decodes (lightweight polling) app.get('/api/wsjtx/decodes', (req, res) => { const sessionId = req.query.session || ''; - const state = - sessionId && WSJTX_RELAY_KEY - ? wsjtxRelaySessions[sessionId] || { decodes: [] } - : wsjtxState; + const state = sessionId && WSJTX_RELAY_KEY ? wsjtxRelaySessions[sessionId] || { decodes: [] } : wsjtxState; const since = parseInt(req.query.since) || 0; - const decodes = since - ? state.decodes.filter((d) => d.timestamp > since) - : state.decodes.slice(-100); + const decodes = since ? state.decodes.filter((d) => d.timestamp > since) : state.decodes.slice(-100); res.json({ decodes, timestamp: Date.now() }); }); @@ -11641,9 +10851,7 @@ app.get('/api/wsjtx/decodes', (req, res) => { app.post('/api/wsjtx/relay', (req, res) => { // Auth check if (!WSJTX_RELAY_KEY) { - return res - .status(503) - .json({ error: 'Relay not configured — set WSJTX_RELAY_KEY in .env' }); + return res.status(503).json({ error: 'Relay not configured — set WSJTX_RELAY_KEY in .env' }); } const authHeader = req.headers.authorization || ''; @@ -11686,10 +10894,7 @@ app.post('/api/wsjtx/relay', (req, res) => { for (const msg of batch) { if (msg && typeof msg.type === 'number' && msg.id) { // Ensure timestamp is reasonable (within last 5 minutes or use server time) - if ( - !msg.timestamp || - Math.abs(Date.now() - msg.timestamp) > 5 * 60 * 1000 - ) { + if (!msg.timestamp || Math.abs(Date.now() - msg.timestamp) > 5 * 60 * 1000) { msg.timestamp = Date.now(); } handleWSJTXMessage(msg, session); @@ -11716,9 +10921,7 @@ app.get('/api/wsjtx/relay/agent.js', (req, res) => { // Embeds relay.js + server URL + relay key into a one-file launcher app.get('/api/wsjtx/relay/download/:platform', (req, res) => { if (!WSJTX_RELAY_KEY) { - return res - .status(503) - .json({ error: 'Relay not configured — set WSJTX_RELAY_KEY in .env' }); + return res.status(503).json({ error: 'Relay not configured — set WSJTX_RELAY_KEY in .env' }); } const platform = req.params.platform; // 'linux', 'mac', or 'windows' @@ -11746,9 +10949,7 @@ app.get('/api/wsjtx/relay/download/:platform', (req, res) => { // SECURITY: Validate platform parameter if (!['linux', 'mac', 'windows'].includes(platform)) { - return res - .status(400) - .json({ error: 'Invalid platform. Use: linux, mac, or windows' }); + return res.status(400).json({ error: 'Invalid platform. Use: linux, mac, or windows' }); } // SECURITY: Sanitize all values embedded into generated scripts to prevent command injection @@ -11767,8 +10968,7 @@ app.get('/api/wsjtx/relay/download/:platform', (req, res) => { '# OpenHamClock WSJT-X Relay — Auto-configured', '# Generated by ' + safeServerURL, '#', - '# Usage: bash ' + - (platform === 'mac' ? 'start-relay.command' : 'start-relay.sh'), + '# Usage: bash ' + (platform === 'mac' ? 'start-relay.command' : 'start-relay.sh'), '# Stop: Ctrl+C', '# Requires: Node.js 14+ (https://nodejs.org)', '#', @@ -11807,13 +11007,9 @@ app.get('/api/wsjtx/relay/download/:platform', (req, res) => { ]; const script = lines.join('\n') + '\n'; - const filename = - platform === 'mac' ? 'start-relay.command' : 'start-relay.sh'; + const filename = platform === 'mac' ? 'start-relay.command' : 'start-relay.sh'; res.setHeader('Content-Type', 'application/x-sh'); - res.setHeader( - 'Content-Disposition', - 'attachment; filename="' + filename + '"', - ); + res.setHeader('Content-Disposition', 'attachment; filename="' + filename + '"'); return res.send(script); } else if (platform === 'windows') { // .bat that auto-downloads portable Node.js if needed, then runs relay @@ -11930,15 +11126,10 @@ app.get('/api/wsjtx/relay/download/:platform', (req, res) => { const script = batLines.join('\r\n') + '\r\n'; res.setHeader('Content-Type', 'application/x-msdos-program'); - res.setHeader( - 'Content-Disposition', - 'attachment; filename="start-relay.bat"', - ); + res.setHeader('Content-Disposition', 'attachment; filename="start-relay.bat"'); return res.send(script); } else { - return res - .status(400) - .json({ error: 'Invalid platform. Use: linux, mac, or windows' }); + return res.status(400).json({ error: 'Invalid platform. Use: linux, mac, or windows' }); } }); @@ -11996,17 +11187,12 @@ app.get('/api/rig/package.json', (req, res) => { app.get('/api/rig/download/:platform', (req, res) => { const platform = req.params.platform; if (!['linux', 'mac', 'windows'].includes(platform)) { - return res - .status(400) - .json({ error: 'Invalid platform. Use: linux, mac, or windows' }); + return res.status(400).json({ error: 'Invalid platform. Use: linux, mac, or windows' }); } const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; const host = req.headers['x-forwarded-host'] || req.headers.host; - const serverURL = (proto + '://' + host).replace( - /[^a-zA-Z0-9._\-:\/\@]/g, - '', - ); + const serverURL = (proto + '://' + host).replace(/[^a-zA-Z0-9._\-:\/\@]/g, ''); if (platform === 'windows') { const NODE_VERSION = 'v22.13.1'; @@ -12127,17 +11313,11 @@ app.get('/api/rig/download/:platform', (req, res) => { ].join('\r\n') + '\r\n'; res.setHeader('Content-Type', 'application/x-msdos-program'); - res.setHeader( - 'Content-Disposition', - 'attachment; filename="OpenHamClock-Rig-Listener.bat"', - ); + res.setHeader('Content-Disposition', 'attachment; filename="OpenHamClock-Rig-Listener.bat"'); return res.send(bat); } else { // Linux / Mac - const filename = - platform === 'mac' - ? 'OpenHamClock-Rig-Listener.command' - : 'OpenHamClock-Rig-Listener.sh'; + const filename = platform === 'mac' ? 'OpenHamClock-Rig-Listener.command' : 'OpenHamClock-Rig-Listener.sh'; const rigDir = '$HOME/openhamclock-rig'; const sh = @@ -12175,14 +11355,10 @@ app.get('/api/rig/download/:platform', (req, res) => { '', '# Download latest rig-listener.js', 'echo " Downloading rig listener..."', - 'curl -sL "' + - serverURL + - '/api/rig/listener.js" -o "$RIG_DIR/rig-listener.js"', + 'curl -sL "' + serverURL + '/api/rig/listener.js" -o "$RIG_DIR/rig-listener.js"', '', '# package.json (always refresh)', - 'curl -sL "' + - serverURL + - '/api/rig/package.json" -o "$RIG_DIR/package.json"', + 'curl -sL "' + serverURL + '/api/rig/package.json" -o "$RIG_DIR/package.json"', '', '# npm install (one-time)', 'if [ ! -d "$RIG_DIR/node_modules/serialport" ]; then', @@ -12202,10 +11378,7 @@ app.get('/api/rig/download/:platform', (req, res) => { ].join('\n') + '\n'; res.setHeader('Content-Type', 'application/x-sh'); - res.setHeader( - 'Content-Disposition', - 'attachment; filename="' + filename + '"', - ); + res.setHeader('Content-Disposition', 'attachment; filename="' + filename + '"'); return res.send(sh); } }); @@ -12213,8 +11386,7 @@ app.get('/api/rig/download/:platform', (req, res) => { const N1MM_UDP_PORT = parseInt(process.env.N1MM_UDP_PORT || '12060'); const N1MM_ENABLED = process.env.N1MM_UDP_ENABLED === 'true'; const N1MM_MAX_QSOS = parseInt(process.env.N1MM_MAX_QSOS || '200'); -const N1MM_QSO_MAX_AGE = - parseInt(process.env.N1MM_QSO_MAX_AGE_MINUTES || '360') * 60 * 1000; +const N1MM_QSO_MAX_AGE = parseInt(process.env.N1MM_QSO_MAX_AGE_MINUTES || '360') * 60 * 1000; const contestQsoState = { qsos: [], @@ -12328,9 +11500,7 @@ function resolveQsoLocation(dxCall, grid, comment) { function pruneContestQsos() { const now = Date.now(); - contestQsoState.qsos = contestQsoState.qsos.filter( - (q) => now - q.timestamp <= N1MM_QSO_MAX_AGE, - ); + contestQsoState.qsos = contestQsoState.qsos.filter((q) => now - q.timestamp <= N1MM_QSO_MAX_AGE); if (contestQsoState.qsos.length > N1MM_MAX_QSOS) { contestQsoState.qsos = contestQsoState.qsos.slice(-N1MM_MAX_QSOS); } @@ -12375,7 +11545,8 @@ function parseContestContactInfo(xml) { if (!dxCall) return null; const source = detectContestSource(xml); - const myCall = normalizeCallsign(getXmlTag(xml, 'mycall')) || + const myCall = + normalizeCallsign(getXmlTag(xml, 'mycall')) || normalizeCallsign(getXmlTag(xml, 'stationprefix')) || CONFIG.callsign; @@ -12383,10 +11554,7 @@ function parseContestContactInfo(xml) { const bandMHz = bandStr ? parseFloat(bandStr) : null; const rxRaw = parseFloat(getXmlTag(xml, 'rxfreq')); const txRaw = parseFloat(getXmlTag(xml, 'txfreq')); - const freqMHz = n1mmFreqToMHz( - !Number.isNaN(rxRaw) ? rxRaw : !Number.isNaN(txRaw) ? txRaw : null, - bandMHz, - ); + const freqMHz = n1mmFreqToMHz(!Number.isNaN(rxRaw) ? rxRaw : !Number.isNaN(txRaw) ? txRaw : null, bandMHz); const mode = (getXmlTag(xml, 'mode') || '').toUpperCase(); const comment = getXmlTag(xml, 'comment') || ''; const gridRaw = getXmlTag(xml, 'gridsquare'); @@ -12437,17 +11605,13 @@ function normalizeContestQso(input, source) { if (!input || typeof input !== 'object') return null; const dxCall = normalizeCallsign(input.dxCall || input.call); if (!dxCall) return null; - const myCall = - normalizeCallsign(input.myCall || input.mycall || input.deCall) || - CONFIG.callsign; + const myCall = normalizeCallsign(input.myCall || input.mycall || input.deCall) || CONFIG.callsign; const bandMHz = parseFloat(input.bandMHz || input.band); const freqMHz = parseFloat(input.freqMHz || input.freq); const mode = (input.mode || '').toUpperCase(); const grid = (input.grid || input.gridsquare || '').toUpperCase(); const timestamp = - typeof input.timestamp === 'number' - ? input.timestamp - : parseN1MMTimestamp(input.timestamp) || Date.now(); + typeof input.timestamp === 'number' ? input.timestamp : parseN1MMTimestamp(input.timestamp) || Date.now(); let lat = parseFloat(input.lat); let lon = parseFloat(input.lon); @@ -12519,16 +11683,12 @@ if (N1MM_ENABLED) { // API endpoint: get contest QSOs app.get('/api/contest/qsos', (req, res) => { const limitRaw = parseInt(req.query.limit); - const limit = Number.isFinite(limitRaw) - ? Math.min(Math.max(limitRaw, 1), 500) - : 200; + const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 500) : 200; const since = parseInt(req.query.since) || 0; pruneContestQsos(); - const filtered = since - ? contestQsoState.qsos.filter((q) => q.timestamp > since) - : contestQsoState.qsos; + const filtered = since ? contestQsoState.qsos.filter((q) => q.timestamp > since) : contestQsoState.qsos; res.json({ qsos: filtered.slice(-limit), @@ -12639,9 +11799,7 @@ app.listen(PORT, '0.0.0.0', () => { console.log(` 📥 Contest logger UDP listener (N1MM/DXLog) on port ${N1MM_UDP_PORT}`); } if (AUTO_UPDATE_ENABLED) { - console.log( - ` 🔄 Auto-update enabled every ${AUTO_UPDATE_INTERVAL_MINUTES || 60} minutes`, - ); + console.log(` 🔄 Auto-update enabled every ${AUTO_UPDATE_INTERVAL_MINUTES || 60} minutes`); } console.log(' 🖥️ Open your browser to start using OpenHamClock'); console.log(''); @@ -12670,19 +11828,10 @@ app.listen(PORT, '0.0.0.0', () => { .catch(() => {}); // Check for outdated systemd service file that prevents auto-update restart - if ( - AUTO_UPDATE_ENABLED && - (process.env.INVOCATION_ID || process.ppid === 1) - ) { + if (AUTO_UPDATE_ENABLED && (process.env.INVOCATION_ID || process.ppid === 1)) { try { - const serviceFile = fs.readFileSync( - '/etc/systemd/system/openhamclock.service', - 'utf8', - ); - if ( - serviceFile.includes('Restart=on-failure') && - !serviceFile.includes('Restart=always') - ) { + const serviceFile = fs.readFileSync('/etc/systemd/system/openhamclock.service', 'utf8'); + if (serviceFile.includes('Restart=on-failure') && !serviceFile.includes('Restart=always')) { console.log(' ⚠️ Your systemd service file uses Restart=on-failure'); console.log(' Auto-updates may not restart properly.'); console.log( diff --git a/src/components/PluginLayer.jsx b/src/components/PluginLayer.jsx index a6a719c1..895c64ed 100644 --- a/src/components/PluginLayer.jsx +++ b/src/components/PluginLayer.jsx @@ -17,9 +17,8 @@ export const PluginLayer = ({ config, onDXChange, dxLocked, - dxLocation + dxLocation, }) => { - const layerFunc = plugin.useLayer || plugin.hook; if (typeof layerFunc === 'function') { @@ -35,7 +34,7 @@ export const PluginLayer = ({ config, onDXChange, dxLocked, - dxLocation + dxLocation, }); } diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 984d6be5..535206b8 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -2,9 +2,9 @@ * WorldMap Component * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites, PSKReporter */ -import { useRef, useEffect, useState, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { MAP_STYLES } from "../utils/config.js"; +import { useRef, useEffect, useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MAP_STYLES } from '../utils/config.js'; import { calculateGridSquare, getSunPosition, @@ -12,8 +12,8 @@ import { getGreatCirclePoints, replicatePath, replicatePoint, -} from "../utils/geo.js"; -import { getBandColor } from "../utils/callsign.js"; +} from '../utils/geo.js'; +import { getBandColor } from '../utils/callsign.js'; import { BAND_LEGEND_ORDER, getBandColorForBand, @@ -21,26 +21,26 @@ import { getEffectiveBandColors, loadBandColorOverrides, saveBandColorOverrides, -} from "../utils/bandColors.js"; -import { createTerminator } from "../utils/terminator.js"; -import { getAllLayers } from "../plugins/layerRegistry.js"; -import useLocalInstall from "../hooks/app/useLocalInstall.js"; -import PluginLayer from "./PluginLayer.jsx"; -import AzimuthalMap from "./AzimuthalMap.jsx"; -import { DXNewsTicker } from "./DXNewsTicker.jsx"; -import { filterDXPaths } from "../utils"; +} from '../utils/bandColors.js'; +import { createTerminator } from '../utils/terminator.js'; +import { getAllLayers } from '../plugins/layerRegistry.js'; +import useLocalInstall from '../hooks/app/useLocalInstall.js'; +import PluginLayer from './PluginLayer.jsx'; +import AzimuthalMap from './AzimuthalMap.jsx'; +import { DXNewsTicker } from './DXNewsTicker.jsx'; +import { filterDXPaths } from '../utils'; // SECURITY: Escape HTML to prevent XSS in Leaflet popups/tooltips // DX cluster data, POTA/SOTA spots, and WSJT-X decodes come from external sources // and could contain malicious HTML/script tags in callsigns, comments, or park names. function esc(str) { - if (str == null) return ""; + if (str == null) return ''; return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } export const WorldMap = ({ @@ -69,11 +69,11 @@ export const WorldMap = ({ showWSJTX, onSpotClick, hoveredSpot, - callsign = "N0CALL", + callsign = 'N0CALL', showDXNews = true, hideOverlays, lowMemoryMode = false, - units = "imperial", + units = 'imperial', mouseZoom, showRotatorBearing = false, rotatorAzimuth = null, @@ -108,7 +108,7 @@ export const WorldMap = ({ // Calculate grid locator from DE location for plugins const deLocator = useMemo(() => { - if (!deLocation?.lat || !deLocation?.lon) return ""; + if (!deLocation?.lat || !deLocation?.lon) return ''; return calculateGridSquare(deLocation.lat, deLocation.lon); }, [deLocation?.lat, deLocation?.lon]); @@ -136,15 +136,8 @@ export const WorldMap = ({ setBandColorOverrides(loadBandColorOverrides()); setBandColorVersion((v) => v + 1); }; - window.addEventListener( - "openhamclock-band-colors-change", - onBandColorsChange, - ); - return () => - window.removeEventListener( - "openhamclock-band-colors-change", - onBandColorsChange, - ); + window.addEventListener('openhamclock-band-colors-change', onBandColorsChange); + return () => window.removeEventListener('openhamclock-band-colors-change', onBandColorsChange); }, []); // Plugin system refs and state @@ -155,7 +148,7 @@ export const WorldMap = ({ // Re-evaluate feature-gated integrations when toggles change in Settings useEffect(() => { - const bump = () => setIntegrationsRev(v => v + 1); + const bump = () => setIntegrationsRev((v) => v + 1); try { window.addEventListener('ohc-n3fjp-config-changed', bump); window.addEventListener('ohc-rotator-config-changed', bump); @@ -167,47 +160,44 @@ export const WorldMap = ({ } catch {} }; }, []); - + // Filter out localOnly layers on hosted version const getAvailableLayers = () => { // Reference integrationsRev so changes trigger a re-render pass. void integrationsRev; let n3fjpEnabled = false; - try { n3fjpEnabled = localStorage.getItem('ohc_n3fjp_enabled') === '1'; } catch {} + try { + n3fjpEnabled = localStorage.getItem('ohc_n3fjp_enabled') === '1'; + } catch {} - return getAllLayers().filter(l => { + return getAllLayers().filter((l) => { if (l.localOnly && !isLocalInstall) return false; // N3FJP is local-only + feature-gated (so hosted never shows it and local users must opt-in) if (l.id === 'n3fjp_logged_qsos' && !n3fjpEnabled) return false; return true; }); }; - + // Load map style from localStorage const getStoredMapSettings = () => { try { - const stored = localStorage.getItem("openhamclock_mapSettings"); + const stored = localStorage.getItem('openhamclock_mapSettings'); return stored ? JSON.parse(stored) : {}; } catch (e) { return {}; } }; const storedSettings = getStoredMapSettings(); - const [mapStyle, setMapStyle] = useState(storedSettings.mapStyle || "dark"); + const [mapStyle, setMapStyle] = useState(storedSettings.mapStyle || 'dark'); const [bandColorVersion, setBandColorVersion] = useState(0); const [editingBand, setEditingBand] = useState(null); - const [editingColor, setEditingColor] = useState("#ff6666"); - const [bandColorOverrides, setBandColorOverrides] = useState(() => - loadBandColorOverrides(), - ); + const [editingColor, setEditingColor] = useState('#ff6666'); + const [bandColorOverrides, setBandColorOverrides] = useState(() => loadBandColorOverrides()); // Tracks whether window.L (Leaflet, loaded via - - - - - - - - - - - - - - - - - - - - - - - - - -
- + + + + + + + + + + + + + + + + + + + + + + + + + +
+ - - + }); + // Clear the reload flag on successful load + window.addEventListener('load', function () { + sessionStorage.removeItem('ohc_chunk_reload'); + }); + // Also catch dynamic import() failures (rejected promises) + window.addEventListener('unhandledrejection', function (e) { + var msg = (e.reason && e.reason.message) || ''; + if ( + msg.indexOf('Failed to fetch dynamically imported module') !== -1 || + msg.indexOf('Importing a module script failed') !== -1 || + msg.indexOf('error loading dynamically imported module') !== -1 + ) { + if (!sessionStorage.getItem('ohc_chunk_reload')) { + sessionStorage.setItem('ohc_chunk_reload', '1'); + console.warn('[OpenHamClock] Stale module detected — reloading...'); + window.location.reload(); + } + } + }); + + + diff --git a/iturhfprop-service/README.md b/iturhfprop-service/README.md index d3219e3a..6be772ef 100644 --- a/iturhfprop-service/README.md +++ b/iturhfprop-service/README.md @@ -16,11 +16,13 @@ This microservice provides HF propagation predictions as a REST API, suitable fo ## API Endpoints ### Health Check + ``` GET /api/health ``` Response: + ```json { "status": "healthy", @@ -30,6 +32,7 @@ Response: ``` ### Single Point Prediction + ``` GET /api/predict?txLat=40.1&txLon=-74.8&rxLat=51.5&rxLon=-0.1&month=1&hour=12&ssn=100 ``` @@ -49,6 +52,7 @@ Parameters: | frequencies | No | Comma-separated MHz values | Response: + ```json { "model": "ITU-R P.533-14", @@ -63,6 +67,7 @@ Response: ``` ### 24-Hour Prediction + ``` GET /api/predict/hourly?txLat=40.1&txLon=-74.8&rxLat=51.5&rxLon=-0.1&month=1&ssn=100 ``` @@ -70,11 +75,13 @@ GET /api/predict/hourly?txLat=40.1&txLon=-74.8&rxLat=51.5&rxLon=-0.1&month=1&ssn Returns predictions for each hour (0-23 UTC). ### Band Conditions (Simplified) + ``` GET /api/bands?txLat=40.1&txLon=-74.8&rxLat=51.5&rxLon=-0.1 ``` Response: + ```json { "model": "ITU-R P.533-14", @@ -96,6 +103,7 @@ Response: 3. Deploy! The Dockerfile will: + - Clone and build ITURHFProp from source - Set up the Node.js API wrapper - Configure all necessary data files @@ -149,10 +157,10 @@ const ITURHFPROP_SERVICE = process.env.ITURHFPROP_URL || 'http://localhost:3001' app.get('/api/propagation', async (req, res) => { const { deLat, deLon, dxLat, dxLon } = req.query; - + try { const response = await fetch( - `${ITURHFPROP_SERVICE}/api/bands?txLat=${deLat}&txLon=${deLon}&rxLat=${dxLat}&rxLon=${dxLon}` + `${ITURHFPROP_SERVICE}/api/bands?txLat=${deLat}&txLon=${deLon}&rxLat=${dxLat}&rxLon=${dxLon}`, ); const data = await response.json(); res.json(data); @@ -174,6 +182,7 @@ app.get('/api/propagation', async (req, res) => { ### ITU-R P.533-14 The prediction model accounts for: + - F2-layer propagation (main HF mode) - E-layer propagation - Sporadic-E when applicable @@ -200,10 +209,10 @@ ITURHFProp is provided by ITU-R Study Group 3 - see [ITU-R-Study-Group-3/ITU-R-H ## Credits - **ITURHFProp** by ITU-R Study Group 3 - The core prediction engine -- **ITU-R P.533-14** - International Telecommunication Union recommendation +- **ITU-R P.533-14** - International Telecommunication Union recommendation - **Chris Behm & George Engelbrecht** - Original ITURHFProp developers - **OpenHamClock** - Integration target --- -*73 de OpenHamClock contributors* +_73 de OpenHamClock contributors_ diff --git a/iturhfprop-service/server.js b/iturhfprop-service/server.js index 9fd3502f..96cb0038 100644 --- a/iturhfprop-service/server.js +++ b/iturhfprop-service/server.js @@ -1,9 +1,9 @@ /** * ITURHFProp Service - * + * * REST API wrapper for the ITURHFProp HF propagation prediction engine * Implements ITU-R P.533-14 "Method for the prediction of the performance of HF circuits" - * + * * Endpoints: * GET /api/predict - Single point prediction * GET /api/predict/hourly - 24-hour prediction @@ -37,7 +37,7 @@ let circuitBreakerOpen = false; let consecutiveFailures = 0; let lastFailureTime = 0; let lastLogTime = 0; -const FAILURE_THRESHOLD = 3; // Open after 3 failures +const FAILURE_THRESHOLD = 3; // Open after 3 failures const CIRCUIT_RESET_TIME = 5 * 60 * 1000; // 5 minutes before retry const LOG_INTERVAL = 5 * 60 * 1000; // Only log every 5 minutes @@ -49,7 +49,7 @@ function getCacheKey(params) { function getFromCache(key) { const cached = predictionCache.get(key); - if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) { + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.data; } predictionCache.delete(key); @@ -76,7 +76,7 @@ function shouldLog() { function checkCircuitBreaker() { if (!circuitBreakerOpen) return false; - + // Check if enough time has passed to try again if (Date.now() - lastFailureTime > CIRCUIT_RESET_TIME) { circuitBreakerOpen = false; @@ -92,11 +92,13 @@ function checkCircuitBreaker() { function recordFailure() { consecutiveFailures++; lastFailureTime = Date.now(); - + if (consecutiveFailures >= FAILURE_THRESHOLD && !circuitBreakerOpen) { circuitBreakerOpen = true; if (shouldLog()) { - console.log(`[Circuit Breaker] OPEN after ${consecutiveFailures} failures - pausing for ${CIRCUIT_RESET_TIME/1000}s`); + console.log( + `[Circuit Breaker] OPEN after ${consecutiveFailures} failures - pausing for ${CIRCUIT_RESET_TIME / 1000}s`, + ); } } } @@ -117,7 +119,7 @@ if (!fs.existsSync(TEMP_DIR)) { // HF band frequencies (MHz) - P.533 valid range is 2-30 MHz const HF_BANDS = { - '160m': 2.0, // Adjusted from 1.9 to meet P.533 minimum of 2 MHz + '160m': 2.0, // Adjusted from 1.9 to meet P.533 minimum of 2 MHz '80m': 3.5, '60m': 5.3, '40m': 7.1, @@ -126,7 +128,7 @@ const HF_BANDS = { '17m': 18.1, '15m': 21.1, '12m': 24.9, - '10m': 28.1 + '10m': 28.1, // Note: 11m (27 MHz) excluded - too close to P.533 upper limit, use built-in calculation // Note: 6m (50 MHz) excluded - outside P.533 HF range (2-30 MHz) }; @@ -136,16 +138,21 @@ const HF_BANDS = { */ function generateInputFile(params) { const { - txLat, txLon, rxLat, rxLon, - year, month, hour, + txLat, + txLon, + rxLat, + rxLon, + year, + month, + hour, ssn = 100, - txPower = 100, // Watts - txGain = 0, // dBi - rxGain = 0, // dBi + txPower = 100, // Watts + txGain = 0, // dBi + rxGain = 0, // dBi frequencies = Object.values(HF_BANDS), - manMadeNoise = 'RESIDENTIAL', // CITY, RESIDENTIAL, RURAL, QUIET + manMadeNoise = 'RESIDENTIAL', // CITY, RESIDENTIAL, RURAL, QUIET requiredReliability = 90, - requiredSNR = 15 // dB for SSB + requiredSNR = 15, // dB for SSB } = params; // Convert coordinates to ITURHFProp format (decimal degrees) @@ -155,8 +162,8 @@ function generateInputFile(params) { const rxLonStr = rxLon >= 0 ? `${rxLon.toFixed(2)} E` : `${Math.abs(rxLon).toFixed(2)} W`; // Format frequencies - comma-separated per ITURHFProp docs - const freqList = frequencies.map(f => f.toFixed(3)).join(', '); - + const freqList = frequencies.map((f) => f.toFixed(3)).join(', '); + // ITURHFProp input file format - complete version with all required fields const input = `PathName "OpenHamClock" PathTXName "TX" @@ -172,7 +179,7 @@ RXGOS 0.0 AntennaOrientation "TX2RX" Path.year ${year} Path.month ${month} -Path.hour ${isNaN(hour) ? 12 : (hour === 0 ? 24 : hour)} +Path.hour ${isNaN(hour) ? 12 : hour === 0 ? 24 : hour} Path.SSN ${ssn} Path.frequency ${freqList} Path.txpower ${(10 * Math.log10(txPower)).toFixed(1)} @@ -205,59 +212,59 @@ function parseOutputFile(outputPath) { try { const output = fs.readFileSync(outputPath, 'utf8'); const lines = output.split('\n'); - + const results = { frequencies: [], - raw: output.substring(0, 3000) // Include raw for debugging + raw: output.substring(0, 3000), // Include raw for debugging }; - + let inDataSection = false; - + for (const line of lines) { const trimmed = line.trim(); - + // Look for "Calculated Parameters" section if (trimmed.includes('Calculated Parameters') && !trimmed.includes('End')) { inDataSection = true; continue; } - + // Stop at end of data if (trimmed.includes('End Calculated') || trimmed.includes('*****')) { if (inDataSection && results.frequencies.length > 0) { break; } } - + // Parse data lines: "02, 05, 2.000,-120.29, -16.04, 0.00" // Format: Month, Hour, Freq, Pr, SNR, BCR if (inDataSection && trimmed && !trimmed.startsWith('*') && !trimmed.startsWith('-')) { - const parts = trimmed.split(',').map(p => p.trim()); - + const parts = trimmed.split(',').map((p) => p.trim()); + if (parts.length >= 6) { const freq = parseFloat(parts[2]); const pr = parseFloat(parts[3]); const snr = parseFloat(parts[4]); const bcr = parseFloat(parts[5]); - + if (!isNaN(freq) && freq > 0) { results.frequencies.push({ freq: freq, sdbw: pr, snr: snr, - reliability: bcr + reliability: bcr, }); } } } } - + // Extract MUF from header section const mufMatch = output.match(/(?:BMUF|MUF|Operational MUF)\s*[:=]?\s*([\d.]+)/i); if (mufMatch) { results.muf = parseFloat(mufMatch[1]); } - + return results; } catch (err) { // File doesn't exist - this is expected when ITURHFProp fails @@ -273,65 +280,67 @@ async function runPrediction(params) { if (checkCircuitBreaker()) { return { error: 'Circuit breaker open', frequencies: [], circuitBreakerOpen: true }; } - + // Check cache const cacheKey = getCacheKey(params); const cached = getFromCache(cacheKey); if (cached) { return { ...cached, fromCache: true }; } - + const id = crypto.randomBytes(8).toString('hex'); const inputPath = path.join(TEMP_DIR, `input_${id}.txt`); const outputPath = path.join(TEMP_DIR, `output_${id}.txt`); - + // Ensure temp dir exists if (!fs.existsSync(TEMP_DIR)) { fs.mkdirSync(TEMP_DIR, { recursive: true }); } - + try { // Generate input file const inputContent = generateInputFile(params); fs.writeFileSync(inputPath, inputContent); - + // Run ITURHFProp const startTime = Date.now(); const cmd = `${ITURHFPROP_PATH} ${inputPath} ${outputPath}`; - + let execStdout = ''; try { execStdout = execSync(cmd, { timeout: 30000, encoding: 'utf8', - env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop:' + (process.env.LD_LIBRARY_PATH || '') } + env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop:' + (process.env.LD_LIBRARY_PATH || '') }, }); } catch (execError) { recordFailure(); - + // Only log occasionally if (shouldLog()) { - console.error(`[ITURHFProp] Failed (exit ${execError.status}), failures: ${consecutiveFailures}, circuit: ${circuitBreakerOpen ? 'OPEN' : 'closed'}`); + console.error( + `[ITURHFProp] Failed (exit ${execError.status}), failures: ${consecutiveFailures}, circuit: ${circuitBreakerOpen ? 'OPEN' : 'closed'}`, + ); } - + // Cache the error to prevent repeated attempts const errorResult = { error: `Exit code ${execError.status}`, frequencies: [] }; setCache(cacheKey, errorResult); return errorResult; } - + const elapsed = Date.now() - startTime; - + // Parse output const results = parseOutputFile(outputPath); - + if (results.frequencies.length === 0) { recordFailure(); const errorResult = { error: 'No frequency results', frequencies: [], elapsed }; setCache(cacheKey, errorResult); return errorResult; } - + // Success! recordSuccess(); results.elapsed = elapsed; @@ -342,18 +351,17 @@ async function runPrediction(params) { rxLon: params.rxLon, hour: params.hour, month: params.month, - ssn: params.ssn + ssn: params.ssn, }; - + // Cache successful result setCache(cacheKey, results); - + if (shouldLog()) { console.log(`[ITURHFProp] Success: ${results.frequencies.length} freqs, MUF=${results.muf || 'N/A'}`); } - + return results; - } finally { // Cleanup temp files try { @@ -376,16 +384,17 @@ app.get('/api/health', (req, res) => { const binaryExists = fs.existsSync(ITURHFPROP_PATH); const dataExists = fs.existsSync(ITURHFPROP_DATA); const dataSubExists = fs.existsSync(ITURHFPROP_DATA + '/Data'); - + // Check for shared libraries const libp533Exists = fs.existsSync('/opt/iturhfprop/libp533.so'); const libp372Exists = fs.existsSync('/opt/iturhfprop/libp372.so'); - + // Check for ionospheric data (ionos12.bin in Data folder) const ionosDataExists = fs.existsSync(ITURHFPROP_DATA + '/Data/ionos12.bin'); - + res.json({ - status: binaryExists && dataSubExists && libp533Exists && ionosDataExists && !circuitBreakerOpen ? 'healthy' : 'degraded', + status: + binaryExists && dataSubExists && libp533Exists && ionosDataExists && !circuitBreakerOpen ? 'healthy' : 'degraded', service: 'iturhfprop', version: '1.0.0', engine: 'ITURHFProp (ITU-R P.533-14)', @@ -397,13 +406,13 @@ app.get('/api/health', (req, res) => { circuitBreaker: { open: circuitBreakerOpen, consecutiveFailures, - cacheSize: predictionCache.size + cacheSize: predictionCache.size, }, paths: { binary: ITURHFPROP_PATH, - data: ITURHFPROP_DATA + data: ITURHFPROP_DATA, }, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); }); @@ -415,9 +424,9 @@ app.get('/api/diag', async (req, res) => { binary: {}, libraries: {}, data: {}, - testRun: {} + testRun: {}, }; - + // Check binary try { const stats = fs.statSync(ITURHFPROP_PATH); @@ -425,20 +434,20 @@ app.get('/api/diag', async (req, res) => { exists: true, size: stats.size, mode: stats.mode.toString(8), - path: ITURHFPROP_PATH + path: ITURHFPROP_PATH, }; } catch (e) { results.binary = { exists: false, error: e.message }; } - + // Check libraries try { - const libs = fs.readdirSync('/opt/iturhfprop').filter(f => f.endsWith('.so')); + const libs = fs.readdirSync('/opt/iturhfprop').filter((f) => f.endsWith('.so')); results.libraries = { found: libs }; } catch (e) { results.libraries = { error: e.message }; } - + // Check data files try { const dataFiles = fs.readdirSync(ITURHFPROP_DATA + '/Data').slice(0, 15); @@ -446,7 +455,7 @@ app.get('/api/diag', async (req, res) => { } catch (e) { results.data.dataDir = { error: e.message }; } - + // Check for ionospheric data try { const ionosExists = fs.existsSync(ITURHFPROP_DATA + '/Data/ionos12.bin'); @@ -454,7 +463,7 @@ app.get('/api/diag', async (req, res) => { } catch (e) { results.data.ionosData = { error: e.message }; } - + // Try running ldd on the binary try { const { execSync } = require('child_process'); @@ -463,30 +472,30 @@ app.get('/api/diag', async (req, res) => { } catch (e) { results.testRun.ldd = { error: e.message }; } - + // Try running the binary with no args to see usage try { const { execSync } = require('child_process'); - const output = execSync(`${ITURHFPROP_PATH} 2>&1 || true`, { + const output = execSync(`${ITURHFPROP_PATH} 2>&1 || true`, { encoding: 'utf8', - env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop' } + env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop' }, }); results.testRun.usage = output.split('\n').slice(0, 10); } catch (e) { results.testRun.usage = { error: e.message, stderr: e.stderr?.toString(), stdout: e.stdout?.toString() }; } - + // List ALL files in Data directory try { const allDataFiles = fs.readdirSync(ITURHFPROP_DATA + '/Data'); results.data.allFiles = allDataFiles; - results.data.hasIonos = allDataFiles.some(f => f.includes('ionos')); - results.data.hasAnt = allDataFiles.some(f => f.endsWith('.ant')); + results.data.hasIonos = allDataFiles.some((f) => f.includes('ionos')); + results.data.hasAnt = allDataFiles.some((f) => f.endsWith('.ant')); results.data.fileCount = allDataFiles.length; } catch (e) { results.data.allFiles = { error: e.message }; } - + // Create a minimal test input file and try to run try { const { execSync } = require('child_process'); @@ -528,13 +537,16 @@ RptFileFormat "RPT_PR | RPT_SNR | RPT_BCR" `; fs.writeFileSync('/tmp/test_input.txt', testInput); results.testRun.inputFile = testInput.split('\n'); - - const testOutput = execSync(`${ITURHFPROP_PATH} /tmp/test_input.txt /tmp/test_output.txt 2>&1 || echo "Exit code: $?"`, { - encoding: 'utf8', - env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop' } - }); + + const testOutput = execSync( + `${ITURHFPROP_PATH} /tmp/test_input.txt /tmp/test_output.txt 2>&1 || echo "Exit code: $?"`, + { + encoding: 'utf8', + env: { ...process.env, LD_LIBRARY_PATH: '/opt/iturhfprop' }, + }, + ); results.testRun.testExec = testOutput.split('\n').slice(0, 20); - + // Check if output was created if (fs.existsSync('/tmp/test_output.txt')) { const output = fs.readFileSync('/tmp/test_output.txt', 'utf8'); @@ -545,29 +557,35 @@ RptFileFormat "RPT_PR | RPT_SNR | RPT_BCR" } catch (e) { results.testRun.testExec = { error: e.message, stderr: e.stderr?.toString(), stdout: e.stdout?.toString() }; } - + res.json(results); }); /** * Single point prediction - * + * * GET /api/predict?txLat=40&txLon=-74&rxLat=51&rxLon=0&month=1&hour=12&ssn=100 */ app.get('/api/predict', async (req, res) => { try { const { - txLat, txLon, rxLat, rxLon, - month, hour, ssn, + txLat, + txLon, + rxLat, + rxLon, + month, + hour, + ssn, year = new Date().getFullYear(), - txPower, frequencies + txPower, + frequencies, } = req.query; - + // Validate required params if (!txLat || !txLon || !rxLat || !rxLon) { return res.status(400).json({ error: 'Missing required coordinates (txLat, txLon, rxLat, rxLon)' }); } - + const params = { txLat: parseFloat(txLat), txLon: parseFloat(txLon), @@ -577,21 +595,20 @@ app.get('/api/predict', async (req, res) => { month: parseInt(month) || new Date().getMonth() + 1, hour: parseInt(hour) || new Date().getUTCHours() || 12, ssn: parseInt(ssn) || 100, - txPower: parseInt(txPower) || 100 + txPower: parseInt(txPower) || 100, }; - + if (frequencies) { - params.frequencies = frequencies.split(',').map(f => parseFloat(f)); + params.frequencies = frequencies.split(',').map((f) => parseFloat(f)); } - + const results = await runPrediction(params); - + res.json({ model: 'ITU-R P.533-14', engine: 'ITURHFProp', - ...results + ...results, }); - } catch (err) { console.error('[API Error]', err); res.status(500).json({ error: err.message }); @@ -600,22 +617,18 @@ app.get('/api/predict', async (req, res) => { /** * 24-hour prediction - * + * * GET /api/predict/hourly?txLat=40&txLon=-74&rxLat=51&rxLon=0&month=1&ssn=100 */ app.get('/api/predict/hourly', async (req, res) => { try { - const { - txLat, txLon, rxLat, rxLon, - month, ssn, - year = new Date().getFullYear() - } = req.query; - + const { txLat, txLon, rxLat, rxLon, month, ssn, year = new Date().getFullYear() } = req.query; + // Validate required params if (!txLat || !txLon || !rxLat || !rxLon) { return res.status(400).json({ error: 'Missing required coordinates (txLat, txLon, rxLat, rxLon)' }); } - + const baseParams = { txLat: parseFloat(txLat), txLon: parseFloat(txLon), @@ -623,12 +636,12 @@ app.get('/api/predict/hourly', async (req, res) => { rxLon: parseFloat(rxLon), year: parseInt(year), month: parseInt(month) || new Date().getMonth() + 1, - ssn: parseInt(ssn) || 100 + ssn: parseInt(ssn) || 100, }; - + // Run predictions for each hour (0-23 UTC) const hourlyResults = []; - + for (let hour = 0; hour < 24; hour++) { const params = { ...baseParams, hour }; try { @@ -636,29 +649,28 @@ app.get('/api/predict/hourly', async (req, res) => { hourlyResults.push({ hour, muf: result.muf, - frequencies: result.frequencies + frequencies: result.frequencies, }); } catch (err) { hourlyResults.push({ hour, - error: err.message + error: err.message, }); } } - + res.json({ model: 'ITU-R P.533-14', engine: 'ITURHFProp', path: { tx: { lat: baseParams.txLat, lon: baseParams.txLon }, - rx: { lat: baseParams.rxLat, lon: baseParams.rxLon } + rx: { lat: baseParams.rxLat, lon: baseParams.rxLon }, }, month: baseParams.month, year: baseParams.year, ssn: baseParams.ssn, - hourly: hourlyResults + hourly: hourlyResults, }); - } catch (err) { console.error('[API Error]', err); res.status(500).json({ error: err.message }); @@ -667,20 +679,17 @@ app.get('/api/predict/hourly', async (req, res) => { /** * Band conditions (simplified format for OpenHamClock) - * + * * GET /api/bands?txLat=40&txLon=-74&rxLat=51&rxLon=0 */ app.get('/api/bands', async (req, res) => { try { - const { - txLat, txLon, rxLat, rxLon, - month, hour, ssn - } = req.query; - + const { txLat, txLon, rxLat, rxLon, month, hour, ssn } = req.query; + if (!txLat || !txLon || !rxLat || !rxLon) { return res.status(400).json({ error: 'Missing required coordinates' }); } - + const params = { txLat: parseFloat(txLat), txLon: parseFloat(txLon), @@ -690,20 +699,18 @@ app.get('/api/bands', async (req, res) => { month: parseInt(month) || new Date().getMonth() + 1, hour: parseInt(hour) || new Date().getUTCHours() || 12, ssn: parseInt(ssn) || 100, - frequencies: Object.values(HF_BANDS) + frequencies: Object.values(HF_BANDS), }; - + const results = await runPrediction(params); - + // Map to band names const bands = {}; const bandFreqs = Object.entries(HF_BANDS); - + for (const freqResult of results.frequencies) { - const bandEntry = bandFreqs.find(([name, freq]) => - Math.abs(freq - freqResult.freq) < 1 - ); - + const bandEntry = bandFreqs.find(([name, freq]) => Math.abs(freq - freqResult.freq) < 1); + if (bandEntry) { const [bandName] = bandEntry; bands[bandName] = { @@ -711,12 +718,11 @@ app.get('/api/bands', async (req, res) => { reliability: freqResult.reliability, snr: freqResult.snr, sdbw: freqResult.sdbw, - status: freqResult.reliability >= 70 ? 'GOOD' : - freqResult.reliability >= 40 ? 'FAIR' : 'POOR' + status: freqResult.reliability >= 70 ? 'GOOD' : freqResult.reliability >= 40 ? 'FAIR' : 'POOR', }; } } - + res.json({ model: 'ITU-R P.533-14', muf: results.muf, @@ -727,11 +733,10 @@ app.get('/api/bands', async (req, res) => { parsedFreqs: results.frequencies, execStdout: results.execStdout, execStderr: results.execStderr, - inputContent: results.inputContent?.substring(0, 1000) + inputContent: results.inputContent?.substring(0, 1000), }, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); - } catch (err) { console.error('[API Error]', err); res.status(500).json({ error: err.message }); diff --git a/package.json b/package.json index 9a6ac652..701767ef 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "npx vite", "electron": "electron .", "electron-builder": "electron-builder", - "build": "npx vite build", + "build": "npx vite build", "preview": "npx vite preview", "prestart": "npm run build", "server": "node server.js", @@ -77,4 +77,4 @@ } ], "license": "MIT" -} \ No newline at end of file +} diff --git a/public/index.html b/public/index.html index d5887b6f..f8ae703b 100644 --- a/public/index.html +++ b/public/index.html @@ -1,95 +1,102 @@ - + - - - - OpenHamClock - Amateur Radio Dashboard | Live DX Cluster, Propagation, Band Conditions - - - - - -
-

📻 OpenHamClock

-

The modular frontend needs to be built first.

- - Use Classic Version Instead - -

Or build the modular version:

-
- npm install - npm run build - npm start + + + + OpenHamClock - Amateur Radio Dashboard | Live DX Cluster, Propagation, Band Conditions + + + + + +
+

📻 OpenHamClock

+

The modular frontend needs to be built first.

+ + Use Classic Version Instead + +

Or build the modular version:

+
+ npm install + npm run build + npm start +
+ +

+ The classic version works without building.
+ The modular version requires Node.js 18+ to build. +

- -

- The classic version works without building.
- The modular version requires Node.js 18+ to build. -

-
- + diff --git a/rig-bridge/README.md b/rig-bridge/README.md index 2ebbd4d5..e5e9359c 100644 --- a/rig-bridge/README.md +++ b/rig-bridge/README.md @@ -7,13 +7,15 @@ The Rig Bridge connects OpenHamClock directly to your radio via USB — no flrig ## Supported Radios ### Direct USB (Recommended) -| Brand | Protocol | Tested Models | -|-------|----------|---------------| -| **Yaesu** | CAT | FT-991A, FT-891, FT-710, FT-DX10, FT-DX101, FT-5000 | -| **Kenwood** | Kenwood | TS-890, TS-590, TS-2000, TS-480 | -| **Icom** | CI-V | IC-7300, IC-7610, IC-9700, IC-705, IC-7851 | + +| Brand | Protocol | Tested Models | +| ----------- | -------- | --------------------------------------------------- | +| **Yaesu** | CAT | FT-991A, FT-891, FT-710, FT-DX10, FT-DX101, FT-5000 | +| **Kenwood** | Kenwood | TS-890, TS-590, TS-2000, TS-480 | +| **Icom** | CI-V | IC-7300, IC-7610, IC-9700, IC-705, IC-7851 | ### Via Control Software (Legacy) + Still works with **flrig** or **rigctld** if you prefer. --- @@ -21,6 +23,7 @@ Still works with **flrig** or **rigctld** if you prefer. ## Quick Start ### Option A: Download the Executable (Easiest) + 1. Download the right file for your OS from the Releases page 2. Double-click to run 3. Open **http://localhost:5555** in your browser @@ -28,11 +31,13 @@ Still works with **flrig** or **rigctld** if you prefer. 5. Click **Save & Connect** ### Option B: Run with Node.js + ```bash cd rig-bridge npm install node rig-bridge.js ``` + Then open **http://localhost:5555** to configure. --- @@ -40,16 +45,19 @@ Then open **http://localhost:5555** to configure. ## Radio Setup Tips ### Yaesu FT-991A + 1. Connect USB-B cable from radio to computer 2. On the radio: **Menu → Operation Setting → CAT Rate → 38400** 3. In Rig Bridge: Select **Yaesu**, pick your COM port, baud **38400**, stop bits **2** ### Icom IC-7300 + 1. Connect USB cable from radio to computer 2. On the radio: **Menu → Connectors → CI-V → CI-V USB Baud Rate → 115200** 3. In Rig Bridge: Select **Icom**, pick COM port, baud **115200**, stop bits **1**, address **0x94** ### Kenwood TS-590 + 1. Connect USB cable from radio to computer 2. In Rig Bridge: Select **Kenwood**, pick COM port, baud **9600**, stop bits **1** @@ -87,13 +95,13 @@ Executables are output to the `dist/` folder. ## Troubleshooting -| Problem | Solution | -|---------|----------| -| No COM ports found | Install USB driver (Silicon Labs CP210x for Yaesu, FTDI for some Kenwood) | -| Port opens but no data | Check baud rate matches radio's CAT Rate setting | -| Icom not responding | Verify CI-V address matches your radio model | -| CORS errors in browser | The bridge allows all origins by default | -| Port already in use | Close flrig/rigctld if running — you don't need them anymore | +| Problem | Solution | +| ---------------------- | ------------------------------------------------------------------------- | +| No COM ports found | Install USB driver (Silicon Labs CP210x for Yaesu, FTDI for some Kenwood) | +| Port opens but no data | Check baud rate matches radio's CAT Rate setting | +| Icom not responding | Verify CI-V address matches your radio model | +| CORS errors in browser | The bridge allows all origins by default | +| Port already in use | Close flrig/rigctld if running — you don't need them anymore | --- @@ -101,13 +109,13 @@ Executables are output to the `dist/` folder. Same API as the original rig-daemon — fully backward compatible: -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/status` | Current freq, mode, PTT, connected status | -| GET | `/stream` | SSE stream of real-time updates | -| POST | `/freq` | Set frequency: `{ "freq": 14074000 }` | -| POST | `/mode` | Set mode: `{ "mode": "USB" }` | -| POST | `/ptt` | Set PTT: `{ "ptt": true }` | -| GET | `/api/ports` | List available serial ports | -| GET | `/api/config` | Get current configuration | -| POST | `/api/config` | Update configuration & reconnect | +| Method | Endpoint | Description | +| ------ | ------------- | ----------------------------------------- | +| GET | `/status` | Current freq, mode, PTT, connected status | +| GET | `/stream` | SSE stream of real-time updates | +| POST | `/freq` | Set frequency: `{ "freq": 14074000 }` | +| POST | `/mode` | Set mode: `{ "mode": "USB" }` | +| POST | `/ptt` | Set PTT: `{ "ptt": true }` | +| GET | `/api/ports` | List available serial ports | +| GET | `/api/config` | Get current configuration | +| POST | `/api/config` | Update configuration & reconnect | diff --git a/rig-bridge/rig-bridge.js b/rig-bridge/rig-bridge.js index d06b1676..ff49d635 100644 --- a/rig-bridge/rig-bridge.js +++ b/rig-bridge/rig-bridge.js @@ -1,15 +1,15 @@ #!/usr/bin/env node /** * OpenHamClock Rig Bridge v1.0.0 - * + * * Standalone bridge that talks DIRECTLY to your radio via USB serial. * No flrig, no rigctld, no Node.js install needed (when compiled with pkg). - * + * * Supports: Yaesu (FT-991A, FT-891, FT-710, FT-DX10, FT-DX101, etc.) * Kenwood (TS-890, TS-590, TS-2000, etc.) * Icom (IC-7300, IC-7610, IC-9700, IC-705, etc.) * + Legacy flrig/rigctld backends - * + * * Usage: node rig-bridge.js (then open http://localhost:5555 to configure) * ohc-rig-bridge-win.exe (compiled standalone) */ @@ -21,22 +21,20 @@ const path = require('path'); const net = require('net'); // ─── Portable config path (works in pkg snapshots too) ─── -const CONFIG_DIR = process.pkg - ? path.dirname(process.execPath) - : __dirname; +const CONFIG_DIR = process.pkg ? path.dirname(process.execPath) : __dirname; const CONFIG_PATH = path.join(CONFIG_DIR, 'rig-bridge-config.json'); // ─── Defaults ─── const DEFAULT_CONFIG = { port: 5555, radio: { - type: 'none', // none | yaesu | kenwood | icom | flrig | rigctld - serialPort: '', // COM3, /dev/ttyUSB0, etc. + type: 'none', // none | yaesu | kenwood | icom | flrig | rigctld + serialPort: '', // COM3, /dev/ttyUSB0, etc. baudRate: 38400, dataBits: 8, - stopBits: 2, // Yaesu default; Icom/Kenwood typically 1 + stopBits: 2, // Yaesu default; Icom/Kenwood typically 1 parity: 'none', - icomAddress: '0x94', // Default CI-V address for IC-7300 + icomAddress: '0x94', // Default CI-V address for IC-7300 pollInterval: 500, pttEnabled: false, // Legacy backend settings @@ -44,7 +42,7 @@ const DEFAULT_CONFIG = { rigctldPort: 4532, flrigHost: '127.0.0.1', flrigPort: 12345, - } + }, }; // ─── Load / save config ─── @@ -56,7 +54,7 @@ function loadConfig() { config = { ...DEFAULT_CONFIG, ...raw, - radio: { ...DEFAULT_CONFIG.radio, ...(raw.radio || {}) } + radio: { ...DEFAULT_CONFIG.radio, ...(raw.radio || {}) }, }; console.log(`[Config] Loaded from ${CONFIG_PATH}`); } @@ -94,7 +92,7 @@ const state = { let sseClients = []; function broadcast(data) { const msg = `data: ${JSON.stringify(data)}\n\n`; - sseClients.forEach(c => c.res.write(msg)); + sseClients.forEach((c) => c.res.write(msg)); } function updateState(prop, value) { if (state[prop] !== value) { @@ -131,7 +129,7 @@ async function listPorts() { try { const { SerialPort: SP } = require('serialport'); const ports = await SP.list(); - return ports.map(p => ({ + return ports.map((p) => ({ path: p.path, manufacturer: p.manufacturer || '', serialNumber: p.serialNumber || '', @@ -153,7 +151,7 @@ function connectSerial() { disconnectSerial(); console.log(`[Serial] Opening ${config.radio.serialPort} at ${config.radio.baudRate} baud...`); - + serialConnection = new SP({ path: config.radio.serialPort, baudRate: config.radio.baudRate, @@ -207,7 +205,9 @@ function connectSerial() { function disconnectSerial() { stopPolling(); if (serialConnection && serialConnection.isOpen) { - try { serialConnection.close(); } catch (e) {} + try { + serialConnection.close(); + } catch (e) {} } serialConnection = null; } @@ -223,11 +223,17 @@ function startPolling() { stopPolling(); pollTimer = setInterval(() => { if (!serialConnection || !serialConnection.isOpen) return; - + switch (config.radio.type) { - case 'yaesu': pollYaesu(); break; - case 'kenwood': pollKenwood(); break; - case 'icom': pollIcom(); break; + case 'yaesu': + pollYaesu(); + break; + case 'kenwood': + pollKenwood(); + break; + case 'icom': + pollIcom(); + break; } }, config.radio.pollInterval || 500); } @@ -246,7 +252,7 @@ function stopPolling() { // ═══════════════════════════════════════════════════════ function pollYaesu() { - // FA = read VFO-A freq, MD0 = read main mode + // FA = read VFO-A freq, MD0 = read main mode // More reliable across models than IF which varies in format serialWrite('FA;'); // Stagger mode query slightly to avoid buffer collisions @@ -258,7 +264,7 @@ function processAsciiBuffer() { while ((idx = rxBuffer.indexOf(';')) !== -1) { const response = rxBuffer.substring(0, idx); rxBuffer = rxBuffer.substring(idx + 1); - + if (config.radio.type === 'yaesu') { parseYaesuResponse(response); } else if (config.radio.type === 'kenwood') { @@ -319,14 +325,26 @@ function parseYaesuResponse(resp) { } const YAESU_MODES = { - '1': 'LSB', '2': 'USB', '3': 'CW', '4': 'FM', - '5': 'AM', '6': 'RTTY-LSB', '7': 'CW-R', '8': 'DATA-LSB', - '9': 'RTTY-USB', 'A': 'DATA-FM', 'B': 'FM-N', 'C': 'DATA-USB', - 'D': 'AM-N', 'E': 'C4FM' + 1: 'LSB', + 2: 'USB', + 3: 'CW', + 4: 'FM', + 5: 'AM', + 6: 'RTTY-LSB', + 7: 'CW-R', + 8: 'DATA-LSB', + 9: 'RTTY-USB', + A: 'DATA-FM', + B: 'FM-N', + C: 'DATA-USB', + D: 'AM-N', + E: 'C4FM', }; const YAESU_MODE_REVERSE = {}; -Object.entries(YAESU_MODES).forEach(([k, v]) => { YAESU_MODE_REVERSE[v] = k; }); +Object.entries(YAESU_MODES).forEach(([k, v]) => { + YAESU_MODE_REVERSE[v] = k; +}); function yaesuSetFreq(hz) { const padded = String(Math.round(hz)).padStart(9, '0'); @@ -339,10 +357,22 @@ function yaesuSetMode(mode) { // Try common aliases if (!digit) { const aliases = { - 'USB': '2', 'LSB': '1', 'CW': '3', 'CW-R': '7', - 'FM': '4', 'AM': '5', 'DATA-USB': 'C', 'DATA-LSB': '8', - 'RTTY': '6', 'RTTY-R': '9', 'FT8': 'C', 'FT4': 'C', - 'DIGI': 'C', 'SSB': '2', 'PSK': 'C', 'JT65': 'C', + USB: '2', + LSB: '1', + CW: '3', + 'CW-R': '7', + FM: '4', + AM: '5', + 'DATA-USB': 'C', + 'DATA-LSB': '8', + RTTY: '6', + 'RTTY-R': '9', + FT8: 'C', + FT4: 'C', + DIGI: 'C', + SSB: '2', + PSK: 'C', + JT65: 'C', }; digit = aliases[mode.toUpperCase()]; } @@ -405,13 +435,22 @@ function parseKenwoodResponse(resp) { } const KENWOOD_MODES = { - '1': 'LSB', '2': 'USB', '3': 'CW', '4': 'FM', - '5': 'AM', '6': 'FSK', '7': 'CW-R', '8': 'DATA-LSB', - '9': 'FSK-R', 'A': 'DATA-USB' + 1: 'LSB', + 2: 'USB', + 3: 'CW', + 4: 'FM', + 5: 'AM', + 6: 'FSK', + 7: 'CW-R', + 8: 'DATA-LSB', + 9: 'FSK-R', + A: 'DATA-USB', }; const KENWOOD_MODE_REVERSE = {}; -Object.entries(KENWOOD_MODES).forEach(([k, v]) => { KENWOOD_MODE_REVERSE[v] = k; }); +Object.entries(KENWOOD_MODES).forEach(([k, v]) => { + KENWOOD_MODE_REVERSE[v] = k; +}); function kenwoodSetFreq(hz) { const padded = String(Math.round(hz)).padStart(11, '0'); @@ -422,9 +461,18 @@ function kenwoodSetMode(mode) { let digit = KENWOOD_MODE_REVERSE[mode]; if (!digit) { const aliases = { - 'USB': '2', 'LSB': '1', 'CW': '3', 'CW-R': '7', - 'FM': '4', 'AM': '5', 'DATA-USB': 'A', 'DATA-LSB': '8', - 'FT8': 'A', 'FT4': 'A', 'DIGI': 'A', 'PSK': 'A', + USB: '2', + LSB: '1', + CW: '3', + 'CW-R': '7', + FM: '4', + AM: '5', + 'DATA-USB': 'A', + 'DATA-LSB': '8', + FT8: 'A', + FT4: 'A', + DIGI: 'A', + PSK: 'A', }; digit = aliases[mode.toUpperCase()]; } @@ -441,7 +489,7 @@ function kenwoodSetPTT(on) { // Binary protocol: FE FE [to] [from] [cmd] [sub] [data...] FD // ═══════════════════════════════════════════════════════ -const ICOM_CONTROLLER = 0xE0; // Our address (controller) +const ICOM_CONTROLLER = 0xe0; // Our address (controller) function getIcomAddress() { const addr = config.radio.icomAddress || '0x94'; @@ -450,10 +498,10 @@ function getIcomAddress() { function icomBuildCmd(cmd, sub, data = []) { const to = getIcomAddress(); - const packet = [0xFE, 0xFE, to, ICOM_CONTROLLER, cmd]; + const packet = [0xfe, 0xfe, to, ICOM_CONTROLLER, cmd]; if (sub !== undefined && sub !== null) packet.push(sub); packet.push(...data); - packet.push(0xFD); + packet.push(0xfd); return Buffer.from(packet); } @@ -469,12 +517,15 @@ function handleIcomData(data) { while (true) { // Find start of frame - const start = rxBinaryBuffer.indexOf(0xFE); - if (start === -1) { rxBinaryBuffer = Buffer.alloc(0); return; } + const start = rxBinaryBuffer.indexOf(0xfe); + if (start === -1) { + rxBinaryBuffer = Buffer.alloc(0); + return; + } if (start > 0) rxBinaryBuffer = rxBinaryBuffer.slice(start); // Need at least FE FE ... FD - const end = rxBinaryBuffer.indexOf(0xFD, 2); + const end = rxBinaryBuffer.indexOf(0xfd, 2); if (end === -1) return; // Wait for more data const frame = rxBinaryBuffer.slice(0, end + 1); @@ -482,7 +533,7 @@ function handleIcomData(data) { // Skip preamble FE FE if (frame.length < 6) continue; - if (frame[0] !== 0xFE || frame[1] !== 0xFE) continue; + if (frame[0] !== 0xfe || frame[1] !== 0xfe) continue; const to = frame[2]; const from = frame[3]; @@ -493,7 +544,8 @@ function handleIcomData(data) { switch (cmd) { case 0x03: // Frequency response - case 0x00: { // Freq update (unsolicited) + case 0x00: { + // Freq update (unsolicited) if (frame.length >= 10) { const freq = icomBCDToFreq(frame.slice(5, 10)); if (freq > 0) updateState('freq', freq); @@ -501,7 +553,8 @@ function handleIcomData(data) { break; } case 0x04: // Mode response - case 0x01: { // Mode update + case 0x01: { + // Mode update if (frame.length >= 7) { const mode = ICOM_MODES[frame[5]] || state.mode; updateState('mode', mode); @@ -511,10 +564,12 @@ function handleIcomData(data) { } break; } - case 0xFB: { // OK acknowledgment + case 0xfb: { + // OK acknowledgment break; } - case 0xFA: { // NG (error) + case 0xfa: { + // NG (error) console.warn('[Icom] Command rejected (NG)'); break; } @@ -527,8 +582,8 @@ function icomBCDToFreq(bytes) { let freq = 0; let mult = 1; for (let i = 0; i < bytes.length; i++) { - const lo = bytes[i] & 0x0F; - const hi = (bytes[i] >> 4) & 0x0F; + const lo = bytes[i] & 0x0f; + const hi = (bytes[i] >> 4) & 0x0f; freq += lo * mult; mult *= 10; freq += hi * mult; @@ -541,22 +596,34 @@ function icomFreqToBCD(freq) { const bytes = []; let f = Math.round(freq); for (let i = 0; i < 5; i++) { - const lo = f % 10; f = Math.floor(f / 10); - const hi = f % 10; f = Math.floor(f / 10); + const lo = f % 10; + f = Math.floor(f / 10); + const hi = f % 10; + f = Math.floor(f / 10); bytes.push((hi << 4) | lo); } return bytes; } const ICOM_MODES = { - 0x00: 'LSB', 0x01: 'USB', 0x02: 'AM', 0x03: 'CW', - 0x04: 'RTTY', 0x05: 'FM', 0x06: 'WFM', 0x07: 'CW-R', - 0x08: 'RTTY-R', 0x11: 'DATA-LSB', 0x12: 'DATA-USB', + 0x00: 'LSB', + 0x01: 'USB', + 0x02: 'AM', + 0x03: 'CW', + 0x04: 'RTTY', + 0x05: 'FM', + 0x06: 'WFM', + 0x07: 'CW-R', + 0x08: 'RTTY-R', + 0x11: 'DATA-LSB', + 0x12: 'DATA-USB', 0x17: 'DATA-FM', }; const ICOM_MODE_REVERSE = {}; -Object.entries(ICOM_MODES).forEach(([k, v]) => { ICOM_MODE_REVERSE[v] = parseInt(k); }); +Object.entries(ICOM_MODES).forEach(([k, v]) => { + ICOM_MODE_REVERSE[v] = parseInt(k); +}); function icomSetFreq(hz) { const bcd = icomFreqToBCD(hz); @@ -567,10 +634,20 @@ function icomSetMode(mode) { let code = ICOM_MODE_REVERSE[mode]; if (code === undefined) { const aliases = { - 'USB': 0x01, 'LSB': 0x00, 'CW': 0x03, 'CW-R': 0x07, - 'FM': 0x05, 'AM': 0x02, 'DATA-USB': 0x12, 'DATA-LSB': 0x11, - 'FT8': 0x12, 'FT4': 0x12, 'DIGI': 0x12, 'PSK': 0x12, - 'RTTY': 0x04, 'RTTY-R': 0x08, + USB: 0x01, + LSB: 0x00, + CW: 0x03, + 'CW-R': 0x07, + FM: 0x05, + AM: 0x02, + 'DATA-USB': 0x12, + 'DATA-LSB': 0x11, + FT8: 0x12, + FT4: 0x12, + DIGI: 0x12, + PSK: 0x12, + RTTY: 0x04, + 'RTTY-R': 0x08, }; code = aliases[mode.toUpperCase()]; } @@ -581,7 +658,7 @@ function icomSetMode(mode) { } function icomSetPTT(on) { - serialWrite(icomBuildCmd(0x1C, 0x00, [on ? 0x01 : 0x00])); + serialWrite(icomBuildCmd(0x1c, 0x00, [on ? 0x01 : 0x00])); } // ═══════════════════════════════════════════════════════ @@ -636,7 +713,10 @@ function startRigctldPoll() { } function rigctldSend(cmd, cb) { - if (!rigctldSocket) { if (cb) cb(new Error('Not connected')); return; } + if (!rigctldSocket) { + if (cb) cb(new Error('Not connected')); + return; + } rigctldQueue.push({ cmd, cb }); rigctldProcess(); } @@ -716,10 +796,18 @@ function startFlrigPoll() { function setFreq(hz) { switch (config.radio.type) { - case 'yaesu': yaesuSetFreq(hz); break; - case 'kenwood': kenwoodSetFreq(hz); break; - case 'icom': icomSetFreq(hz); break; - case 'rigctld': rigctldSend(`F ${hz}`); break; + case 'yaesu': + yaesuSetFreq(hz); + break; + case 'kenwood': + kenwoodSetFreq(hz); + break; + case 'icom': + icomSetFreq(hz); + break; + case 'rigctld': + rigctldSend(`F ${hz}`); + break; case 'flrig': if (flrigClient) flrigClient.methodCall('rig.set_frequency', [parseFloat(hz) + 0.1], () => {}); break; @@ -728,10 +816,18 @@ function setFreq(hz) { function setModeCmd(mode) { switch (config.radio.type) { - case 'yaesu': yaesuSetMode(mode); break; - case 'kenwood': kenwoodSetMode(mode); break; - case 'icom': icomSetMode(mode); break; - case 'rigctld': rigctldSend(`M ${mode} 0`); break; + case 'yaesu': + yaesuSetMode(mode); + break; + case 'kenwood': + kenwoodSetMode(mode); + break; + case 'icom': + icomSetMode(mode); + break; + case 'rigctld': + rigctldSend(`M ${mode} 0`); + break; case 'flrig': if (flrigClient) flrigClient.methodCall('rig.set_mode', [mode], () => {}); break; @@ -744,10 +840,18 @@ function setPTTCmd(on) { return false; } switch (config.radio.type) { - case 'yaesu': yaesuSetPTT(on); break; - case 'kenwood': kenwoodSetPTT(on); break; - case 'icom': icomSetPTT(on); break; - case 'rigctld': rigctldSend(on ? 'T 1' : 'T 0'); break; + case 'yaesu': + yaesuSetPTT(on); + break; + case 'kenwood': + kenwoodSetPTT(on); + break; + case 'icom': + icomSetPTT(on); + break; + case 'rigctld': + rigctldSend(on ? 'T 1' : 'T 0'); + break; case 'flrig': if (flrigClient) flrigClient.methodCall('rig.set_ptt', [on ? 1 : 0], () => {}); break; @@ -762,7 +866,7 @@ function setPTTCmd(on) { function startConnection() { stopConnection(); console.log(`[Bridge] Starting connection, type: ${config.radio.type}`); - + switch (config.radio.type) { case 'yaesu': case 'kenwood': @@ -785,7 +889,12 @@ function startConnection() { function stopConnection() { stopPolling(); disconnectSerial(); - if (rigctldSocket) { try { rigctldSocket.destroy(); } catch(e) {} rigctldSocket = null; } + if (rigctldSocket) { + try { + rigctldSocket.destroy(); + } catch (e) {} + rigctldSocket = null; + } flrigClient = null; rigctldQueue = []; rigctldPending = null; @@ -828,10 +937,10 @@ app.post('/api/config', (req, res) => { config.radio = { ...config.radio, ...newConfig.radio }; } saveConfig(); - + // Restart connection with new config startConnection(); - + res.json({ success: true, config }); }); @@ -840,7 +949,7 @@ app.post('/api/test', async (req, res) => { // Quick serial port test const testPort = req.body.serialPort || config.radio.serialPort; const testBaud = req.body.baudRate || config.radio.baudRate; - + const SP = getSerialPort(); if (!SP) return res.json({ success: false, error: 'serialport module not available' }); @@ -850,7 +959,7 @@ app.post('/api/test', async (req, res) => { baudRate: testBaud, autoOpen: false, }); - + testConn.open((err) => { if (err) { return res.json({ success: false, error: err.message }); @@ -880,7 +989,7 @@ app.get('/stream', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*', }); @@ -898,7 +1007,7 @@ app.get('/stream', (req, res) => { sseClients.push({ id: clientId, res }); req.on('close', () => { - sseClients = sseClients.filter(c => c.id !== clientId); + sseClients = sseClients.filter((c) => c.id !== clientId); }); }); diff --git a/rig-control/UserGuide.md b/rig-control/UserGuide.md index 6ab9dc93..fd4c4412 100644 --- a/rig-control/UserGuide.md +++ b/rig-control/UserGuide.md @@ -144,9 +144,10 @@ node rig-daemon.js ## ✅ You're Done! -Navigate to the dashboard. You should see the Rig Control panel (if enabled). +Navigate to the dashboard. You should see the Rig Control panel (if enabled). **Try it out:** + - Click a spot on the **World Map**. - Click a row in the **DX Cluster** list. - Click a **POTA** or **SOTA** spot. diff --git a/rig-control/rig-daemon.js b/rig-control/rig-daemon.js index 78498707..662c7949 100644 --- a/rig-control/rig-daemon.js +++ b/rig-control/rig-daemon.js @@ -10,20 +10,20 @@ * node rig-daemon.js --type flrig --rig-host 127.0.0.1 --rig-port 12345 --http-port 5555 */ -const express = require("express"); -const cors = require("cors"); -const net = require("net"); -const xmlrpc = require("xmlrpc"); +const express = require('express'); +const cors = require('cors'); +const net = require('net'); +const xmlrpc = require('xmlrpc'); // Configuration Defaults -const fs = require("fs"); -const path = require("path"); +const fs = require('fs'); +const path = require('path'); let CONFIG = { - server: { host: "0.0.0.0", port: 5555 }, + server: { host: '0.0.0.0', port: 5555 }, radio: { - type: "rigctld", - host: "127.0.0.1", + type: 'rigctld', + host: '127.0.0.1', rigPort: 4532, pollInterval: 1000, pttEnabled: false, @@ -31,10 +31,10 @@ let CONFIG = { }; // Load Config File -const configPath = path.join(__dirname, "rig-config.json"); +const configPath = path.join(__dirname, 'rig-config.json'); if (fs.existsSync(configPath)) { try { - const fileConfig = JSON.parse(fs.readFileSync(configPath, "utf8")); + const fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); console.log(`[Config] Loaded configuration from ${configPath}`); // Merge Server Config @@ -56,17 +56,17 @@ if (fs.existsSync(configPath)) { // Legacy CLI Args (Prioritize over config file if provided) const ARGS = process.argv.slice(2); for (let i = 0; i < ARGS.length; i++) { - if (ARGS[i] === "--type") CONFIG.radio.type = ARGS[++i]; - if (ARGS[i] === "--rig-host") CONFIG.radio.host = ARGS[++i]; - if (ARGS[i] === "--rig-port") CONFIG.radio.rigPort = parseInt(ARGS[++i]); - if (ARGS[i] === "--http-port") CONFIG.server.port = parseInt(ARGS[++i]); + if (ARGS[i] === '--type') CONFIG.radio.type = ARGS[++i]; + if (ARGS[i] === '--rig-host') CONFIG.radio.host = ARGS[++i]; + if (ARGS[i] === '--rig-port') CONFIG.radio.rigPort = parseInt(ARGS[++i]); + if (ARGS[i] === '--http-port') CONFIG.server.port = parseInt(ARGS[++i]); } // Adjust default port if flrig and not manually set (heuristic) if ( - CONFIG.radio.type === "flrig" && + CONFIG.radio.type === 'flrig' && CONFIG.radio.rigPort === 4532 && - !process.argv.includes("--rig-port") && + !process.argv.includes('--rig-port') && !fs.existsSync(configPath) ) { CONFIG.radio.rigPort = 12345; @@ -79,7 +79,7 @@ console.log(`[Config] HTTP: ${CONFIG.server.port}`); // State const state = { freq: 0, - mode: "", + mode: '', width: 0, ptt: false, connected: false, @@ -112,38 +112,36 @@ const RigaAdapter = { RigaAdapter.connect(); // Poll loop setInterval(() => { - if (!state.connected || CONFIG.radio.type !== "rigctld") return; - RigaAdapter.send("f"); - RigaAdapter.send("m"); - RigaAdapter.send("t"); + if (!state.connected || CONFIG.radio.type !== 'rigctld') return; + RigaAdapter.send('f'); + RigaAdapter.send('m'); + RigaAdapter.send('t'); }, CONFIG.radio.pollInterval); }, connect: () => { if (RigaAdapter.socket) return; - console.log( - `[Rigctld] Connecting to ${CONFIG.radio.host}:${CONFIG.radio.rigPort}...`, - ); + console.log(`[Rigctld] Connecting to ${CONFIG.radio.host}:${CONFIG.radio.rigPort}...`); const s = new net.Socket(); s.connect(CONFIG.radio.rigPort, CONFIG.radio.host, () => { - console.log("[Rigctld] Connected"); + console.log('[Rigctld] Connected'); state.connected = true; RigaAdapter.socket = s; }); - s.on("data", (data) => { - const lines = data.toString().split("\n"); + s.on('data', (data) => { + const lines = data.toString().split('\n'); for (const line of lines) { if (!line.trim()) continue; RigaAdapter.handleResponse(line.trim()); } }); - s.on("close", () => { - console.log("[Rigctld] Disconnected"); + s.on('close', () => { + console.log('[Rigctld] Disconnected'); state.connected = false; RigaAdapter.socket = null; setTimeout(RigaAdapter.connect, 5000); }); - s.on("error", (err) => { + s.on('error', (err) => { console.error(`[Rigctld] Error: ${err.message}`); s.destroy(); }); @@ -151,7 +149,7 @@ const RigaAdapter = { send: (cmd, cb) => { if (!RigaAdapter.socket) { - if (cb) cb(new Error("Not connected")); + if (cb) cb(new Error('Not connected')); return; } RigaAdapter.queue.push({ cmd, cb }); @@ -159,15 +157,10 @@ const RigaAdapter = { }, process: () => { - if ( - RigaAdapter.pending || - RigaAdapter.queue.length === 0 || - !RigaAdapter.socket - ) - return; + if (RigaAdapter.pending || RigaAdapter.queue.length === 0 || !RigaAdapter.socket) return; const req = RigaAdapter.queue.shift(); RigaAdapter.pending = req; - RigaAdapter.socket.write(req.cmd + "\n"); + RigaAdapter.socket.write(req.cmd + '\n'); }, handleResponse: (line) => { @@ -175,27 +168,27 @@ const RigaAdapter = { const req = RigaAdapter.pending; RigaAdapter.pending = null; - if (req.cmd === "f" || req.cmd.startsWith("F")) { + if (req.cmd === 'f' || req.cmd.startsWith('F')) { const newFreq = parseInt(line); if (newFreq !== state.freq) { state.freq = newFreq; - broadcast({ type: "update", prop: "freq", value: state.freq }); + broadcast({ type: 'update', prop: 'freq', value: state.freq }); } - } else if (req.cmd === "m" || req.cmd.startsWith("M")) { - const parts = line.split(" "); + } else if (req.cmd === 'm' || req.cmd.startsWith('M')) { + const parts = line.split(' '); const newMode = parts[0]; - const newWidth = parseInt(parts[1] || "0"); + const newWidth = parseInt(parts[1] || '0'); if (newMode !== state.mode || newWidth !== state.width) { state.mode = newMode; state.width = newWidth; - broadcast({ type: "update", prop: "mode", value: state.mode }); + broadcast({ type: 'update', prop: 'mode', value: state.mode }); } - } else if (req.cmd === "t" || req.cmd.startsWith("T")) { - const newPTT = line === "1"; + } else if (req.cmd === 't' || req.cmd.startsWith('T')) { + const newPTT = line === '1'; if (newPTT !== state.ptt) { state.ptt = newPTT; - broadcast({ type: "update", prop: "ptt", value: state.ptt }); + broadcast({ type: 'update', prop: 'ptt', value: state.ptt }); } } @@ -215,57 +208,57 @@ const FlrigAdapter = { FlrigAdapter.client = xmlrpc.createClient({ host: CONFIG.radio.host, port: CONFIG.radio.rigPort, - path: "/", + path: '/', }); state.connected = true; // Assume connected as it's connectionless (HTTP), validation happens on poll - console.log("[Flrig] Client initialized (XML-RPC is connectionless)"); + console.log('[Flrig] Client initialized (XML-RPC is connectionless)'); // Poll loop setInterval(FlrigAdapter.poll, CONFIG.radio.pollInterval); // FEATURE: Log supported modes - FlrigAdapter.client.methodCall("rig.get_modes", [], (err, val) => { + FlrigAdapter.client.methodCall('rig.get_modes', [], (err, val) => { if (!err && val) { - console.log("[Flrig] Supported Modes:", val); + console.log('[Flrig] Supported Modes:', val); } else if (err) { - console.warn("[Flrig] Could not fetch modes:", err.message); + console.warn('[Flrig] Could not fetch modes:', err.message); } }); }, poll: () => { // Get Freq - FlrigAdapter.client.methodCall("rig.get_vfo", [], (err, val) => { + FlrigAdapter.client.methodCall('rig.get_vfo', [], (err, val) => { if (err) { - if (state.connected) console.error("[Flrig] Poll Error:", err.message); + if (state.connected) console.error('[Flrig] Poll Error:', err.message); state.connected = false; } else { if (!state.connected) { state.connected = true; - broadcast({ type: "update", prop: "connected", value: true }); + broadcast({ type: 'update', prop: 'connected', value: true }); } const newFreq = parseFloat(val); if (newFreq !== state.freq) { state.freq = newFreq; - broadcast({ type: "update", prop: "freq", value: state.freq }); + broadcast({ type: 'update', prop: 'freq', value: state.freq }); } state.lastUpdate = Date.now(); } }); // Get Mode - FlrigAdapter.client.methodCall("rig.get_mode", [], (err, val) => { + FlrigAdapter.client.methodCall('rig.get_mode', [], (err, val) => { if (!err && val !== state.mode) { state.mode = val; - broadcast({ type: "update", prop: "mode", value: state.mode }); + broadcast({ type: 'update', prop: 'mode', value: state.mode }); } }); // Get PTT - FlrigAdapter.client.methodCall("rig.get_ptt", [], (err, val) => { + FlrigAdapter.client.methodCall('rig.get_ptt', [], (err, val) => { if (!err) { const newPTT = !!val; if (newPTT !== state.ptt) { state.ptt = newPTT; - broadcast({ type: "update", prop: "ptt", value: state.ptt }); + broadcast({ type: 'update', prop: 'ptt', value: state.ptt }); } } }); @@ -275,43 +268,36 @@ const FlrigAdapter = { // flrig expects a double. If we pass an integer (e.g. 14000000), // the xmlrpc lib sends and flrig throws a type error. // We add a tiny fraction to force serialization. - FlrigAdapter.client.methodCall( - "rig.set_frequency", - [parseFloat(freq) + 0.1], - cb, - ); + FlrigAdapter.client.methodCall('rig.set_frequency', [parseFloat(freq) + 0.1], cb); }, setMode: (mode, cb) => { // flrig set_mode just takes mode string - FlrigAdapter.client.methodCall("rig.set_mode", [mode], cb); + FlrigAdapter.client.methodCall('rig.set_mode', [mode], cb); }, setPTT: (ptt, cb) => { // flrig set_ptt takes integer 0 or 1 - FlrigAdapter.client.methodCall("rig.set_ptt", [ptt ? 1 : 0], cb); + FlrigAdapter.client.methodCall('rig.set_ptt', [ptt ? 1 : 0], cb); }, tune: () => { - console.log("[Flrig] Sending Tune command..."); + console.log('[Flrig] Sending Tune command...'); // Try rig.tune first - FlrigAdapter.client.methodCall("rig.tune", [1], (err, _val) => { + FlrigAdapter.client.methodCall('rig.tune', [1], (err, _val) => { if (err) { - console.warn( - "[Flrig] rig.tune failed, trying fallback (PTT toggle):", - err.message, - ); + console.warn('[Flrig] rig.tune failed, trying fallback (PTT toggle):', err.message); // Fallback: Toggle PTT if TUNE command not supported/failed FlrigAdapter.setPTT(true, () => { const delay = getTuneDelay(); setTimeout(() => { FlrigAdapter.setPTT(false, () => { - console.log("[Flrig] Fallback Tune (PTT) completed"); + console.log('[Flrig] Fallback Tune (PTT) completed'); }); }, delay); // Transmit for configured duration }); } else { - console.log("[Flrig] Tune command sent successfully"); + console.log('[Flrig] Tune command sent successfully'); // If rig.tune is momentary, we might need to turn it off? // Usually tune starts a cycle. Let's assume it triggers the tuner. } @@ -328,10 +314,10 @@ const FlrigAdapter = { // ========================================== const MockAdapter = { init: () => { - console.log("[Mock] Initializing Simulation Mode..."); + console.log('[Mock] Initializing Simulation Mode...'); state.connected = true; state.freq = 14074000; - state.mode = "USB"; + state.mode = 'USB'; state.width = 2400; state.ptt = false; state.lastUpdate = Date.now(); @@ -345,29 +331,29 @@ const MockAdapter = { setFreq: (freq, cb) => { console.log(`[Mock] SET FREQ: ${freq}`); state.freq = parseInt(freq); - broadcast({ type: "update", prop: "freq", value: state.freq }); + broadcast({ type: 'update', prop: 'freq', value: state.freq }); if (cb) cb(null); }, setMode: (mode, cb) => { console.log(`[Mock] SET MODE: ${mode}`); state.mode = mode; - broadcast({ type: "update", prop: "mode", value: state.mode }); + broadcast({ type: 'update', prop: 'mode', value: state.mode }); if (cb) cb(null); }, setPTT: (ptt, cb) => { console.log(`[Mock] SET PTT: ${ptt}`); state.ptt = !!ptt; - broadcast({ type: "update", prop: "ptt", value: state.ptt }); + broadcast({ type: 'update', prop: 'ptt', value: state.ptt }); if (cb) cb(null); }, tune: () => { - console.log("[Mock] TUNE COMMAND RECEIVED"); - console.log("[Mock] Simulating tuner cycle (3s)..."); + console.log('[Mock] TUNE COMMAND RECEIVED'); + console.log('[Mock] Simulating tuner cycle (3s)...'); setTimeout(() => { - console.log("[Mock] TUNE COMPLETED"); + console.log('[Mock] TUNE COMPLETED'); }, 3000); }, }; @@ -378,9 +364,9 @@ const MockAdapter = { // Initialize selected adapter // Initialize selected adapter -if (CONFIG.radio.type === "flrig") { +if (CONFIG.radio.type === 'flrig') { FlrigAdapter.init(); -} else if (CONFIG.radio.type === "mock") { +} else if (CONFIG.radio.type === 'mock') { MockAdapter.init(); } else { RigaAdapter.init(); @@ -390,7 +376,7 @@ const app = express(); app.use(cors()); app.use(express.json()); -app.get("/status", (_req, res) => { +app.get('/status', (_req, res) => { res.json({ connected: state.connected, freq: state.freq, @@ -401,18 +387,18 @@ app.get("/status", (_req, res) => { }); }); -app.get("/stream", (req, res) => { +app.get('/stream', (req, res) => { // SSE Headers res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', }); // Send initial state const initialData = { - type: "init", + type: 'init', connected: state.connected, freq: state.freq, mode: state.mode, @@ -427,18 +413,18 @@ app.get("/stream", (req, res) => { clients.push(newClient); // Cleanup on close - req.on("close", () => { + req.on('close', () => { clients = clients.filter((c) => c.id !== clientId); }); }); -app.post("/freq", (req, res) => { +app.post('/freq', (req, res) => { const { freq } = req.body; - if (!freq) return res.status(400).json({ error: "Missing freq" }); + if (!freq) return res.status(400).json({ error: 'Missing freq' }); console.log(`[API] Setting Freq: ${freq}`); - if (CONFIG.radio.type === "flrig") { + if (CONFIG.radio.type === 'flrig') { FlrigAdapter.setFreq(freq, (err, _val) => { if (err) return res.status(500).json({ error: err.message }); @@ -456,7 +442,7 @@ app.post("/freq", (req, res) => { res.json({ success: true }); }); - } else if (CONFIG.radio.type === "mock") { + } else if (CONFIG.radio.type === 'mock') { MockAdapter.setFreq(freq, (_err) => { if (req.body.tune) MockAdapter.tune(); res.json({ success: true }); @@ -464,63 +450,61 @@ app.post("/freq", (req, res) => { } else { RigaAdapter.send(`F ${freq}`, (err, _val) => { if (err) return res.status(500).json({ error: err.message }); - setTimeout(() => RigaAdapter.send("f"), 100); + setTimeout(() => RigaAdapter.send('f'), 100); res.json({ success: true }); }); } }); -app.post("/mode", (req, res) => { +app.post('/mode', (req, res) => { const { mode } = req.body; - if (!mode) return res.status(400).json({ error: "Missing mode" }); + if (!mode) return res.status(400).json({ error: 'Missing mode' }); // passband is optional, default usually 2400 for SSB (only used for rigctld) const passband = req.body.passband || 0; console.log(`[API] Setting Mode: ${mode}`); - if (CONFIG.radio.type === "flrig") { + if (CONFIG.radio.type === 'flrig') { FlrigAdapter.setMode(mode, (err, _val) => { if (err) return res.status(500).json({ error: err.message }); setTimeout(FlrigAdapter.poll, 100); res.json({ success: true }); }); - } else if (CONFIG.radio.type === "mock") { + } else if (CONFIG.radio.type === 'mock') { MockAdapter.setMode(mode, (_err) => { res.json({ success: true }); }); } else { RigaAdapter.send(`M ${mode} ${passband}`, (err, _val) => { if (err) return res.status(500).json({ error: err.message }); - setTimeout(() => RigaAdapter.send("m"), 100); + setTimeout(() => RigaAdapter.send('m'), 100); res.json({ success: true }); }); } }); -app.post("/ptt", (req, res) => { +app.post('/ptt', (req, res) => { const { ptt } = req.body; if (ptt && !CONFIG.radio.pttEnabled) { - console.warn( - `[API] PTT request BLOCKED by configuration (pttEnabled: false)`, - ); - return res.status(403).json({ error: "PTT disabled in configuration" }); + console.warn(`[API] PTT request BLOCKED by configuration (pttEnabled: false)`); + return res.status(403).json({ error: 'PTT disabled in configuration' }); } console.log(`[API] Setting PTT: ${ptt}`); - if (CONFIG.radio.type === "flrig") { + if (CONFIG.radio.type === 'flrig') { FlrigAdapter.setPTT(ptt, (err, _val) => { if (err) return res.status(500).json({ error: err.message }); state.ptt = !!ptt; res.json({ success: true }); }); - } else if (CONFIG.radio.type === "mock") { + } else if (CONFIG.radio.type === 'mock') { MockAdapter.setPTT(ptt, (_err) => { res.json({ success: true }); }); } else { - const cmd = ptt ? "T 1" : "T 0"; + const cmd = ptt ? 'T 1' : 'T 0'; RigaAdapter.send(cmd, (err, _val) => { if (err) return res.status(500).json({ error: err.message }); state.ptt = !!ptt; @@ -532,7 +516,5 @@ app.post("/ptt", (req, res) => { app.listen(CONFIG.server.port, CONFIG.server.host, () => { console.log(`[HTTP] Rig Daemon listening on port ${CONFIG.server.port}`); console.log(`[HTTP] CORS enabled for all origins`); - console.log( - `[HTTP] Connects to ${CONFIG.radio.type} at ${CONFIG.radio.host}:${CONFIG.radio.rigPort}`, - ); + console.log(`[HTTP] Connects to ${CONFIG.radio.type} at ${CONFIG.radio.host}:${CONFIG.radio.rigPort}`); }); diff --git a/rig-control/scripts/README.md b/rig-control/scripts/README.md index dbbc5d83..29a60360 100644 --- a/rig-control/scripts/README.md +++ b/rig-control/scripts/README.md @@ -3,9 +3,11 @@ This directory contains scripts to install the **Rig Control Daemon** as a background service on Linux, macOS, and Windows. ## Prerequisite + - **Node.js** must be installed on your system. ## Linux (systemd) + Installs as a user-level systemd service (`openhamclock-rig`). 1. Open a terminal. @@ -21,6 +23,7 @@ Installs as a user-level systemd service (`openhamclock-rig`). - Stop: `sudo systemctl stop openhamclock-rig` ## macOS (launchd) + Installs as a LaunchAgent (`com.openhamclock.rig`) for the current user. 1. Open Terminal. @@ -35,6 +38,7 @@ Installs as a LaunchAgent (`com.openhamclock.rig`) for the current user. - Stop: `launchctl unload ~/Library/LaunchAgents/com.openhamclock.rig.plist` ## Windows (Task Scheduler) + Installs as a Scheduled Task that runs at logon (hidden window). 1. Open PowerShell as Administrator (optional, but recommended for task registration). @@ -49,4 +53,5 @@ Installs as a Scheduled Task that runs at logon (hidden window). - You can manually Start/End the task from there. --- + **Note:** The daemon listens on port **4532** by default. Ensure this port is allowed in your firewall if accessing from another machine. diff --git a/rig-control/test-flrig.js b/rig-control/test-flrig.js index 559ca318..acedba77 100644 --- a/rig-control/test-flrig.js +++ b/rig-control/test-flrig.js @@ -1,33 +1,33 @@ -const xmlrpc = require("xmlrpc"); +const xmlrpc = require('xmlrpc'); -const HOST = "127.0.0.1"; +const HOST = '127.0.0.1'; const PORT = 12345; -const client = xmlrpc.createClient({ host: HOST, port: PORT, path: "/" }); +const client = xmlrpc.createClient({ host: HOST, port: PORT, path: '/' }); console.log(`Connecting to flrig at ${HOST}:${PORT}...`); // Test 1: Get Frequency (Should work) -client.methodCall("rig.get_vfo", [], (err, val) => { +client.methodCall('rig.get_vfo', [], (err, val) => { if (err) { - console.error("get_vfo failed:", err); + console.error('get_vfo failed:', err); } else { - console.log("Current Freq:", val); + console.log('Current Freq:', val); // Test 2: Set Freq (Integer) - Might fail const targetInt = 14075000; console.log(`Attempting to set Integer Freq: ${targetInt}`); - client.methodCall("rig.set_frequency", [targetInt], (err2, val2) => { - if (err2) console.error("Set Int failed:", err2); - else console.log("Set Int result:", val2); + client.methodCall('rig.set_frequency', [targetInt], (err2, val2) => { + if (err2) console.error('Set Int failed:', err2); + else console.log('Set Int result:', val2); }); // Test 3: Set Freq (Double) - Should work const targetDouble = 14076000.1; console.log(`Attempting to set Double Freq: ${targetDouble}`); - client.methodCall("rig.set_frequency", [targetDouble], (err3, val3) => { - if (err3) console.error("Set Double failed:", err3); - else console.log("Set Double result:", val3); + client.methodCall('rig.set_frequency', [targetDouble], (err3, val3) => { + if (err3) console.error('Set Double failed:', err3); + else console.log('Set Double result:', val3); }); } }); diff --git a/rig-control/test_plan.md b/rig-control/test_plan.md index d3930c4c..974608fc 100644 --- a/rig-control/test_plan.md +++ b/rig-control/test_plan.md @@ -5,30 +5,35 @@ **Branch:** `feat/rig-control-minimal` ## 1. Introduction + This document outlines the test procedures for verifying the new Rig Control integration in OpenHamClock. The goal is to ensure that the application can communicate with an amateur radio transceiver via the intermediate daemon, correctly display frequency/mode, and control the rig (tuning and PTT) from various panels. ## 2. Prerequisites ### Hardware + - A computer running OpenHamClock (local dev or deployed). - An amateur radio transceiver (Rig) with CAT control capabilities. - A USB CAT cable connecting the computer to the Rig. ### Software + - **Node.js** (v16 or higher) installed on the machine connected to the Rig. - **Hamlib (`rigctld`)** OR **`flrig`** installed and configured for your specific radio. - - *Note: `rigctld` is part of the `hamlib` package.* - - *Note: `flrig` is a standalone GUI program.* + - _Note: `rigctld` is part of the `hamlib` package._ + - _Note: `flrig` is a standalone GUI program._ ## 3. Setup & Installation ### Step 3.1: Start the Rig Interface (Backend) + 1. **Connect your Rig** and ensure it is powered on. 2. **Start your CAT control software**: - **Option A (flrig):** Open `flrig`, configure your transceiver, and ensure "Init" is successful. Default XML-RPC port is usually `12345`. - **Option B (rigctld):** Run `rigctld -m -r -t 4532` (adjust model/device as needed). ### Step 3.2: Start the OpenHamClock Daemon + The OpenHamClock web app cannot talk directly to your rig; it needs a small bridge (daemon). 1. Navigate to the `rig-control/` directory in the project: @@ -47,9 +52,10 @@ The OpenHamClock web app cannot talk directly to your rig; it needs a small brid ```bash node rig-daemon.js ``` - *Success Indicator: The terminal should show "Connected to flrig/hamlib on..." and "Listening on http://0.0.0.0:5555".* + _Success Indicator: The terminal should show "Connected to flrig/hamlib on..." and "Listening on http://0.0.0.0:5555"._ ### Step 3.3: Start OpenHamClock (Frontend) + 1. In the main project directory, start the development server: ```bash npm run dev @@ -70,14 +76,18 @@ The OpenHamClock web app cannot talk directly to your rig; it needs a small brid ## 5. Test Cases ### TC-01: Connectivity Verification + **Objective:** Confirm OpenHamClock can read rig status. + 1. Open the **Rig Control** panel (it should appear in your layout, or switch to "Modern" layout if not visible). 2. **Verify:** The status LED in the top-right of the panel is **GREEN**. 3. **Verify:** The large frequency display matches your rig's VFO A. 4. **Verify:** The mode badge (e.g., USB, CW) matches your rig. ### TC-02: Manual Control + **Objective:** Confirm OpenHamClock can command the rig via the panel. + 1. In the Rig Control panel, enter a frequency (e.g., `14.074`) in the input box. 2. Click **Set**. 3. **Verify:** The rig physically changes to `14.074.000` Hz. @@ -89,7 +99,9 @@ The OpenHamClock web app cannot talk directly to your rig; it needs a small brid 9. **Verify:** The Rig returns to Receive (`RX`) mode. ### TC-03: On-Air Indicator + **Objective:** Confirm the On-Air panel reflects TX status. + 1. Add the **On Air** panel to your layout (if using Dockable layout) or observe the PTT status. 2. Press PTT on the rig microphone OR use the on-screen PTT. 3. **Verify:** The On Air panel turns **RED** and displays "ON AIR". @@ -97,7 +109,9 @@ The OpenHamClock web app cannot talk directly to your rig; it needs a small brid 5. **Verify:** The panel returns to standard/gray "RX" state. ### TC-04: Click-to-Tune (Spot Integration) + **Objective:** Confirm clicking spots retunes the rig. + 1. Open the **DX Cluster** panel or **POTA** panel. 2. Find a spot and click on it. 3. **Verify:** The rig retunes to the spot's frequency (regardless of "Click-to-tune" setting). @@ -105,21 +119,24 @@ The OpenHamClock web app cannot talk directly to your rig; it needs a small brid 5. **Verify:** The rig mode changes automatically based on the new frequency. ### TC-05: Mode Logic Verification (Critical) + **Objective:** Verify that specific modes are enforced based on band conventions. -*NOTE: The logic enforces `< 10 MHz = LSB` and `>= 10 MHz = USB`, except for 60m (USB).* +_NOTE: The logic enforces `< 10 MHz = LSB` and `>= 10 MHz = USB`, except for 60m (USB)._ -| Test Step | Action | Expected Frequency | Expected Mode | -| :--- | :--- | :--- | :--- | -| **5.1** | Click a **20m FT8** spot (e.g., 14.074) | 14.074.000 | **USB** | -| **5.2** | Click a **40m FT8** spot (e.g., 7.074) | 7.074.000 | **LSB** | -| **5.3** | Click a **80m FT8** spot (e.g., 3.573) | 3.573.000 | **LSB** | -| **5.4** | Click a **20m CW** spot (e.g., 14.010) | 14.010.000 | **USB** | -| **5.5** | Click a **40m CW** spot (e.g., 7.010) | 7.010.000 | **LSB** | -| **5.6** | Click a **60m** spot (e.g., 5.357) | 5.357.000 | **USB** | +| Test Step | Action | Expected Frequency | Expected Mode | +| :-------- | :-------------------------------------- | :----------------- | :------------ | +| **5.1** | Click a **20m FT8** spot (e.g., 14.074) | 14.074.000 | **USB** | +| **5.2** | Click a **40m FT8** spot (e.g., 7.074) | 7.074.000 | **LSB** | +| **5.3** | Click a **80m FT8** spot (e.g., 3.573) | 3.573.000 | **LSB** | +| **5.4** | Click a **20m CW** spot (e.g., 14.010) | 14.010.000 | **USB** | +| **5.5** | Click a **40m CW** spot (e.g., 7.010) | 7.010.000 | **LSB** | +| **5.6** | Click a **60m** spot (e.g., 5.357) | 5.357.000 | **USB** | ### TC-06: Error Handling + **Objective:** Confirm behavior when components fail. + 1. Stop the `rig-daemon.js` (Ctrl+C in terminal). 2. **Verify:** The Rig Control panel status LED turns **RED** (within ~5 seconds). 3. **Verify:** An error banner "Daemon not reachable" appears in the panel. @@ -127,15 +144,17 @@ The OpenHamClock web app cannot talk directly to your rig; it needs a small brid 5. **Verify:** The panel recovers to **GREEN** automatically. ### TC-07: Configurable Tune Delay + **Objective:** Verify that the ATU trigger duration can be customized. **Prerequisite:** Feature "Click-to-tune" enabled in Settings. -| Test Step | Action | Expected Behavior | -| :--- | :--- | :--- | -| **7.1** | Default Config | Stop daemon. Ensure `tuneDelay` is `3000` (or missing) in `rig-config.json`. Start daemon. Click a spot. Verify "Tune" (or PTT) lasts ~3 seconds. | -| **7.2** | Custom Config | Stop daemon. Edit `rig-config.json`: set `"tuneDelay": 5000`. Start daemon. Click a spot. Verify "Tune" (or PTT) lasts ~5 seconds. | +| Test Step | Action | Expected Behavior | +| :-------- | :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | +| **7.1** | Default Config | Stop daemon. Ensure `tuneDelay` is `3000` (or missing) in `rig-config.json`. Start daemon. Click a spot. Verify "Tune" (or PTT) lasts ~3 seconds. | +| **7.2** | Custom Config | Stop daemon. Edit `rig-config.json`: set `"tuneDelay": 5000`. Start daemon. Click a spot. Verify "Tune" (or PTT) lasts ~5 seconds. | ## 6. Cleanup + - Stop the daemon (`Ctrl+C`). - Stop the frontend (`Ctrl+C`). - (Optional) Uncheck "Enable Hamlib integration" in Settings if returning to normal use without the daemon. diff --git a/rig-listener/README.md b/rig-listener/README.md index 481b269d..80c48893 100644 --- a/rig-listener/README.md +++ b/rig-listener/README.md @@ -8,12 +8,12 @@ No flrig. No rigctld. No Node.js. Just a single executable that connects your ra Grab the right file for your computer from the [Releases](../../releases) page: -| Platform | Download | -|----------|----------| -| **Windows** (64-bit) | `rig-listener-win-x64.exe` | -| **Mac** (Apple Silicon — M1/M2/M3) | `rig-listener-mac-arm64` | -| **Mac** (Intel) | `rig-listener-mac-x64` | -| **Linux** (64-bit) | `rig-listener-linux-x64` | +| Platform | Download | +| ---------------------------------- | -------------------------- | +| **Windows** (64-bit) | `rig-listener-win-x64.exe` | +| **Mac** (Apple Silicon — M1/M2/M3) | `rig-listener-mac-arm64` | +| **Mac** (Intel) | `rig-listener-mac-x64` | +| **Linux** (64-bit) | `rig-listener-linux-x64` | ## Setup (One Time) @@ -24,13 +24,16 @@ Grab the right file for your computer from the [Releases](../../releases) page: **Windows:** Double-click `rig-listener-win-x64.exe` **Mac:** Open Terminal, then: + ```bash chmod +x rig-listener-mac-arm64 ./rig-listener-mac-arm64 ``` + > Mac may show a security warning. Go to System Settings → Privacy & Security → click "Allow Anyway". **Linux:** + ```bash chmod +x rig-listener-linux-x64 ./rig-listener-linux-x64 @@ -62,6 +65,7 @@ The wizard lists your serial ports, asks your radio brand, and saves the config: ### 4. Connect OpenHamClock In **Settings → Rig Control**: + - ☑ Enable Rig Control - Host: `http://localhost` - Port: `5555` @@ -88,12 +92,12 @@ To re-run the wizard: `rig-listener --wizard` ## Supported Radios -| Brand | Models | Protocol | -|-------|--------|----------| -| **Yaesu** | FT-991A, FT-891, FT-710, FT-DX10, FT-DX101, FT-450D, FT-817/818 | CAT | -| **Kenwood** | TS-590, TS-890, TS-480, TS-2000 | Kenwood | -| **Elecraft** | K3, K4, KX3, KX2 | Kenwood-compatible | -| **Icom** | IC-7300, IC-7610, IC-705, IC-9700, IC-7100 | CI-V | +| Brand | Models | Protocol | +| ------------ | --------------------------------------------------------------- | ------------------ | +| **Yaesu** | FT-991A, FT-891, FT-710, FT-DX10, FT-DX101, FT-450D, FT-817/818 | CAT | +| **Kenwood** | TS-590, TS-890, TS-480, TS-2000 | Kenwood | +| **Elecraft** | K3, K4, KX3, KX2 | Kenwood-compatible | +| **Icom** | IC-7300, IC-7610, IC-705, IC-9700, IC-7100 | CI-V | ## Radio Configuration @@ -121,19 +125,23 @@ The listener polls your radio every 500ms for frequency/mode/PTT and pushes chan ## Troubleshooting **No serial ports detected** + - Is the USB cable plugged in? - Windows: Check Device Manager → Ports. You may need the [Silicon Labs CP210x driver](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) - Linux: `sudo usermod -a -G dialout $USER` then log out/in **"Port in use"** + - Close flrig, rigctld, WSJT-X, fldigi, or any other program using the same serial port. Only one program can use a serial port at a time. **Connected but no frequency updates** + - Baud rate mismatch — must match your radio's CAT rate setting exactly - Wrong brand selected — re-run with `--wizard` - Icom: CI-V address must match (re-run wizard to change) **Mac security warning** + - System Settings → Privacy & Security → scroll down → click "Allow Anyway" ## Command Line Options @@ -160,6 +168,7 @@ node rig-listener.js ``` To build your own executable: + ```bash npm run build ``` diff --git a/rig-listener/build.js b/rig-listener/build.js index e27bc947..76d9e32a 100644 --- a/rig-listener/build.js +++ b/rig-listener/build.js @@ -1,11 +1,11 @@ #!/usr/bin/env node /** * Build helper — creates a standalone executable for the current platform. - * + * * Usage: * node build.js Build for current OS * node build.js --all Build for all platforms (from GitHub Actions) - * + * * Requires: npm install (serialport must be installed first) * Uses: @yao-pkg/pkg (auto-downloaded via npx) */ diff --git a/rig-listener/rig-listener.js b/rig-listener/rig-listener.js index c89f60cd..308c8fa6 100644 --- a/rig-listener/rig-listener.js +++ b/rig-listener/rig-listener.js @@ -27,9 +27,7 @@ const VERSION = '1.0.0'; const HTTP_PORT_DEFAULT = 5555; // Config lives NEXT TO the executable (or cwd for dev), NOT inside the pkg snapshot -const CONFIG_DIR = process.pkg - ? path.dirname(process.execPath) - : __dirname; +const CONFIG_DIR = process.pkg ? path.dirname(process.execPath) : __dirname; const CONFIG_FILE = path.join(CONFIG_DIR, 'rig-listener-config.json'); // ============================================ @@ -48,8 +46,13 @@ let sseClients = []; function broadcast(data) { const msg = `data: ${JSON.stringify(data)}\n\n`; - sseClients = sseClients.filter(c => { - try { c.write(msg); return true; } catch { return false; } + sseClients = sseClients.filter((c) => { + try { + c.write(msg); + return true; + } catch { + return false; + } }); } @@ -65,13 +68,21 @@ function updateState(prop, value) { // FT-991A, FT-891, FT-710, FT-DX10, FT-DX101, FT-450D, etc. // ============================================ const YAESU_MODES = { - '1': 'LSB', '2': 'USB', '3': 'CW', '4': 'FM', '5': 'AM', - '6': 'RTTY-LSB', '7': 'CW-R', '8': 'DATA-LSB', '9': 'RTTY-USB', - 'A': 'DATA-FM', 'B': 'FM-N', 'C': 'DATA-USB', 'D': 'AM-N', + 1: 'LSB', + 2: 'USB', + 3: 'CW', + 4: 'FM', + 5: 'AM', + 6: 'RTTY-LSB', + 7: 'CW-R', + 8: 'DATA-LSB', + 9: 'RTTY-USB', + A: 'DATA-FM', + B: 'FM-N', + C: 'DATA-USB', + D: 'AM-N', }; -const YAESU_MODES_REV = Object.fromEntries( - Object.entries(YAESU_MODES).map(([k, v]) => [v, k]) -); +const YAESU_MODES_REV = Object.fromEntries(Object.entries(YAESU_MODES).map(([k, v]) => [v, k])); const YaesuProtocol = { buffer: '', @@ -126,12 +137,16 @@ const YaesuProtocol = { // KENWOOD / ELECRAFT PROTOCOL // ============================================ const KENWOOD_MODES = { - '1': 'LSB', '2': 'USB', '3': 'CW', '4': 'FM', '5': 'AM', - '6': 'FSK', '7': 'CW-R', '9': 'FSK-R', + 1: 'LSB', + 2: 'USB', + 3: 'CW', + 4: 'FM', + 5: 'AM', + 6: 'FSK', + 7: 'CW-R', + 9: 'FSK-R', }; -const KENWOOD_MODES_REV = Object.fromEntries( - Object.entries(KENWOOD_MODES).map(([k, v]) => [v, k]) -); +const KENWOOD_MODES_REV = Object.fromEntries(Object.entries(KENWOOD_MODES).map(([k, v]) => [v, k])); const KenwoodProtocol = { buffer: '', @@ -184,42 +199,56 @@ const KenwoodProtocol = { // ICOM CI-V PROTOCOL (binary) // ============================================ const ICOM_MODES = { - 0x00: 'LSB', 0x01: 'USB', 0x02: 'AM', 0x03: 'CW', 0x04: 'RTTY', - 0x05: 'FM', 0x06: 'WFM', 0x07: 'CW-R', 0x08: 'RTTY-R', 0x17: 'DV', + 0x00: 'LSB', + 0x01: 'USB', + 0x02: 'AM', + 0x03: 'CW', + 0x04: 'RTTY', + 0x05: 'FM', + 0x06: 'WFM', + 0x07: 'CW-R', + 0x08: 'RTTY-R', + 0x17: 'DV', }; -const ICOM_MODES_REV = Object.fromEntries( - Object.entries(ICOM_MODES).map(([k, v]) => [v, parseInt(k)]) -); +const ICOM_MODES_REV = Object.fromEntries(Object.entries(ICOM_MODES).map(([k, v]) => [v, parseInt(k)])); const ICOM_ADDRESSES = { - 'IC-7300': 0x94, 'IC-7610': 0x98, 'IC-705': 0xA4, - 'IC-9700': 0xA2, 'IC-7100': 0x88, 'IC-7851': 0x8E, - 'IC-7600': 0x7A, 'IC-746': 0x56, 'IC-718': 0x5E, + 'IC-7300': 0x94, + 'IC-7610': 0x98, + 'IC-705': 0xa4, + 'IC-9700': 0xa2, + 'IC-7100': 0x88, + 'IC-7851': 0x8e, + 'IC-7600': 0x7a, + 'IC-746': 0x56, + 'IC-718': 0x5e, }; const IcomProtocol = { buffer: Buffer.alloc(0), civAddr: 0x94, - controllerAddr: 0xE0, + controllerAddr: 0xe0, buildPollCommands() { - return [ - this._frame([0x03]), - this._frame([0x04]), - this._frame([0x1C, 0x00]), - ]; + return [this._frame([0x03]), this._frame([0x04]), this._frame([0x1c, 0x00])]; }, _frame(payload) { - return Buffer.from([0xFE, 0xFE, this.civAddr, this.controllerAddr, ...payload, 0xFD]); + return Buffer.from([0xfe, 0xfe, this.civAddr, this.controllerAddr, ...payload, 0xfd]); }, parseResponse(chunk) { this.buffer = Buffer.concat([this.buffer, typeof chunk === 'string' ? Buffer.from(chunk, 'binary') : chunk]); while (true) { - const start = this.buffer.indexOf(Buffer.from([0xFE, 0xFE])); - if (start === -1) { this.buffer = Buffer.alloc(0); return; } - const endIdx = this.buffer.indexOf(0xFD, start + 2); - if (endIdx === -1) { this.buffer = this.buffer.subarray(start); return; } + const start = this.buffer.indexOf(Buffer.from([0xfe, 0xfe])); + if (start === -1) { + this.buffer = Buffer.alloc(0); + return; + } + const endIdx = this.buffer.indexOf(0xfd, start + 2); + if (endIdx === -1) { + this.buffer = this.buffer.subarray(start); + return; + } const frame = this.buffer.subarray(start, endIdx + 1); this.buffer = this.buffer.subarray(endIdx + 1); @@ -234,17 +263,20 @@ const IcomProtocol = { if (freq > 0) updateState('freq', freq); } else if ((cmd === 0x04 || cmd === 0x01) && data.length >= 1) { updateState('mode', ICOM_MODES[data[0]] || `MODE_${data[0].toString(16)}`); - } else if (cmd === 0x1C && data.length >= 2 && data[0] === 0x00) { + } else if (cmd === 0x1c && data.length >= 2 && data[0] === 0x00) { updateState('ptt', data[1] === 0x01); } } }, _bcdToFreq(data) { - let freq = 0, mult = 1; + let freq = 0, + mult = 1; for (let i = 0; i < Math.min(data.length, 5); i++) { - freq += (data[i] & 0x0F) * mult; mult *= 10; - freq += ((data[i] >> 4) & 0x0F) * mult; mult *= 10; + freq += (data[i] & 0x0f) * mult; + mult *= 10; + freq += ((data[i] >> 4) & 0x0f) * mult; + mult *= 10; } return freq; }, @@ -253,8 +285,10 @@ const IcomProtocol = { const buf = Buffer.alloc(5); let f = Math.round(hz); for (let i = 0; i < 5; i++) { - const lo = f % 10; f = Math.floor(f / 10); - const hi = f % 10; f = Math.floor(f / 10); + const lo = f % 10; + f = Math.floor(f / 10); + const hi = f % 10; + f = Math.floor(f / 10); buf[i] = (hi << 4) | lo; } return buf; @@ -270,7 +304,7 @@ const IcomProtocol = { }, setPttCmd(on) { - return this._frame([0x1C, 0x00, on ? 0x01 : 0x00]); + return this._frame([0x1c, 0x00, on ? 0x01 : 0x00]); }, }; @@ -278,11 +312,19 @@ const IcomProtocol = { // MOCK PROTOCOL // ============================================ const MockProtocol = { - buildPollCommands() { return []; }, + buildPollCommands() { + return []; + }, parseResponse() {}, - setFreqCmd() { return null; }, - setModeCmd() { return null; }, - setPttCmd() { return null; }, + setFreqCmd() { + return null; + }, + setModeCmd() { + return null; + }, + setPttCmd() { + return null; + }, }; // ============================================ @@ -299,9 +341,19 @@ async function initSerial(cfg) { if (brand === 'yaesu') protocol = YaesuProtocol; else if (brand === 'kenwood' || brand === 'elecraft') protocol = KenwoodProtocol; - else if (brand === 'icom') { protocol = IcomProtocol; IcomProtocol.civAddr = cfg.radio.civAddress || 0x94; } - else if (brand === 'mock') { protocol = MockProtocol; state.connected = true; state.freq = 14074000; state.mode = 'USB'; return; } - else { console.error(`[Error] Unknown brand: ${brand}`); process.exit(1); } + else if (brand === 'icom') { + protocol = IcomProtocol; + IcomProtocol.civAddr = cfg.radio.civAddress || 0x94; + } else if (brand === 'mock') { + protocol = MockProtocol; + state.connected = true; + state.freq = 14074000; + state.mode = 'USB'; + return; + } else { + console.error(`[Error] Unknown brand: ${brand}`); + process.exit(1); + } let SerialPort; try { @@ -337,7 +389,9 @@ async function initSerial(cfg) { pollTimer = setInterval(() => { if (!serialPort?.isOpen) return; for (const cmd of protocol.buildPollCommands()) { - try { serialPort.write(cmd); } catch {} + try { + serialPort.write(cmd); + } catch {} } }, cfg.radio.pollInterval || 500); }); @@ -367,10 +421,8 @@ async function initSerial(cfg) { console.error(' Troubleshooting:'); console.error(' • Is the USB cable connected?'); console.error(' • Is another program using this port? (flrig, WSJT-X, etc.)'); - if (process.platform === 'linux') - console.error(' • Try: sudo usermod -a -G dialout $USER (then log out/in)'); - if (process.platform === 'win32') - console.error(' • Check Device Manager → Ports for correct COM port'); + if (process.platform === 'linux') console.error(' • Try: sudo usermod -a -G dialout $USER (then log out/in)'); + if (process.platform === 'win32') console.error(' • Check Device Manager → Ports for correct COM port'); console.error(''); setTimeout(() => reconnect(cfg), 5000); } @@ -378,15 +430,25 @@ async function initSerial(cfg) { } function reconnect(cfg) { - if (serialPort) { try { serialPort.close(); } catch {} serialPort = null; } + if (serialPort) { + try { + serialPort.close(); + } catch {} + serialPort = null; + } console.log(`[Serial] Reconnecting to ${cfg.serial.port}...`); initSerial(cfg); } function sendToRadio(data) { if (!serialPort?.isOpen) return false; - try { serialPort.write(data); return true; } - catch (e) { console.error(`[Serial] Write error: ${e.message}`); return false; } + try { + serialPort.write(data); + return true; + } catch (e) { + console.error(`[Serial] Write error: ${e.message}`); + return false; + } } // ============================================ @@ -397,65 +459,126 @@ function startServer(port) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } const pathname = new URL(req.url, `http://${req.headers.host}`).pathname; if (req.method === 'GET' && pathname === '/status') { res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ connected: state.connected, freq: state.freq, mode: state.mode, width: state.width, ptt: state.ptt, timestamp: state.lastUpdate })); + res.end( + JSON.stringify({ + connected: state.connected, + freq: state.freq, + mode: state.mode, + width: state.width, + ptt: state.ptt, + timestamp: state.lastUpdate, + }), + ); } else if (req.method === 'GET' && pathname === '/stream') { - res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' }); - res.write(`data: ${JSON.stringify({ type: 'init', connected: state.connected, freq: state.freq, mode: state.mode, width: state.width, ptt: state.ptt })}\n\n`); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + res.write( + `data: ${JSON.stringify({ type: 'init', connected: state.connected, freq: state.freq, mode: state.mode, width: state.width, ptt: state.ptt })}\n\n`, + ); sseClients.push(res); - req.on('close', () => { sseClients = sseClients.filter(c => c !== res); }); + req.on('close', () => { + sseClients = sseClients.filter((c) => c !== res); + }); } else if (req.method === 'POST' && pathname === '/freq') { parseBody(req, (body) => { - if (!body?.freq) { res.writeHead(400); res.end('{"error":"Missing freq"}'); return; } + if (!body?.freq) { + res.writeHead(400); + res.end('{"error":"Missing freq"}'); + return; + } const cmd = protocol.setFreqCmd(body.freq); - if (cmd) { console.log(`[CMD] Freq → ${body.freq} Hz`); sendToRadio(cmd); } - res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"success":true}'); + if (cmd) { + console.log(`[CMD] Freq → ${body.freq} Hz`); + sendToRadio(cmd); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"success":true}'); }); } else if (req.method === 'POST' && pathname === '/mode') { parseBody(req, (body) => { - if (!body?.mode) { res.writeHead(400); res.end('{"error":"Missing mode"}'); return; } + if (!body?.mode) { + res.writeHead(400); + res.end('{"error":"Missing mode"}'); + return; + } const cmd = protocol.setModeCmd(body.mode); - if (cmd) { console.log(`[CMD] Mode → ${body.mode}`); sendToRadio(cmd); } - res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"success":true}'); + if (cmd) { + console.log(`[CMD] Mode → ${body.mode}`); + sendToRadio(cmd); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"success":true}'); }); } else if (req.method === 'POST' && pathname === '/ptt') { parseBody(req, (body) => { - if (!config?.radio?.pttEnabled && body?.ptt) { res.writeHead(403); res.end('{"error":"PTT disabled"}'); return; } + if (!config?.radio?.pttEnabled && body?.ptt) { + res.writeHead(403); + res.end('{"error":"PTT disabled"}'); + return; + } const cmd = protocol.setPttCmd(!!body?.ptt); - if (cmd) { console.log(`[CMD] PTT → ${body.ptt ? 'ON' : 'OFF'}`); sendToRadio(cmd); } - res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"success":true}'); + if (cmd) { + console.log(`[CMD] PTT → ${body.ptt ? 'ON' : 'OFF'}`); + sendToRadio(cmd); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"success":true}'); }); } else if (req.method === 'GET' && pathname === '/') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ name: 'OpenHamClock Rig Listener', version: VERSION, connected: state.connected })); } else { - res.writeHead(404); res.end('{"error":"Not found"}'); + res.writeHead(404); + res.end('{"error":"Not found"}'); } }); - server.listen(port, '0.0.0.0', () => { console.log(`[HTTP] Listening on port ${port}`); }); + server.listen(port, '0.0.0.0', () => { + console.log(`[HTTP] Listening on port ${port}`); + }); server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { console.error(`\n ❌ Port ${port} already in use.\n`); process.exit(1); } + if (err.code === 'EADDRINUSE') { + console.error(`\n ❌ Port ${port} already in use.\n`); + process.exit(1); + } }); } function parseBody(req, cb) { let d = ''; - req.on('data', c => d += c); - req.on('end', () => { try { cb(JSON.parse(d)); } catch { cb(null); } }); + req.on('data', (c) => (d += c)); + req.on('end', () => { + try { + cb(JSON.parse(d)); + } catch { + cb(null); + } + }); } // ============================================ // LIST SERIAL PORTS // ============================================ async function listPorts() { - try { return await require('serialport').SerialPort.list(); } - catch { return []; } + try { + return await require('serialport').SerialPort.list(); + } catch { + return []; + } } // ============================================ @@ -463,7 +586,7 @@ async function listPorts() { // ============================================ async function runWizard() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const ask = (q) => new Promise(r => rl.question(q, r)); + const ask = (q) => new Promise((r) => rl.question(q, r)); console.log(''); console.log(' ┌──────────────────────────────────────────────┐'); @@ -490,11 +613,15 @@ async function runWizard() { if (ports.length > 0) { const choice = await ask(` Select port (1-${ports.length}, or type path): `); const idx = parseInt(choice) - 1; - selectedPort = (idx >= 0 && idx < ports.length) ? ports[idx].path : choice.trim(); + selectedPort = idx >= 0 && idx < ports.length ? ports[idx].path : choice.trim(); } else { selectedPort = (await ask(' Enter serial port (e.g. COM3 or /dev/ttyUSB0): ')).trim(); } - if (!selectedPort) { console.log('\n ❌ No port selected.\n'); rl.close(); process.exit(1); } + if (!selectedPort) { + console.log('\n ❌ No port selected.\n'); + rl.close(); + process.exit(1); + } console.log(`\n ✅ Port: ${selectedPort}\n`); console.log(' 📻 Radio brand:\n'); @@ -504,7 +631,7 @@ async function runWizard() { console.log(' 4) Icom (IC-7300, IC-7610, IC-705, IC-9700)'); console.log(''); const brandChoice = (await ask(' Select brand (1-4): ')).trim(); - const brand = { '1': 'yaesu', '2': 'kenwood', '3': 'elecraft', '4': 'icom' }[brandChoice] || 'yaesu'; + const brand = { 1: 'yaesu', 2: 'kenwood', 3: 'elecraft', 4: 'icom' }[brandChoice] || 'yaesu'; console.log(`\n ✅ Brand: ${brand}\n`); const model = (await ask(' Radio model (optional, e.g. FT-991A): ')).trim(); @@ -525,7 +652,8 @@ async function runWizard() { if (civInput) civAddress = parseInt(civInput, 16) || civAddress; } - const httpPort = parseInt((await ask(`\n HTTP port for OpenHamClock [${HTTP_PORT_DEFAULT}]: `)).trim()) || HTTP_PORT_DEFAULT; + const httpPort = + parseInt((await ask(`\n HTTP port for OpenHamClock [${HTTP_PORT_DEFAULT}]: `)).trim()) || HTTP_PORT_DEFAULT; rl.close(); const cfg = { @@ -548,13 +676,28 @@ function parseCLI() { const o = {}; for (let i = 0; i < args.length; i++) { switch (args[i]) { - case '--port': case '-p': o.serialPort = args[++i]; break; - case '--baud': case '-b': o.baudRate = parseInt(args[++i]); break; - case '--brand': o.brand = args[++i]; break; - case '--http-port': o.httpPort = parseInt(args[++i]); break; - case '--mock': o.mock = true; break; - case '--wizard': o.forceWizard = true; break; - case '--help': case '-h': + case '--port': + case '-p': + o.serialPort = args[++i]; + break; + case '--baud': + case '-b': + o.baudRate = parseInt(args[++i]); + break; + case '--brand': + o.brand = args[++i]; + break; + case '--http-port': + o.httpPort = parseInt(args[++i]); + break; + case '--mock': + o.mock = true; + break; + case '--wizard': + o.forceWizard = true; + break; + case '--help': + case '-h': console.log(` OpenHamClock Rig Listener v${VERSION} @@ -596,17 +739,26 @@ async function main() { if (cli.mock) { config = { radio: { brand: 'mock', pttEnabled: false }, server: { port: cli.httpPort || HTTP_PORT_DEFAULT } }; - protocol = MockProtocol; state.connected = true; state.freq = 14074000; state.mode = 'USB'; + protocol = MockProtocol; + state.connected = true; + state.freq = 14074000; + state.mode = 'USB'; console.log(' 📻 Simulation mode — no radio needed\n'); - startServer(config.server.port); printInstructions(config.server.port); return; + startServer(config.server.port); + printInstructions(config.server.port); + return; } let cfg; if (cli.forceWizard || !fs.existsSync(CONFIG_FILE)) { cfg = await runWizard(); } else { - try { cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); console.log(` 📂 Loaded: ${CONFIG_FILE}`); } - catch { cfg = await runWizard(); } + try { + cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + console.log(` 📂 Loaded: ${CONFIG_FILE}`); + } catch { + cfg = await runWizard(); + } } if (cli.serialPort) cfg.serial.port = cli.serialPort; @@ -614,7 +766,10 @@ async function main() { if (cli.brand) cfg.radio.brand = cli.brand; if (cli.httpPort) cfg.server.port = cli.httpPort; - if (!cfg.serial.port) { console.error(' ❌ No serial port. Run with --wizard\n'); process.exit(1); } + if (!cfg.serial.port) { + console.error(' ❌ No serial port. Run with --wizard\n'); + process.exit(1); + } console.log(` 📻 Radio: ${cfg.radio.brand.toUpperCase()} ${cfg.radio.model || ''}`); console.log(` 🔌 Port: ${cfg.serial.port} @ ${cfg.serial.baudRate} baud`); @@ -641,9 +796,19 @@ function printInstructions(port) { process.on('SIGINT', () => { console.log('\n Shutting down...'); if (pollTimer) clearInterval(pollTimer); - if (serialPort?.isOpen) { serialPort.close(() => { console.log(' 73!'); process.exit(0); }); } - else { console.log(' 73!'); process.exit(0); } + if (serialPort?.isOpen) { + serialPort.close(() => { + console.log(' 73!'); + process.exit(0); + }); + } else { + console.log(' 73!'); + process.exit(0); + } }); process.on('SIGTERM', () => process.emit('SIGINT')); -main().catch(err => { console.error(`\n❌ ${err.message}\n`); process.exit(1); }); +main().catch((err) => { + console.error(`\n❌ ${err.message}\n`); + process.exit(1); +}); diff --git a/src/App.jsx b/src/App.jsx index 7075cce1..a9fedb70 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -29,7 +29,7 @@ import { useSatellites, useSolarIndices, usePSKReporter, - useWSJTX + useWSJTX, } from './hooks'; import useAppConfig from './hooks/app/useAppConfig'; @@ -52,13 +52,7 @@ const App = () => { const { t } = useTranslation(); // Core config/state - const { - config, - configLoaded, - showDxWeather, - classicAnalogClock, - handleSaveConfig - } = useAppConfig(); + const { config, configLoaded, showDxWeather, classicAnalogClock, handleSaveConfig } = useAppConfig(); const [showSettings, setShowSettings] = useState(false); const [showDXFilters, setShowDXFilters] = useState(false); @@ -66,13 +60,17 @@ const App = () => { const [layoutResetKey, setLayoutResetKey] = useState(0); const [, setBandColorChangeVersion] = useState(0); const [tempUnit, setTempUnit] = useState(() => { - try { return localStorage.getItem('openhamclock_tempUnit') || 'F'; } catch { return 'F'; } + try { + return localStorage.getItem('openhamclock_tempUnit') || 'F'; + } catch { + return 'F'; + } }); const [updateInProgress, setUpdateInProgress] = useState(false); useEffect(() => { const onBandColorsChange = () => { - setBandColorChangeVersion(v => v + 1); + setBandColorChangeVersion((v) => v + 1); }; window.addEventListener('openhamclock-band-colors-change', onBandColorsChange); return () => window.removeEventListener('openhamclock-band-colors-change', onBandColorsChange); @@ -83,12 +81,12 @@ const App = () => { const hasLocalStorage = localStorage.getItem('openhamclock_config'); if (!hasLocalStorage && config.callsign === 'N0CALL') { setShowSettings(true); - + // Auto-detect mobile/tablet on first visit and set appropriate layout const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; const isSmallScreen = window.innerWidth <= 768; const isTabletSize = window.innerWidth > 768 && window.innerWidth <= 1200; - + if (isTouchDevice && isSmallScreen) { // Phone → compact layout handleSaveConfig({ ...config, layout: 'compact' }); @@ -101,7 +99,7 @@ const App = () => { const handleResetLayout = useCallback(() => { resetLayout(); - setLayoutResetKey(prev => prev + 1); + setLayoutResetKey((prev) => prev + 1); }, []); const handleUpdateClick = useCallback(async () => { @@ -112,13 +110,21 @@ const App = () => { try { const res = await fetch('/api/update', { method: 'POST' }); let payload = {}; - try { payload = await res.json(); } catch { /* ignore */ } + try { + payload = await res.json(); + } catch { + /* ignore */ + } if (!res.ok) { throw new Error(payload.error || t('app.update.failedToStart')); } alert(t('app.update.started')); setTimeout(() => { - try { window.location.reload(); } catch { /* ignore */ } + try { + window.location.reload(); + } catch { + /* ignore */ + } }, 15000); } catch (err) { setUpdateInProgress(false); @@ -127,12 +133,7 @@ const App = () => { }, [updateInProgress, t]); // Location & map state - const { - dxLocation, - dxLocked, - handleToggleDxLock, - handleDXChange - } = useDXLocation(config.defaultDX); + const { dxLocation, dxLocked, handleToggleDxLock, handleDXChange } = useDXLocation(config.defaultDX); const { mapLayers, @@ -144,15 +145,10 @@ const App = () => { toggleSatellites, togglePSKReporter, toggleWSJTX, - toggleDXNews + toggleDXNews, } = useMapLayers(); - const { - dxFilters, - setDxFilters, - pskFilters, - setPskFilters - } = useFilters(); + const { dxFilters, setDxFilters, pskFilters, setPskFilters } = useFilters(); const { isFullscreen, handleFullscreenToggle } = useFullscreen(); const scale = useResponsiveScale(); @@ -177,15 +173,11 @@ const App = () => { const pskReporter = usePSKReporter(config.callsign, { minutes: config.lowMemoryMode ? 5 : 30, enabled: config.callsign !== 'N0CALL', - maxSpots: config.lowMemoryMode ? 50 : 500 + maxSpots: config.lowMemoryMode ? 50 : 500, }); const wsjtx = useWSJTX(); - const { - satelliteFilters, - setSatelliteFilters, - filteredSatellites - } = useSatellitesFilters(satellites.data); + const { satelliteFilters, setSatelliteFilters, filteredSatellites } = useSatellitesFilters(satellites.data); const { currentTime, @@ -199,7 +191,7 @@ const App = () => { deGrid, dxGrid, deSunTimes, - dxSunTimes + dxSunTimes, } = useTimeState(config.location, dxLocation, config.timezone); const filteredPskSpots = useMemo(() => { @@ -207,7 +199,7 @@ const App = () => { if (!pskFilters?.bands?.length && !pskFilters?.grids?.length && !pskFilters?.modes?.length) { return allSpots; } - return allSpots.filter(spot => { + return allSpots.filter((spot) => { if (pskFilters?.bands?.length && !pskFilters.bands.includes(spot.band)) return false; if (pskFilters?.modes?.length && !pskFilters.modes.includes(spot.mode)) return false; if (pskFilters?.grids?.length) { @@ -223,23 +215,27 @@ const App = () => { const wsjtxMapSpots = useMemo(() => { // Apply same age filter as panel (stored in localStorage) let ageMinutes = 30; - try { ageMinutes = parseInt(localStorage.getItem('ohc_wsjtx_age')) || 30; } catch {} + try { + ageMinutes = parseInt(localStorage.getItem('ohc_wsjtx_age')) || 30; + } catch {} const ageCutoff = Date.now() - ageMinutes * 60 * 1000; - + // Map all decodes with resolved coordinates (CQ, QSO exchanges, prefix estimates) // WorldMap deduplicates by callsign, keeping most recent - return wsjtx.decodes.filter(d => d.lat && d.lon && d.timestamp >= ageCutoff); + return wsjtx.decodes.filter((d) => d.lat && d.lon && d.timestamp >= ageCutoff); }, [wsjtx.decodes]); // Map hover const [hoveredSpot, setHoveredSpot] = useState(null); // Sidebar visibility & layout (used by some layouts) - const leftSidebarVisible = config.panels?.deLocation?.visible !== false || + const leftSidebarVisible = + config.panels?.deLocation?.visible !== false || config.panels?.dxLocation?.visible !== false || config.panels?.solar?.visible !== false || config.panels?.propagation?.visible !== false; - const rightSidebarVisible = config.panels?.dxCluster?.visible !== false || + const rightSidebarVisible = + config.panels?.dxCluster?.visible !== false || config.panels?.pskReporter?.visible !== false || config.panels?.dxpeditions?.visible !== false || config.panels?.pota?.visible !== false || @@ -323,31 +319,28 @@ const App = () => { leftSidebarVisible, rightSidebarVisible, getGridTemplateColumns, - scale + scale, }; return ( -
+
{config.layout === 'dockable' ? ( - - ) : (config.layout === 'classic' || config.layout === 'tablet' || config.layout === 'compact') ? ( + + ) : config.layout === 'classic' || config.layout === 'tablet' || config.layout === 'compact' ? ( ) : ( - + )} diff --git a/src/DockableApp.jsx b/src/DockableApp.jsx index b75727c1..29971bc2 100644 --- a/src/DockableApp.jsx +++ b/src/DockableApp.jsx @@ -25,7 +25,7 @@ import { AnalogClockPanel, RigControlPanel, OnAirPanel, - IDTimerPanel + IDTimerPanel, } from './components'; import { loadLayout, saveLayout, DEFAULT_LAYOUT } from './store/layoutStore.js'; @@ -33,7 +33,7 @@ import { DockableLayoutProvider } from './contexts'; import { useRig } from './contexts/RigContext.jsx'; import './styles/flexlayout-openhamclock.css'; import useMapLayers from './hooks/app/useMapLayers'; -import useRotator from "./hooks/useRotator"; +import useRotator from './hooks/useRotator'; // Icons const PlusIcon = () => ( @@ -158,18 +158,22 @@ export const DockableApp = ({ try { const stored = localStorage.getItem('openhamclock_panelZoom'); return stored ? JSON.parse(stored) : {}; - } catch { return {}; } + } catch { + return {}; + } }); useEffect(() => { - try { localStorage.setItem('openhamclock_panelZoom', JSON.stringify(panelZoom)); } catch { } + try { + localStorage.setItem('openhamclock_panelZoom', JSON.stringify(panelZoom)); + } catch {} }, [panelZoom]); const ZOOM_STEPS = [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 1.75, 2.0]; const adjustZoom = useCallback((component, delta) => { - setPanelZoom(prev => { + setPanelZoom((prev) => { const current = prev[component] || 1.0; - const currentIdx = ZOOM_STEPS.findIndex(s => s >= current - 0.01); + const currentIdx = ZOOM_STEPS.findIndex((s) => s >= current - 0.01); const newIdx = Math.max(0, Math.min(ZOOM_STEPS.length - 1, (currentIdx >= 0 ? currentIdx : 3) + delta)); const newZoom = ZOOM_STEPS[newIdx]; if (newZoom === 1.0) { @@ -184,41 +188,44 @@ export const DockableApp = ({ const { tuneTo, enabled } = useRig(); // Unified Spot Click Handler (Tune + Set DX) - const handleSpotClick = useCallback((spot) => { - if (!spot) return; - - // 1. Tune Rig if frequency is available and rig control is enabled - if (enabled && (spot.freq || spot.freqMHz || spot.dialFrequency)) { - let freqToSend; - - // WSJT-X decodes have dialFrequency (the VFO frequency to tune to) - // The freq field is just the audio delta offset within the passband - if (spot.dialFrequency) { - freqToSend = spot.dialFrequency; // Use dial frequency directly - } else { - // For other spot types (DX Cluster, POTA, etc.), use freq or freqMHz as-is - freqToSend = spot.freq || spot.freqMHz; + const handleSpotClick = useCallback( + (spot) => { + if (!spot) return; + + // 1. Tune Rig if frequency is available and rig control is enabled + if (enabled && (spot.freq || spot.freqMHz || spot.dialFrequency)) { + let freqToSend; + + // WSJT-X decodes have dialFrequency (the VFO frequency to tune to) + // The freq field is just the audio delta offset within the passband + if (spot.dialFrequency) { + freqToSend = spot.dialFrequency; // Use dial frequency directly + } else { + // For other spot types (DX Cluster, POTA, etc.), use freq or freqMHz as-is + freqToSend = spot.freq || spot.freqMHz; + } + + tuneTo(freqToSend, spot.mode); } - tuneTo(freqToSend, spot.mode); - } - - // 2. Set DX Location if location data is available - // For DX Cluster spots, we need to find the path data which contains coordinates - // For POTA/SOTA, the spot object itself has lat/lon - if (spot.lat && spot.lon) { - handleDXChange({ lat: spot.lat, lon: spot.lon }); - } else if (spot.call) { - // Try to find in DX Cluster paths - const path = (dxClusterData.paths || []).find(p => p.dxCall === spot.call); - if (path && path.dxLat != null && path.dxLon != null) { - handleDXChange({ lat: path.dxLat, lon: path.dxLon }); + // 2. Set DX Location if location data is available + // For DX Cluster spots, we need to find the path data which contains coordinates + // For POTA/SOTA, the spot object itself has lat/lon + if (spot.lat && spot.lon) { + handleDXChange({ lat: spot.lat, lon: spot.lon }); + } else if (spot.call) { + // Try to find in DX Cluster paths + const path = (dxClusterData.paths || []).find((p) => p.dxCall === spot.call); + if (path && path.dxLat != null && path.dxLon != null) { + handleDXChange({ lat: path.dxLat, lon: path.dxLon }); + } } - } - }, [tuneTo, enabled, handleDXChange, dxClusterData.paths]); + }, + [tuneTo, enabled, handleDXChange, dxClusterData.paths], + ); const resetZoom = useCallback((component) => { - setPanelZoom(prev => { + setPanelZoom((prev) => { const { [component]: _, ...rest } = prev; return rest; }); @@ -245,7 +252,9 @@ export const DockableApp = ({ const hasAmbient = (() => { try { return !!(import.meta.env?.VITE_AMBIENT_API_KEY && import.meta.env?.VITE_AMBIENT_APPLICATION_KEY); - } catch { return false; } + } catch { + return false; + } })(); return { @@ -253,25 +262,25 @@ export const DockableApp = ({ 'de-location': { name: 'DE Location', icon: '📍' }, 'dx-location': { name: 'DX Target', icon: '🎯' }, 'analog-clock': { name: 'Analog Clock', icon: '🕐' }, - 'solar': { name: 'Solar (all views)', icon: '☀️' }, + solar: { name: 'Solar (all views)', icon: '☀️' }, 'solar-image': { name: 'Solar Image', icon: '☀️', group: 'Solar' }, 'solar-indices': { name: 'Solar Indices', icon: '📊', group: 'Solar' }, 'solar-xray': { name: 'X-Ray Flux', icon: '⚡', group: 'Solar' }, - 'lunar': { name: 'Lunar Phase', icon: '🌙', group: 'Solar' }, - 'propagation': { name: 'Propagation (all views)', icon: '📡' }, + lunar: { name: 'Lunar Phase', icon: '🌙', group: 'Solar' }, + propagation: { name: 'Propagation (all views)', icon: '📡' }, 'propagation-chart': { name: 'VOACAP Chart', icon: '📈', group: 'Propagation' }, 'propagation-bars': { name: 'VOACAP Bars', icon: '📊', group: 'Propagation' }, 'band-conditions': { name: 'Band Conditions', icon: '📶', group: 'Propagation' }, 'band-health': { name: 'Band Health', icon: '📶' }, 'dx-cluster': { name: 'DX Cluster', icon: '📻' }, 'psk-reporter': { name: 'PSK Reporter', icon: '📡' }, - 'dxpeditions': { name: 'DXpeditions', icon: '🏝️' }, - 'pota': { name: 'POTA', icon: '🏕️' }, - 'wwff': { name: 'WWFF', icon: '🌲' }, - 'sota': { name: 'SOTA', icon: '⛰️' }, - ...(isLocalInstall ? { 'rotator': { name: 'Rotator', icon: '🧭' } } : {}), - 'contests': { name: 'Contests', icon: '🏆' }, - ...(hasAmbient ? { 'ambient': { name: 'Ambient Weather', icon: '🌦️' } } : {}), + dxpeditions: { name: 'DXpeditions', icon: '🏝️' }, + pota: { name: 'POTA', icon: '🏕️' }, + wwff: { name: 'WWFF', icon: '🌲' }, + sota: { name: 'SOTA', icon: '⛰️' }, + ...(isLocalInstall ? { rotator: { name: 'Rotator', icon: '🧭' } } : {}), + contests: { name: 'Contests', icon: '🏆' }, + ...(hasAmbient ? { ambient: { name: 'Ambient Weather', icon: '🌦️' } } : {}), 'rig-control': { name: 'Rig Control', icon: '📻' }, 'on-air': { name: 'On Air', icon: '🔴' }, 'id-timer': { name: 'ID Timer', icon: '📢' }, @@ -279,22 +288,34 @@ export const DockableApp = ({ }, [isLocalInstall]); // Add panel - const handleAddPanel = useCallback((panelId) => { - if (!targetTabSetId || !panelDefs[panelId]) return; - model.doAction(Actions.addNode( - { type: 'tab', name: panelDefs[panelId].name, component: panelId, id: `${panelId}-${Date.now()}` }, - targetTabSetId, DockLocation.CENTER, -1, true - )); - setShowPanelPicker(false); - }, [model, targetTabSetId, panelDefs]); + const handleAddPanel = useCallback( + (panelId) => { + if (!targetTabSetId || !panelDefs[panelId]) return; + model.doAction( + Actions.addNode( + { type: 'tab', name: panelDefs[panelId].name, component: panelId, id: `${panelId}-${Date.now()}` }, + targetTabSetId, + DockLocation.CENTER, + -1, + true, + ), + ); + setShowPanelPicker(false); + }, + [model, targetTabSetId, panelDefs], + ); // Render DE Location panel content const renderDELocation = (nodeId) => (
-
📍 DE - YOUR LOCATION
+
+ 📍 DE - YOUR LOCATION +
{deGrid}
-
{config.location.lat.toFixed(4)}°, {config.location.lon.toFixed(4)}°
+
+ {config.location.lat.toFixed(4)}°, {config.location.lon.toFixed(4)}° +
{deSunTimes.sunrise} @@ -306,7 +327,12 @@ export const DockableApp = ({ { setTempUnit(unit); try { localStorage.setItem('openhamclock_tempUnit', unit); } catch { } }} + onTempUnitChange={(unit) => { + setTempUnit(unit); + try { + localStorage.setItem('openhamclock_tempUnit', unit); + } catch {} + }} nodeId={nodeId} />
@@ -332,7 +358,7 @@ export const DockableApp = ({ cursor: 'pointer', display: 'flex', alignItems: 'center', - gap: '3px' + gap: '3px', }} > {dxLocked ? '🔒' : '🔓'} @@ -349,11 +375,17 @@ export const DockableApp = ({ const sign = utcOffsetH >= 0 ? '+' : ''; return (
- {hh}:{mm} (UTC{sign}{utcOffsetH}) + {hh}:{mm}{' '} + + (UTC{sign} + {utcOffsetH}) +
); })()} -
{dxLocation.lat.toFixed(4)}°, {dxLocation.lon.toFixed(4)}°
+
+ {dxLocation.lat.toFixed(4)}°, {dxLocation.lon.toFixed(4)}° +
{dxSunTimes.sunrise} @@ -365,7 +397,12 @@ export const DockableApp = ({ { setTempUnit(unit); try { localStorage.setItem('openhamclock_tempUnit', unit); } catch { } }} + onTempUnitChange={(unit) => { + setTempUnit(unit); + try { + localStorage.setItem('openhamclock_tempUnit', unit); + } catch {} + }} nodeId={nodeId} /> )} @@ -374,14 +411,14 @@ export const DockableApp = ({ const rot = useRotator({ mock: false, - endpointUrl: isLocalInstall ? "/api/rotator/status" : undefined, + endpointUrl: isLocalInstall ? '/api/rotator/status' : undefined, pollMs: 2000, staleMs: 5000, }); const turnRotator = useCallback(async (azimuth) => { - const res = await fetch("/api/rotator/turn", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const res = await fetch('/api/rotator/turn', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ azimuth }), }); const data = await res.json().catch(() => ({})); @@ -392,7 +429,7 @@ export const DockableApp = ({ }, []); const stopRotator = useCallback(async () => { - const res = await fetch("/api/rotator/stop", { method: "POST" }); + const res = await fetch('/api/rotator/stop', { method: 'POST' }); const data = await res.json().catch(() => ({})); if (!res.ok || data?.ok === false) { throw new Error(data?.error || `HTTP ${res.status}`); @@ -408,7 +445,6 @@ export const DockableApp = ({ dxLocation={dxLocation} onDXChange={handleDXChange} dxLocked={dxLocked} - potaSpots={potaSpots.data} wwffSpots={wwffSpots.data} sotaSpots={sotaSpots.data} @@ -418,27 +454,20 @@ export const DockableApp = ({ satellites={filteredSatellites} pskReporterSpots={filteredPskSpots} wsjtxSpots={wsjtxMapSpots} - showDXPaths={mapLayersEff.showDXPaths} showDXLabels={mapLayersEff.showDXLabels} onToggleDXLabels={mapLayersEff.showDXPaths ? toggleDXLabelsEff : undefined} - showPOTA={mapLayersEff.showPOTA} showPOTALabels={mapLayersEff.showPOTALabels} - showWWFF={mapLayersEff.showWWFF} showWWFFLabels={mapLayersEff.showWWFFLabels} - showSOTA={mapLayersEff.showSOTA} showSOTALabels={mapLayersEff.showSOTALabels} - showSatellites={mapLayersEff.showSatellites} onToggleSatellites={toggleSatellitesEff} - showPSKReporter={mapLayersEff.showPSKReporter} showWSJTX={mapLayersEff.showWSJTX} showDXNews={mapLayersEff.showDXNews} - // ✅ Rotator bearing overlay support showRotatorBearing={mapLayersEff.showRotatorBearing} rotatorAzimuth={rot.azimuth} @@ -446,7 +475,6 @@ export const DockableApp = ({ rotatorIsStale={rot.isStale} rotatorControlEnabled={!rot.isStale} onRotatorTurnRequest={turnRotator} - hoveredSpot={hoveredSpot} leftSidebarVisible={true} rightSidebarVisible={true} @@ -459,301 +487,398 @@ export const DockableApp = ({
); - // Factory for rendering panel content - const factory = useCallback((node) => { - const component = node.getComponent(); - const nodeId = node.getId(); - - let content; - switch (component) { - case 'world-map': - return renderWorldMap(); // Map has its own zoom — skip panel zoom - - case 'de-location': - content = renderDELocation(nodeId); - break; - - case 'dx-location': - content = renderDXLocation(nodeId); - break; - - case 'analog-clock': - content = ; - break; - - case 'solar': - content = ; - break; - - case 'solar-image': - content = ; - break; - - case 'solar-indices': - content = ; - break; - - case 'solar-xray': - content = ; - break; - - case 'lunar': - content = ; - break; - - case 'propagation': - content = ; - break; - - case 'propagation-chart': - content = ; - break; - - case 'propagation-bars': - content = ; - break; - - case 'band-conditions': - content = ; - break; - - case 'band-health': - return ( - - ); - - case 'dx-cluster': - content = ( - setShowDXFilters(true)} - onHoverSpot={setHoveredSpot} - onSpotClick={handleSpotClick} - hoveredSpot={hoveredSpot} - showOnMap={mapLayersEff.showDXPaths} - onToggleMap={toggleDXPathsEff} - /> - ); - break; - - case 'psk-reporter': - content = ( - setShowPSKFilters(true)} - onSpotClick={handleSpotClick} - wsjtxDecodes={wsjtx.decodes} - wsjtxClients={wsjtx.clients} - wsjtxQsos={wsjtx.qsos} - wsjtxStats={wsjtx.stats} - wsjtxLoading={wsjtx.loading} - wsjtxEnabled={wsjtx.enabled} - wsjtxPort={wsjtx.port} - wsjtxRelayEnabled={wsjtx.relayEnabled} - wsjtxRelayConnected={wsjtx.relayConnected} - wsjtxSessionId={wsjtx.sessionId} - showWSJTXOnMap={mapLayersEff.showWSJTX} - onToggleWSJTXMap={toggleWSJTXEff} - /> - ); - break; - - case 'dxpeditions': - content = ; - break; - - case 'pota': - content = ( - - ); - break; - - case 'wwff': - content = ( - - ); - break; - - case 'sota': - content = ( - { + const component = node.getComponent(); + const nodeId = node.getId(); + + let content; + switch (component) { + case 'world-map': + return renderWorldMap(); // Map has its own zoom — skip panel zoom + + case 'de-location': + content = renderDELocation(nodeId); + break; + + case 'dx-location': + content = renderDXLocation(nodeId); + break; + + case 'analog-clock': + content = ; + break; + + case 'solar': + content = ; + break; + + case 'solar-image': + content = ; + break; + + case 'solar-indices': + content = ; + break; + + case 'solar-xray': + content = ; + break; + + case 'lunar': + content = ; + break; + + case 'propagation': + content = ( + - ); - break; - - case 'contests': - content = ; - break; - - case "rotator": - return ( - - ); + ); + break; + + case 'propagation-chart': + content = ( + + ); + break; + + case 'propagation-bars': + content = ( + + ); + break; + + case 'band-conditions': + content = ( + + ); + break; + + case 'band-health': + return ; + + case 'dx-cluster': + content = ( + setShowDXFilters(true)} + onHoverSpot={setHoveredSpot} + onSpotClick={handleSpotClick} + hoveredSpot={hoveredSpot} + showOnMap={mapLayersEff.showDXPaths} + onToggleMap={toggleDXPathsEff} + /> + ); + break; + + case 'psk-reporter': + content = ( + setShowPSKFilters(true)} + onSpotClick={handleSpotClick} + wsjtxDecodes={wsjtx.decodes} + wsjtxClients={wsjtx.clients} + wsjtxQsos={wsjtx.qsos} + wsjtxStats={wsjtx.stats} + wsjtxLoading={wsjtx.loading} + wsjtxEnabled={wsjtx.enabled} + wsjtxPort={wsjtx.port} + wsjtxRelayEnabled={wsjtx.relayEnabled} + wsjtxRelayConnected={wsjtx.relayConnected} + wsjtxSessionId={wsjtx.sessionId} + showWSJTXOnMap={mapLayersEff.showWSJTX} + onToggleWSJTXMap={toggleWSJTXEff} + /> + ); + break; + + case 'dxpeditions': + content = ; + break; + + case 'pota': + content = ( + + ); + break; + + case 'wwff': + content = ( + + ); + break; + + case 'sota': + content = ( + + ); + break; + case 'contests': + content = ; + break; - case 'ambient': - content = ( - { - setTempUnit(unit); - try { localStorage.setItem('openhamclock_tempUnit', unit); } catch { } - }} - /> - ); - break; + case 'rotator': + return ( + + ); - case 'rig-control': - content = ; - break; + case 'ambient': + content = ( + { + setTempUnit(unit); + try { + localStorage.setItem('openhamclock_tempUnit', unit); + } catch {} + }} + /> + ); + break; - case 'on-air': - content = ; - break; + case 'rig-control': + content = ; + break; - case 'id-timer': - content = ; - break; + case 'on-air': + content = ; + break; - default: - content = ( -
-
Outdated panel: {component}
-
Click "Reset" button below to update layout
-
- ); - } + case 'id-timer': + content = ; + break; - // Apply per-panel zoom - const zoom = panelZoom[component] || 1.0; - if (zoom !== 1.0) { - return ( -
- {content} -
- ); - } - return content; - }, [ - config, deGrid, dxGrid, dxLocation, deSunTimes, dxSunTimes, showDxWeather, tempUnit, localWeather, dxWeather, solarIndices, - propagation, bandConditions, dxClusterData, dxFilters, hoveredSpot, mapLayers, potaSpots, wwffSpots, sotaSpots, - mySpots, satellites, filteredSatellites, filteredPskSpots, wsjtxMapSpots, dxpeditions, contests, - pskFilters, wsjtx, handleDXChange, setDxFilters, setShowDXFilters, setShowPSKFilters, - setHoveredSpot, toggleDXPaths, toggleDXLabels, togglePOTA, toggleWWFF, toggleSOTA, toggleSatellites, togglePSKReporter, toggleWSJTX, - dxLocked, handleToggleDxLock, panelZoom - ]); + default: + content = ( +
+
Outdated panel: {component}
+
Click "Reset" button below to update layout
+
+ ); + } + + // Apply per-panel zoom + const zoom = panelZoom[component] || 1.0; + if (zoom !== 1.0) { + return
{content}
; + } + return content; + }, + [ + config, + deGrid, + dxGrid, + dxLocation, + deSunTimes, + dxSunTimes, + showDxWeather, + tempUnit, + localWeather, + dxWeather, + solarIndices, + propagation, + bandConditions, + dxClusterData, + dxFilters, + hoveredSpot, + mapLayers, + potaSpots, + wwffSpots, + sotaSpots, + mySpots, + satellites, + filteredSatellites, + filteredPskSpots, + wsjtxMapSpots, + dxpeditions, + contests, + pskFilters, + wsjtx, + handleDXChange, + setDxFilters, + setShowDXFilters, + setShowPSKFilters, + setHoveredSpot, + toggleDXPaths, + toggleDXLabels, + togglePOTA, + toggleWWFF, + toggleSOTA, + toggleSatellites, + togglePSKReporter, + toggleWSJTX, + dxLocked, + handleToggleDxLock, + panelZoom, + ], + ); // Add + and font size buttons to tabsets - const onRenderTabSet = useCallback((node, renderValues) => { - // Get the active tab's component name for zoom controls - const selectedNode = node.getSelectedNode?.(); - const selectedComponent = selectedNode?.getComponent?.(); + const onRenderTabSet = useCallback( + (node, renderValues) => { + // Get the active tab's component name for zoom controls + const selectedNode = node.getSelectedNode?.(); + const selectedComponent = selectedNode?.getComponent?.(); - // Skip zoom controls for world-map - if (selectedComponent && selectedComponent !== 'world-map') { - const currentZoom = panelZoom[selectedComponent] || 1.0; - const zoomPct = Math.round(currentZoom * 100); + // Skip zoom controls for world-map + if (selectedComponent && selectedComponent !== 'world-map') { + const currentZoom = panelZoom[selectedComponent] || 1.0; + const zoomPct = Math.round(currentZoom * 100); - renderValues.stickyButtons.push( - - ); - if (currentZoom !== 1.0) { renderValues.stickyButtons.push( , + ); + if (currentZoom !== 1.0) { + renderValues.stickyButtons.push( + , + ); + } + renderValues.stickyButtons.push( + + A+ + , ); } + renderValues.stickyButtons.push( + + , ); - } - - renderValues.stickyButtons.push( - - ); - }, [panelZoom, adjustZoom, resetZoom]); + }, + [panelZoom, adjustZoom, resetZoom], + ); // Get unused panels const getAvailablePanels = useCallback(() => { @@ -763,11 +888,21 @@ export const DockableApp = ({ (n.getChildren?.() || []).forEach(walk); }; walk(model.getRoot()); - return Object.entries(panelDefs).filter(([id]) => !used.has(id)).map(([id, def]) => ({ id, ...def })); + return Object.entries(panelDefs) + .filter(([id]) => !used.has(id)) + .map(([id, def]) => ({ id, ...def })); }, [model, panelDefs]); return ( -
+
{/* Header */}
setShowPanelPicker(false)} >
e.stopPropagation()} + style={{ + background: 'rgba(26,32,44,0.98)', + border: '1px solid #2d3748', + borderRadius: '12px', + padding: '20px', + minWidth: '350px', + }} + onClick={(e) => e.stopPropagation()} > -

Add Panel

+

+ Add Panel +

{(() => { const panels = getAvailablePanels(); - const ungrouped = panels.filter(p => !p.group); + const ungrouped = panels.filter((p) => !p.group); const groups = {}; - panels.filter(p => p.group).forEach(p => { - if (!groups[p.group]) groups[p.group] = []; - groups[p.group].push(p); - }); + panels + .filter((p) => p.group) + .forEach((p) => { + if (!groups[p.group]) groups[p.group] = []; + groups[p.group].push(p); + }); return ( <> - {ungrouped.map(p => ( + {ungrouped.map((p) => ( ))} {Object.entries(groups).map(([group, items]) => ( -
+
{group} Sub-panels
- {items.map(p => ( + {items.map((p) => ( ))} @@ -872,7 +1055,16 @@ export const DockableApp = ({ )} @@ -881,5 +1073,5 @@ export const DockableApp = ({ )}
); -} -export default DockableApp; \ No newline at end of file +}; +export default DockableApp; diff --git a/src/components/AmbientPanel.jsx b/src/components/AmbientPanel.jsx index 14e4ad5c..0f189297 100644 --- a/src/components/AmbientPanel.jsx +++ b/src/components/AmbientPanel.jsx @@ -1,14 +1,14 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { useAmbientWeather } from "../hooks/useAmbientWeather.js"; +import React, { useEffect, useMemo, useState } from 'react'; +import { useAmbientWeather } from '../hooks/useAmbientWeather.js'; const row = (label, value) => ( -
- {label} - {value} +
+ {label} + {value}
); -const STORAGE_KEY = "openhamclock_ambientPanel"; +const STORAGE_KEY = 'openhamclock_ambientPanel'; const DEFAULT_SHOW = { // Outside @@ -54,12 +54,12 @@ const DEFAULT_SHOW = { function safeLocalTimeString(v) { try { - if (v == null || v === "") return ""; + if (v == null || v === '') return ''; const d = new Date(v); - if (Number.isNaN(d.getTime())) return ""; + if (Number.isNaN(d.getTime())) return ''; return d.toLocaleString(); } catch { - return ""; + return ''; } } @@ -81,21 +81,18 @@ function loadPanelPrefs() { function savePanelPrefs(prefs) { try { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify({ autoHideMissing: prefs.autoHideMissing, show: prefs.show }) - ); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ autoHideMissing: prefs.autoHideMissing, show: prefs.show })); } catch { // ignore } } function fmtBoolOk(v) { - if (v == null) return "--"; - return v ? "OK" : "LOW"; + if (v == null) return '--'; + return v ? 'OK' : 'LOW'; } -export default function AmbientPanel({ tempUnit = "F" }) { +export default function AmbientPanel({ tempUnit = 'F' }) { // ✅ Hooks must always run in the same order const ambient = useAmbientWeather(tempUnit); const w = ambient.data; @@ -107,10 +104,10 @@ export default function AmbientPanel({ tempUnit = "F" }) { }, [prefs]); // ✅ Derive units safely even when w is null - const deg = `°${(w?.tempUnit || tempUnit)}`; - const windUnit = w?.windUnit || "mph"; - const rainUnit = w?.rainUnit || "in"; - const pressureUnit = w?.pressureUnit || "inHg"; + const deg = `°${w?.tempUnit || tempUnit}`; + const windUnit = w?.windUnit || 'mph'; + const rainUnit = w?.rainUnit || 'in'; + const pressureUnit = w?.pressureUnit || 'inHg'; // ✅ useMemo MUST run on every render (return [] when no data) const rows = useMemo(() => { @@ -118,45 +115,81 @@ export default function AmbientPanel({ tempUnit = "F" }) { return [ // Outside - { key: "temp", label: "Temp", value: w.temp, fmt: (v) => `${v}${deg}`, group: "outside" }, - { key: "feelsLike", label: "Feels", value: w.feelsLike, fmt: (v) => `${v}${deg}`, group: "outside" }, - { key: "humidity", label: "Humidity", value: w.humidity, fmt: (v) => `${v}%`, group: "outside" }, - { key: "dewPoint", label: "Dew Pt", value: w.dewPoint, fmt: (v) => `${v}${deg}`, group: "outside" }, + { key: 'temp', label: 'Temp', value: w.temp, fmt: (v) => `${v}${deg}`, group: 'outside' }, + { key: 'feelsLike', label: 'Feels', value: w.feelsLike, fmt: (v) => `${v}${deg}`, group: 'outside' }, + { key: 'humidity', label: 'Humidity', value: w.humidity, fmt: (v) => `${v}%`, group: 'outside' }, + { key: 'dewPoint', label: 'Dew Pt', value: w.dewPoint, fmt: (v) => `${v}${deg}`, group: 'outside' }, // Pressure - { key: "pressureRel", label: "Pressure (Rel)", value: w.pressureRel, fmt: (v) => `${v} ${pressureUnit}`, group: "outside" }, - { key: "pressureAbs", label: "Pressure (Abs)", value: w.pressureAbs, fmt: (v) => `${v} ${pressureUnit}`, group: "outside" }, + { + key: 'pressureRel', + label: 'Pressure (Rel)', + value: w.pressureRel, + fmt: (v) => `${v} ${pressureUnit}`, + group: 'outside', + }, + { + key: 'pressureAbs', + label: 'Pressure (Abs)', + value: w.pressureAbs, + fmt: (v) => `${v} ${pressureUnit}`, + group: 'outside', + }, // Wind - { key: "windSpeed", label: "Wind", value: w.windSpeed, fmt: (v) => `${v} ${windUnit}`, group: "wind" }, + { key: 'windSpeed', label: 'Wind', value: w.windSpeed, fmt: (v) => `${v} ${windUnit}`, group: 'wind' }, // NOTE: windDir is a compass string (E, ESE, etc.) — no degree symbol - { key: "windDir", label: "Direction", value: w.windDir, fmt: (v) => `${v}`, group: "wind" }, - { key: "windDirDeg", label: "Dir (deg)", value: w.windDirDeg, fmt: (v) => `${v}°`, group: "wind" }, - { key: "windGust", label: "Gust", value: w.windGust, fmt: (v) => `${v} ${windUnit}`, group: "wind" }, - { key: "maxDailyGust", label: "Max Gust", value: w.maxDailyGust, fmt: (v) => `${v} ${windUnit}`, group: "wind" }, + { key: 'windDir', label: 'Direction', value: w.windDir, fmt: (v) => `${v}`, group: 'wind' }, + { key: 'windDirDeg', label: 'Dir (deg)', value: w.windDirDeg, fmt: (v) => `${v}°`, group: 'wind' }, + { key: 'windGust', label: 'Gust', value: w.windGust, fmt: (v) => `${v} ${windUnit}`, group: 'wind' }, + { key: 'maxDailyGust', label: 'Max Gust', value: w.maxDailyGust, fmt: (v) => `${v} ${windUnit}`, group: 'wind' }, // Rain - { key: "rainDaily", label: "Rain Today", value: w.rainDaily, fmt: (v) => `${v} ${rainUnit}`, group: "rain" }, - { key: "rainRate", label: "Rain Rate", value: w.rainRate, fmt: (v) => `${v} ${rainUnit}`, group: "rain" }, - { key: "rainHourly", label: "Rain Hourly", value: w.rainHourly, fmt: (v) => `${v} ${rainUnit}`, group: "rain" }, - { key: "rainWeekly", label: "Rain Weekly", value: w.rainWeekly, fmt: (v) => `${v} ${rainUnit}`, group: "rain" }, - { key: "rainMonthly", label: "Rain Monthly", value: w.rainMonthly, fmt: (v) => `${v} ${rainUnit}`, group: "rain" }, - { key: "rainYearly", label: "Rain Yearly", value: w.rainYearly, fmt: (v) => `${v} ${rainUnit}`, group: "rain" }, + { key: 'rainDaily', label: 'Rain Today', value: w.rainDaily, fmt: (v) => `${v} ${rainUnit}`, group: 'rain' }, + { key: 'rainRate', label: 'Rain Rate', value: w.rainRate, fmt: (v) => `${v} ${rainUnit}`, group: 'rain' }, + { key: 'rainHourly', label: 'Rain Hourly', value: w.rainHourly, fmt: (v) => `${v} ${rainUnit}`, group: 'rain' }, + { key: 'rainWeekly', label: 'Rain Weekly', value: w.rainWeekly, fmt: (v) => `${v} ${rainUnit}`, group: 'rain' }, + { + key: 'rainMonthly', + label: 'Rain Monthly', + value: w.rainMonthly, + fmt: (v) => `${v} ${rainUnit}`, + group: 'rain', + }, + { key: 'rainYearly', label: 'Rain Yearly', value: w.rainYearly, fmt: (v) => `${v} ${rainUnit}`, group: 'rain' }, // Indoor - { key: "indoorTemp", label: "Indoor Temp", value: w.indoorTemp, fmt: (v) => `${v}${deg}`, group: "indoor" }, - { key: "indoorHumidity", label: "Indoor Humidity", value: w.indoorHumidity, fmt: (v) => `${v}%`, group: "indoor" }, - { key: "indoorFeelsLike", label: "Indoor Feels", value: w.indoorFeelsLike, fmt: (v) => `${v}${deg}`, group: "indoor" }, - { key: "indoorDewPoint", label: "Indoor Dew Pt", value: w.indoorDewPoint, fmt: (v) => `${v}${deg}`, group: "indoor" }, + { key: 'indoorTemp', label: 'Indoor Temp', value: w.indoorTemp, fmt: (v) => `${v}${deg}`, group: 'indoor' }, + { + key: 'indoorHumidity', + label: 'Indoor Humidity', + value: w.indoorHumidity, + fmt: (v) => `${v}%`, + group: 'indoor', + }, + { + key: 'indoorFeelsLike', + label: 'Indoor Feels', + value: w.indoorFeelsLike, + fmt: (v) => `${v}${deg}`, + group: 'indoor', + }, + { + key: 'indoorDewPoint', + label: 'Indoor Dew Pt', + value: w.indoorDewPoint, + fmt: (v) => `${v}${deg}`, + group: 'indoor', + }, // Extras - { key: "uv", label: "UV", value: w.uv, fmt: (v) => `${v}`, group: "extras" }, - { key: "solar", label: "Solar", value: w.solar, fmt: (v) => `${v}`, group: "extras" }, + { key: 'uv', label: 'UV', value: w.uv, fmt: (v) => `${v}`, group: 'extras' }, + { key: 'solar', label: 'Solar', value: w.solar, fmt: (v) => `${v}`, group: 'extras' }, // Battery - { key: "battOutOk", label: "Batt Out", value: w.battOutOk, fmt: fmtBoolOk, group: "battery" }, - { key: "battInOk", label: "Batt In", value: w.battInOk, fmt: fmtBoolOk, group: "battery" }, - { key: "battRainOk", label: "Batt Rain", value: w.battRainOk, fmt: fmtBoolOk, group: "battery" }, + { key: 'battOutOk', label: 'Batt Out', value: w.battOutOk, fmt: fmtBoolOk, group: 'battery' }, + { key: 'battInOk', label: 'Batt In', value: w.battInOk, fmt: fmtBoolOk, group: 'battery' }, + { key: 'battRainOk', label: 'Batt Rain', value: w.battRainOk, fmt: fmtBoolOk, group: 'battery' }, ]; }, [w, deg, windUnit, rainUnit, pressureUnit]); @@ -165,21 +198,19 @@ export default function AmbientPanel({ tempUnit = "F" }) { // Render helper (not a hook; safe anywhere) const Code = ({ children }) => ( - - {children} - + {children} ); // ✅ Now it’s safe to early-return after hooks - if (hasError && ambient.error?.code === "missing_credentials") { + if (hasError && ambient.error?.code === 'missing_credentials') { return (
🌦️ Ambient Weather
-
+
Missing Ambient credentials.
- Put VITE_AMBIENT_API_KEY and VITE_AMBIENT_APPLICATION_KEY in{" "} - .env.local, then restart. + Put VITE_AMBIENT_API_KEY and VITE_AMBIENT_APPLICATION_KEY in .env.local + , then restart.
); @@ -189,7 +220,7 @@ export default function AmbientPanel({ tempUnit = "F" }) { return (
🌦️ Ambient Weather
-
Loading…
+
Loading…
); } @@ -198,8 +229,8 @@ export default function AmbientPanel({ tempUnit = "F" }) { return (
🌦️ Ambient Weather
-
- No data yet. {hasError ? `(${ambient.error?.message || "error"})` : ""} +
+ No data yet. {hasError ? `(${ambient.error?.message || 'error'})` : ''}
); @@ -208,21 +239,21 @@ export default function AmbientPanel({ tempUnit = "F" }) { const filtered = rows.filter((r) => { if (!prefs.show[r.key]) return false; if (!prefs.autoHideMissing) return true; - return !(r.value == null || r.value === ""); + return !(r.value == null || r.value === ''); }); const byGroup = (g) => filtered.filter((r) => r.group === g); - const outsideRows = byGroup("outside"); - const windRows = byGroup("wind"); - const rainRows = byGroup("rain"); - const indoorRows = byGroup("indoor"); - const extraRows = byGroup("extras"); - const batteryRows = byGroup("battery"); + const outsideRows = byGroup('outside'); + const windRows = byGroup('wind'); + const rainRows = byGroup('rain'); + const indoorRows = byGroup('indoor'); + const extraRows = byGroup('extras'); + const batteryRows = byGroup('battery'); const Toggle = ({ k, label }) => ( -