diff --git a/.gitignore b/.gitignore index 84376b6..30dbac5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ app/node_modules/ +.env diff --git a/app/public/app.js b/app/public/app.js index 16ed945..cf856e8 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -300,6 +300,8 @@ const STORAGE_KEYS = { alertThresholds: 'weather.alertThresholds', digestHour: 'weather.digestHour', pwsUrl: 'weather.pwsUrl', + ambientApiKey: 'weather.ambientApiKey', + ambientAppKey: 'weather.ambientAppKey', alertIdsSeen: 'weather.alertIdsSeen', }; @@ -344,6 +346,8 @@ const state = { alertThresholds: JSON.parse(localStorage.getItem(STORAGE_KEYS.alertThresholds) || '{}'), digestHour: Number(localStorage.getItem(STORAGE_KEYS.digestHour) ?? 7), pwsUrl: localStorage.getItem(STORAGE_KEYS.pwsUrl) || '', + ambientApiKey: localStorage.getItem(STORAGE_KEYS.ambientApiKey) || '', + ambientAppKey: localStorage.getItem(STORAGE_KEYS.ambientAppKey) || '', pushSub: null, hourlyExtended: false, hourlyFeelsLike: false, @@ -1957,6 +1961,9 @@ async function loadAll(city, source) { // Fetch discussion non-blocking — US only, silently skip if unavailable fetchDiscussion(city.latitude, city.longitude); + + // Fetch personal station non-blocking + fetchAmbientStation(); try { if (state.showLightning || state.unifiedMapLayers.lightning) renderLightningStrikes(city); @@ -2653,6 +2660,44 @@ if (digestHourInput) { } // ── PWS URL ──────────────────────────────────────────────────────────────────── +// ── Ambient Weather key settings ────────────────────────────────────────────── +const ambientApiKeyInput = document.getElementById('ambient-api-key-input'); +const ambientAppKeyInput = document.getElementById('ambient-app-key-input'); +const ambientSaveBtn = document.getElementById('ambient-save-btn'); +const ambientStatusEl = document.getElementById('ambient-status'); +if (ambientApiKeyInput) ambientApiKeyInput.value = state.ambientApiKey; +if (ambientAppKeyInput) ambientAppKeyInput.value = state.ambientAppKey; +if (ambientSaveBtn) { + ambientSaveBtn.addEventListener('click', async () => { + const apiKey = ambientApiKeyInput?.value?.trim(); + const appKey = ambientAppKeyInput?.value?.trim(); + if (!apiKey || !appKey) { + if (ambientStatusEl) { ambientStatusEl.textContent = 'Both keys are required.'; show(ambientStatusEl); } + return; + } + ambientSaveBtn.disabled = true; + try { + const res = await fetch(`/api/ambient?apiKey=${encodeURIComponent(apiKey)}&appKey=${encodeURIComponent(appKey)}`); + if (res.ok) { + const devices = await res.json(); + state.ambientApiKey = apiKey; + state.ambientAppKey = appKey; + localStorage.setItem(STORAGE_KEYS.ambientApiKey, apiKey); + localStorage.setItem(STORAGE_KEYS.ambientAppKey, appKey); + const stationName = devices?.[0]?.info?.name || devices?.[0]?.macAddress || 'station'; + if (ambientStatusEl) { ambientStatusEl.textContent = `✓ Connected to ${stationName}`; show(ambientStatusEl); } + fetchAmbientStation(); + } else { + const { error } = await res.json().catch(() => ({})); + if (ambientStatusEl) { ambientStatusEl.textContent = `✗ ${error || 'Connection failed'}`; show(ambientStatusEl); } + } + } catch { + if (ambientStatusEl) { ambientStatusEl.textContent = '✗ Request failed.'; show(ambientStatusEl); } + } + ambientSaveBtn.disabled = false; + }); +} + const pwsUrlInput = document.getElementById('pws-url-input'); const pwsSaveBtn = document.getElementById('pws-save-btn'); const pwsStatusEl = document.getElementById('pws-status'); @@ -2758,3 +2803,135 @@ if (pwsSaveBtn) { // silent startup fallback } })(); + +// ── Ambient Weather personal station ───────────────────────────────────────── +let stationRefreshTimer = null; + +async function fetchAmbientStation() { + const loadingEl = document.getElementById('station-loading'); + const contentEl = document.getElementById('station-content'); + const errorEl = document.getElementById('station-error'); + const peekEl = document.getElementById('station-drawer-peek'); + + const apiKey = state.ambientApiKey; + const appKey = state.ambientAppKey; + const params = new URLSearchParams(); + if (apiKey) params.set('apiKey', apiKey); + if (appKey) params.set('appKey', appKey); + const qs = params.toString() ? `?${params}` : ''; + + try { + const r = await fetch(`/api/ambient${qs}`); + if (!r.ok) { + const { error } = await r.json().catch(() => ({})); + if (errorEl) { errorEl.textContent = error || 'Station unavailable'; show(errorEl); } + if (loadingEl) hide(loadingEl); + return; + } + const devices = await r.json(); + if (!Array.isArray(devices) || !devices.length) { + if (errorEl) { errorEl.textContent = 'No devices found on this account.'; show(errorEl); } + if (loadingEl) hide(loadingEl); + return; + } + if (loadingEl) hide(loadingEl); + if (errorEl) hide(errorEl); + renderAmbientStation(devices[0]); + + // Auto-refresh every 60 s + clearInterval(stationRefreshTimer); + stationRefreshTimer = setInterval(async () => { + try { + const p2 = new URLSearchParams(); + if (state.ambientApiKey) p2.set('apiKey', state.ambientApiKey); + if (state.ambientAppKey) p2.set('appKey', state.ambientAppKey); + const q2 = p2.toString() ? `?${p2}` : ''; + const r2 = await fetch(`/api/ambient${q2}`); + if (r2.ok) { + const d2 = await r2.json(); + if (Array.isArray(d2) && d2.length) renderAmbientStation(d2[0]); + } + } catch {} + }, 60000); + } catch (e) { + if (errorEl) { errorEl.textContent = 'Could not reach station.'; show(errorEl); } + if (loadingEl) hide(loadingEl); + } +} + +function renderAmbientStation(device) { + const data = device?.lastData || {}; + const info = device?.info || {}; + const contentEl = document.getElementById('station-content'); + const peekEl = document.getElementById('station-drawer-peek'); + + const stName = info.name || device?.macAddress || 'My Station'; + const nameEl = document.getElementById('station-name'); + if (nameEl) nameEl.textContent = stName; + + const updatedEl = document.getElementById('station-updated'); + if (updatedEl && data.dateutc) { + const ago = Math.round((Date.now() - data.dateutc) / 60000); + updatedEl.textContent = ago <= 1 ? 'just now' : `${ago} min ago`; + } + + const isMetric = state.units !== 'us'; + + function fmtTemp(f) { + if (f == null) return '--'; + return isMetric ? Math.round((f - 32) * 5 / 9) : Math.round(f); + } + function fmtSpeed(mph) { + if (mph == null) return '--'; + return isMetric ? Math.round(mph * 1.60934) : Math.round(mph); + } + function fmtRain(inches) { + if (inches == null) return '--'; + return isMetric ? (inches * 25.4).toFixed(1) : inches.toFixed(2); + } + + const tempUnit = isMetric ? 'C' : 'F'; + const speedUnit = isMetric ? 'km/h' : 'mph'; + const rainUnit = isMetric ? 'mm' : 'in'; + + const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; + + setEl('st-temp', fmtTemp(data.tempf)); + setEl('st-temp-unit', tempUnit); + setEl('st-feels', data.feelsLike != null ? `Feels ${fmtTemp(data.feelsLike)}°` : ''); + setEl('st-hum', data.humidity != null ? `${data.humidity}` : '--'); + + if (data.tempf != null && data.humidity != null) { + const tc = (data.tempf - 32) * 5 / 9; + const alpha = Math.log(data.humidity / 100) + (17.62 * tc) / (243.12 + tc); + const dewC = (243.12 * alpha) / (17.62 - alpha); + const dewDisplay = isMetric ? Math.round(dewC) : Math.round(dewC * 9 / 5 + 32); + setEl('st-dew', `Dew ${dewDisplay}°`); + } + + setEl('st-wind', fmtSpeed(data.windspeedmph)); + setEl('st-wind-unit', speedUnit); + setEl('st-wind-dir', data.winddir != null ? compassDirFull(data.winddir) : ''); + setEl('st-gust', fmtSpeed(data.windgustmph)); + setEl('st-gust-unit', speedUnit); + setEl('st-rain-day', fmtRain(data.dailyrainin)); + setEl('st-rain-unit', rainUnit); + const rrDisplay = data.hourlyrainin != null ? `${fmtRain(data.hourlyrainin)} ${rainUnit}/hr` : ''; + setEl('st-rain-rate', rrDisplay); + setEl('st-pressure', data.baromrelin != null ? data.baromrelin.toFixed(2) : '--'); + setEl('st-uv', data.uv != null ? data.uv : '--'); + setEl('st-solar', data.solarradiation != null ? Math.round(data.solarradiation) : '--'); + + const indoorCard = document.getElementById('st-indoor-card'); + if (data.tempinf != null) { + setEl('st-tempin', fmtTemp(data.tempinf)); + setEl('st-humin', data.humidityin != null ? `${data.humidityin}% RH` : ''); + if (indoorCard) indoorCard.classList.remove('hidden'); + } + + if (peekEl && data.tempf != null) { + peekEl.textContent = `· ${fmtTemp(data.tempf)}°${tempUnit} ${data.humidity != null ? data.humidity + '% RH' : ''}`; + } + + if (contentEl) show(contentEl); +} diff --git a/app/public/index.html b/app/public/index.html index 5b4ab45..794c42e 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -237,6 +237,68 @@ + +
+ + My Station + + › + +
+
Loading station data…
+ + +
+
+ +
Ambient Weather Station
+
+
+ API Key +
+ +
+
+
+ Application Key + +
+ +

From ambientweather.net → Account → API Keys. Keys are stored locally in your browser.

+ +
+
Personal Weather Station
diff --git a/app/public/style.css b/app/public/style.css index 6647159..9944cad 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -2472,3 +2472,18 @@ body.map-fs #unified-map { } .mini-remove:hover { color: var(--accent); } + +/* ── My Station (Ambient Weather) ──────────────────────────────────────────── */ +.station-drawer { margin-top: 1rem; } +.station-drawer-peek { font-size: 0.8rem; color: var(--ink-light); margin-left: 0.5rem; font-weight: 400; } +.station-loading { color: var(--ink-light); font-size: 0.9rem; padding: 0.5rem 0; } +.station-error { color: #e57373; font-size: 0.88rem; padding: 0.4rem 0; } +.station-name-row { + display: flex; + align-items: baseline; + gap: 0.6rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} +.station-name { font-weight: 600; font-size: 0.95rem; } +.station-updated { font-size: 0.75rem; color: var(--ink-light); } diff --git a/app/server.js b/app/server.js index 76eb59c..34a7806 100644 --- a/app/server.js +++ b/app/server.js @@ -440,6 +440,30 @@ app.get('/api/spc/reports', async (_req, res) => { } }); +// ── Ambient Weather personal station ───────────────────────────────────────── +const ambientCaches = new Map(); // keyed by apiKey so each user's cache is independent +app.get('/api/ambient', async (req, res) => { + const apiKey = req.query.apiKey || process.env.AMBIENT_API_KEY; + const appKey = req.query.appKey || process.env.AMBIENT_APP_KEY; + if (!apiKey || !appKey) return res.status(503).json({ error: 'Ambient Weather keys not configured' }); + + const cacheKey = apiKey; + const cached = ambientCaches.get(cacheKey); + if (cached && Date.now() - cached.at < 60 * 1000) return res.json(cached.data); + + try { + const url = `https://rt.ambientweather.net/v1/devices?apiKey=${encodeURIComponent(apiKey)}&applicationKey=${encodeURIComponent(appKey)}`; + const r = await fetchWithRetry(url, { attempts: 2, timeoutMs: 10000, retryDelayMs: 500 }); + const devices = await r.json(); + ambientCaches.set(cacheKey, { data: devices, at: Date.now() }); + res.json(devices); + } catch (e) { + console.error('Ambient Weather fetch error:', e.message); + if (cached) return res.json(cached.data); + res.status(502).json({ error: 'Ambient Weather fetch failed' }); + } +}); + // ── Personal weather station proxy ──────────────────────────────────────────── app.get('/api/pws', async (req, res) => { const { url } = req.query; diff --git a/docker-compose.yml b/docker-compose.yml index f5ad626..6a6ff05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-} - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} - VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:nate@necloud.us} + # Ambient Weather personal station integration + - AMBIENT_API_KEY=${AMBIENT_API_KEY:-} + - AMBIENT_APP_KEY=${AMBIENT_APP_KEY:-} # Optional: how often to poll NWS alerts (ms). Default: 300000 (5 min) # - ALERT_POLL_INTERVAL_MS=300000 volumes: