Files
weatherfeeder/internal/sources/openmeteo/observation.go
Eric Rakestraw aa4774e0dd weatherfeeder: split the former maximumdirect.net/weatherd project in two.
feedkit now contains a reusable core, while weatherfeeder is a concrete implementation that includes weather-specific functions.
2026-01-13 18:14:21 -06:00

239 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package openmeteo
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"gitea.maximumdirect.net/ejr/feedkit/config"
"gitea.maximumdirect.net/ejr/feedkit/event"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/model"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
)
// ObservationSource polls an Open-Meteo endpoint and emits one Observation event.
//
// Typical URL shape (you provide this via config):
//
// https://api.open-meteo.com/v1/forecast?latitude=...&longitude=...&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m,surface_pressure,pressure_msl&timezone=GMT
type ObservationSource struct {
name string
url string
userAgent string
client *http.Client
}
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
if strings.TrimSpace(cfg.Name) == "" {
return nil, fmt.Errorf("openmeteo_observation: name is required")
}
if cfg.Params == nil {
return nil, fmt.Errorf("openmeteo_observation %q: params are required (need params.url)", cfg.Name)
}
// Open-Meteo needs only a URL; everything else is optional.
url, ok := cfg.ParamString("url", "URL")
if !ok {
return nil, fmt.Errorf("openmeteo_observation %q: params.url is required", cfg.Name)
}
// Open-Meteo doesn't require a special User-Agent, but including one is polite.
// If the caller doesn't provide one, we supply a reasonable default.
ua := cfg.ParamStringDefault("weatherfeeder (open-meteo client)", "user_agent", "userAgent")
return &ObservationSource{
name: cfg.Name,
url: url,
userAgent: ua,
client: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
func (s *ObservationSource) Name() string { return s.name }
// Kind is used for routing/policy. Note that the TYPE is domain-agnostic (event.Kind).
func (s *ObservationSource) Kind() event.Kind { return event.Kind("observation") }
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
obs, effectiveAt, eventID, err := s.fetchAndParse(ctx)
if err != nil {
return nil, err
}
// Make EffectiveAt a stable pointer.
effectiveAtCopy := effectiveAt
e := event.Event{
ID: eventID,
Kind: s.Kind(),
Source: s.name,
EmittedAt: time.Now().UTC(),
EffectiveAt: &effectiveAtCopy,
// Optional but useful for downstream consumers once multiple event types exist.
Schema: "weather.observation.v1",
// The payload domain-specific (model.WeatherObservation).
// feedkit treats this as opaque.
Payload: obs,
}
if err := e.Validate(); err != nil {
return nil, err
}
return []event.Event{e}, nil
}
// ---- Open-Meteo JSON parsing ----
type omResponse struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Timezone string `json:"timezone"`
UTCOffsetSeconds int `json:"utc_offset_seconds"`
Elevation float64 `json:"elevation"`
Current omCurrent `json:"current"`
}
type omCurrent struct {
Time string `json:"time"` // e.g. "2026-01-10T12:30"
Interval int `json:"interval"`
Temperature2m float64 `json:"temperature_2m"`
RelativeHumidity2m float64 `json:"relative_humidity_2m"`
WeatherCode int `json:"weather_code"`
WindSpeed10m float64 `json:"wind_speed_10m"` // km/h
WindDirection10m float64 `json:"wind_direction_10m"` // degrees
WindGusts10m float64 `json:"wind_gusts_10m"` // km/h
Precipitation float64 `json:"precipitation"`
SurfacePressure float64 `json:"surface_pressure"` // hPa
PressureMSL float64 `json:"pressure_msl"` // hPa
CloudCover float64 `json:"cloud_cover"`
ApparentTemperature float64 `json:"apparent_temperature"`
IsDay int `json:"is_day"`
}
func (s *ObservationSource) fetchAndParse(ctx context.Context) (model.WeatherObservation, time.Time, string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", s.url, nil)
if err != nil {
return model.WeatherObservation{}, time.Time{}, "", err
}
req.Header.Set("User-Agent", s.userAgent)
req.Header.Set("Accept", "application/json")
res, err := s.client.Do(req)
if err != nil {
return model.WeatherObservation{}, time.Time{}, "", err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return model.WeatherObservation{}, time.Time{}, "", fmt.Errorf("openmeteo_observation %q: HTTP %s", s.name, res.Status)
}
var parsed omResponse
if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil {
return model.WeatherObservation{}, time.Time{}, "", err
}
// Parse current.time.
// Open-Meteo "time" commonly looks like "YYYY-MM-DDTHH:MM" (no timezone suffix).
// We'll interpret it in the timezone returned by the API (best-effort).
t, err := parseOpenMeteoTime(parsed.Current.Time, parsed.Timezone, parsed.UTCOffsetSeconds)
if err != nil {
return model.WeatherObservation{}, time.Time{}, "", fmt.Errorf("openmeteo_observation %q: parse time %q: %w", s.name, parsed.Current.Time, err)
}
// Normalize to UTC inside the domain model; presentation can localize later.
effectiveAt := t.UTC()
// Measurements
tempC := parsed.Current.Temperature2m
rh := parsed.Current.RelativeHumidity2m
wdir := parsed.Current.WindDirection10m
wsKmh := parsed.Current.WindSpeed10m
wgKmh := parsed.Current.WindGusts10m
surfacePa := parsed.Current.SurfacePressure * 100.0
mslPa := parsed.Current.PressureMSL * 100.0
elevM := parsed.Elevation
// Canonical condition (WMO)
isDay := parsed.Current.IsDay == 1
wmo := model.WMOCode(parsed.Current.WeatherCode)
canonicalText := standards.WMOText(wmo, &isDay)
obs := model.WeatherObservation{
// Open-Meteo isn't a station feed; well label this with a synthetic identifier.
StationID: fmt.Sprintf("OPENMETEO(%.5f,%.5f)", parsed.Latitude, parsed.Longitude),
StationName: "Open-Meteo",
Timestamp: effectiveAt,
// Canonical conditions
ConditionCode: wmo,
ConditionText: canonicalText,
IsDay: &isDay,
// Provider evidence (Open-Meteo does not provide a separate raw description here)
ProviderRawDescription: "",
// Human-facing fields:
// Populate TextDescription with canonical text so downstream output remains consistent.
TextDescription: canonicalText,
TemperatureC: &tempC,
RelativeHumidityPercent: &rh,
WindDirectionDegrees: &wdir,
WindSpeedKmh: &wsKmh,
WindGustKmh: &wgKmh,
BarometricPressurePa: &surfacePa,
SeaLevelPressurePa: &mslPa,
ElevationMeters: &elevM,
}
// Build a stable event ID.
// Open-Meteo doesn't supply a unique ID, so we key by source + effective time.
eventID := fmt.Sprintf("openmeteo:%s:%s", s.name, effectiveAt.Format(time.RFC3339Nano))
return obs, effectiveAt, eventID, nil
}
func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, fmt.Errorf("empty time")
}
// Typical Open-Meteo format: "2006-01-02T15:04"
const layout = "2006-01-02T15:04"
// Best effort: try to load the timezone as an IANA name.
// Examples Open-Meteo might return: "GMT", "America/Chicago".
if tz != "" {
if loc, err := time.LoadLocation(tz); err == nil {
return time.ParseInLocation(layout, s, loc)
}
}
// Fallback: use the offset seconds to create a fixed zone.
// (If offset is 0, this is UTC.)
loc := time.FixedZone("open-meteo", utcOffsetSeconds)
return time.ParseInLocation(layout, s, loc)
}