Files
go-weatherstation/internal/providers/openmeteo.go
2026-01-28 17:00:24 +11:00

217 lines
5.1 KiB
Go

package providers
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
)
type OpenMeteo struct {
Client *http.Client
}
func (o *OpenMeteo) Name() string { return "open_meteo" }
var openMeteoHourlyFields = []string{
"temperature_2m",
"pressure_msl",
"wind_speed_10m",
"wind_gusts_10m",
"wind_direction_10m",
"precipitation",
"cloud_cover",
"relative_humidity_1000hPa",
}
func OpenMeteoRequestURL(site Site, model string) (string, error) {
u, err := url.Parse("https://api.open-meteo.com/v1/ecmwf")
if err != nil {
return "", err
}
q := u.Query()
q.Set("latitude", fmt.Sprintf("%.6f", site.Latitude))
q.Set("longitude", fmt.Sprintf("%.6f", site.Longitude))
q.Set("hourly", join(openMeteoHourlyFields))
q.Set("wind_speed_unit", "ms")
q.Set("temperature_unit", "celsius")
q.Set("precipitation_unit", "mm")
u.RawQuery = q.Encode()
_ = model // endpoint is fixed to ECMWF; model is metadata only.
return u.String(), nil
}
func (o *OpenMeteo) Fetch(ctxDone <-chan struct{}, site Site, model string) (*ForecastResult, error) {
if o.Client == nil {
o.Client = &http.Client{Timeout: 15 * time.Second}
}
reqURL, err := OpenMeteoRequestURL(site, model)
if err != nil {
return nil, err
}
safeURL, err := url.Parse(reqURL)
if err != nil {
return nil, err
}
safeQuery := safeURL.Query()
if safeQuery.Has("apikey") {
safeQuery.Set("apikey", "redacted")
}
safeURL.RawQuery = safeQuery.Encode()
log.Printf("open-meteo request url=%s", safeURL.String())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { <-ctxDone; cancel() }()
req, _ := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
resp, err := o.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
const maxLogBody = int64(65536)
bodyBuf, _ := io.ReadAll(io.LimitReader(resp.Body, maxLogBody+1))
truncated := int64(len(bodyBuf)) > maxLogBody
if truncated {
bodyBuf = bodyBuf[:maxLogBody]
}
if truncated {
log.Printf("open-meteo response status=%d bytes=%d truncated=true body=%s", resp.StatusCode, len(bodyBuf), string(bodyBuf))
} else {
log.Printf("open-meteo response status=%d bytes=%d body=%s", resp.StatusCode, len(bodyBuf), string(bodyBuf))
}
if resp.StatusCode/100 != 2 {
var apiErr struct {
Error bool `json:"error"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(bodyBuf, &apiErr); err == nil && apiErr.Reason != "" {
return nil, fmt.Errorf("open-meteo HTTP %d: %s", resp.StatusCode, apiErr.Reason)
}
if len(bodyBuf) > 0 {
return nil, fmt.Errorf("open-meteo HTTP %d: %s", resp.StatusCode, string(bodyBuf))
}
return nil, fmt.Errorf("open-meteo HTTP %d", resp.StatusCode)
}
var raw map[string]any
if err := json.Unmarshal(bodyBuf, &raw); err != nil {
return nil, err
}
// Parse the hourly arrays
hr, ok := raw["hourly"].(map[string]any)
if !ok {
return nil, fmt.Errorf("unexpected open-meteo payload: missing hourly")
}
times, err := parseTimeArray(hr["time"])
if err != nil {
return nil, err
}
// Helpers pull float arrays (may be absent)
temp := floatArray(hr["temperature_2m"])
rh := floatArray(hr["relative_humidity_1000hPa"])
if rh == nil {
rh = floatArray(hr["relative_humidity_2m"])
}
msl := floatArray(hr["pressure_msl"])
if msl == nil {
msl = floatArray(hr["surface_pressure"])
}
ws := floatArray(hr["wind_speed_10m"])
gust := floatArray(hr["wind_gusts_10m"])
wd := floatArray(hr["wind_direction_10m"])
precip := floatArray(hr["precipitation"])
cloud := floatArray(hr["cloud_cover"])
points := make([]HourlyForecastPoint, 0, len(times))
for i := range times {
points = append(points, HourlyForecastPoint{
TS: times[i],
TempC: idx(temp, i),
RH: idx(rh, i),
PressureMSLH: idx(msl, i),
WindMS: idx(ws, i),
WindGustMS: idx(gust, i),
WindDirDeg: idx(wd, i),
PrecipMM: idx(precip, i),
PrecipProb: nil,
CloudCover: idx(cloud, i),
})
}
return &ForecastResult{
RetrievedAt: time.Now().UTC(),
Model: model,
Hourly: points,
Raw: raw,
}, nil
}
func join(items []string) string {
out := ""
for i, s := range items {
if i > 0 {
out += ","
}
out += s
}
return out
}
func parseTimeArray(v any) ([]time.Time, error) {
arr, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("hourly.time not array")
}
out := make([]time.Time, 0, len(arr))
for _, x := range arr {
s, _ := x.(string)
t, err := time.Parse("2006-01-02T15:04", s) // open-meteo uses ISO without seconds
if err != nil {
return nil, err
}
out = append(out, t.UTC())
}
return out, nil
}
func floatArray(v any) []float64 {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]float64, 0, len(arr))
for _, x := range arr {
switch n := x.(type) {
case float64:
out = append(out, n)
case int:
out = append(out, float64(n))
default:
// if null or unexpected, use NaN sentinel? We'll instead skip by storing nil via idx()
out = append(out, 0)
}
}
return out
}
func idx(a []float64, i int) *float64 {
if a == nil || i < 0 || i >= len(a) {
return nil
}
v := a[i]
return &v
}