diff --git a/cmd/weatherfeeder/config.yml b/cmd/weatherfeeder/config.yml index abdf509..05e8eda 100644 --- a/cmd/weatherfeeder/config.yml +++ b/cmd/weatherfeeder/config.yml @@ -40,12 +40,20 @@ sources: url: "https://api.weather.gov/stations/KCPS/observations/latest" user_agent: "HomeOps (eric@maximumdirect.net)" - - name: NWSHourlyForecastSTL +# - name: NWSHourlyForecastSTL +# kind: forecast +# driver: nws_forecast +# every: 45m +# params: +# url: "https://api.weather.gov/gridpoints/LSX/90,74/forecast/hourly" +# user_agent: "HomeOps (eric@maximumdirect.net)" + + - name: OpenMeteoHourlyForecastSTL kind: forecast - driver: nws_forecast - every: 45m + driver: openmeteo_forecast + every: 60m params: - url: "https://api.weather.gov/gridpoints/LSX/90,74/forecast/hourly" + url: https://api.open-meteo.com/v1/forecast?latitude=38.6239&longitude=-90.3571&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation_probability,precipitation,snowfall,weather_code,surface_pressure,wind_speed_10m,wind_direction_10m&forecast_days=3 user_agent: "HomeOps (eric@maximumdirect.net)" - name: NWSAlertsSTL diff --git a/internal/sources/builtins.go b/internal/sources/builtins.go index 7bb0ceb..6aa6f8c 100644 --- a/internal/sources/builtins.go +++ b/internal/sources/builtins.go @@ -27,6 +27,9 @@ func RegisterBuiltins(r *fksource.Registry) { r.Register("openmeteo_observation", func(cfg config.SourceConfig) (fksource.Source, error) { return openmeteo.NewObservationSource(cfg) }) + r.Register("openmeteo_forecast", func(cfg config.SourceConfig) (fksource.Source, error) { + return openmeteo.NewForecastSource(cfg) + }) // OpenWeatherMap drivers r.Register("openweather_observation", func(cfg config.SourceConfig) (fksource.Source, error) { diff --git a/internal/sources/openmeteo/forecast.go b/internal/sources/openmeteo/forecast.go new file mode 100644 index 0000000..346a89c --- /dev/null +++ b/internal/sources/openmeteo/forecast.go @@ -0,0 +1,110 @@ +package openmeteo + +import ( + "context" + "encoding/json" + "strings" + "time" + + "gitea.maximumdirect.net/ejr/feedkit/config" + "gitea.maximumdirect.net/ejr/feedkit/event" + "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo" + "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/common" + "gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards" +) + +// ForecastSource polls an Open-Meteo hourly forecast endpoint and emits one RAW Forecast Event. +type ForecastSource struct { + http *common.HTTPSource +} + +func NewForecastSource(cfg config.SourceConfig) (*ForecastSource, error) { + const driver = "openmeteo_forecast" + + hs, err := common.NewHTTPSource(driver, cfg, "application/json") + if err != nil { + return nil, err + } + + return &ForecastSource{http: hs}, nil +} + +func (s *ForecastSource) Name() string { return s.http.Name } + +func (s *ForecastSource) Kind() event.Kind { return event.Kind("forecast") } + +func (s *ForecastSource) Poll(ctx context.Context) ([]event.Event, error) { + raw, meta, err := s.fetchRaw(ctx) + if err != nil { + return nil, err + } + + // Open-Meteo does not expose a true "issued at" timestamp for forecast runs. + // We use current.time when present; otherwise we fall back to the first hourly time + // as a proxy for the start of the forecast horizon. + var effectiveAt *time.Time + if !meta.ParsedTimestamp.IsZero() { + t := meta.ParsedTimestamp.UTC() + effectiveAt = &t + } + + emittedAt := time.Now().UTC() + eventID := common.ChooseEventID("", s.http.Name, effectiveAt, emittedAt) + + return common.SingleRawEvent( + s.Kind(), + s.http.Name, + standards.SchemaRawOpenMeteoHourlyForecastV1, + eventID, + emittedAt, + effectiveAt, + raw, + ) +} + +// ---- RAW fetch + minimal metadata decode ---- + +type forecastMeta struct { + Timezone string `json:"timezone"` + UTCOffsetSeconds int `json:"utc_offset_seconds"` + + Current struct { + Time string `json:"time"` + } `json:"current"` + + Hourly struct { + Time []string `json:"time"` + } `json:"hourly"` + + ParsedTimestamp time.Time `json:"-"` +} + +func (s *ForecastSource) fetchRaw(ctx context.Context) (json.RawMessage, forecastMeta, error) { + raw, err := s.http.FetchJSON(ctx) + if err != nil { + return nil, forecastMeta{}, err + } + + var meta forecastMeta + if err := json.Unmarshal(raw, &meta); err != nil { + // If metadata decode fails, still return raw; Poll will fall back to Source:EmittedAt. + return raw, forecastMeta{}, nil + } + + ts := strings.TrimSpace(meta.Current.Time) + if ts == "" { + for _, v := range meta.Hourly.Time { + if ts = strings.TrimSpace(v); ts != "" { + break + } + } + } + + if ts != "" { + if t, err := openmeteo.ParseTime(ts, meta.Timezone, meta.UTCOffsetSeconds); err == nil { + meta.ParsedTimestamp = t.UTC() + } + } + + return raw, meta, nil +}