'use strict'; /* ── Config ── */ const ROOT = document.getElementById('mirror-root'); const POLL_MS = parseInt(ROOT.dataset.pollMs || '2500', 10); const SENSORS = JSON.parse(ROOT.dataset.sensors || '[]'); const WEATHER_POLL_MS = 10 * 60 * 1000; // 10 min — matches server cache /* ── State ── */ let lastLogCount = 0; let alertsSeen = 0; let chartData = []; let firstPoll = true; let consumerMode = false; let latestValues = []; /* ══════════════════════════════════ VIEW TOGGLE ══════════════════════════════════ */ const STORAGE_KEY = 'sm_view'; function toggleView() { consumerMode = !consumerMode; document.body.classList.toggle('consumer-mode', consumerMode); try { localStorage.setItem(STORAGE_KEY, consumerMode ? '1' : '0'); } catch(_) {} } // Restore last view try { if (localStorage.getItem(STORAGE_KEY) === '1') toggleView(); } catch(_) {} // Keyboard shortcut: Alt+D (developer) / Alt+C (consumer) document.addEventListener('keydown', e => { if (e.altKey && e.key === 'd') { consumerMode = true; toggleView(); } if (e.altKey && e.key === 'c') { consumerMode = false; toggleView(); } if (e.key === 'F2') toggleView(); }); /* ══════════════════════════════════ CLOCK — ticks every second ══════════════════════════════════ */ const DAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; const MONTHS = ['Januar','Februar','März','April','Mai','Juni', 'Juli','August','September','Oktober','November','Dezember']; function tickClock() { const n = new Date(); const pad = x => String(x).padStart(2, '0'); const hms = `${pad(n.getHours())}:${pad(n.getMinutes())}:${pad(n.getSeconds())}`; const hm = `${pad(n.getHours())}:${pad(n.getMinutes())}`; const date = `${DAYS[n.getDay()]}, ${n.getDate()}. ${MONTHS[n.getMonth()]} ${n.getFullYear()}`; // Dev clock const dt = document.getElementById('clock-time'); const dd = document.getElementById('clock-date'); if (dt) dt.textContent = hms; if (dd) dd.textContent = date; // Consumer clock const ct = document.getElementById('c-time'); const cd = document.getElementById('c-date'); if (ct) ct.textContent = hm; if (cd) cd.textContent = date; } setInterval(tickClock, 1000); tickClock(); /* ══════════════════════════════════ CANVAS CHART ══════════════════════════════════ */ const canvas = document.getElementById('chart'); const ctx = canvas ? canvas.getContext('2d') : null; function resizeCanvas() { if (!canvas) return; canvas.width = canvas.parentElement.clientWidth - 36; canvas.height = 130; } if (canvas) { window.addEventListener('resize', () => { resizeCanvas(); drawChart(); }); resizeCanvas(); } function drawChart() { if (!ctx) return; const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); const pts = chartData.slice(-80).map(d => d.v); if (pts.length < 2) { ctx.fillStyle = 'rgba(228,233,245,.15)'; ctx.font = `11px 'JetBrains Mono', monospace`; ctx.fillText('Warte auf Messdaten …', 10, H / 2 + 4); return; } const mn = Math.min(...pts); const mx = Math.max(...pts); const rng = (mx - mn) || 1; const sx = W / (pts.length - 1); const yp = v => H - ((v - mn) / rng) * H * 0.80 - H * 0.10; ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1; ctx.font = `9px 'JetBrains Mono', monospace`; for (let i = 0; i <= 4; i++) { const y = H - (i / 4) * H * 0.80 - H * 0.10; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); ctx.fillStyle = 'rgba(228,233,245,.18)'; ctx.fillText((mn + (i / 4) * rng).toFixed(1) + '°', 4, y - 3); } const grd = ctx.createLinearGradient(0, 0, 0, H); grd.addColorStop(0, 'rgba(59,130,246,.2)'); grd.addColorStop(1, 'rgba(59,130,246,.0)'); ctx.beginPath(); pts.forEach((v, i) => { i === 0 ? ctx.moveTo(i*sx, yp(v)) : ctx.lineTo(i*sx, yp(v)); }); ctx.lineTo((pts.length-1)*sx, H); ctx.lineTo(0, H); ctx.closePath(); ctx.fillStyle = grd; ctx.fill(); ctx.beginPath(); pts.forEach((v, i) => { i === 0 ? ctx.moveTo(i*sx, yp(v)) : ctx.lineTo(i*sx, yp(v)); }); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; ctx.shadowColor = 'rgba(59,130,246,.5)'; ctx.shadowBlur = 10; ctx.stroke(); ctx.shadowBlur = 0; const lv = pts[pts.length-1], lx = (pts.length-1)*sx, ly = yp(lv); ctx.beginPath(); ctx.arc(lx, ly, 4, 0, Math.PI*2); ctx.fillStyle = '#3b82f6'; ctx.shadowColor = '#3b82f6'; ctx.shadowBlur = 14; ctx.fill(); ctx.shadowBlur = 0; } /* ══════════════════════════════════ SENSOR TILES (dev) ══════════════════════════════════ */ function updateSensorTiles(values) { latestValues = values; SENSORS.forEach((s, i) => { const idx = s.field_index; if (idx >= values.length) return; const v = values[idx]; const tile = document.getElementById(`sc-${i}`); const valEl= document.getElementById(`sv-${i}`); if (!tile || !valEl) return; const fmt = Number.isInteger(v) ? String(v) : v.toFixed(1); valEl.textContent = `${fmt}${s.unit ? ' '+s.unit : ''}`; tile.querySelectorAll('.sensor-alert-badge').forEach(b => b.remove()); tile.classList.remove('breach-high', 'breach-low'); if (s.notify_on_high && s.threshold_high != null && v > s.threshold_high) { tile.classList.add('breach-high'); const b = Object.assign(document.createElement('span'), { className:'sensor-alert-badge', textContent:'↑ Überschritten'}); tile.querySelector('.sensor-meta').appendChild(b); } else if (s.notify_on_low && s.threshold_low != null && v < s.threshold_low) { tile.classList.add('breach-low'); const b = Object.assign(document.createElement('span'), { className:'sensor-alert-badge low', textContent:'↓ Unterschritten'}); tile.querySelector('.sensor-meta').appendChild(b); } }); // Mirror sensor values to consumer view updateConsumerSensors(values); } function updateConsumerSensors(values) { // field_index 0 = temp, 1 = humidity const tempSensor = SENSORS.find(s => s.field_index === 0); const humSensor = SENSORS.find(s => s.field_index === 1); if (tempSensor) { const v = values[0]; const el = document.getElementById('c-sensor-temp'); if (el) { el.textContent = `${v.toFixed(1)}°C`; el.classList.remove('loading'); } } if (humSensor) { const v = values[1]; const el = document.getElementById('c-sensor-hum'); if (el) { el.textContent = `${v.toFixed(0)} %`; el.classList.remove('loading'); } } } /* ══════════════════════════════════ TABS ══════════════════════════════════ */ function switchTab(name) { document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name)); document.querySelectorAll('.tab-pane').forEach(p => p.classList.toggle('active', p.id === `pane-${name}`)); if (name === 'alerts') { const b = document.getElementById('alert-badge'); b.classList.remove('visible'); b.textContent = ''; } } /* ══════════════════════════════════ WEATHER FETCH ══════════════════════════════════ */ async function fetchWeather() { try { const res = await fetch('/api/weather'); const data = await res.json(); if (!data.temp) return; // Location const loc = document.getElementById('c-location'); if (loc) loc.textContent = data.location || ''; // Icon + temp const icon = document.getElementById('c-icon'); const temp = document.getElementById('c-temp-out'); const desc = document.getElementById('c-desc'); if (icon) icon.textContent = data.icon || '—'; if (temp) { temp.textContent = `${Math.round(data.temp)}°`; temp.classList.remove('loading'); } if (desc) desc.textContent = data.description || ''; // Stats setConsumerStat('c-humidity', data.humidity != null ? `${data.humidity} %` : null); setConsumerStat('c-rain', data.rain_prob != null ? `${data.rain_prob} %` : null); setConsumerStat('c-feels', data.feels_like != null ? `${Math.round(data.feels_like)}°` : null); setConsumerStat('c-wind', data.wind_kmh != null ? `${Math.round(data.wind_kmh)} km/h` : null); // Rain bar const bar = document.getElementById('c-rain-bar'); if (bar && data.rain_prob != null) bar.style.width = `${data.rain_prob}%`; } catch(e) { console.warn('[Mirror] Weather error:', e); } } function setConsumerStat(id, val) { const el = document.getElementById(id); if (!el) return; if (val != null) { el.textContent = val; el.classList.remove('loading'); } } // Fetch immediately then every 10 min fetchWeather(); setInterval(fetchWeather, WEATHER_POLL_MS); /* ══════════════════════════════════ SENSOR POLL ══════════════════════════════════ */ async function poll() { try { const [dataRes, alertRes] = await Promise.all([ fetch('/api/data'), fetch('/api/alerts'), ]); const data = await dataRes.json(); const alerts = await alertRes.json(); /* Connection badge */ const badge = document.getElementById('conn-badge'); if (badge) { const ok = data.status.connected; badge.className = `conn-badge card ${ok ? 'ok' : 'err'}`; const ct = document.getElementById('conn-text'); if (ct) ct.textContent = ok ? `${data.status.port} · ${data.status.baud} Bd` : 'Getrennt — warte …'; } /* Stats */ const set = (id, v) => { const e = document.getElementById(id); if(e) e.textContent = v; }; set('stat-total', data.status.total_lines); set('stat-errors', data.status.errors); set('stat-port', data.status.port || '—'); set('stat-baud', data.status.baud ? `${data.status.baud} baud` : '—'); if (data.entries.length) { const latest = data.entries[data.entries.length - 1]; const disp = latest.values?.length ? latest.values[0].toFixed(2) : latest.raw.slice(0, 14); set('stat-last', disp); set('stat-ts', latest.ts.split('T')[1].slice(0, 8)); if (latest.values?.length) { updateSensorTiles(latest.values); chartData.push({ ts: latest.ts, v: latest.values[0] }); if (chartData.length > 200) chartData = chartData.slice(-200); } } /* Log */ const newEntries = data.entries.slice(lastLogCount); lastLogCount = data.entries.length; const logPane = document.getElementById('pane-log'); if (firstPoll && newEntries.length > 0) { logPane.innerHTML = ''; firstPoll = false; } else if (firstPoll) { // keep placeholder } newEntries.forEach(e => { const row = document.createElement('div'); row.className = 'log-entry'; const num = e.values?.map(v => v.toFixed(2)).join(' ') || ''; row.innerHTML = `${e.ts.split('T')[1].slice(0,8)}` + `${e.raw}` + (num ? `${num}` : ''); logPane.prepend(row); while (logPane.children.length > 100) logPane.removeChild(logPane.lastChild); }); /* Alerts */ if (alerts.length > alertsSeen) { const newAlerts = alerts.slice(alertsSeen); alertsSeen = alerts.length; const alertPane = document.getElementById('pane-alerts'); if (alertsSeen === newAlerts.length) alertPane.innerHTML = ''; newAlerts.forEach(a => { const row = document.createElement('div'); row.className = 'alert-entry'; row.innerHTML = `` + `${a.ts.split('T')[1].slice(0,8)}` + `${a.subject}`; alertPane.prepend(row); }); const ab = document.getElementById('alert-badge'); if (ab && !document.getElementById('pane-alerts').classList.contains('active')) { ab.textContent = newAlerts.length > 9 ? '9+' : String(newAlerts.length); ab.classList.add('visible'); } } drawChart(); } catch(err) { console.warn('[Mirror] Poll error:', err); } setTimeout(poll, POLL_MS); } poll();