Moved shared OpenMeteo time parsing code into a shared internal/providers/openmeteo library.

This commit is contained in:
2026-01-15 10:17:56 -06:00
parent 675c5a6117
commit e92577c30e
4 changed files with 91 additions and 56 deletions

View File

@@ -2,39 +2,18 @@
package openmeteo package openmeteo
import ( import (
"fmt"
"strings"
"time" "time"
openmeteo "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo"
) )
// parseOpenMeteoTime parses Open-Meteo timestamps. // parseOpenMeteoTime parses Open-Meteo timestamps.
// //
// Open-Meteo commonly returns "YYYY-MM-DDTHH:MM" (no timezone suffix) when timezone // The actual parsing logic lives in internal/providers/openmeteo so both the
// is provided separately. When a timezone suffix is present (RFC3339), we accept it too. // source (envelope EffectiveAt / event ID) and normalizer (canonical payload)
// can share identical timestamp behavior.
// //
// This is provider-specific because it relies on Open-Meteo's timezone and offset fields. // We keep this thin wrapper to avoid churn in the normalizer package.
func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) { func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) {
s = strings.TrimSpace(s) return openmeteo.ParseTime(s, tz, utcOffsetSeconds)
if s == "" {
return time.Time{}, fmt.Errorf("empty time")
}
// If the server returned an RFC3339 timestamp with timezone, treat it as authoritative.
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
// 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.
if tz != "" {
if loc, err := time.LoadLocation(tz); err == nil {
return time.ParseInLocation(layout, s, loc)
}
}
// Fallback: use a fixed zone from the offset seconds.
loc := time.FixedZone("open-meteo", utcOffsetSeconds)
return time.ParseInLocation(layout, s, loc)
} }

View File

@@ -0,0 +1,79 @@
// FILE: ./internal/providers/openmeteo/time.go
package openmeteo
import (
"fmt"
"strings"
"time"
)
// ParseTime parses timestamps as returned by Open-Meteo.
//
// Open-Meteo commonly returns timestamps in one of these forms:
//
// - RFC3339 / RFC3339Nano with an explicit timezone suffix, e.g.
// "2026-01-10T18:30:00Z" or "2026-01-10T12:30:00-06:00"
//
// - A local time string WITHOUT a timezone suffix, typically:
// "YYYY-MM-DDTHH:MM" (and sometimes "YYYY-MM-DDTHH:MM:SS")
// In that case, the timezone is provided separately via the top-level
// "timezone" field (IANA name) and/or "utc_offset_seconds".
//
// Parsing strategy:
//
// 1. If the timestamp includes a timezone suffix (RFC3339/RFC3339Nano),
// treat it as authoritative.
//
// 2. Otherwise, attempt to interpret the timestamp in the provided IANA
// timezone name (if loadable), then fall back to a fixed zone derived
// from utcOffsetSeconds.
//
// This helper lives in internal/providers so BOTH sources and normalizers can
// share Open-Meteo quirks without duplicating logic.
func ParseTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, fmt.Errorf("empty time")
}
// If the server returned a timezone-aware RFC3339 timestamp, accept it.
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
// Open-Meteo "local timestamp" formats (no timezone suffix).
// Commonly "2006-01-02T15:04"; sometimes seconds are present.
layouts := []string{
"2006-01-02T15:04:05",
"2006-01-02T15:04",
}
// Prefer the IANA timezone name if it's valid on this system.
if tz = strings.TrimSpace(tz); tz != "" {
if loc, err := time.LoadLocation(tz); err == nil {
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, s, loc); err == nil {
return t, nil
}
}
}
}
// Fall back to a fixed zone derived from the offset seconds.
zoneName := tz
if zoneName == "" {
zoneName = "open-meteo"
}
loc := time.FixedZone(zoneName, utcOffsetSeconds)
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, s, loc); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unsupported open-meteo timestamp format: %q", s)
}

View File

@@ -17,10 +17,6 @@ import (
// ObservationSource polls an NWS station observation endpoint and emits a RAW observation Event. // ObservationSource polls an NWS station observation endpoint and emits a RAW observation Event.
// //
// Key refactor:
// - Source responsibility: fetch bytes + emit a valid event envelope.
// - Normalizer responsibility: interpret raw JSON + map to canonical domain model.
//
// This corresponds to URLs like: // This corresponds to URLs like:
// //
// https://api.weather.gov/stations/KSTL/observations/latest // https://api.weather.gov/stations/KSTL/observations/latest
@@ -62,7 +58,7 @@ func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
} }
// Event.ID must be set BEFORE normalization (feedkit requires it). // Event.ID must be set BEFORE normalization (feedkit requires it).
// Prefer NWS-provided "id" (stable URL). Fallback to a stable-ish computed key. // Prefer NWS-provided "id" (stable URL). Fallback to a stable computed key.
eventID := strings.TrimSpace(meta.ID) eventID := strings.TrimSpace(meta.ID)
if eventID == "" { if eventID == "" {
ts := meta.ParsedTimestamp ts := meta.ParsedTimestamp

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/ejr/feedkit/config" "gitea.maximumdirect.net/ejr/feedkit/config"
"gitea.maximumdirect.net/ejr/feedkit/event" "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/sources/common"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
) )
@@ -103,7 +104,9 @@ func (s *ObservationSource) fetchRaw(ctx context.Context) (json.RawMessage, open
return raw, openMeteoMeta{}, nil return raw, openMeteoMeta{}, nil
} }
if t, err := parseOpenMeteoTime(meta.Current.Time, meta.Timezone, meta.UTCOffsetSeconds); err == nil { // Best effort: compute a stable EffectiveAt + event ID component.
// If parsing fails, we simply omit EffectiveAt and fall back to time.Now() in buildEventID.
if t, err := openmeteo.ParseTime(meta.Current.Time, meta.Timezone, meta.UTCOffsetSeconds); err == nil {
meta.ParsedTimestamp = t.UTC() meta.ParsedTimestamp = t.UTC()
} }
@@ -125,25 +128,3 @@ func buildEventID(sourceName string, meta openMeteoMeta) string {
return fmt.Sprintf("openmeteo:current:%s:%s:%s", sourceName, locKey, ts.Format(time.RFC3339Nano)) return fmt.Sprintf("openmeteo:current:%s:%s:%s", sourceName, locKey, ts.Format(time.RFC3339Nano))
} }
func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, fmt.Errorf("empty time")
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
const layout = "2006-01-02T15:04"
if tz != "" {
if loc, err := time.LoadLocation(tz); err == nil {
return time.ParseInLocation(layout, s, loc)
}
}
loc := time.FixedZone("open-meteo", utcOffsetSeconds)
return time.ParseInLocation(layout, s, loc)
}