- Fix ComputeRoomBudget: no-AC rooms check if open-window ventilation can offset gains instead of defaulting to Overloaded. Net-cooling rooms are now Comfortable; ventilation-solvable rooms are Marginal. - Add "comfort" cool mode for hours where outdoor is >5°C below indoor and budget is not overloaded (winter/cold scenarios). - Reorder determineCoolMode: sealed now before overloaded, fixing humid+cold+no-AC giving "overloaded" instead of "sealed". - Update LLM prompts: document comfort coolMode, add cold-weather guidance for summary and actions generation. - Add dashboard forecast refresh button: fetches fresh forecast + warnings, then re-runs compute and LLM pipelines. - Extract forecast fetch into shared fetchForecastForProfile() in db.js, deduplicating logic between setup.js and dashboard.js. - Add indoor humidity support, pressure display, and cool mode sealed integration test.
359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
// IndexedDB wrapper for HeatGuard
|
|
const DB_NAME = "heatguard";
|
|
const DB_VERSION = 1;
|
|
|
|
const STORES = {
|
|
profiles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "name", keyPath: "name", unique: true }] },
|
|
rooms: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
|
|
devices: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] },
|
|
occupants: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] },
|
|
ac_units: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
|
|
ac_assignments: { keyPath: ["acId", "roomId"] },
|
|
forecasts: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
|
|
warnings: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
|
|
toggles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId_name", keyPath: ["profileId", "name"] }] },
|
|
settings: { keyPath: "key" },
|
|
};
|
|
|
|
let dbPromise = null;
|
|
|
|
function openDB() {
|
|
if (dbPromise) return dbPromise;
|
|
dbPromise = new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
req.onupgradeneeded = (e) => {
|
|
const db = e.target.result;
|
|
for (const [name, cfg] of Object.entries(STORES)) {
|
|
if (!db.objectStoreNames.contains(name)) {
|
|
const opts = { keyPath: cfg.keyPath };
|
|
if (cfg.autoIncrement) opts.autoIncrement = true;
|
|
const store = db.createObjectStore(name, opts);
|
|
if (cfg.indexes) {
|
|
for (const idx of cfg.indexes) {
|
|
store.createIndex(idx.name, idx.keyPath, { unique: !!idx.unique });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
return dbPromise;
|
|
}
|
|
|
|
async function dbPut(storeName, item) {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(storeName, "readwrite");
|
|
const store = tx.objectStore(storeName);
|
|
const req = store.put(item);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function dbAdd(storeName, item) {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(storeName, "readwrite");
|
|
const store = tx.objectStore(storeName);
|
|
const req = store.add(item);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function dbGet(storeName, key) {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(storeName, "readonly");
|
|
const req = tx.objectStore(storeName).get(key);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function dbGetAll(storeName) {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(storeName, "readonly");
|
|
const req = tx.objectStore(storeName).getAll();
|
|
req.onsuccess = () => resolve(req.result || []);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function dbGetByIndex(storeName, indexName, key) {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(storeName, "readonly");
|
|
const store = tx.objectStore(storeName);
|
|
const index = store.index(indexName);
|
|
const req = index.getAll(key);
|
|
req.onsuccess = () => resolve(req.result || []);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function dbDelete(storeName, key) {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(storeName, "readwrite");
|
|
const req = tx.objectStore(storeName).delete(key);
|
|
req.onsuccess = () => resolve();
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function dbClear(storeName) {
|
|
const db = await openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(storeName, "readwrite");
|
|
const req = tx.objectStore(storeName).clear();
|
|
req.onsuccess = () => resolve();
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
// Cascade delete: delete a profile and all related data
|
|
async function deleteProfile(profileId) {
|
|
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
|
for (const room of rooms) {
|
|
await deleteRoomData(room.id);
|
|
}
|
|
await deleteByIndex("ac_units", "profileId", profileId);
|
|
await deleteByIndex("forecasts", "profileId", profileId);
|
|
await deleteByIndex("warnings", "profileId", profileId);
|
|
|
|
// Delete toggles for this profile
|
|
const toggles = await dbGetAll("toggles");
|
|
for (const t of toggles) {
|
|
if (t.profileId === profileId) await dbDelete("toggles", t.id);
|
|
}
|
|
|
|
// Delete ac_assignments for ac_units that belonged to this profile
|
|
const allAssignments = await dbGetAll("ac_assignments");
|
|
const acUnits = await dbGetByIndex("ac_units", "profileId", profileId);
|
|
const acIds = new Set(acUnits.map(u => u.id));
|
|
for (const a of allAssignments) {
|
|
if (acIds.has(a.acId)) await dbDelete("ac_assignments", [a.acId, a.roomId]);
|
|
}
|
|
|
|
await dbDelete("profiles", profileId);
|
|
}
|
|
|
|
async function deleteRoomData(roomId) {
|
|
await deleteByIndex("devices", "roomId", roomId);
|
|
await deleteByIndex("occupants", "roomId", roomId);
|
|
// Delete ac_assignments for this room
|
|
const assignments = await dbGetAll("ac_assignments");
|
|
for (const a of assignments) {
|
|
if (a.roomId === roomId) await dbDelete("ac_assignments", [a.acId, a.roomId]);
|
|
}
|
|
await dbDelete("rooms", roomId);
|
|
}
|
|
|
|
async function deleteByIndex(storeName, indexName, key) {
|
|
const items = await dbGetByIndex(storeName, indexName, key);
|
|
for (const item of items) {
|
|
const pk = item.id !== undefined ? item.id : null;
|
|
if (pk !== null) await dbDelete(storeName, pk);
|
|
}
|
|
}
|
|
|
|
// Settings helpers
|
|
async function getSetting(key) {
|
|
const item = await dbGet("settings", key);
|
|
return item ? item.value : null;
|
|
}
|
|
|
|
async function setSetting(key, value) {
|
|
await dbPut("settings", { key, value });
|
|
}
|
|
|
|
async function getActiveProfileId() {
|
|
return await getSetting("activeProfileId");
|
|
}
|
|
|
|
async function setActiveProfileId(id) {
|
|
await setSetting("activeProfileId", id);
|
|
}
|
|
|
|
// Fetch forecast + warnings for a profile and store in IndexedDB.
|
|
// Returns { forecasts: number, warnings: number } counts.
|
|
async function fetchForecastForProfile(profileId) {
|
|
const profiles = await dbGetAll("profiles");
|
|
const profile = profiles.find(p => p.id === profileId);
|
|
if (!profile) throw new Error("Profile not found");
|
|
|
|
// Fetch forecast
|
|
const resp = await fetch("/api/weather/forecast", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
lat: profile.latitude,
|
|
lon: profile.longitude,
|
|
timezone: profile.timezone || "Europe/Berlin",
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error(await resp.text());
|
|
const data = await resp.json();
|
|
|
|
// Clear old forecasts and store new ones
|
|
await deleteByIndex("forecasts", "profileId", profileId);
|
|
const hourly = data.Hourly || data.hourly || [];
|
|
for (const h of hourly) {
|
|
await dbAdd("forecasts", {
|
|
profileId,
|
|
timestamp: h.Timestamp || h.timestamp,
|
|
temperatureC: h.TemperatureC ?? h.temperatureC ?? null,
|
|
humidityPct: h.HumidityPct ?? h.humidityPct ?? null,
|
|
cloudCoverPct: h.CloudCoverPct ?? h.cloudCoverPct ?? null,
|
|
sunshineMin: h.SunshineMin ?? h.sunshineMin ?? null,
|
|
apparentTempC: h.ApparentTempC ?? h.apparentTempC ?? null,
|
|
pressureHpa: h.PressureHpa ?? h.pressureHpa ?? null,
|
|
});
|
|
}
|
|
|
|
// Fetch warnings (optional — don't fail if this errors)
|
|
let warningCount = 0;
|
|
try {
|
|
const wResp = await fetch("/api/weather/warnings", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ lat: profile.latitude, lon: profile.longitude }),
|
|
});
|
|
if (wResp.ok) {
|
|
const wData = await wResp.json();
|
|
await deleteByIndex("warnings", "profileId", profileId);
|
|
for (const w of (wData.warnings || [])) {
|
|
await dbAdd("warnings", {
|
|
profileId,
|
|
headline: w.Headline || w.headline || "",
|
|
severity: w.Severity || w.severity || "",
|
|
description: w.Description || w.description || "",
|
|
instruction: w.Instruction || w.instruction || "",
|
|
onset: w.Onset || w.onset || "",
|
|
expires: w.Expires || w.expires || "",
|
|
});
|
|
warningCount++;
|
|
}
|
|
}
|
|
} catch (_) { /* warnings are optional */ }
|
|
|
|
await setSetting("lastFetched", new Date().toISOString());
|
|
return { forecasts: hourly.length, warnings: warningCount };
|
|
}
|
|
|
|
// Build full compute payload from IndexedDB
|
|
async function getComputePayload(profileId, dateStr) {
|
|
const profiles = await dbGetAll("profiles");
|
|
const profile = profiles.find(p => p.id === profileId);
|
|
if (!profile) return null;
|
|
|
|
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
|
const allDevices = [];
|
|
const allOccupants = [];
|
|
for (const room of rooms) {
|
|
const devices = await dbGetByIndex("devices", "roomId", room.id);
|
|
allDevices.push(...devices);
|
|
const occupants = await dbGetByIndex("occupants", "roomId", room.id);
|
|
allOccupants.push(...occupants);
|
|
}
|
|
|
|
const acUnits = await dbGetByIndex("ac_units", "profileId", profileId);
|
|
const allAssignments = await dbGetAll("ac_assignments");
|
|
const acIds = new Set(acUnits.map(u => u.id));
|
|
const acAssignments = allAssignments.filter(a => acIds.has(a.acId));
|
|
|
|
const forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
|
|
const warnings = await dbGetByIndex("warnings", "profileId", profileId);
|
|
|
|
// Toggles
|
|
const allToggles = await dbGetAll("toggles");
|
|
const toggles = {};
|
|
for (const t of allToggles) {
|
|
if (t.profileId === profileId && t.active) {
|
|
toggles[t.name] = true;
|
|
}
|
|
}
|
|
|
|
return {
|
|
profile: {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
latitude: profile.latitude,
|
|
longitude: profile.longitude,
|
|
timezone: profile.timezone || "Europe/Berlin",
|
|
},
|
|
rooms: rooms.map(r => ({
|
|
id: r.id,
|
|
profileId: r.profileId,
|
|
name: r.name,
|
|
areaSqm: r.areaSqm || 0,
|
|
ceilingHeightM: r.ceilingHeightM || 2.5,
|
|
floor: r.floor || 0,
|
|
orientation: r.orientation || "S",
|
|
shadingType: r.shadingType || "none",
|
|
shadingFactor: r.shadingFactor ?? 1.0,
|
|
ventilation: r.ventilation || "natural",
|
|
ventilationAch: r.ventilationAch || 0.5,
|
|
windowFraction: r.windowFraction || 0.15,
|
|
shgc: r.shgc || 0.6,
|
|
insulation: r.insulation || "average",
|
|
indoorTempC: r.indoorTempC || 0,
|
|
indoorHumidityPct: r.indoorHumidityPct || null,
|
|
})),
|
|
devices: allDevices.map(d => ({
|
|
id: d.id,
|
|
roomId: d.roomId,
|
|
name: d.name,
|
|
deviceType: d.deviceType || "electronics",
|
|
wattsIdle: d.wattsIdle || 0,
|
|
wattsTypical: d.wattsTypical || 0,
|
|
wattsPeak: d.wattsPeak || 0,
|
|
dutyCycle: d.dutyCycle ?? 1.0,
|
|
})),
|
|
occupants: allOccupants.map(o => ({
|
|
id: o.id,
|
|
roomId: o.roomId,
|
|
count: o.count || 1,
|
|
activityLevel: o.activityLevel || "sedentary",
|
|
vulnerable: !!o.vulnerable,
|
|
})),
|
|
acUnits: acUnits.map(a => ({
|
|
id: a.id,
|
|
profileId: a.profileId,
|
|
name: a.name,
|
|
acType: a.acType || "portable",
|
|
capacityBtu: a.capacityBtu || 0,
|
|
hasDehumidify: !!a.hasDehumidify,
|
|
efficiencyEer: a.efficiencyEer || 10,
|
|
})),
|
|
acAssignments: acAssignments.map(a => ({
|
|
acId: a.acId,
|
|
roomId: a.roomId,
|
|
})),
|
|
toggles,
|
|
forecasts: forecasts.map(f => ({
|
|
timestamp: f.timestamp,
|
|
temperatureC: f.temperatureC ?? null,
|
|
humidityPct: f.humidityPct ?? null,
|
|
cloudCoverPct: f.cloudCoverPct ?? null,
|
|
sunshineMin: f.sunshineMin ?? null,
|
|
apparentTempC: f.apparentTempC ?? null,
|
|
pressureHpa: f.pressureHpa ?? null,
|
|
})),
|
|
warnings: warnings.map(w => ({
|
|
headline: w.headline || "",
|
|
severity: w.severity || "",
|
|
description: w.description || "",
|
|
instruction: w.instruction || "",
|
|
onset: w.onset || "",
|
|
expires: w.expires || "",
|
|
})),
|
|
date: dateStr,
|
|
};
|
|
}
|