Files
HeatGuard/internal/weather/brightsky.go
vikingowl 1c9db02334 feat: add web UI with full CRUD setup page
Add server-side rendered setup UI accessible via `heatwave web`.
The dashboard is now re-rendered per request and includes a nav bar
linking to the new /setup page. Setup provides full CRUD for profiles,
rooms, devices, occupants, AC units (with room assignment), scenario
toggles, and forecast fetching — all via POST/redirect/GET forms.

- Add ShowNav field to DashboardData for conditional nav bar
- Extract fetchForecastForProfile() for reuse by web handler
- Create setup.html.tmpl with Tailwind-styled entity sections
- Create web_handlers.go with 15 route handlers and flash cookies
- Switch web.go from pre-rendered to per-request dashboard rendering
- Graceful dashboard fallback when no forecast data exists
2026-02-09 10:39:00 +01:00

122 lines
3.1 KiB
Go

package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const brightSkyBaseURL = "https://api.brightsky.dev/weather"
// BrightSky implements Provider using the Bright Sky API (DWD MOSMIX wrapper).
type BrightSky struct {
client *http.Client
baseURL string
}
// NewBrightSky creates a new Bright Sky provider.
func NewBrightSky(client *http.Client) *BrightSky {
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
return &BrightSky{client: client, baseURL: brightSkyBaseURL}
}
func (b *BrightSky) Name() string { return "brightsky" }
type brightSkyResponse struct {
Weather []struct {
Timestamp string `json:"timestamp"`
Temperature *float64 `json:"temperature"`
Humidity *float64 `json:"relative_humidity"`
DewPoint *float64 `json:"dew_point"`
CloudCover *float64 `json:"cloud_cover"`
WindSpeed *float64 `json:"wind_speed"`
WindDirection *float64 `json:"wind_direction"`
Precipitation *float64 `json:"precipitation"`
Sunshine *float64 `json:"sunshine"`
PressureMSL *float64 `json:"pressure_msl"`
Condition string `json:"condition"`
} `json:"weather"`
}
func (b *BrightSky) FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error) {
now := time.Now()
params := url.Values{
"lat": {fmt.Sprintf("%.4f", lat)},
"lon": {fmt.Sprintf("%.4f", lon)},
"date": {now.Format("2006-01-02")},
"last_date": {now.Add(72 * time.Hour).Format("2006-01-02")},
}
reqURL := b.baseURL + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := b.client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch brightsky: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brightsky returned status %d", resp.StatusCode)
}
var raw brightSkyResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode brightsky: %w", err)
}
result := &ForecastResponse{Source: "brightsky"}
for _, w := range raw.Weather {
t, err := time.Parse(time.RFC3339, w.Timestamp)
if err != nil {
continue
}
hf := HourlyForecast{
Timestamp: t,
Condition: w.Condition,
}
if w.Temperature != nil {
hf.TemperatureC = *w.Temperature
}
if w.Humidity != nil {
hf.HumidityPct = *w.Humidity
}
if w.DewPoint != nil {
hf.DewPointC = *w.DewPoint
}
if w.CloudCover != nil {
hf.CloudCoverPct = *w.CloudCover
}
if w.WindSpeed != nil {
hf.WindSpeedMs = *w.WindSpeed / 3.6 // km/h -> m/s
}
if w.WindDirection != nil {
hf.WindDirectionDeg = *w.WindDirection
}
if w.Precipitation != nil {
hf.PrecipitationMm = *w.Precipitation
}
if w.Sunshine != nil {
hf.SunshineMin = *w.Sunshine
}
if w.PressureMSL != nil {
hf.PressureHpa = *w.PressureMSL
}
// BrightSky doesn't provide IsDay — approximate from hour
hour := t.Hour()
hf.IsDay = hour >= 6 && hour < 21
result.Hourly = append(result.Hourly, hf)
}
return result, nil
}