Add Ambient Weather personal station integration

* Add Ambient Weather personal station integration

Fetches live data from the user's Ambient Weather station via their
REST API and displays it as a "My Station" card in the conditions tab.

- server.js: add /api/ambient endpoint with 60s cache using
  AMBIENT_API_KEY and AMBIENT_APP_KEY env vars
- docker-compose.yml: expose AMBIENT_API_KEY/AMBIENT_APP_KEY env vars
- index.html: add collapsible "My Station" drawer to conditions tab
  showing temp, humidity, wind, gusts, daily rain, pressure, UV,
  solar radiation, and indoor readings
- app.js: add fetchAmbientStation() and renderAmbientStation(),
  auto-refresh every 60s, peek text summary in drawer header
- style.css: station drawer styles
- .gitignore: add .env so API keys stay off git

Co-Authored-By: Claude <noreply@anthropic.com>

* Move Ambient Weather keys to Settings panel

Users can now enter their API Key and Application Key directly in the
Settings tab under "Ambient Weather Station". Keys are saved to
localStorage and sent per-request to the server proxy.

- index.html: add API Key + Application Key inputs with Save button
- app.js: persist keys in localStorage, populate inputs on load,
  wire Save button (validates by testing the connection), pass keys
  as query params in fetchAmbientStation and its auto-refresh loop
- server.js: accept apiKey/appKey from query params with env var
  fallback; cache keyed per-user so multiple callers don't collide

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude authored and GitHub committed 2026-08-19 11:35:16 -04:00
1 parent b9e6cad190
commit b9e2b463f5
6 files changed
+303

No files matched your search

+1
View File
@@ -1 +1,2 @@
app/node_modules/ app/node_modules/
.env
+177
View File
@@ -300,6 +300,8 @@ const STORAGE_KEYS = {
alertThresholds: 'weather.alertThresholds', alertThresholds: 'weather.alertThresholds',
digestHour: 'weather.digestHour', digestHour: 'weather.digestHour',
pwsUrl: 'weather.pwsUrl', pwsUrl: 'weather.pwsUrl',
ambientApiKey: 'weather.ambientApiKey',
ambientAppKey: 'weather.ambientAppKey',
alertIdsSeen: 'weather.alertIdsSeen', alertIdsSeen: 'weather.alertIdsSeen',
}; };
@@ -344,6 +346,8 @@ const state = {
alertThresholds: JSON.parse(localStorage.getItem(STORAGE_KEYS.alertThresholds) || '{}'), alertThresholds: JSON.parse(localStorage.getItem(STORAGE_KEYS.alertThresholds) || '{}'),
digestHour: Number(localStorage.getItem(STORAGE_KEYS.digestHour) ?? 7), digestHour: Number(localStorage.getItem(STORAGE_KEYS.digestHour) ?? 7),
pwsUrl: localStorage.getItem(STORAGE_KEYS.pwsUrl) || '', pwsUrl: localStorage.getItem(STORAGE_KEYS.pwsUrl) || '',
ambientApiKey: localStorage.getItem(STORAGE_KEYS.ambientApiKey) || '',
ambientAppKey: localStorage.getItem(STORAGE_KEYS.ambientAppKey) || '',
pushSub: null, pushSub: null,
hourlyExtended: false, hourlyExtended: false,
hourlyFeelsLike: false, hourlyFeelsLike: false,
@@ -1958,6 +1962,9 @@ async function loadAll(city, source) {
// Fetch discussion non-blocking — US only, silently skip if unavailable // Fetch discussion non-blocking — US only, silently skip if unavailable
fetchDiscussion(city.latitude, city.longitude); fetchDiscussion(city.latitude, city.longitude);
// Fetch personal station non-blocking
fetchAmbientStation();
try { try {
if (state.showLightning || state.unifiedMapLayers.lightning) renderLightningStrikes(city); if (state.showLightning || state.unifiedMapLayers.lightning) renderLightningStrikes(city);
if (state.showWind || state.unifiedMapLayers.wind) renderWindArrows(city); if (state.showWind || state.unifiedMapLayers.wind) renderWindArrows(city);
@@ -2653,6 +2660,44 @@ if (digestHourInput) {
} }
// ── PWS URL ──────────────────────────────────────────────────────────────────── // ── 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 pwsUrlInput = document.getElementById('pws-url-input');
const pwsSaveBtn = document.getElementById('pws-save-btn'); const pwsSaveBtn = document.getElementById('pws-save-btn');
const pwsStatusEl = document.getElementById('pws-status'); const pwsStatusEl = document.getElementById('pws-status');
@@ -2758,3 +2803,135 @@ if (pwsSaveBtn) {
// silent startup fallback // 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);
}
+83
View File
@@ -237,6 +237,68 @@
</div> </div>
</details> </details>
<!-- ── MY STATION (Ambient Weather) ── -->
<details class="details-drawer station-drawer" id="station-drawer" open>
<summary class="drawer-summary">
My Station
<span class="station-drawer-peek" id="station-drawer-peek"></span>
<span class="drawer-chevron">›</span>
</summary>
<div class="drawer-body" id="station-body">
<div class="station-loading" id="station-loading">Loading station data…</div>
<div id="station-content" class="hidden">
<div class="station-name-row">
<span class="station-name" id="station-name"></span>
<span class="station-updated" id="station-updated"></span>
</div>
<div class="card-group drawer-card-group">
<div class="stat-card">
<div class="stat-label">Outdoor Temp</div>
<div class="stat-val"><span id="st-temp">--</span>°<span id="st-temp-unit">F</span></div>
<div class="stat-sub" id="st-feels"></div>
</div>
<div class="stat-card">
<div class="stat-label">Humidity</div>
<div class="stat-val"><span id="st-hum">--</span><span class="stat-unit">%</span></div>
<div class="stat-sub" id="st-dew"></div>
</div>
<div class="stat-card">
<div class="stat-label">Wind</div>
<div class="stat-val"><span id="st-wind">--</span> <span class="stat-unit" id="st-wind-unit">mph</span></div>
<div class="stat-sub" id="st-wind-dir"></div>
</div>
<div class="stat-card">
<div class="stat-label">Gusts</div>
<div class="stat-val"><span id="st-gust">--</span> <span class="stat-unit" id="st-gust-unit">mph</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Rain Today</div>
<div class="stat-val"><span id="st-rain-day">--</span> <span class="stat-unit" id="st-rain-unit">in</span></div>
<div class="stat-sub" id="st-rain-rate"></div>
</div>
<div class="stat-card">
<div class="stat-label">Pressure</div>
<div class="stat-val"><span id="st-pressure">--</span> <span class="stat-unit">inHg</span></div>
</div>
<div class="stat-card" id="st-uv-card">
<div class="stat-label">UV Index</div>
<div class="stat-val"><span id="st-uv">--</span></div>
</div>
<div class="stat-card" id="st-solar-card">
<div class="stat-label">Solar Rad.</div>
<div class="stat-val"><span id="st-solar">--</span> <span class="stat-unit">W/m²</span></div>
</div>
<div class="stat-card" id="st-indoor-card" class="hidden">
<div class="stat-label">Indoor Temp</div>
<div class="stat-val"><span id="st-tempin">--</span>°</div>
<div class="stat-sub" id="st-humin"></div>
</div>
</div>
</div>
<div id="station-error" class="station-error hidden"></div>
</div>
</details>
<div class="footer-note"> <div class="footer-note">
Weather: <a href="https://open-meteo.com" target="_blank">Open-Meteo</a> · Weather: <a href="https://open-meteo.com" target="_blank">Open-Meteo</a> ·
Radar: <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> · Radar: <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> ·
@@ -518,6 +580,27 @@
<input id="digest-hour" class="settings-input settings-input--short" type="number" min="0" max="23" value="7"> <input id="digest-hour" class="settings-input settings-input--short" type="number" min="0" max="23" value="7">
</div> </div>
<div class="settings-section-title">Ambient Weather Station</div>
<div class="settings-row settings-row--block">
<div class="settings-row-head">
<span class="settings-label">API Key</span>
</div>
<input id="ambient-api-key-input" class="settings-input" type="password"
placeholder="Paste your Ambient Weather API key"
autocomplete="off" spellcheck="false">
</div>
<div class="settings-row settings-row--block">
<div class="settings-row-head">
<span class="settings-label">Application Key</span>
<button id="ambient-save-btn" class="settings-btn" type="button">Save</button>
</div>
<input id="ambient-app-key-input" class="settings-input" type="password"
placeholder="Paste your Ambient Weather Application key"
autocomplete="off" spellcheck="false">
<p class="settings-help">From ambientweather.net → Account → API Keys. Keys are stored locally in your browser.</p>
<p id="ambient-status" class="settings-help hidden"></p>
</div>
<div class="settings-section-title">Personal Weather Station</div> <div class="settings-section-title">Personal Weather Station</div>
<div class="settings-row settings-row--block"> <div class="settings-row settings-row--block">
<div class="settings-row-head"> <div class="settings-row-head">
+15
View File
@@ -2472,3 +2472,18 @@ body.map-fs #unified-map {
} }
.mini-remove:hover { color: var(--accent); } .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); }
+24
View File
@@ -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 ──────────────────────────────────────────── // ── Personal weather station proxy ────────────────────────────────────────────
app.get('/api/pws', async (req, res) => { app.get('/api/pws', async (req, res) => {
const { url } = req.query; const { url } = req.query;
+3
View File
@@ -14,6 +14,9 @@ services:
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-} - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:nate@necloud.us} - 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) # Optional: how often to poll NWS alerts (ms). Default: 300000 (5 min)
# - ALERT_POLL_INTERVAL_MS=300000 # - ALERT_POLL_INTERVAL_MS=300000
volumes: volumes: