feat: integrate bettervent.me device database and add BTU/kW unit switcher

Add bettervent.me provider with lazy-cached device list (~6,700 Eurovent-certified
heat pumps), search API endpoint, device search UI with auto-populate, BTU/kW unit
switcher for European users, and extended AC fields (SEER, SCOP, COP, TOL, Tbiv,
refrigerant). Closes #2.
This commit is contained in:
2026-02-11 00:31:39 +01:00
parent 21154d5d7f
commit a334dd57a0
14 changed files with 820 additions and 37 deletions

View File

@@ -594,6 +594,234 @@
showToast("Occupant saved", false);
});
// ========== Unit Switcher ==========
const BTU_PER_KW = 3412.14;
let _capacityUnit = "btuh"; // "btuh" or "kw"
async function loadCapacityUnit() {
const saved = await getSetting("capacityUnit");
if (saved === "kw" || saved === "btuh") _capacityUnit = saved;
updateUnitToggleUI();
}
function updateUnitToggleUI() {
const btn = document.getElementById("ac-unit-toggle");
if (!btn) return;
btn.dataset.unit = _capacityUnit;
btn.textContent = _capacityUnit === "kw" ? "kW" : "BTU/h";
if (_capacityUnit === "kw") {
btn.classList.add("bg-orange-600", "text-white", "border-orange-600");
btn.classList.remove("border-gray-300", "dark:border-gray-600");
} else {
btn.classList.remove("bg-orange-600", "text-white", "border-orange-600");
btn.classList.add("border-gray-300", "dark:border-gray-600");
}
updateCapacityLabels();
}
function updateCapacityLabels() {
const suffix = _capacityUnit === "kw" ? " (kW)" : " (BTU)";
const capLabel = document.getElementById("ac-capacity-label");
const heatLabel = document.getElementById("ac-heating-capacity-label");
if (capLabel) {
const tooltip = capLabel.querySelector(".tooltip-trigger");
capLabel.textContent = "";
capLabel.append((_capacityUnit === "kw" ? "Capacity (kW) " : "Capacity (BTU) "));
if (tooltip) capLabel.appendChild(tooltip);
}
if (heatLabel) {
const tooltip = heatLabel.querySelector(".tooltip-trigger");
heatLabel.textContent = "";
heatLabel.append((_capacityUnit === "kw" ? "Heating Capacity (kW) " : "Heating Capacity (BTU) "));
if (tooltip) heatLabel.appendChild(tooltip);
}
// Update step for inputs
const capInput = document.querySelector('#ac-form input[name="capacityBtu"]');
const heatInput = document.querySelector('#ac-form input[name="heatingCapacityBtu"]');
if (capInput) capInput.step = _capacityUnit === "kw" ? "0.1" : "100";
if (heatInput) heatInput.step = _capacityUnit === "kw" ? "0.1" : "100";
}
function displayToUnit(btuValue) {
if (_capacityUnit === "kw") return +(btuValue / BTU_PER_KW).toFixed(2);
return btuValue;
}
function inputToBtu(inputValue) {
if (_capacityUnit === "kw") return +(inputValue * BTU_PER_KW).toFixed(0);
return inputValue;
}
function formatCapacity(btuValue) {
if (_capacityUnit === "kw") return (btuValue / BTU_PER_KW).toFixed(2) + " kW";
return btuValue.toFixed(0) + " BTU";
}
const unitToggle = document.getElementById("ac-unit-toggle");
if (unitToggle) {
unitToggle.addEventListener("click", async () => {
// Convert currently displayed values before switching
const capInput = document.querySelector('#ac-form input[name="capacityBtu"]');
const heatInput = document.querySelector('#ac-form input[name="heatingCapacityBtu"]');
const oldUnit = _capacityUnit;
_capacityUnit = _capacityUnit === "btuh" ? "kw" : "btuh";
await setSetting("capacityUnit", _capacityUnit);
// Convert displayed values
if (capInput && capInput.value) {
const raw = parseFloat(capInput.value);
if (!isNaN(raw)) {
if (oldUnit === "btuh" && _capacityUnit === "kw") capInput.value = (raw / BTU_PER_KW).toFixed(2);
else if (oldUnit === "kw" && _capacityUnit === "btuh") capInput.value = Math.round(raw * BTU_PER_KW);
}
}
if (heatInput && heatInput.value) {
const raw = parseFloat(heatInput.value);
if (!isNaN(raw)) {
if (oldUnit === "btuh" && _capacityUnit === "kw") heatInput.value = (raw / BTU_PER_KW).toFixed(2);
else if (oldUnit === "kw" && _capacityUnit === "btuh") heatInput.value = Math.round(raw * BTU_PER_KW);
}
}
updateUnitToggleUI();
await loadACUnits();
});
}
// ========== Device Search ==========
let _searchTimer = null;
const searchInput = document.getElementById("ac-device-search");
const searchResults = document.getElementById("ac-search-results");
if (searchInput) {
searchInput.addEventListener("input", () => {
if (_searchTimer) clearTimeout(_searchTimer);
const q = searchInput.value.trim();
if (q.length < 3) {
searchResults.classList.add("hidden");
return;
}
_searchTimer = setTimeout(() => doDeviceSearch(q), 300);
});
// Close dropdown on outside click
document.addEventListener("click", (e) => {
if (!e.target.closest("#ac-device-search") && !e.target.closest("#ac-search-results")) {
searchResults.classList.add("hidden");
}
});
}
async function doDeviceSearch(query) {
try {
const resp = await fetch(`/api/bettervent/search?q=${encodeURIComponent(query)}`);
if (!resp.ok) return;
const data = await resp.json();
renderSearchResults(data.devices || []);
} catch (e) {
console.error("Device search error:", e);
}
}
function renderSearchResults(devices) {
searchResults.replaceChildren();
if (devices.length === 0) {
const p = document.createElement("div");
p.className = "px-3 py-2 text-sm text-gray-400";
p.textContent = "No devices found";
searchResults.appendChild(p);
searchResults.classList.remove("hidden");
return;
}
for (const d of devices) {
const item = document.createElement("div");
item.className = "px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0";
item.innerHTML = `<div class="text-sm font-medium">${esc(d.tradeName)}${esc(d.modelName)}</div>` +
`<div class="text-xs text-gray-400">${d.coolingKw} kW cool / ${d.heatingKw} kW heat · EER ${d.eer} · ${d.seerClass || ""}</div>`;
item.addEventListener("click", () => selectDevice(d));
searchResults.appendChild(item);
}
searchResults.classList.remove("hidden");
}
function mapMountingToType(mounting) {
const m = (mounting || "").toLowerCase();
if (m.includes("wall")) return "split";
if (m.includes("floor")) return "portable";
if (m.includes("ceiling") || m.includes("cassette")) return "central";
if (m.includes("ducted")) return "central";
return "split";
}
function selectDevice(d) {
const form = document.getElementById("ac-form");
form.querySelector('input[name="name"]').value = d.tradeName + " " + d.modelName;
form.querySelector('select[name="acType"]').value = mapMountingToType(d.mounting);
// Capacity: convert kW to appropriate unit
const coolBtu = d.coolingKw * BTU_PER_KW;
const heatBtu = d.heatingKw * BTU_PER_KW;
form.querySelector('input[name="capacityBtu"]').value = _capacityUnit === "kw" ? d.coolingKw.toFixed(2) : Math.round(coolBtu);
form.querySelector('input[name="efficiencyEer"]').value = d.eer || 10;
const canHeatCb = form.querySelector('input[name="canHeat"]');
canHeatCb.checked = d.heatingKw > 0;
form.querySelector('input[name="heatingCapacityBtu"]').value = _capacityUnit === "kw" ? d.heatingKw.toFixed(2) : Math.round(heatBtu);
// Store extended fields in form dataset for later save
form.dataset.seer = d.seer || "";
form.dataset.seerClass = d.seerClass || "";
form.dataset.scop = d.scop || "";
form.dataset.scopClass = d.scopClass || "";
form.dataset.cop = d.cop || "";
form.dataset.tol = d.tol || "";
form.dataset.tbiv = d.tbiv || "";
form.dataset.refrigerant = d.refrigerant || "";
showExtendedInfo(d);
// Clear search
searchInput.value = "";
searchResults.classList.add("hidden");
}
function showExtendedInfo(d) {
const el = document.getElementById("ac-extended-info");
document.getElementById("ac-extended-title").textContent = (d.tradeName || "") + " " + (d.modelName || d.name || "");
document.getElementById("ac-ext-seer").textContent = d.seer ? `${d.seer} (${d.seerClass || ""})` : "—";
document.getElementById("ac-ext-scop").textContent = d.scop ? `${d.scop} (${d.scopClass || ""})` : "—";
document.getElementById("ac-ext-cop").textContent = d.cop || "—";
document.getElementById("ac-ext-tol").textContent = d.tol ? `${d.tol}\u00b0C` : "—";
document.getElementById("ac-ext-tbiv").textContent = d.tbiv ? `${d.tbiv}\u00b0C` : "—";
document.getElementById("ac-ext-refrigerant").textContent = d.refrigerant || "—";
el.classList.remove("hidden");
}
function hideExtendedInfo() {
document.getElementById("ac-extended-info").classList.add("hidden");
}
function clearACExtendedData() {
const form = document.getElementById("ac-form");
delete form.dataset.seer;
delete form.dataset.seerClass;
delete form.dataset.scop;
delete form.dataset.scopClass;
delete form.dataset.cop;
delete form.dataset.tol;
delete form.dataset.tbiv;
delete form.dataset.refrigerant;
hideExtendedInfo();
}
function esc(s) {
if (!s) return "";
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// ========== AC Units ==========
async function loadACUnits() {
const profileId = await getActiveProfileId();
@@ -614,8 +842,10 @@
const roomIds = assignments.filter(a => a.acId === u.id).map(a => a.roomId);
const roomNames = roomIds.map(id => roomMap[id] || `Room ${id}`).join(", ");
el.querySelector('[data-slot="name"]').textContent = u.name;
const heatInfo = u.canHeat ? ` \u00b7 Heat ${u.heatingCapacityBtu || u.capacityBtu} BTU` : '';
el.querySelector('[data-slot="details"]').textContent = `${u.capacityBtu} BTU \u00b7 ${u.acType}${heatInfo}${roomNames ? ' \u00b7 ' + roomNames : ''}`;
const capStr = formatCapacity(u.capacityBtu);
const heatInfo = u.canHeat ? ` \u00b7 Heat ${formatCapacity(u.heatingCapacityBtu || u.capacityBtu)}` : '';
const seerInfo = u.seer ? ` \u00b7 SEER ${u.seer}` : '';
el.querySelector('[data-slot="details"]').textContent = `${capStr} \u00b7 ${u.acType}${heatInfo}${seerInfo}${roomNames ? ' \u00b7 ' + roomNames : ''}`;
el.firstElementChild.dataset.id = u.id;
list.appendChild(el);
}
@@ -628,11 +858,25 @@
form.querySelector('input[name="id"]').value = u.id;
form.querySelector('input[name="name"]').value = u.name;
form.querySelector('select[name="acType"]').value = u.acType || "portable";
form.querySelector('input[name="capacityBtu"]').value = u.capacityBtu || 0;
form.querySelector('input[name="capacityBtu"]').value = displayToUnit(u.capacityBtu || 0);
form.querySelector('input[name="efficiencyEer"]').value = u.efficiencyEer || 10;
form.querySelector('input[name="hasDehumidify"]').checked = !!u.hasDehumidify;
form.querySelector('input[name="canHeat"]').checked = !!u.canHeat;
form.querySelector('input[name="heatingCapacityBtu"]').value = u.heatingCapacityBtu || 0;
form.querySelector('input[name="heatingCapacityBtu"]').value = displayToUnit(u.heatingCapacityBtu || 0);
// Extended fields
form.dataset.seer = u.seer || "";
form.dataset.seerClass = u.seerClass || "";
form.dataset.scop = u.scop || "";
form.dataset.scopClass = u.scopClass || "";
form.dataset.cop = u.cop || "";
form.dataset.tol = u.tol || "";
form.dataset.tbiv = u.tbiv || "";
form.dataset.refrigerant = u.refrigerant || "";
if (u.seer || u.scop || u.refrigerant) {
showExtendedInfo(u);
} else {
hideExtendedInfo();
}
// Check assigned rooms
const assignments = await dbGetAll("ac_assignments");
const assignedRoomIds = new Set(assignments.filter(a => a.acId === id).map(a => a.roomId));
@@ -670,16 +914,28 @@
const profileId = await getActiveProfileId();
if (!profileId) { showToast("Select a profile first", true); return; }
const data = formData(e.target);
const rawCap = numOrDefault(data.capacityBtu, 0);
const rawHeat = numOrDefault(data.heatingCapacityBtu, 0);
const unit = {
profileId,
name: data.name,
acType: data.acType || "portable",
capacityBtu: numOrDefault(data.capacityBtu, 0),
capacityBtu: inputToBtu(rawCap),
efficiencyEer: numOrDefault(data.efficiencyEer, 10),
hasDehumidify: !!data.hasDehumidify,
canHeat: !!data.canHeat,
heatingCapacityBtu: numOrDefault(data.heatingCapacityBtu, 0),
heatingCapacityBtu: inputToBtu(rawHeat),
};
// Extended fields from form dataset
const form = e.target;
if (form.dataset.seer) unit.seer = parseFloat(form.dataset.seer) || 0;
if (form.dataset.seerClass) unit.seerClass = form.dataset.seerClass;
if (form.dataset.scop) unit.scop = parseFloat(form.dataset.scop) || 0;
if (form.dataset.scopClass) unit.scopClass = form.dataset.scopClass;
if (form.dataset.cop) unit.cop = parseFloat(form.dataset.cop) || 0;
if (form.dataset.tol) unit.tol = parseFloat(form.dataset.tol) || 0;
if (form.dataset.tbiv) unit.tbiv = parseFloat(form.dataset.tbiv) || 0;
if (form.dataset.refrigerant) unit.refrigerant = form.dataset.refrigerant;
let acId;
if (data.id) {
unit.id = parseInt(data.id);
@@ -698,6 +954,7 @@
await dbPut("ac_assignments", { acId, roomId: parseInt(cb.value) });
}
resetForm(e.target);
clearACExtendedData();
await loadACUnits();
await updateTabBadges();
showToast("AC unit saved", false);
@@ -892,6 +1149,7 @@
// ========== Init ==========
async function init() {
await loadCapacityUnit();
await loadProfiles();
await loadRooms();
await loadDevices();