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.
116 lines
2.6 KiB
Go
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
|
|
}
|