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
168 lines
5.2 KiB
Go
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
|
|
}
|