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=...¤t=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; we’ll 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) }