Moved shared OpenMeteo time parsing code into a shared internal/providers/openmeteo library.
This commit is contained in:
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
// 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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user