Use station data for current conditions; persist keys server-side

Station overlay:
- When Ambient Weather station data is present and fresh (<15 min),
  its readings replace the model's current conditions: temp, feels-like,
  humidity, dewpoint, wind speed/direction, gusts, pressure (inHg→hPa),
  UV index, rain today, wind peek summary, and data-panel readouts
- A small "📡 My Station" badge appears in the hero when active
- applyStationOverlay() is called at the end of renderWeather (instant
  if station data already loaded) and again whenever station data
  refreshes (so the display stays live every 60 s)
- Hourly/daily forecasts continue to use Open-Meteo

Server-side key persistence:
- POST /api/ambient-config saves keys to data/ambient-config.json
  (same persistent volume as VAPID and subscriptions)
- GET /api/ambient-config returns {configured: bool} without exposing keys
- /api/ambient checks query params → saved config → env vars in order
- Settings Save button posts keys to server after a successful test;
  confirms "saved for all devices" in the status line
- On page load, if no localStorage keys but server is configured,
  the station loads automatically on any device
This commit is contained in:
Claude committed 2026-08-19 15:53:10 +00:00
1 parent b9e2b463f5
commit 0005698264
4 files changed
+151 -4

No files matched your search

+103 -1
View File
@@ -348,6 +348,7 @@ const state = {
pwsUrl: localStorage.getItem(STORAGE_KEYS.pwsUrl) || '',
ambientApiKey: localStorage.getItem(STORAGE_KEYS.ambientApiKey) || '',
ambientAppKey: localStorage.getItem(STORAGE_KEYS.ambientAppKey) || '',
ambientDevice: null,
pushSub: null,
hourlyExtended: false,
hourlyFeelsLike: false,
@@ -1503,6 +1504,9 @@ function renderWeather(data, city) {
show(weatherDiv);
setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 80);
// Apply station overlay if we have fresh station data
if (state.ambientDevice) applyStationOverlay(state.ambientDevice);
}
function renderDailyForecast(daily) {
@@ -2684,8 +2688,14 @@ if (ambientSaveBtn) {
state.ambientAppKey = appKey;
localStorage.setItem(STORAGE_KEYS.ambientApiKey, apiKey);
localStorage.setItem(STORAGE_KEYS.ambientAppKey, appKey);
// Also persist keys server-side so all devices share them
fetch('/api/ambient-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey, appKey }),
}).catch(() => {});
const stationName = devices?.[0]?.info?.name || devices?.[0]?.macAddress || 'station';
if (ambientStatusEl) { ambientStatusEl.textContent = `✓ Connected to ${stationName}`; show(ambientStatusEl); }
if (ambientStatusEl) { ambientStatusEl.textContent = `✓ Connected to ${stationName} — saved for all devices`; show(ambientStatusEl); }
fetchAmbientStation();
} else {
const { error } = await res.json().catch(() => ({}));
@@ -2698,6 +2708,14 @@ if (ambientSaveBtn) {
});
}
// On load, check if server already has keys configured (other device saved them)
fetch('/api/ambient-config').then(r => r.json()).then(({ configured }) => {
if (configured && !state.ambientApiKey) {
if (ambientStatusEl) { ambientStatusEl.textContent = '✓ Keys configured on server'; show(ambientStatusEl); }
fetchAmbientStation();
}
}).catch(() => {});
const pwsUrlInput = document.getElementById('pws-url-input');
const pwsSaveBtn = document.getElementById('pws-save-btn');
const pwsStatusEl = document.getElementById('pws-status');
@@ -2859,7 +2877,88 @@ async function fetchAmbientStation() {
}
}
function applyStationOverlay(device) {
if (!device) return;
const data = device.lastData || {};
if (!data.dateutc || Date.now() - data.dateutc > 15 * 60 * 1000) return; // stale > 15 min
const isMetric = state.units !== 'us';
const fT = f => f == null ? null : (isMetric ? Math.round((f - 32) * 5 / 9) : Math.round(f));
const fS = mph => mph == null ? null : (isMetric ? Math.round(mph * 1.60934) : Math.round(mph));
const setEl = (id, val) => { if (val == null) return; const el = document.getElementById(id); if (el) el.textContent = val; };
if (data.tempf != null) {
setEl('cur-temp', fT(data.tempf));
const panelTemp = document.getElementById('panel-temp');
if (panelTemp) panelTemp.textContent = `${fT(data.tempf)}°${tempUnit()}`;
}
if (data.feelsLike != null) {
setEl('cur-feels', fT(data.feelsLike));
const feelsLine = document.getElementById('feels-line');
if (feelsLine) {
const label = apparentLabel(data.tempf, data.feelsLike);
feelsLine.innerHTML = `${label} <span id="cur-feels">${fT(data.feelsLike)}</span>°${tempUnit()}`;
}
}
if (data.humidity != null) {
setEl('cur-hum', data.humidity);
setEl('hum-feel', humidityFeel(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);
setEl('cur-dew', isMetric ? Math.round(dewC) : Math.round(dewC * 9 / 5 + 32));
}
if (data.windspeedmph != null) {
setEl('cur-wind', fS(data.windspeedmph));
const panelWind = document.getElementById('panel-wind-speed');
if (panelWind) panelWind.textContent = `${fS(data.windspeedmph)} ${speedUnit()}`;
}
if (data.winddir != null) {
setEl('cur-wind-dir', compassDirFull(data.winddir));
const panelDir = document.getElementById('panel-wind-dir');
if (panelDir) panelDir.textContent = compassDirFull(data.winddir);
}
if (data.windgustmph != null) setEl('cur-gust', fS(data.windgustmph));
// Pressure: Ambient gives inHg, display expects hPa
if (data.baromrelin != null) setEl('cur-pres', Math.round(data.baromrelin * 33.8639));
if (data.uv != null) setEl('cur-uv', data.uv);
// Rain summary in hero sub-line
const rainEl = document.getElementById('cur-precip');
if (rainEl && data.dailyrainin != null) {
const rainVal = isMetric ? (data.dailyrainin * 25.4).toFixed(1) : data.dailyrainin.toFixed(2);
const rainUnit2 = isMetric ? 'mm' : 'in';
rainEl.textContent = data.dailyrainin > 0
? `${rainVal} ${rainUnit2} rain today`
: 'No rain today';
}
// Wind peek summary
const peekEl = document.getElementById('wind-drawer-peek');
if (peekEl && data.windspeedmph != null) {
peekEl.textContent = `· ${fS(data.windspeedmph)} ${speedUnit()} ${compassDir(data.winddir || 0)} · ${data.humidity ?? '--'}% humidity`;
}
// Station badge
const badge = document.getElementById('station-badge');
const badgeName = document.getElementById('station-badge-name');
if (badge) {
if (badgeName) badgeName.textContent = device.info?.name || 'My Station';
show(badge);
}
}
function renderAmbientStation(device) {
state.ambientDevice = device;
const data = device?.lastData || {};
const info = device?.info || {};
const contentEl = document.getElementById('station-content');
@@ -2934,4 +3033,7 @@ function renderAmbientStation(device) {
}
if (contentEl) show(contentEl);
// Update the main conditions display with real station readings
if (state.currentWeather) applyStationOverlay(device);
}
+1
View File
@@ -64,6 +64,7 @@
<div id="cur-icon" class="hero-icon"></div>
<div id="cur-desc" class="hero-desc"></div>
<div id="cur-precip" class="hero-sub"></div>
<div id="station-badge" class="station-badge hidden">📡 <span id="station-badge-name">My Station</span></div>
</div>
<div class="hero-center">
<div class="hero-temp"><span id="cur-temp">--</span><span class="deg">°F</span></div>
+15
View File
@@ -2487,3 +2487,18 @@ body.map-fs #unified-map {
}
.station-name { font-weight: 600; font-size: 0.95rem; }
.station-updated { font-size: 0.75rem; color: var(--ink-light); }
.station-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.4rem;
font-size: 0.72rem;
font-weight: 600;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-radius: 999px;
padding: 0.15rem 0.55rem;
letter-spacing: 0.02em;
}
+32 -3
View File
@@ -441,10 +441,39 @@ 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
const AMBIENT_CONFIG_FILE = path.join(DATA_DIR, 'ambient-config.json');
let ambientServerConfig = { apiKey: '', appKey: '' };
try {
if (fs.existsSync(AMBIENT_CONFIG_FILE)) {
ambientServerConfig = JSON.parse(fs.readFileSync(AMBIENT_CONFIG_FILE, 'utf8'));
}
} catch {}
function saveAmbientConfig() {
try { fs.writeFileSync(AMBIENT_CONFIG_FILE, JSON.stringify(ambientServerConfig)); } catch {}
}
const ambientCaches = new Map();
// Save keys server-side so all devices share them
app.post('/api/ambient-config', (req, res) => {
const { apiKey, appKey } = req.body || {};
if (!apiKey || !appKey) return res.status(400).json({ error: 'apiKey and appKey required' });
ambientServerConfig = { apiKey, appKey };
saveAmbientConfig();
res.json({ ok: true });
});
// Return whether server-side keys exist (never expose the actual keys)
app.get('/api/ambient-config', (_req, res) => {
const hasKeys = !!(ambientServerConfig.apiKey && ambientServerConfig.appKey);
res.json({ configured: hasKeys });
});
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;
// Priority: query params (client override) → server-saved → env vars
const apiKey = req.query.apiKey || ambientServerConfig.apiKey || process.env.AMBIENT_API_KEY;
const appKey = req.query.appKey || ambientServerConfig.appKey || process.env.AMBIENT_APP_KEY;
if (!apiKey || !appKey) return res.status(503).json({ error: 'Ambient Weather keys not configured' });
const cacheKey = apiKey;