Model heating mode when rooms have net heat loss in cold weather (<10°C). AC units with heat pump capability (canHeat) provide heating capacity, with the same 20% headroom threshold used for cooling. Adds cold risk detection, cold-weather actions, and full frontend support including heating mode timeline colors, room budget heating display, and i18n.
401 lines
14 KiB
JavaScript
401 lines
14 KiB
JavaScript
// IndexedDB wrapper for HeatGuard
|
|
const DB_NAME = "heatguard";
|
|
const DB_VERSION = 2;
|
|
|
|
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" },
|
|
windows: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] },
|
|
};
|
|
|
|
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;
|
|
const tx = e.target.transaction;
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// v1 → v2: migrate existing rooms to have a synthetic window each
|
|
if (e.oldVersion < 2 && db.objectStoreNames.contains("rooms") && db.objectStoreNames.contains("windows")) {
|
|
const roomStore = tx.objectStore("rooms");
|
|
const winStore = tx.objectStore("windows");
|
|
roomStore.openCursor().onsuccess = (ce) => {
|
|
const cursor = ce.target.result;
|
|
if (!cursor) return;
|
|
const r = cursor.value;
|
|
const area = (r.areaSqm || 15) * (r.windowFraction || 0.15);
|
|
winStore.add({
|
|
roomId: r.id,
|
|
orientation: r.orientation || "S",
|
|
areaSqm: Math.round(area * 100) / 100,
|
|
shgc: r.shgc || 0.6,
|
|
shadingType: r.shadingType || "none",
|
|
shadingFactor: r.shadingFactor ?? 1.0,
|
|
});
|
|
cursor.continue();
|
|
};
|
|
}
|
|
};
|
|
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);
|
|
await deleteByIndex("windows", "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 = [];
|
|
const roomWindows = {};
|
|
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 wins = await dbGetByIndex("windows", "roomId", room.id);
|
|
if (wins.length > 0) roomWindows[room.id] = wins;
|
|
}
|
|
|
|
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 => {
|
|
const rm = {
|
|
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,
|
|
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,
|
|
};
|
|
const wins = roomWindows[r.id];
|
|
if (wins && wins.length > 0) {
|
|
rm.windows = wins.map(w => ({
|
|
id: w.id,
|
|
roomId: w.roomId,
|
|
orientation: w.orientation || "S",
|
|
areaSqm: w.areaSqm || 0,
|
|
shgc: w.shgc || 0.6,
|
|
shadingType: w.shadingType || "none",
|
|
shadingFactor: w.shadingFactor ?? 1.0,
|
|
}));
|
|
}
|
|
return rm;
|
|
}),
|
|
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,
|
|
canHeat: !!a.canHeat,
|
|
heatingCapacityBtu: a.heatingCapacityBtu || 0,
|
|
})),
|
|
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,
|
|
};
|
|
}
|