Files
HeatGuard/internal/bettervent/provider.go
vikingowl a334dd57a0 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.
2026-02-11 00:31:39 +01:00

116 lines
2.6 KiB
Go

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
}