Moved shared OpenMeteo time parsing code into a shared internal/providers/openmeteo library.
This commit is contained in:
@@ -2,39 +2,18 @@
|
||||
package openmeteo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
openmeteo "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo"
|
||||
)
|
||||
|
||||
// parseOpenMeteoTime parses Open-Meteo timestamps.
|
||||
//
|
||||
// Open-Meteo commonly returns "YYYY-MM-DDTHH:MM" (no timezone suffix) when timezone
|
||||
// is provided separately. When a timezone suffix is present (RFC3339), we accept it too.
|
||||
// The actual parsing logic lives in internal/providers/openmeteo so both the
|
||||
// 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) {
|
||||
s = strings.TrimSpace(s)
|
||||
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)
|
||||
return openmeteo.ParseTime(s, tz, utcOffsetSeconds)
|
||||
}
|
||||
|
||||
79
internal/providers/openmeteo/time.go
Normal file
79
internal/providers/openmeteo/time.go
Normal 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)
|
||||
}
|
||||
@@ -17,10 +17,6 @@ import (
|
||||
|
||||
// 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:
|
||||
//
|
||||
// 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).
|
||||
// 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)
|
||||
if eventID == "" {
|
||||
ts := meta.ParsedTimestamp
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -103,7 +104,9 @@ func (s *ObservationSource) fetchRaw(ctx context.Context) (json.RawMessage, open
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user