Files
HeatGuard/internal/weather/openmeteo.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

168 lines
5.2 KiB
Go

package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const openMeteoBaseURL = "https://api.open-meteo.com/v1/forecast"
// OpenMeteo implements Provider using the Open-Meteo API.
type OpenMeteo struct {
client *http.Client
baseURL string
}
// NewOpenMeteo creates a new Open-Meteo provider.
func NewOpenMeteo(client *http.Client) *OpenMeteo {
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
return &OpenMeteo{client: client, baseURL: openMeteoBaseURL}
}
func (o *OpenMeteo) Name() string { return "openmeteo" }
type openMeteoResponse struct {
Hourly struct {
Time []string `json:"time"`
Temperature2m []float64 `json:"temperature_2m"`
ApparentTemperature []float64 `json:"apparent_temperature"`
RelativeHumidity2m []float64 `json:"relative_humidity_2m"`
DewPoint2m []float64 `json:"dew_point_2m"`
CloudCover []float64 `json:"cloud_cover"`
WindSpeed10m []float64 `json:"wind_speed_10m"`
WindDirection10m []float64 `json:"wind_direction_10m"`
Precipitation []float64 `json:"precipitation"`
SunshineDuration []float64 `json:"sunshine_duration"`
ShortwaveRadiation []float64 `json:"shortwave_radiation"`
SurfacePressure []float64 `json:"surface_pressure"`
IsDay []int `json:"is_day"`
} `json:"hourly"`
Daily struct {
Time []string `json:"time"`
Temperature2mMax []float64 `json:"temperature_2m_max"`
Temperature2mMin []float64 `json:"temperature_2m_min"`
ApparentTemperatureMax []float64 `json:"apparent_temperature_max"`
Sunrise []string `json:"sunrise"`
Sunset []string `json:"sunset"`
} `json:"daily"`
}
func (o *OpenMeteo) FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error) {
if timezone == "" {
timezone = "Europe/Berlin"
}
params := url.Values{
"latitude": {fmt.Sprintf("%.4f", lat)},
"longitude": {fmt.Sprintf("%.4f", lon)},
"hourly": {"temperature_2m,apparent_temperature,relative_humidity_2m,dew_point_2m,cloud_cover,wind_speed_10m,wind_direction_10m,precipitation,sunshine_duration,shortwave_radiation,surface_pressure,is_day"},
"daily": {"temperature_2m_max,temperature_2m_min,apparent_temperature_max,sunrise,sunset"},
"wind_speed_unit": {"ms"},
"timezone": {timezone},
"forecast_days": {"3"},
}
reqURL := o.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 := o.client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch openmeteo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("openmeteo returned status %d", resp.StatusCode)
}
var raw openMeteoResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode openmeteo: %w", err)
}
loc, _ := time.LoadLocation(timezone)
result := &ForecastResponse{Source: "openmeteo"}
for i, ts := range raw.Hourly.Time {
t, err := time.ParseInLocation("2006-01-02T15:04", ts, loc)
if err != nil {
continue
}
hf := HourlyForecast{
Timestamp: t,
Condition: "",
}
if i < len(raw.Hourly.Temperature2m) {
hf.TemperatureC = raw.Hourly.Temperature2m[i]
}
if i < len(raw.Hourly.ApparentTemperature) {
hf.ApparentTempC = raw.Hourly.ApparentTemperature[i]
}
if i < len(raw.Hourly.RelativeHumidity2m) {
hf.HumidityPct = raw.Hourly.RelativeHumidity2m[i]
}
if i < len(raw.Hourly.DewPoint2m) {
hf.DewPointC = raw.Hourly.DewPoint2m[i]
}
if i < len(raw.Hourly.CloudCover) {
hf.CloudCoverPct = raw.Hourly.CloudCover[i]
}
if i < len(raw.Hourly.WindSpeed10m) {
hf.WindSpeedMs = raw.Hourly.WindSpeed10m[i]
}
if i < len(raw.Hourly.WindDirection10m) {
hf.WindDirectionDeg = raw.Hourly.WindDirection10m[i]
}
if i < len(raw.Hourly.Precipitation) {
hf.PrecipitationMm = raw.Hourly.Precipitation[i]
}
if i < len(raw.Hourly.SunshineDuration) {
hf.SunshineMin = raw.Hourly.SunshineDuration[i] / 60.0 // seconds -> minutes
}
if i < len(raw.Hourly.ShortwaveRadiation) {
hf.ShortwaveRadW = raw.Hourly.ShortwaveRadiation[i]
}
if i < len(raw.Hourly.SurfacePressure) {
hf.PressureHpa = raw.Hourly.SurfacePressure[i]
}
if i < len(raw.Hourly.IsDay) {
hf.IsDay = raw.Hourly.IsDay[i] == 1
}
result.Hourly = append(result.Hourly, hf)
}
for i, ds := range raw.Daily.Time {
t, err := time.ParseInLocation("2006-01-02", ds, loc)
if err != nil {
continue
}
df := DailyForecast{Date: t}
if i < len(raw.Daily.Temperature2mMax) {
df.TempMaxC = raw.Daily.Temperature2mMax[i]
}
if i < len(raw.Daily.Temperature2mMin) {
df.TempMinC = raw.Daily.Temperature2mMin[i]
}
if i < len(raw.Daily.ApparentTemperatureMax) {
df.ApparentTempMaxC = raw.Daily.ApparentTemperatureMax[i]
}
if i < len(raw.Daily.Sunrise) {
df.Sunrise, _ = time.ParseInLocation("2006-01-02T15:04", raw.Daily.Sunrise[i], loc)
}
if i < len(raw.Daily.Sunset) {
df.Sunset, _ = time.ParseInLocation("2006-01-02T15:04", raw.Daily.Sunset[i], loc)
}
result.Daily = append(result.Daily, df)
}
return result, nil
}