From e8bb117568eb3e2074ecf084fc7c747c502e9a61 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:24:14 -0400 Subject: [PATCH] Add thermostat integration via HomeKit push (iOS Shortcuts) (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Ecobee thermostat integration to dashboard - Server: PIN-based OAuth flow to authorize Ecobee access without exposing redirect URIs; tokens stored server-side and auto-refreshed - Server: /api/ecobee/config, /api/ecobee/auth/pin, /api/ecobee/auth/complete, /api/ecobee/data, /api/ecobee/runtime endpoints behind dashboard auth - Dashboard: Ecobee section card showing indoor temp, humidity, heat/cool setpoints, HVAC mode, equipment status badge, and per-sensor temperature readings from remote sensors - Dashboard: stacked bar chart of daily heating/cooling runtime hours for the past 7 days - Dashboard: Ecobee setup panel with API key entry and step-by-step PIN authorization flow Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01YMhuFaecxraQzUPN4h9nsB * Replace ecobee API integration with HomeKit push endpoint Ecobee removed public developer API access, so pivot to HomeKit: - Add POST /api/homekit/push accepting thermostat data from iOS Shortcuts - Add GET /api/homekit/data returning latest reading to dashboard - Add GET /api/homekit/config and POST /api/homekit/config/regenerate for token management - Auto-generate auth token on first run, persist to data/homekit-config.json - Auto-convert Celsius→Fahrenheit if temp < 50 (HomeKit always returns Celsius) - Update dashboard section card to show temp, setpoints, humidity, mode, HVAC state - Replace ecobee settings panel with push URL, token copy, and Shortcut setup instructions - Remove runtime chart (not available without API access) --------- Co-authored-by: Claude --- app/public/dashboard.html | 165 ++++++++++++++++++++++++++++++++++++++ app/server.js | 72 +++++++++++++++++ 2 files changed, 237 insertions(+) diff --git a/app/public/dashboard.html b/app/public/dashboard.html index cf515ae..db41467 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -178,6 +178,21 @@ a { color: var(--cool); text-decoration: none; } .today-sep { width: 1px; height: 1.1em; background: var(--border); flex-shrink: 0; } .today-lbl { font-size: 0.75rem; color: var(--ink-light); } +/* ── Ecobee section ── */ +.hvac-badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; } +.hvac-heating { background: rgba(194,90,42,0.18); color: var(--accent); border: 1px solid var(--accent); } +.hvac-cooling { background: rgba(42,107,138,0.18); color: var(--cool); border: 1px solid var(--cool); } +.hvac-fan { background: rgba(42,107,138,0.1); color: var(--cool); border: 1px solid var(--border); } +.hvac-idle { background: var(--paper-darker); color: var(--ink-light); border: 1px solid var(--border); } +.ecobee-sensors { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; } +.sensor-chip { background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.35rem 0.7rem; font-size: 0.8rem; display: flex; align-items: center; gap: 0.4rem; } +.sensor-chip-name { color: var(--ink-light); font-size: 0.72rem; } +.sensor-chip-val { font-weight: 700; } +.ecobee-row { display: flex; gap: 1.5rem; align-items: flex-start; flex-wrap: wrap; } +.ecobee-main { flex: 0 0 auto; min-width: 120px; } +.ecobee-setpoints { display: flex; gap: 1rem; margin-top: 0.4rem; font-size: 0.85rem; color: var(--ink-light); } +.ecobee-setpoints strong { color: var(--ink); } + /* ── Tile color tints ── */ .stat-hot { border-color: rgba(220,80,30,0.55) !important; background: rgba(220,80,30,0.07) !important; } .stat-warm { border-color: rgba(200,155,0,0.5) !important; background: rgba(200,155,0,0.06) !important; } @@ -319,6 +334,29 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem + + +
History: @@ -365,6 +403,43 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
+ +
+ Thermostat (HomeKit Push) Setup +
+

+ +
iOS Shortcut Push URL
+

Configure your iOS Shortcut to POST thermostat data to this URL:

+
+ + +
+ +
Auth Token
+

Include this as the x-homekit-token header in your Shortcut's URL request:

+
+ + + +
+ + +
+ +
iOS Shortcut Setup
+
    +
  1. Open Shortcuts → tap + to create a new shortcut
  2. +
  3. Add Get my HomeKit value for → select your thermostat → Current Temperature → save as variable temp
  4. +
  5. Repeat for Target Temperature (save as targetTemp) and Current Relative Humidity (save as humidity)
  6. +
  7. Add Get contents of URL → paste the Push URL above → Method: POST
  8. +
  9. Add header: x-homekit-token = (paste your token)
  10. +
  11. Request Body: JSON → add keys: temperature=temp, heatSetF=targetTemp, humidity=humidity
  12. +
  13. Go to Automation tab → + → Time of Day → set repeat every 5 min → run this shortcut (no notification)
  14. +
+
+
+
Station API Keys @@ -1543,6 +1618,94 @@ document.getElementById('saveLocBtn')?.addEventListener('click', async () => { } catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; } }); +// ── HomeKit thermostat push ─────────────────────────────────────────────────── +function hvacBadge(hvacState) { + if (!hvacState) return { text: 'Idle', cls: 'hvac-idle' }; + const s = hvacState.toLowerCase(); + if (s.includes('cool')) return { text: 'Cooling', cls: 'hvac-cooling' }; + if (s.includes('heat')) return { text: 'Heating', cls: 'hvac-heating' }; + if (s.includes('fan')) return { text: 'Fan', cls: 'hvac-fan' }; + return { text: 'Idle', cls: 'hvac-idle' }; +} + +async function loadHomekitData() { + const section = document.getElementById('ecobeeSection'); + try { + const r = await fetch('/api/homekit/data'); + if (!r.ok) return; + const d = await r.json(); + if (d.tempF == null) return; + + section.style.display = ''; + const set = (id, val) => { const el = document.getElementById(id); if (el && val != null) el.textContent = val; }; + set('ecobeeTempF', d.tempF != null ? d.tempF.toFixed(1) : '—'); + set('ecobeeHeatSet', d.heatSetF != null ? d.heatSetF.toFixed(0) : '—'); + set('ecobeeCoolSet', d.coolSetF != null ? d.coolSetF.toFixed(0) : '—'); + set('ecobeeHum', d.humidity ?? '—'); + set('ecobeeMode', d.mode ?? '—'); + + const badge = document.getElementById('ecobeeBadge'); + if (badge) { + const { text, cls } = hvacBadge(d.hvacState); + badge.textContent = text; + badge.className = `hvac-badge ${cls}`; + } + + const upd = document.getElementById('ecobeeUpdated'); + if (upd && d.receivedAt) { + upd.textContent = 'Updated ' + new Date(d.receivedAt).toLocaleTimeString(); + } + } catch {} +} + +async function loadHomekitConfigStatus() { + try { + const r = await fetch('/api/homekit/config'); + if (!r.ok) return; + const d = await r.json(); + const el = document.getElementById('ecobeeConfigStatus'); + if (d.token) { + el.textContent = '✓ Push endpoint active — configure your iOS Shortcut below'; + el.className = 'keys-status ok'; + } else { + el.textContent = '⚠ Token not configured'; + el.className = 'keys-status missing'; + } + const urlInput = document.getElementById('homekitPushUrl'); + if (urlInput) urlInput.value = window.location.origin + '/api/homekit/push'; + const tokenInput = document.getElementById('homekitTokenDisplay'); + if (tokenInput) tokenInput.value = d.token || ''; + } catch {} +} + +document.getElementById('homekitCopyUrlBtn')?.addEventListener('click', () => { + const val = document.getElementById('homekitPushUrl')?.value; + if (val) navigator.clipboard.writeText(val).catch(() => {}); +}); + +document.getElementById('homekitCopyTokenBtn')?.addEventListener('click', () => { + const val = document.getElementById('homekitTokenDisplay')?.value; + if (val) navigator.clipboard.writeText(val).catch(() => {}); +}); + +document.getElementById('homekitRegenBtn')?.addEventListener('click', async () => { + const msg = document.getElementById('homekitRegenMsg'); + try { + const r = await fetch('/api/homekit/config/regenerate', { method: 'POST' }); + const d = await r.json(); + if (r.ok && d.token) { + const tokenInput = document.getElementById('homekitTokenDisplay'); + if (tokenInput) tokenInput.value = d.token; + msg.textContent = 'Token regenerated — update your Shortcut'; msg.className = 'save-msg'; + } else { msg.textContent = 'Error'; msg.className = 'save-msg err'; } + setTimeout(() => msg.textContent = '', 4000); + } catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; } +}); + +document.getElementById('ecobeeSettingsPanel')?.addEventListener('toggle', function() { + if (this.open) loadHomekitConfigStatus(); +}); + // ── Init all new features ───────────────────────────────────────────────────── updateLatest(); updatePressureTrend(); @@ -1556,11 +1719,13 @@ loadThresholds(); loadAlerts(); loadStreak(); loadLocation(); +loadHomekitData(); setInterval(updatePressureTrend, 120000); setInterval(updateExtStats, 300000); setInterval(updateTrends, 120000); setInterval(loadAlerts, 5 * 60 * 1000); +setInterval(loadHomekitData, 3 * 60 * 1000); diff --git a/app/server.js b/app/server.js index f2998b5..9bc2390 100644 --- a/app/server.js +++ b/app/server.js @@ -600,6 +600,78 @@ app.get('/api/ambient', async (req, res) => { } }); +// ── HomeKit thermostat push ─────────────────────────────────────────────────── +const HOMEKIT_CONFIG_FILE = path.join(DATA_DIR, 'homekit-config.json'); +const HOMEKIT_LATEST_FILE = path.join(DATA_DIR, 'homekit-latest.json'); + +let homekitConfig = { token: '' }; +let homekitLatest = null; + +try { + if (fs.existsSync(HOMEKIT_CONFIG_FILE)) { + homekitConfig = JSON.parse(fs.readFileSync(HOMEKIT_CONFIG_FILE, 'utf8')); + } +} catch {} + +if (!homekitConfig.token) { + homekitConfig.token = crypto.randomBytes(32).toString('hex'); + try { fs.writeFileSync(HOMEKIT_CONFIG_FILE, JSON.stringify(homekitConfig)); } catch {} +} + +try { + if (fs.existsSync(HOMEKIT_LATEST_FILE)) { + homekitLatest = JSON.parse(fs.readFileSync(HOMEKIT_LATEST_FILE, 'utf8')); + } +} catch {} + +// Get push token — dashboard auth +app.get('/api/homekit/config', requireDashboardAuth, (_req, res) => { + res.json({ token: homekitConfig.token }); +}); + +// Regenerate push token — dashboard auth +app.post('/api/homekit/config/regenerate', requireDashboardAuth, (_req, res) => { + homekitConfig.token = crypto.randomBytes(32).toString('hex'); + try { fs.writeFileSync(HOMEKIT_CONFIG_FILE, JSON.stringify(homekitConfig)); } catch {} + res.json({ token: homekitConfig.token }); +}); + +// Receive thermostat data from iOS Shortcut — token header auth +app.post('/api/homekit/push', (req, res) => { + const token = req.headers['x-homekit-token'] || req.query.token; + if (!token || token !== homekitConfig.token) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const body = req.body || {}; + // HomeKit reports temperatures in Celsius; auto-detect and convert + let tempF = parseFloat(body.tempF ?? body.temperature); + if (!isNaN(tempF) && tempF < 50) tempF = +(tempF * 9 / 5 + 32).toFixed(1); + + let heatSetF = body.heatSetF != null ? parseFloat(body.heatSetF) : null; + if (heatSetF != null && heatSetF < 50) heatSetF = +(heatSetF * 9 / 5 + 32).toFixed(1); + + let coolSetF = body.coolSetF != null ? parseFloat(body.coolSetF) : null; + if (coolSetF != null && coolSetF < 50) coolSetF = +(coolSetF * 9 / 5 + 32).toFixed(1); + + homekitLatest = { + tempF: isNaN(tempF) ? null : tempF, + heatSetF: heatSetF != null && !isNaN(heatSetF) ? +parseFloat(heatSetF).toFixed(1) : null, + coolSetF: coolSetF != null && !isNaN(coolSetF) ? +parseFloat(coolSetF).toFixed(1) : null, + humidity: body.humidity != null ? +parseFloat(body.humidity).toFixed(0) : null, + mode: body.mode || null, + hvacState: body.hvacState || null, + receivedAt: Date.now(), + }; + try { fs.writeFileSync(HOMEKIT_LATEST_FILE, JSON.stringify(homekitLatest)); } catch {} + res.json({ ok: true }); +}); + +// Latest thermostat reading — dashboard auth +app.get('/api/homekit/data', requireDashboardAuth, (_req, res) => { + if (!homekitLatest) return res.status(503).json({ error: 'No data yet' }); + res.json(homekitLatest); +}); + // ── Personal weather station proxy ──────────────────────────────────────────── app.get('/api/pws', async (req, res) => { const { url } = req.query;