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:
115
internal/bettervent/provider.go
Normal file
115
internal/bettervent/provider.go
Normal file
@@ -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
|
||||
}
|
||||
178
internal/bettervent/provider_test.go
Normal file
178
internal/bettervent/provider_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
68
internal/bettervent/types.go
Normal file
68
internal/bettervent/types.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
33
web/js/db.js
33
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,
|
||||
|
||||
270
web/js/setup.js
270
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 = `<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();
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<div class="mx-auto px-4 sm:px-6 lg:px-8 text-center text-xs text-gray-400 dark:text-gray-500 space-y-1">
|
||||
<div>{{t "app.name"}} v1.0.0 — {{t "app.tagline"}}</div>
|
||||
<div><a href="https://somegit.dev/vikingowl/HeatGuard" class="hover:text-orange-600 dark:hover:text-orange-400" target="_blank" rel="noopener">{{t "footer.source"}}</a> · {{t "footer.license"}}</div>
|
||||
<div>{{t "footer.betterventCredit"}} <a href="https://bettervent.me" class="hover:text-orange-600 dark:hover:text-orange-400" target="_blank" rel="noopener">bettervent.me</a></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -265,6 +265,23 @@
|
||||
<section id="tab-ac" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.ac.help"}}</p>
|
||||
<div id="ac-no-rooms" class="hidden text-sm text-amber-600 dark:text-amber-400 mb-3">{{t "setup.ac.noRooms"}}</div>
|
||||
|
||||
<!-- Device search -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.search.label"}}</label>
|
||||
<div class="relative">
|
||||
<input type="text" id="ac-device-search" placeholder="{{t "setup.ac.search.placeholder"}}" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<div id="ac-search-results" class="hidden absolute z-40 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">{{t "setup.ac.search.hint"}} — <a href="https://bettervent.me" class="text-orange-600 dark:text-orange-400 hover:underline" target="_blank" rel="noopener">bettervent.me</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Unit toggle -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{t "setup.ac.unit.switch"}}:</span>
|
||||
<button type="button" id="ac-unit-toggle" class="px-3 py-1 text-xs rounded-full border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition font-medium" data-unit="btuh">BTU/h</button>
|
||||
</div>
|
||||
|
||||
<form id="ac-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
@@ -279,7 +296,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.capacity.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.capacity.tooltip"}}">?</span></label>
|
||||
<label class="block text-sm font-medium mb-1" id="ac-capacity-label">{{t "setup.ac.capacity.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.capacity.tooltip"}}">?</span></label>
|
||||
<input type="number" name="capacityBtu" step="100" required class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
@@ -299,7 +316,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{{t "setup.ac.heatingCapacity.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.heatingCapacity.tooltip"}}">?</span></label>
|
||||
<label class="block text-sm font-medium mb-1" id="ac-heating-capacity-label">{{t "setup.ac.heatingCapacity.label"}} <span class="tooltip-trigger" data-tooltip="{{t "setup.ac.heatingCapacity.tooltip"}}">?</span></label>
|
||||
<input type="number" name="heatingCapacityBtu" step="100" value="0" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,6 +330,20 @@
|
||||
</div>
|
||||
<input type="hidden" name="id" value="">
|
||||
</form>
|
||||
|
||||
<!-- Extended device info (shown after search selection or when editing device with extended data) -->
|
||||
<div id="ac-extended-info" class="hidden bg-gray-50 dark:bg-gray-900 rounded-lg p-3 text-sm space-y-1 mb-4">
|
||||
<div class="font-medium text-gray-600 dark:text-gray-300 mb-1" id="ac-extended-title"></div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div>{{t "setup.ac.seer"}}: <span id="ac-ext-seer" class="font-medium text-gray-700 dark:text-gray-200"></span></div>
|
||||
<div>{{t "setup.ac.scop"}}: <span id="ac-ext-scop" class="font-medium text-gray-700 dark:text-gray-200"></span></div>
|
||||
<div>{{t "setup.ac.cop"}}: <span id="ac-ext-cop" class="font-medium text-gray-700 dark:text-gray-200"></span></div>
|
||||
<div>{{t "setup.ac.tol"}}: <span id="ac-ext-tol" class="font-medium text-gray-700 dark:text-gray-200"></span></div>
|
||||
<div>{{t "setup.ac.tbiv"}}: <span id="ac-ext-tbiv" class="font-medium text-gray-700 dark:text-gray-200"></span></div>
|
||||
<div>{{t "setup.ac.refrigerant"}}: <span id="ac-ext-refrigerant" class="font-medium text-gray-700 dark:text-gray-200"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ac-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.ac.noItems"}}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user