package nws 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 NWS station observation endpoint and emits a single Observation Event. // // This corresponds to URLs like: // // https://api.weather.gov/stations/KSTL/observations/latest 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("nws_observation: name is required") } if cfg.Params == nil { return nil, fmt.Errorf("nws_observation %q: params are required (need params.url and params.user_agent)", cfg.Name) } // feedkit keeps config domain-agnostic by storing driver-specific settings in Params. // Use ParamString so we don't have to type-assert cfg.Params["url"] everywhere. url, ok := cfg.ParamString("url", "URL") if !ok { return nil, fmt.Errorf("nws_observation %q: params.url is required", cfg.Name) } ua, ok := cfg.ParamString("user_agent", "userAgent") if !ok { return nil, fmt.Errorf("nws_observation %q: params.user_agent is required", cfg.Name) } // A small timeout is good hygiene for daemons: you want polls to fail fast, // not hang forever and block subsequent ticks. client := &http.Client{ Timeout: 10 * time.Second, } return &ObservationSource{ name: cfg.Name, url: url, userAgent: ua, client: client, }, nil } func (s *ObservationSource) Name() string { return s.name } // Kind is used for routing/policy. func (s *ObservationSource) Kind() event.Kind { return event.Kind("observation") } // Poll fetches "current conditions" and emits exactly one Event (under normal conditions). func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) { obs, eventID, err := s.fetchAndParse(ctx) if err != nil { return nil, err } // EffectiveAt is optional. // For observations, the natural effective time is the observation timestamp. var effectiveAt *time.Time if !obs.Timestamp.IsZero() { t := obs.Timestamp effectiveAt = &t } e := event.Event{ ID: eventID, Kind: s.Kind(), Source: s.name, EmittedAt: time.Now().UTC(), EffectiveAt: effectiveAt, // Optional: makes downstream decoding/inspection easier. Schema: "weather.observation.v1", // Payload remains domain-specific for now. Payload: obs, } if err := e.Validate(); err != nil { return nil, err } return []event.Event{e}, nil } // --- JSON parsing (minimal model of NWS observation payload) --- type nwsObservationResponse struct { ID string `json:"id"` // a stable unique identifier URL in the payload you pasted Properties struct { StationID string `json:"stationId"` StationName string `json:"stationName"` Timestamp string `json:"timestamp"` TextDescription string `json:"textDescription"` Icon string `json:"icon"` RawMessage string `json:"rawMessage"` Elevation struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"elevation"` Temperature struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"temperature"` Dewpoint struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"dewpoint"` WindDirection struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"windDirection"` WindSpeed struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"windSpeed"` WindGust struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"windGust"` BarometricPressure struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"barometricPressure"` SeaLevelPressure struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"seaLevelPressure"` Visibility struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"visibility"` RelativeHumidity struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"relativeHumidity"` WindChill struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"windChill"` HeatIndex struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"heatIndex"` // NWS returns "presentWeather" as decoded METAR phenomena objects. // We decode these initially as generic maps so we can: // 1) preserve the raw objects in model.PresentWeather{Raw: ...} // 2) also decode them into a typed struct for our WMO mapping logic. PresentWeather []map[string]any `json:"presentWeather"` CloudLayers []struct { Base struct { UnitCode string `json:"unitCode"` Value *float64 `json:"value"` } `json:"base"` Amount string `json:"amount"` } `json:"cloudLayers"` } `json:"properties"` } // metarPhenomenon is a typed view of NWS presentWeather objects. // You provided the schema for these values (intensity/modifier/weather/rawString). type metarPhenomenon struct { Intensity *string `json:"intensity"` // "light", "heavy", or null Modifier *string `json:"modifier"` // "freezing", "showers", etc., or null Weather string `json:"weather"` // e.g., "rain", "snow", "fog_mist", ... RawString string `json:"rawString"` // InVicinity exists in the schema; we ignore it for now because WMO codes // don't directly represent "in vicinity" semantics. InVicinity *bool `json:"inVicinity"` } func (s *ObservationSource) fetchAndParse(ctx context.Context) (model.WeatherObservation, string, error) { req, err := http.NewRequestWithContext(ctx, "GET", s.url, nil) if err != nil { return model.WeatherObservation{}, "", err } // NWS requests: a real User-Agent with contact info is strongly recommended. req.Header.Set("User-Agent", s.userAgent) req.Header.Set("Accept", "application/geo+json, application/json") res, err := s.client.Do(req) if err != nil { return model.WeatherObservation{}, "", err } defer res.Body.Close() if res.StatusCode < 200 || res.StatusCode >= 300 { return model.WeatherObservation{}, "", fmt.Errorf("nws_observation %q: HTTP %s", s.name, res.Status) } var parsed nwsObservationResponse if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil { return model.WeatherObservation{}, "", err } // Parse timestamp (RFC3339) var ts time.Time if strings.TrimSpace(parsed.Properties.Timestamp) != "" { t, err := time.Parse(time.RFC3339, parsed.Properties.Timestamp) if err != nil { return model.WeatherObservation{}, "", fmt.Errorf("nws_observation %q: invalid timestamp %q: %w", s.name, parsed.Properties.Timestamp, err) } ts = t } cloudLayers := make([]model.CloudLayer, 0, len(parsed.Properties.CloudLayers)) for _, cl := range parsed.Properties.CloudLayers { cloudLayers = append(cloudLayers, model.CloudLayer{ BaseMeters: cl.Base.Value, Amount: cl.Amount, }) } // Preserve the raw presentWeather objects (as before) in the domain model. present := make([]model.PresentWeather, 0, len(parsed.Properties.PresentWeather)) for _, pw := range parsed.Properties.PresentWeather { present = append(present, model.PresentWeather{Raw: pw}) } // Decode presentWeather into a typed slice for improved mapping. phenomena := decodeMetarPhenomena(parsed.Properties.PresentWeather) // Provider description (NWS vocabulary). We store this for troubleshooting only. providerDesc := strings.TrimSpace(parsed.Properties.TextDescription) // Map NWS -> canonical WMO code using best-effort heuristics: // 1) presentWeather (METAR phenomena) if present // 2) provider textDescription keywords // 3) cloud layers fallback wmo := mapNWSToWMO(providerDesc, cloudLayers, phenomena) // Canonical text comes from our shared WMO table. // NWS does not give us an explicit day/night flag here, so we leave it nil. canonicalText := standards.WMOText(wmo, nil) obs := model.WeatherObservation{ StationID: parsed.Properties.StationID, StationName: parsed.Properties.StationName, Timestamp: ts, // Canonical conditions ConditionCode: wmo, ConditionText: canonicalText, IsDay: nil, // Provider evidence (for troubleshooting mapping) ProviderRawDescription: providerDesc, // Human-facing fields: // Populate TextDescription with canonical text so downstream output stays consistent. TextDescription: canonicalText, IconURL: parsed.Properties.Icon, TemperatureC: parsed.Properties.Temperature.Value, DewpointC: parsed.Properties.Dewpoint.Value, WindDirectionDegrees: parsed.Properties.WindDirection.Value, WindSpeedKmh: parsed.Properties.WindSpeed.Value, WindGustKmh: parsed.Properties.WindGust.Value, BarometricPressurePa: parsed.Properties.BarometricPressure.Value, SeaLevelPressurePa: parsed.Properties.SeaLevelPressure.Value, VisibilityMeters: parsed.Properties.Visibility.Value, RelativeHumidityPercent: parsed.Properties.RelativeHumidity.Value, WindChillC: parsed.Properties.WindChill.Value, HeatIndexC: parsed.Properties.HeatIndex.Value, ElevationMeters: parsed.Properties.Elevation.Value, RawMessage: parsed.Properties.RawMessage, PresentWeather: present, CloudLayers: cloudLayers, } // Event ID: prefer the NWS-provided "id" (stable unique URL), else fall back to computed. eventID := strings.TrimSpace(parsed.ID) if eventID == "" { eventID = fmt.Sprintf("observation:%s:%s:%s", s.name, obs.StationID, obs.Timestamp.UTC().Format(time.RFC3339Nano), ) } return obs, eventID, nil } func decodeMetarPhenomena(raw []map[string]any) []metarPhenomenon { if len(raw) == 0 { return nil } out := make([]metarPhenomenon, 0, len(raw)) for _, m := range raw { // Encode/decode is slightly inefficient, but it's simple and very readable. // presentWeather payloads are small; this is fine for a polling daemon. b, err := json.Marshal(m) if err != nil { continue } var p metarPhenomenon if err := json.Unmarshal(b, &p); err != nil { continue } p.Weather = strings.ToLower(strings.TrimSpace(p.Weather)) p.RawString = strings.TrimSpace(p.RawString) out = append(out, p) } return out } // mapNWSToWMO maps NWS signals into a canonical WMO code. // // Precedence: // 1. METAR phenomena (presentWeather) — most reliable for precip/hazards // 2. textDescription keywords — weaker, but still useful // 3. cloud layers fallback — only for sky-only conditions func mapNWSToWMO(providerDesc string, cloudLayers []model.CloudLayer, phenomena []metarPhenomenon) model.WMOCode { // 1) Prefer METAR phenomena if present. if code := wmoFromPhenomena(phenomena); code != model.WMOUnknown { return code } // 2) Fall back to provider textDescription keywords. if code := wmoFromTextDescription(providerDesc); code != model.WMOUnknown { return code } // 3) Fall back to cloud layers. if code := wmoFromCloudLayers(cloudLayers); code != model.WMOUnknown { return code } return model.WMOUnknown } func wmoFromPhenomena(phenomena []metarPhenomenon) model.WMOCode { if len(phenomena) == 0 { return model.WMOUnknown } // Helper accessors (avoid repeating nil checks everywhere). intensityOf := func(p metarPhenomenon) string { if p.Intensity == nil { return "" } return strings.ToLower(strings.TrimSpace(*p.Intensity)) } modifierOf := func(p metarPhenomenon) string { if p.Modifier == nil { return "" } return strings.ToLower(strings.TrimSpace(*p.Modifier)) } // Pass 1: thunder + hail overrides everything (hazard). // // WMO provides: // 95 = thunderstorm // 96 = light thunderstorms with hail // 99 = thunderstorms with hail hasThunder := false hailIntensity := "" for _, p := range phenomena { switch p.Weather { case "thunderstorms": hasThunder = true case "hail": if hailIntensity == "" { hailIntensity = intensityOf(p) } } } if hasThunder { if hailIntensity != "" || containsWeather(phenomena, "hail") { if hailIntensity == "heavy" { return 99 } // Default to "light" hail when unknown return 96 } return 95 } // Pass 2: freezing hazards. // // Modifier includes "freezing". for _, p := range phenomena { if modifierOf(p) != "freezing" { continue } switch p.Weather { case "rain": if intensityOf(p) == "light" { return 66 } // Default to freezing rain when unknown/heavy. return 67 case "drizzle": if intensityOf(p) == "light" { return 56 } return 57 case "fog", "fog_mist": // "Freezing fog" isn't a perfect match for "Rime Fog", // but within our current WMO subset, 48 is the closest. return 48 } } // Pass 3: fog / obscuration. for _, p := range phenomena { switch p.Weather { case "fog", "fog_mist": return 45 case "haze", "smoke", "dust", "sand", "spray", "volcanic_ash": // Our current WMO table subset doesn't include haze/smoke/dust codes. // "Foggy" (45) is a reasonable umbrella for "visibility obscured". return 45 } } // Pass 4: precip families. for _, p := range phenomena { inten := intensityOf(p) mod := modifierOf(p) // Handle "showers" modifier explicitly (rain vs snow showers). if mod == "showers" { switch p.Weather { case "rain": if inten == "light" { return 80 } if inten == "heavy" { return 82 } return 81 case "snow": if inten == "light" { return 85 } return 86 } } switch p.Weather { // Drizzle case "drizzle": if inten == "heavy" { return 55 } if inten == "light" { return 51 } return 53 // Rain case "rain": if inten == "heavy" { return 65 } if inten == "light" { return 61 } return 63 // Snow case "snow": if inten == "heavy" { return 75 } if inten == "light" { return 71 } return 73 // Snow grains case "snow_grains": return 77 // We don’t currently have sleet/ice pellet codes in our shared WMO subset. // We make conservative choices within the available codes. case "ice_pellets", "snow_pellets": // Closest within our subset is "Snow" (73). If you later expand the WMO table // to include sleet/ice pellet codes, update this mapping. return 73 } } return model.WMOUnknown } func containsWeather(phenomena []metarPhenomenon, weather string) bool { weather = strings.ToLower(strings.TrimSpace(weather)) for _, p := range phenomena { if p.Weather == weather { return true } } return false } func wmoFromTextDescription(providerDesc string) model.WMOCode { s := strings.ToLower(strings.TrimSpace(providerDesc)) if s == "" { return model.WMOUnknown } // Thunder / hail if strings.Contains(s, "thunder") { if strings.Contains(s, "hail") { return 99 } return 95 } // Freezing hazards if strings.Contains(s, "freezing rain") { if strings.Contains(s, "light") { return 66 } return 67 } if strings.Contains(s, "freezing drizzle") { if strings.Contains(s, "light") { return 56 } return 57 } // Drizzle if strings.Contains(s, "drizzle") { if strings.Contains(s, "heavy") || strings.Contains(s, "dense") { return 55 } if strings.Contains(s, "light") { return 51 } return 53 } // Showers if strings.Contains(s, "showers") { if strings.Contains(s, "heavy") { return 82 } if strings.Contains(s, "light") { return 80 } return 81 } // Rain if strings.Contains(s, "rain") { if strings.Contains(s, "heavy") { return 65 } if strings.Contains(s, "light") { return 61 } return 63 } // Snow if strings.Contains(s, "snow showers") { if strings.Contains(s, "light") { return 85 } return 86 } if strings.Contains(s, "snow grains") { return 77 } if strings.Contains(s, "snow") { if strings.Contains(s, "heavy") { return 75 } if strings.Contains(s, "light") { return 71 } return 73 } // Fog if strings.Contains(s, "rime fog") { return 48 } if strings.Contains(s, "fog") || strings.Contains(s, "mist") { return 45 } // Sky-only if strings.Contains(s, "overcast") { return 3 } if strings.Contains(s, "cloudy") { return 3 } if strings.Contains(s, "partly cloudy") { return 2 } if strings.Contains(s, "mostly sunny") || strings.Contains(s, "mostly clear") || strings.Contains(s, "mainly sunny") || strings.Contains(s, "mainly clear") { return 1 } if strings.Contains(s, "clear") || strings.Contains(s, "sunny") { return 0 } return model.WMOUnknown } func wmoFromCloudLayers(cloudLayers []model.CloudLayer) model.WMOCode { // NWS cloud layer amount values commonly include: // OVC, BKN, SCT, FEW, SKC, CLR, VV (vertical visibility / obscured sky) // // We interpret these conservatively: // - OVC / BKN / VV => Cloudy (3) // - SCT => Partly Cloudy (2) // - FEW => Mainly Sunny/Clear (1) // - CLR / SKC => Sunny/Clear (0) // // If multiple layers exist, we bias toward the "most cloudy" layer. mostCloudy := "" for _, cl := range cloudLayers { a := strings.ToUpper(strings.TrimSpace(cl.Amount)) if a == "" { continue } switch a { case "OVC": return 3 case "BKN", "VV": if mostCloudy != "OVC" { mostCloudy = a } case "SCT": if mostCloudy == "" { mostCloudy = "SCT" } case "FEW": if mostCloudy == "" { mostCloudy = "FEW" } case "CLR", "SKC": if mostCloudy == "" { mostCloudy = "CLR" } } } switch mostCloudy { case "BKN", "VV": return 3 case "SCT": return 2 case "FEW": return 1 case "CLR": return 0 default: return model.WMOUnknown } }