Add thermostat integration via HomeKit push (iOS Shortcuts) (#5)

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude authored and GitHub committed 2026-08-23 08:24:14 -04:00
1 parent 151e3c9202
commit e8bb117568
2 files changed
+237

No files matched your search

+165
View File
@@ -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-sep { width: 1px; height: 1.1em; background: var(--border); flex-shrink: 0; }
.today-lbl { font-size: 0.75rem; color: var(--ink-light); } .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 ── */ /* ── Tile color tints ── */
.stat-hot { border-color: rgba(220,80,30,0.55) !important; background: rgba(220,80,30,0.07) !important; } .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; } .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
</div> </div>
</div> </div>
<!-- Thermostat (HomeKit push) -->
<div class="section-card" id="ecobeeSection" style="display:none">
<div class="section-card-hdr" style="display:flex;align-items:center;gap:0.75rem">
Thermostat
<span id="ecobeeBadge" class="hvac-badge hvac-idle">—</span>
<span style="font-size:0.72rem;color:var(--ink-light);margin-left:auto" id="ecobeeUpdated"></span>
</div>
<div class="ecobee-row">
<div class="ecobee-main">
<div class="hero-temp-row">
<div class="hero-temp" id="ecobeeTempF" style="font-size:3.5rem">—</div>
<div class="hero-temp-unit" style="font-size:1.4rem;margin-top:0.4rem">°F</div>
</div>
<div class="ecobee-setpoints">
<span>Heat: <strong id="ecobeeHeatSet">—</strong>°</span>
<span>Cool: <strong id="ecobeeCoolSet">—</strong>°</span>
</div>
<div style="margin-top:0.35rem;font-size:0.82rem;color:var(--ink-light)">Humidity: <strong id="ecobeeHum">—</strong>%</div>
<div style="margin-top:0.25rem;font-size:0.82rem;color:var(--ink-light)">Mode: <strong id="ecobeeMode">—</strong></div>
</div>
</div>
</div>
<!-- Range selector --> <!-- Range selector -->
<div class="range-bar"> <div class="range-bar">
<span>History:</span> <span>History:</span>
@@ -365,6 +403,43 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
</div> </div>
</details> </details>
<!-- HomeKit Thermostat Push Settings -->
<details class="settings-panel" id="ecobeeSettingsPanel" style="margin-top:1rem">
<summary>Thermostat (HomeKit Push) Setup</summary>
<div class="settings-body">
<p id="ecobeeConfigStatus" class="keys-status"></p>
<div class="settings-section-hdr">iOS Shortcut Push URL</div>
<p class="keys-hint" style="margin:0 0 0.5rem">Configure your iOS Shortcut to POST thermostat data to this URL:</p>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;max-width:560px">
<input id="homekitPushUrl" type="text" readonly style="flex:1;font-family:monospace;font-size:0.8rem">
<button id="homekitCopyUrlBtn">Copy</button>
</div>
<div class="settings-section-hdr">Auth Token</div>
<p class="keys-hint" style="margin:0 0 0.5rem">Include this as the <code style="background:var(--paper-darker);padding:0.1rem 0.3rem;border-radius:4px">x-homekit-token</code> header in your Shortcut's URL request:</p>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem;max-width:560px">
<input id="homekitTokenDisplay" type="text" readonly style="flex:1;font-family:monospace;font-size:0.8rem">
<button id="homekitCopyTokenBtn">Copy</button>
<button id="homekitRegenBtn">Regenerate</button>
</div>
<span id="homekitRegenMsg" class="save-msg"></span>
<hr class="settings-divider">
<div class="settings-section-hdr">iOS Shortcut Setup</div>
<ol class="keys-hint" style="margin:0;padding-left:1.4rem;line-height:2">
<li>Open <strong>Shortcuts</strong> → tap <strong>+</strong> to create a new shortcut</li>
<li>Add <strong>Get my HomeKit value for</strong> → select your thermostat → <strong>Current Temperature</strong> → save as variable <em>temp</em></li>
<li>Repeat for <strong>Target Temperature</strong> (save as <em>targetTemp</em>) and <strong>Current Relative Humidity</strong> (save as <em>humidity</em>)</li>
<li>Add <strong>Get contents of URL</strong> → paste the Push URL above → Method: <strong>POST</strong></li>
<li>Add header: <code style="background:var(--paper-darker);padding:0.1rem 0.3rem;border-radius:4px">x-homekit-token</code> = (paste your token)</li>
<li>Request Body: <strong>JSON</strong> → add keys: <code style="background:var(--paper-darker);padding:0.1rem 0.3rem;border-radius:4px">temperature</code>=<em>temp</em>, <code style="background:var(--paper-darker);padding:0.1rem 0.3rem;border-radius:4px">heatSetF</code>=<em>targetTemp</em>, <code style="background:var(--paper-darker);padding:0.1rem 0.3rem;border-radius:4px">humidity</code>=<em>humidity</em></li>
<li>Go to <strong>Automation</strong> tab → <strong>+</strong> → <strong>Time of Day</strong> → set repeat every 5 min → run this shortcut (no notification)</li>
</ol>
</div>
</details>
<!-- Settings --> <!-- Settings -->
<details class="settings-panel" id="settingsPanel"> <details class="settings-panel" id="settingsPanel">
<summary>Station API Keys</summary> <summary>Station API Keys</summary>
@@ -1543,6 +1618,94 @@ document.getElementById('saveLocBtn')?.addEventListener('click', async () => {
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; } } 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 ───────────────────────────────────────────────────── // ── Init all new features ─────────────────────────────────────────────────────
updateLatest(); updateLatest();
updatePressureTrend(); updatePressureTrend();
@@ -1556,11 +1719,13 @@ loadThresholds();
loadAlerts(); loadAlerts();
loadStreak(); loadStreak();
loadLocation(); loadLocation();
loadHomekitData();
setInterval(updatePressureTrend, 120000); setInterval(updatePressureTrend, 120000);
setInterval(updateExtStats, 300000); setInterval(updateExtStats, 300000);
setInterval(updateTrends, 120000); setInterval(updateTrends, 120000);
setInterval(loadAlerts, 5 * 60 * 1000); setInterval(loadAlerts, 5 * 60 * 1000);
setInterval(loadHomekitData, 3 * 60 * 1000);
</script> </script>
</body> </body>
</html> </html>
+72
View File
@@ -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 ──────────────────────────────────────────── // ── 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;