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

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"}`