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:
1 parent
151e3c9202
commit
e8bb117568
2 files changed
+237
No files matched your search
@@ -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
|
||||
</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 -->
|
||||
<div class="range-bar">
|
||||
<span>History:</span>
|
||||
@@ -365,6 +403,43 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
|
||||
</div>
|
||||
</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 -->
|
||||
<details class="settings-panel" id="settingsPanel">
|
||||
<summary>Station API Keys</summary>
|
||||
@@ -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);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user