diff --git a/internal/bettervent/provider.go b/internal/bettervent/provider.go new file mode 100644 index 0000000..eb90658 --- /dev/null +++ b/internal/bettervent/provider.go @@ -0,0 +1,115 @@ +package bettervent + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +const ( + defaultBaseURL = "https://bettervent.me" + maxResults = 20 + cacheTTL = 24 * time.Hour +) + +// Provider fetches and searches the bettervent.me device database. +type Provider struct { + client *http.Client + baseURL string + + mu sync.Mutex + devices []Device + cachedAt time.Time +} + +// New creates a new Provider. A nil client uses http.DefaultClient. +func New(client *http.Client) *Provider { + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + return &Provider{ + client: client, + baseURL: defaultBaseURL, + } +} + +// NewWithBaseURL creates a Provider with a custom base URL (for testing). +func NewWithBaseURL(client *http.Client, baseURL string) *Provider { + p := New(client) + p.baseURL = baseURL + return p +} + +// Search returns devices matching the query string (case-insensitive substring +// match on TradeName + ModelName + Range). Returns up to 20 results. +// An empty query returns an empty result. +func (p *Provider) Search(ctx context.Context, query string) (*SearchResponse, error) { + if query == "" { + return &SearchResponse{}, nil + } + + devices, err := p.loadDevices(ctx) + if err != nil { + return nil, err + } + + q := strings.ToLower(query) + var matches []Device + for _, d := range devices { + haystack := strings.ToLower(d.TradeName + " " + d.ModelName + " " + d.Range) + if strings.Contains(haystack, q) { + matches = append(matches, d) + if len(matches) >= maxResults { + break + } + } + } + + return &SearchResponse{ + Devices: matches, + Total: len(matches), + }, nil +} + +func (p *Provider) loadDevices(ctx context.Context) ([]Device, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.devices != nil && time.Since(p.cachedAt) < cacheTTL { + return p.devices, nil + } + + url := p.baseURL + "/api/devices?format=json" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("bettervent: create request: %w", err) + } + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("bettervent: fetch devices: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bettervent: unexpected status %d", resp.StatusCode) + } + + var raw []apiDevice + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("bettervent: decode response: %w", err) + } + + devices := make([]Device, len(raw)) + for i, a := range raw { + devices[i] = a.toDevice() + } + + p.devices = devices + p.cachedAt = time.Now() + return devices, nil +} diff --git a/internal/bettervent/provider_test.go b/internal/bettervent/provider_test.go new file mode 100644 index 0000000..f643bc5 --- /dev/null +++ b/internal/bettervent/provider_test.go @@ -0,0 +1,178 @@ +package bettervent + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +var testDevices = []apiDevice{ + { + ID: 1, TradeName: "MITSUBISHI ELECTRIC", ModelName: "MSZ-AP25VGK", + Range: "MSZ-AP series", Pc: 2.5, Ph: 3.2, EER: 4.0, COP: 4.6, + SEER: 8.7, SEERClass: "A+++", SCOP: 4.6, SCOPClass: "A++", + TOL: -25, Tbiv: -15, Mounting: "Wall mounted", Refrigerant: "R32", + }, + { + ID: 2, TradeName: "DAIKIN", ModelName: "FTXM25R", + Range: "Perfera", Pc: 2.5, Ph: 3.0, EER: 3.8, COP: 4.5, + SEER: 8.73, SEERClass: "A+++", SCOP: 5.1, SCOPClass: "A+++", + TOL: -25, Tbiv: -15, Mounting: "Wall mounted", Refrigerant: "R32", + }, + { + ID: 3, TradeName: "MITSUBISHI ELECTRIC", ModelName: "MFZ-KT50VG", + Range: "Floor standing", Pc: 5.0, Ph: 5.8, EER: 3.5, COP: 4.2, + SEER: 7.0, SEERClass: "A++", SCOP: 4.3, SCOPClass: "A++", + TOL: -15, Tbiv: -7, Mounting: "Floor standing", Refrigerant: "R410A", + }, +} + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/devices" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(testDevices) + })) +} + +func TestSearch_MatchesByTradeName(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + p := NewWithBaseURL(nil, ts.URL) + resp, err := p.Search(context.Background(), "daikin") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Total != 1 { + t.Fatalf("got %d results, want 1", resp.Total) + } + if resp.Devices[0].TradeName != "DAIKIN" { + t.Errorf("got trade name %q, want DAIKIN", resp.Devices[0].TradeName) + } + if resp.Devices[0].CoolingKW != 2.5 { + t.Errorf("got cooling %v kW, want 2.5", resp.Devices[0].CoolingKW) + } +} + +func TestSearch_MatchesByModelName(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + p := NewWithBaseURL(nil, ts.URL) + resp, err := p.Search(context.Background(), "MSZ-AP25") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Total != 1 { + t.Fatalf("got %d results, want 1", resp.Total) + } + if resp.Devices[0].ID != 1 { + t.Errorf("got ID %d, want 1", resp.Devices[0].ID) + } +} + +func TestSearch_MultipleMatches(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + p := NewWithBaseURL(nil, ts.URL) + resp, err := p.Search(context.Background(), "mitsubishi") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Total != 2 { + t.Fatalf("got %d results, want 2", resp.Total) + } +} + +func TestSearch_EmptyQuery(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + p := NewWithBaseURL(nil, ts.URL) + resp, err := p.Search(context.Background(), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Total != 0 { + t.Errorf("got %d results for empty query, want 0", resp.Total) + } +} + +func TestSearch_NoResults(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + p := NewWithBaseURL(nil, ts.URL) + resp, err := p.Search(context.Background(), "nonexistent-brand-xyz") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Total != 0 { + t.Errorf("got %d results, want 0", resp.Total) + } +} + +func TestSearch_CacheReuse(t *testing.T) { + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(testDevices) + })) + defer ts.Close() + + p := NewWithBaseURL(nil, ts.URL) + ctx := context.Background() + + _, _ = p.Search(ctx, "daikin") + _, _ = p.Search(ctx, "mitsubishi") + + if callCount != 1 { + t.Errorf("expected 1 API call (cached), got %d", callCount) + } +} + +func TestSearch_FieldMapping(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + p := NewWithBaseURL(nil, ts.URL) + resp, err := p.Search(context.Background(), "FTXM25R") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Total != 1 { + t.Fatalf("got %d results, want 1", resp.Total) + } + + d := resp.Devices[0] + if d.SEER != 8.73 { + t.Errorf("SEER = %v, want 8.73", d.SEER) + } + if d.SEERClass != "A+++" { + t.Errorf("SEERClass = %q, want A+++", d.SEERClass) + } + if d.SCOP != 5.1 { + t.Errorf("SCOP = %v, want 5.1", d.SCOP) + } + if d.TOL != -25 { + t.Errorf("TOL = %v, want -25", d.TOL) + } + if d.Tbiv != -15 { + t.Errorf("Tbiv = %v, want -15", d.Tbiv) + } + if d.Refrigerant != "R32" { + t.Errorf("Refrigerant = %q, want R32", d.Refrigerant) + } + if d.Mounting != "Wall mounted" { + t.Errorf("Mounting = %q, want Wall mounted", d.Mounting) + } +} diff --git a/internal/bettervent/types.go b/internal/bettervent/types.go new file mode 100644 index 0000000..36236e3 --- /dev/null +++ b/internal/bettervent/types.go @@ -0,0 +1,68 @@ +package bettervent + +// Device holds normalized device data from the bettervent.me API. +type Device struct { + ID int `json:"id"` + TradeName string `json:"tradeName"` + ModelName string `json:"modelName"` + CoolingKW float64 `json:"coolingKw"` + HeatingKW float64 `json:"heatingKw"` + EER float64 `json:"eer"` + COP float64 `json:"cop"` + SEER float64 `json:"seer"` + SEERClass string `json:"seerClass"` + SCOP float64 `json:"scop"` + SCOPClass string `json:"scopClass"` + TOL float64 `json:"tol"` + Tbiv float64 `json:"tbiv"` + Mounting string `json:"mounting"` + Refrigerant string `json:"refrigerant"` + Range string `json:"range"` +} + +// SearchResponse holds the result of a device search. +type SearchResponse struct { + Devices []Device `json:"devices"` + Total int `json:"total"` +} + +// apiDevice maps the raw JSON fields from the bettervent.me API. +type apiDevice struct { + ID int `json:"ID"` + TradeName string `json:"TradeName"` + ModelName string `json:"ModelName"` + Range string `json:"Range"` + Pc float64 `json:"Pc"` + Ph float64 `json:"Ph"` + EER float64 `json:"EER"` + COP float64 `json:"COP"` + SEER float64 `json:"SEER"` + SEERClass string `json:"SEERClass"` + SCOP float64 `json:"SCOP"` + SCOPClass string `json:"SCOPClass"` + TOL float64 `json:"TOL"` + Tbiv float64 `json:"Tbiv"` + Mounting string `json:"Mounting"` + Refrigerant string `json:"Refrigerant"` +} + +func (a apiDevice) toDevice() Device { + return Device{ + ID: a.ID, + TradeName: a.TradeName, + ModelName: a.ModelName, + CoolingKW: a.Pc, + HeatingKW: a.Ph, + EER: a.EER, + COP: a.COP, + SEER: a.SEER, + SEERClass: a.SEERClass, + SCOP: a.SCOP, + SCOPClass: a.SCOPClass, + TOL: a.TOL, + Tbiv: a.Tbiv, + Mounting: a.Mounting, + Refrigerant: a.Refrigerant, + Range: a.Range, + } +} diff --git a/internal/compute/types.go b/internal/compute/types.go index 081c0a1..b14e650 100644 --- a/internal/compute/types.go +++ b/internal/compute/types.go @@ -74,6 +74,14 @@ type ACUnit struct { EfficiencyEER float64 `json:"efficiencyEer"` CanHeat bool `json:"canHeat"` HeatingCapacityBTU float64 `json:"heatingCapacityBtu"` + SEER float64 `json:"seer,omitempty"` + SEERClass string `json:"seerClass,omitempty"` + SCOP float64 `json:"scop,omitempty"` + SCOPClass string `json:"scopClass,omitempty"` + COP float64 `json:"cop,omitempty"` + TOL float64 `json:"tol,omitempty"` + Tbiv float64 `json:"tbiv,omitempty"` + Refrigerant string `json:"refrigerant,omitempty"` } // ACAssignment maps an AC unit to a room. diff --git a/internal/server/api.go b/internal/server/api.go index ad518db..15c171f 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -311,6 +311,25 @@ func (s *Server) handleLLMConfig(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleBetterventSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + q := r.URL.Query().Get("q") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + resp, err := s.betterventProvider.Search(ctx, q) + if err != nil { + jsonError(w, err.Error(), http.StatusBadGateway) + return + } + + jsonResponse(w, resp) +} + func jsonResponse(w http.ResponseWriter, data any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) diff --git a/internal/server/server.go b/internal/server/server.go index 5b87a83..e5485d4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,18 +10,20 @@ import ( "path/filepath" "sync/atomic" + "github.com/cnachtigall/heatwave-autopilot/internal/bettervent" "github.com/cnachtigall/heatwave-autopilot/internal/config" "github.com/cnachtigall/heatwave-autopilot/internal/llm" ) // Server holds the HTTP server state. type Server struct { - mux *http.ServeMux - trans *translations - cfg config.Config - llmProvider llm.Provider - devMode bool - ready atomic.Bool + mux *http.ServeMux + trans *translations + cfg config.Config + llmProvider llm.Provider + betterventProvider *bettervent.Provider + devMode bool + ready atomic.Bool } // Options configures the server. @@ -73,6 +75,9 @@ func New(opts Options) (*Server, error) { // Set up LLM provider s.llmProvider = buildLLMProvider(s.cfg) + // Set up bettervent provider + s.betterventProvider = bettervent.New(nil) + // Static assets if opts.DevMode { s.mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("web")))) @@ -96,6 +101,7 @@ func New(opts Options) (*Server, error) { s.mux.HandleFunc("/api/llm/summarize", s.handleLLMSummarize) s.mux.HandleFunc("/api/llm/actions", s.handleLLMActions) s.mux.HandleFunc("/api/llm/config", s.handleLLMConfig) + s.mux.HandleFunc("/api/bettervent/search", s.handleBetterventSearch) s.ready.Store(true) return s, nil diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 29df719..4937b7f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -188,6 +188,46 @@ func TestReadyz(t *testing.T) { } } +func TestBetterventSearch(t *testing.T) { + s := testServer(t) + req := httptest.NewRequest("GET", "/api/bettervent/search?q=test", nil) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + // The test server won't reach bettervent.me, but the handler should still + // return valid JSON (either results or a 502 error). + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("got content-type %q, want application/json", ct) + } +} + +func TestBetterventSearch_EmptyQuery(t *testing.T) { + s := testServer(t) + req := httptest.NewRequest("GET", "/api/bettervent/search?q=", nil) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("got status %d, want 200", w.Code) + } + var resp struct { + Devices []any `json:"devices"` + Total int `json:"total"` + } + json.NewDecoder(w.Body).Decode(&resp) + if resp.Total != 0 { + t.Errorf("expected 0 results for empty query, got %d", resp.Total) + } +} + +func TestBetterventSearch_MethodNotAllowed(t *testing.T) { + s := testServer(t) + req := httptest.NewRequest("POST", "/api/bettervent/search?q=test", nil) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("got status %d, want 405", w.Code) + } +} + func TestLLMSummarize_Noop(t *testing.T) { s := testServer(t) body := `{"date":"2025-07-15","peakTempC":35,"riskLevel":"high"}` diff --git a/web/i18n/de.json b/web/i18n/de.json index 2002a2e..319a327 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -115,7 +115,24 @@ "rooms": { "label": "Zugewiesene R\u00e4ume", "tooltip": "Welche R\u00e4ume dieses Klimager\u00e4t versorgt" }, "add": "Klimager\u00e4t hinzuf\u00fcgen", "save": "Klimager\u00e4t speichern", - "noItems": "Noch keine Klimager\u00e4te." + "noItems": "Noch keine Klimager\u00e4te.", + "search": { + "label": "Ger\u00e4tedatenbank durchsuchen", + "hint": "Daten von bettervent.me (Eurovent-zertifiziert)", + "placeholder": "Nach Marke oder Modell suchen...", + "noResults": "Keine Ger\u00e4te gefunden" + }, + "unit": { + "btuh": "BTU/h", + "kw": "kW", + "switch": "Einheit wechseln" + }, + "seer": "SEER", + "scop": "SCOP", + "cop": "COP", + "tol": "Min. Betriebstemp.", + "tbiv": "Bivalenztemperatur", + "refrigerant": "K\u00e4ltemittel" }, "toggles": { "title": "Schalter", @@ -282,7 +299,8 @@ }, "footer": { "source": "Quellcode", - "license": "GPL-3.0-Lizenz" + "license": "GPL-3.0-Lizenz", + "betterventCredit": "W\u00e4rmepumpendaten bereitgestellt von" }, "common": { "save": "Speichern", diff --git a/web/i18n/en.json b/web/i18n/en.json index d4ab664..3c8998d 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -115,7 +115,24 @@ "rooms": { "label": "Assigned Rooms", "tooltip": "Which rooms this AC unit serves" }, "add": "Add AC Unit", "save": "Save AC Unit", - "noItems": "No AC units yet." + "noItems": "No AC units yet.", + "search": { + "label": "Search device database", + "hint": "Data from bettervent.me (Eurovent certified)", + "placeholder": "Search by brand or model...", + "noResults": "No devices found" + }, + "unit": { + "btuh": "BTU/h", + "kw": "kW", + "switch": "Switch unit" + }, + "seer": "SEER", + "scop": "SCOP", + "cop": "COP", + "tol": "Min. operating temp", + "tbiv": "Bivalent temp", + "refrigerant": "Refrigerant" }, "toggles": { "title": "Toggles", @@ -282,7 +299,8 @@ }, "footer": { "source": "Source Code", - "license": "GPL-3.0 License" + "license": "GPL-3.0 License", + "betterventCredit": "Heat pump data powered by" }, "common": { "save": "Save", diff --git a/web/js/dashboard.js b/web/js/dashboard.js index cd36789..41ce6bc 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -10,6 +10,14 @@ let _currentTimezone = null; let _currentDashDate = null; + const BTU_PER_KW = 3412.14; + let _capacityUnit = "btuh"; + + function fmtCap(btuh) { + if (_capacityUnit === "kw") return (btuh / BTU_PER_KW).toFixed(2) + " kW"; + return btuh.toFixed(0) + " BTU/h"; + } + function show(id) { $(id).classList.remove("hidden"); } function hide(id) { $(id).classList.add("hidden"); } @@ -118,6 +126,10 @@ } const data = await resp.json(); + // Load unit preference + const savedUnit = await getSetting("capacityUnit"); + if (savedUnit === "kw" || savedUnit === "btuh") _capacityUnit = savedUnit; + hide("loading"); show("data-display"); await renderDashboard(data); @@ -361,9 +373,9 @@ el.querySelector('[data-slot="internal-gains"]').textContent = `${rb.internalGainsW.toFixed(0)} W`; el.querySelector('[data-slot="solar-gain"]').textContent = `${rb.solarGainW.toFixed(0)} W`; el.querySelector('[data-slot="vent-gain"]').textContent = `${rb.ventGainW.toFixed(0)} W`; - el.querySelector('[data-slot="total-gain"]').textContent = `${rb.totalGainBtuh.toFixed(0)} BTU/h`; - el.querySelector('[data-slot="ac-capacity"]').textContent = `${rb.acCapacityBtuh.toFixed(0)} BTU/h`; - el.querySelector('[data-slot="headroom-value"]').textContent = `${rb.headroomBtuh.toFixed(0)} BTU/h`; + el.querySelector('[data-slot="total-gain"]').textContent = fmtCap(rb.totalGainBtuh); + el.querySelector('[data-slot="ac-capacity"]').textContent = fmtCap(rb.acCapacityBtuh); + el.querySelector('[data-slot="headroom-value"]').textContent = fmtCap(rb.headroomBtuh); if (rb.headroomBtuh >= 0) { const badEl = el.querySelector('[data-slot="headroom-bad"]'); @@ -372,7 +384,7 @@ const okEl = el.querySelector('[data-slot="headroom-ok"]'); if (okEl) okEl.remove(); const badEl = el.querySelector('[data-slot="headroom-bad"]'); - badEl.textContent = `${badEl.dataset.label} ${Math.abs(rb.headroomBtuh).toFixed(0)} BTU/h`; + badEl.textContent = `${badEl.dataset.label} ${fmtCap(Math.abs(rb.headroomBtuh))}`; badEl.classList.remove("hidden"); } @@ -386,9 +398,9 @@ if (rb.thermalMode === "heating" && heatingSection) { heatingSection.classList.remove("hidden"); const ts = t(); - el.querySelector('[data-slot="heat-deficit"]').textContent = `${(rb.heatDeficitBtuh || 0).toFixed(0)} BTU/h`; - el.querySelector('[data-slot="heating-capacity"]').textContent = `${(rb.heatingCapBtuh || 0).toFixed(0)} BTU/h`; - el.querySelector('[data-slot="heating-headroom-value"]').textContent = `${(rb.heatingHeadroom || 0).toFixed(0)} BTU/h`; + el.querySelector('[data-slot="heat-deficit"]').textContent = fmtCap(rb.heatDeficitBtuh || 0); + el.querySelector('[data-slot="heating-capacity"]').textContent = fmtCap(rb.heatingCapBtuh || 0); + el.querySelector('[data-slot="heating-headroom-value"]').textContent = fmtCap(rb.heatingHeadroom || 0); if ((rb.heatingHeadroom || 0) >= 0) { const hbad = el.querySelector('[data-slot="heating-headroom-bad"]'); if (hbad) hbad.remove(); @@ -397,7 +409,7 @@ if (hok) hok.remove(); const hbad = el.querySelector('[data-slot="heating-headroom-bad"]'); if (hbad) { - hbad.textContent = `${hbad.dataset.label} ${Math.abs(rb.heatingHeadroom || 0).toFixed(0)} BTU/h`; + hbad.textContent = `${hbad.dataset.label} ${fmtCap(Math.abs(rb.heatingHeadroom || 0))}`; hbad.classList.remove("hidden"); } } diff --git a/web/js/db.js b/web/js/db.js index e1ffd02..3b3c363 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -362,17 +362,28 @@ async function getComputePayload(profileId, dateStr) { 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, - })), + acUnits: acUnits.map(a => { + const unit = { + 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, + }; + if (a.seer) unit.seer = a.seer; + if (a.seerClass) unit.seerClass = a.seerClass; + if (a.scop) unit.scop = a.scop; + if (a.scopClass) unit.scopClass = a.scopClass; + if (a.cop) unit.cop = a.cop; + if (a.tol !== undefined && a.tol !== 0) unit.tol = a.tol; + if (a.tbiv !== undefined && a.tbiv !== 0) unit.tbiv = a.tbiv; + if (a.refrigerant) unit.refrigerant = a.refrigerant; + return unit; + }), acAssignments: acAssignments.map(a => ({ acId: a.acId, roomId: a.roomId, diff --git a/web/js/setup.js b/web/js/setup.js index ee94517..7ac68da 100644 --- a/web/js/setup.js +++ b/web/js/setup.js @@ -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 = `
${esc(d.tradeName)} — ${esc(d.modelName)}
` + + `
${d.coolingKw} kW cool / ${d.heatingKw} kW heat · EER ${d.eer} · ${d.seerClass || ""}
`; + 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(); diff --git a/web/templates/layout.html b/web/templates/layout.html index a569e5d..ad17dae 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -40,6 +40,7 @@
{{t "app.name"}} v1.0.0 — {{t "app.tagline"}}
{{t "footer.source"}} · {{t "footer.license"}}
+
{{t "footer.betterventCredit"}} bettervent.me
diff --git a/web/templates/setup.html b/web/templates/setup.html index 7ba3339..766383e 100644 --- a/web/templates/setup.html +++ b/web/templates/setup.html @@ -265,6 +265,23 @@