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 = `