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:
4 files changed
+151
-4
No files matched your search
+103
-1
@@ -348,6 +348,7 @@ const state = {
|
|||||||
pwsUrl: localStorage.getItem(STORAGE_KEYS.pwsUrl) || '',
|
pwsUrl: localStorage.getItem(STORAGE_KEYS.pwsUrl) || '',
|
||||||
ambientApiKey: localStorage.getItem(STORAGE_KEYS.ambientApiKey) || '',
|
ambientApiKey: localStorage.getItem(STORAGE_KEYS.ambientApiKey) || '',
|
||||||
ambientAppKey: localStorage.getItem(STORAGE_KEYS.ambientAppKey) || '',
|
ambientAppKey: localStorage.getItem(STORAGE_KEYS.ambientAppKey) || '',
|
||||||
|
ambientDevice: null,
|
||||||
pushSub: null,
|
pushSub: null,
|
||||||
hourlyExtended: false,
|
hourlyExtended: false,
|
||||||
hourlyFeelsLike: false,
|
hourlyFeelsLike: false,
|
||||||
@@ -1503,6 +1504,9 @@ function renderWeather(data, city) {
|
|||||||
|
|
||||||
show(weatherDiv);
|
show(weatherDiv);
|
||||||
setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 80);
|
setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 80);
|
||||||
|
|
||||||
|
// Apply station overlay if we have fresh station data
|
||||||
|
if (state.ambientDevice) applyStationOverlay(state.ambientDevice);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDailyForecast(daily) {
|
function renderDailyForecast(daily) {
|
||||||
@@ -2684,8 +2688,14 @@ if (ambientSaveBtn) {
|
|||||||
state.ambientAppKey = appKey;
|
state.ambientAppKey = appKey;
|
||||||
localStorage.setItem(STORAGE_KEYS.ambientApiKey, apiKey);
|
localStorage.setItem(STORAGE_KEYS.ambientApiKey, apiKey);
|
||||||
localStorage.setItem(STORAGE_KEYS.ambientAppKey, appKey);
|
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';
|
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();
|
fetchAmbientStation();
|
||||||
} else {
|
} else {
|
||||||
const { error } = await res.json().catch(() => ({}));
|
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 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');
|
||||||
@@ -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) {
|
function renderAmbientStation(device) {
|
||||||
|
state.ambientDevice = device;
|
||||||
const data = device?.lastData || {};
|
const data = device?.lastData || {};
|
||||||
const info = device?.info || {};
|
const info = device?.info || {};
|
||||||
const contentEl = document.getElementById('station-content');
|
const contentEl = document.getElementById('station-content');
|
||||||
@@ -2934,4 +3033,7 @@ function renderAmbientStation(device) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contentEl) show(contentEl);
|
if (contentEl) show(contentEl);
|
||||||
|
|
||||||
|
// Update the main conditions display with real station readings
|
||||||
|
if (state.currentWeather) applyStationOverlay(device);
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
<div id="cur-icon" class="hero-icon"></div>
|
<div id="cur-icon" class="hero-icon"></div>
|
||||||
<div id="cur-desc" class="hero-desc"></div>
|
<div id="cur-desc" class="hero-desc"></div>
|
||||||
<div id="cur-precip" class="hero-sub"></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>
|
||||||
<div class="hero-center">
|
<div class="hero-center">
|
||||||
<div class="hero-temp"><span id="cur-temp">--</span><span class="deg">°F</span></div>
|
<div class="hero-temp"><span id="cur-temp">--</span><span class="deg">°F</span></div>
|
||||||
|
|||||||
@@ -2487,3 +2487,18 @@ body.map-fs #unified-map {
|
|||||||
}
|
}
|
||||||
.station-name { font-weight: 600; font-size: 0.95rem; }
|
.station-name { font-weight: 600; font-size: 0.95rem; }
|
||||||
.station-updated { font-size: 0.75rem; color: var(--ink-light); }
|
.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
@@ -441,10 +441,39 @@ app.get('/api/spc/reports', async (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Ambient Weather personal station ─────────────────────────────────────────
|
// ── 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) => {
|
app.get('/api/ambient', async (req, res) => {
|
||||||
const apiKey = req.query.apiKey || process.env.AMBIENT_API_KEY;
|
// Priority: query params (client override) → server-saved → env vars
|
||||||
const appKey = req.query.appKey || process.env.AMBIENT_APP_KEY;
|
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' });
|
if (!apiKey || !appKey) return res.status(503).json({ error: 'Ambient Weather keys not configured' });
|
||||||
|
|
||||||
const cacheKey = apiKey;
|
const cacheKey = apiKey;
|
||||||
|
|||||||
Reference in new issue
Block a user