From 398cfdb77caa9d4f8832532ffb514ac08bc8c420 Mon Sep 17 00:00:00 2001 From: Ryan D McGuire Date: Sat, 4 Jan 2025 17:51:55 -0500 Subject: [PATCH] Refactor and complete providers --- go.mod | 2 +- pkg/ambient/ambient.go | 81 +++++++++++++++++++++++++++ pkg/ambient/http.go | 61 -------------------- pkg/awn/types.go | 47 ---------------- pkg/provider/awn/provider.go | 79 ++++++++++++++++++++++++++ pkg/provider/awn/types.go | 30 ++++++++++ pkg/provider/provider.go | 15 +++++ pkg/provider/wunderground/provider.go | 70 +++++++++++++++++++++++ pkg/provider/wunderground/types.go | 29 ++++++++++ pkg/weather/enrich.go | 54 ++++++++++++++++++ pkg/weather/metrics.go | 3 + pkg/weather/types.go | 39 +++++++++++++ 12 files changed, 401 insertions(+), 109 deletions(-) create mode 100644 pkg/ambient/ambient.go delete mode 100644 pkg/ambient/http.go delete mode 100644 pkg/awn/types.go create mode 100644 pkg/provider/awn/provider.go create mode 100644 pkg/provider/awn/types.go create mode 100644 pkg/provider/provider.go create mode 100644 pkg/provider/wunderground/provider.go create mode 100644 pkg/provider/wunderground/types.go create mode 100644 pkg/weather/enrich.go create mode 100644 pkg/weather/metrics.go create mode 100644 pkg/weather/types.go diff --git a/go.mod b/go.mod index 63450c2..d7cee08 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( gitea.libretechconsulting.com/rmcguire/go-app v0.1.0 github.com/gorilla/schema v1.4.1 github.com/rs/zerolog v1.33.0 + go.opentelemetry.io/otel v1.33.0 golang.org/x/sys v0.29.0 ) @@ -29,7 +30,6 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect - go.opentelemetry.io/otel v1.33.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect diff --git a/pkg/ambient/ambient.go b/pkg/ambient/ambient.go new file mode 100644 index 0000000..cdd2450 --- /dev/null +++ b/pkg/ambient/ambient.go @@ -0,0 +1,81 @@ +// This provides a shim between HTTP GET requests sent +// by ambient devices, and the providers that may be +// configured (awn, wunderground) +package ambient + +import ( + "context" + "fmt" + "net/http" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel" + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + + "gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/provider" + "gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/provider/awn" + "gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/provider/wunderground" +) + +// These providers implement support for the update sent +// when either "AmbientWeather" or "Wunderground" are selected +// in the "Custom" section of the AWNet app, or the web UI +// of an Ambient WeatherHub +var ( + awnProvider = &awn.AWNProvider{} + wuProvider = &wunderground.WUProvider{} +) + +func GetAWNHandlerFunc(appCtx context.Context) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + handleProviderRequest(appCtx, awnProvider, w, r) + } +} + +func GetWundergroundHandlerFunc(appCtx context.Context) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + handleProviderRequest(appCtx, wuProvider, w, r) + } +} + +// Takes an HTTP requests and convers it to a +// stable type. Enrich is called on the type to complete +// any missing fields as the two providers supported by Ambient +// devices (awn/wunderground) produce different fields +func handleProviderRequest( + appCtx context.Context, + p provider.AmbientProvider, + w http.ResponseWriter, + r *http.Request, +) { + l := zerolog.Ctx(appCtx) + tracer := otel.GetTracer(appCtx, p.Name()+".http.handler") + + ctx, span := tracer.Start(r.Context(), p.Name()+".update") + span.SetAttributes(attribute.String("provider", p.Name())) + defer span.End() + + l.Trace().Str("p", p.Name()). + Any("query", r.URL.Query()).Send() + + // Convert to WeatherUpdate + update, err := p.ReqToWeather(ctx, r) + if err != nil { + l.Err(err).Send() + span.RecordError(err) + span.SetStatus(codes.Error, + fmt.Sprintf("failed to handle %s update: %s", + p.Name(), err.Error())) + + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + } + + // Calculate any fields that may be missing + // such as dew point and wind chill + update.Enrich() + + l.Trace().Any("update", update).Send() + w.Write([]byte("ok")) +} diff --git a/pkg/ambient/http.go b/pkg/ambient/http.go deleted file mode 100644 index de5ff7d..0000000 --- a/pkg/ambient/http.go +++ /dev/null @@ -1,61 +0,0 @@ -package ambient - -import ( - "context" - "io" - "net/http" - - "gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel" - "github.com/rs/zerolog" - - "gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/awn" -) - -func GetAWNHandlerFunc(appCtx context.Context) func(http.ResponseWriter, *http.Request) { - l := zerolog.Ctx(appCtx) - tracer := otel.GetTracer(appCtx, "awn.http.handler") - - return func(w http.ResponseWriter, r *http.Request) { - _, span := tracer.Start(r.Context(), "update") - defer span.End() - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - } - - l.Trace().Bytes("body", bodyBytes) - l.Trace().Any("request", r.URL.Query()).Send() - - update, err := awn.UnmarshalQueryParams(r.URL.Query()) - if err != nil { - l.Err(err).Send() - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - } - - l.Trace().Any("update", update).Send() - - w.Write([]byte("ok")) - } -} - -func GetWundergroundHandlerFunc(appCtx context.Context) func(http.ResponseWriter, *http.Request) { - l := zerolog.Ctx(appCtx) - tracer := otel.GetTracer(appCtx, "wunderground.http.handler") - - return func(w http.ResponseWriter, r *http.Request) { - _, span := tracer.Start(r.Context(), "update") - defer span.End() - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - } - - l.Trace().Bytes("body", bodyBytes) - l.Trace().Any("request", r.URL.Query()).Send() - - w.Write([]byte("ok")) - } -} diff --git a/pkg/awn/types.go b/pkg/awn/types.go deleted file mode 100644 index d5c7282..0000000 --- a/pkg/awn/types.go +++ /dev/null @@ -1,47 +0,0 @@ -package awn - -import ( - "net/url" - - "github.com/gorilla/schema" -) - -type AmbientWeatherUpdate struct { - PassKey string `json:"PASSKEY,omitempty"` - StationType string `json:"stationtype,omitempty"` - DateUTC string `json:"dateutc,omitempty"` - TempF float32 `json:"tempf,omitempty"` - Humidity int `json:"humidity,omitempty"` - WindSpeedMPH float32 `json:"windspeedmph,omitempty"` - WindGustMPH float32 `json:"windgustmph,omitempty"` - MaxDailyGust float32 `json:"maxdailygust,omitempty"` - WindDir int `json:"winddir,omitempty"` - WindDirAVG10m int `json:"winddir_avg10m,omitempty"` - UV int `json:"uv,omitempty"` - SolarRadiation float32 `json:"solarradiation,omitempty"` - HourlyRainIn float32 `json:"hourlyrainin,omitempty"` - EventRainIn float32 `json:"eventrainin,omitempty"` - DailyRainIn float32 `json:"dailyrainin,omitempty"` - WeeklyRainIn float32 `json:"weeklyrainin,omitempty"` - MonthlyRainIn float32 `json:"monthlyrainin,omitempty"` - YearlyRainIn float32 `json:"yearlyrainin,omitempty"` - TotalRainIn float32 `json:"totalrainin,omitempty"` - BattOut int `json:"battout,omitempty"` - BattRain int `json:"battrain,omitempty"` - TempInF float32 `json:"tempinf,omitempty"` - HumidityIn int `json:"humidityin,omitempty"` - BaromRelIn float32 `json:"baromrelin,omitempty"` - BaromAbsIn float32 `json:"baromabsin,omitempty"` - BattIn int `json:"battin,omitempty"` -} - -func UnmarshalQueryParams(query url.Values) (*AmbientWeatherUpdate, error) { - update := new(AmbientWeatherUpdate) - - decoder := schema.NewDecoder() - if err := decoder.Decode(update, query); err != nil { - return nil, err - } - - return update, nil -} diff --git a/pkg/provider/awn/provider.go b/pkg/provider/awn/provider.go new file mode 100644 index 0000000..62ab0f1 --- /dev/null +++ b/pkg/provider/awn/provider.go @@ -0,0 +1,79 @@ +package awn + +import ( + "context" + "net/http" + "net/url" + "time" + + "github.com/gorilla/schema" + + "gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather" +) + +type AWNProvider struct{} + +const providerName = "awn" + +func (awn *AWNProvider) Name() string { + return providerName +} + +// Takes an inbound request from the ambient device and maps +// to a stable struct for weather updates +func (awn *AWNProvider) ReqToWeather(_ context.Context, r *http.Request) ( + *weather.WeatherUpdate, error, +) { + awnUpdate, err := UnmarshalQueryParams(r.URL.Query()) + if err != nil { + return nil, err + } + + return MapAwnUpdate(awnUpdate), nil +} + +func MapAwnUpdate(awnUpdate *AmbientWeatherUpdate) *weather.WeatherUpdate { + updateTime, err := time.Parse(time.DateTime, awnUpdate.DateUTC) + if err != nil { + updateTime = time.Now() + } + + return &weather.WeatherUpdate{ + StationType: awnUpdate.StationType, + DateUTC: &updateTime, + TempF: awnUpdate.TempF, + Humidity: awnUpdate.Humidity, + WindSpeedMPH: awnUpdate.WindGustMPH, + WindGustMPH: awnUpdate.WindGustMPH, + MaxDailyGust: awnUpdate.MaxDailyGust, + WindDir: awnUpdate.WindDir, + WindDirAVG10m: awnUpdate.WindDirAVG10m, + UV: awnUpdate.UV, + SolarRadiation: awnUpdate.SolarRadiation, + HourlyRainIn: awnUpdate.HourlyRainIn, + EventRainIn: awnUpdate.EventRainIn, + DailyRainIn: awnUpdate.DailyRainIn, + WeeklyRainIn: awnUpdate.WeeklyRainIn, + MonthlyRainIn: awnUpdate.MonthlyRainIn, + YearlyRainIn: awnUpdate.YearlyRainIn, + TotalRainIn: awnUpdate.TotalRainIn, + BattOutdoorSensor: awnUpdate.BattOut, + BattIndoorSensor: awnUpdate.BattIn, + BattRainSensor: awnUpdate.BattRain, + TempInsideF: awnUpdate.TempInF, + HumidityInside: awnUpdate.HumidityIn, + BaromRelativeIn: awnUpdate.BaromRelIn, + BaromAbsoluteIn: awnUpdate.BaromAbsIn, + } +} + +func UnmarshalQueryParams(query url.Values) (*AmbientWeatherUpdate, error) { + update := new(AmbientWeatherUpdate) + + decoder := schema.NewDecoder() + if err := decoder.Decode(update, query); err != nil { + return nil, err + } + + return update, nil +} diff --git a/pkg/provider/awn/types.go b/pkg/provider/awn/types.go new file mode 100644 index 0000000..61bd49f --- /dev/null +++ b/pkg/provider/awn/types.go @@ -0,0 +1,30 @@ +package awn + +type AmbientWeatherUpdate struct { + PassKey string `json:"PASSKEY,omitempty" schema:"PASSKEY"` + StationType string `json:"stationtype,omitempty" schema:"stationtype"` + DateUTC string `json:"dateutc,omitempty" schema:"dateutc"` + TempF float32 `json:"tempf,omitempty" schema:"tempf"` + Humidity int `json:"humidity,omitempty" schema:"humidity"` + WindSpeedMPH float32 `json:"windspeedmph,omitempty" schema:"windspeedmph"` + WindGustMPH float32 `json:"windgustmph,omitempty" schema:"windgustmph"` + MaxDailyGust float32 `json:"maxdailygust,omitempty" schema:"maxdailygust"` + WindDir int `json:"winddir,omitempty" schema:"winddir"` + WindDirAVG10m int `json:"winddir_avg10m,omitempty" schema:"winddir_avg10m"` + UV int `json:"uv,omitempty" schema:"uv"` + SolarRadiation float32 `json:"solarradiation,omitempty" schema:"solarradiation"` + HourlyRainIn float32 `json:"hourlyrainin,omitempty" schema:"hourlyrainin"` + EventRainIn float32 `json:"eventrainin,omitempty" schema:"eventrainin"` + DailyRainIn float32 `json:"dailyrainin,omitempty" schema:"dailyrainin"` + WeeklyRainIn float32 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"` + MonthlyRainIn float32 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"` + YearlyRainIn float32 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"` + TotalRainIn float32 `json:"totalrainin,omitempty" schema:"totalrainin"` + BattOut int `json:"battout,omitempty" schema:"battout"` + BattRain int `json:"battrain,omitempty" schema:"battrain"` + TempInF float32 `json:"tempinf,omitempty" schema:"tempinf"` + HumidityIn int `json:"humidityin,omitempty" schema:"humidityin"` + BaromRelIn float32 `json:"baromrelin,omitempty" schema:"baromrelin"` + BaromAbsIn float32 `json:"baromabsin,omitempty" schema:"baromabsin"` + BattIn int `json:"battin,omitempty" schema:"battin"` +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go new file mode 100644 index 0000000..3370705 --- /dev/null +++ b/pkg/provider/provider.go @@ -0,0 +1,15 @@ +package provider + +import ( + "context" + "net/http" + + "gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather" +) + +// Simple interface used for converting Wunderground and +// Ambient Weather Network HTTP requests to a stable struct +type AmbientProvider interface { + ReqToWeather(context.Context, *http.Request) (*weather.WeatherUpdate, error) + Name() string +} diff --git a/pkg/provider/wunderground/provider.go b/pkg/provider/wunderground/provider.go new file mode 100644 index 0000000..e0e1b47 --- /dev/null +++ b/pkg/provider/wunderground/provider.go @@ -0,0 +1,70 @@ +package wunderground + +import ( + "context" + "net/http" + "net/url" + "time" + + "github.com/gorilla/schema" + + "gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather" +) + +type WUProvider struct{} + +const providerName = "weatherunderground" + +func (wu *WUProvider) Name() string { + return providerName +} + +// Takes an inbound request from the ambient device and maps +// to a stable struct for weather updates +func (wu *WUProvider) ReqToWeather(_ context.Context, r *http.Request) ( + *weather.WeatherUpdate, error, +) { + wuUpdate, err := UnmarshalQueryParams(r.URL.Query()) + if err != nil { + return nil, err + } + + return MapWUUpdate(wuUpdate), nil +} + +func MapWUUpdate(wuUpdate *WundergroundUpdate) *weather.WeatherUpdate { + updateTime, err := time.Parse(time.DateTime, wuUpdate.DateUTC) + if err != nil { + updateTime = time.Now() + } + + return &weather.WeatherUpdate{ + StationType: wuUpdate.SoftwareType, + DateUTC: &updateTime, + TempF: wuUpdate.Tempf, + Humidity: wuUpdate.Humidity, + WindSpeedMPH: wuUpdate.WindGustMPH, + WindGustMPH: wuUpdate.WindGustMPH, + WindDir: wuUpdate.WindDir, + UV: wuUpdate.UV, + SolarRadiation: wuUpdate.SolarRadiation, + HourlyRainIn: wuUpdate.RainIn, + DailyRainIn: wuUpdate.DailyRainIn, + WeeklyRainIn: wuUpdate.WeeklyRainIn, + MonthlyRainIn: wuUpdate.MonthlyRainIn, + YearlyRainIn: wuUpdate.YearlyRainIn, + TempInsideF: wuUpdate.IndoorTempF, + HumidityInside: wuUpdate.IndoorHumidity, + BaromRelativeIn: wuUpdate.BaromIn, + } +} + +func UnmarshalQueryParams(query url.Values) (*WundergroundUpdate, error) { + update := new(WundergroundUpdate) + + if err := schema.NewDecoder().Decode(update, query); err != nil { + return nil, err + } + + return update, nil +} diff --git a/pkg/provider/wunderground/types.go b/pkg/provider/wunderground/types.go new file mode 100644 index 0000000..964a525 --- /dev/null +++ b/pkg/provider/wunderground/types.go @@ -0,0 +1,29 @@ +package wunderground + +type WundergroundUpdate struct { + ID string `json:"ID,omitempty" schema:"ID"` + Password string `json:"PASSWORD,omitempty" schema:"PASSWORD"` + UV int `json:"UV,omitempty" schema:"UV"` + Action string `json:"action,omitempty" schema:"action"` + BaromIn float32 `json:"baromin,omitempty" schema:"baromin"` + DailyRainIn float32 `json:"dailyrainin,omitempty" schema:"dailyrainin"` + DateUTC string `json:"dateutc,omitempty" schema:"dateutc"` + DewPtF float32 `json:"dewptf,omitempty" schema:"dewptf"` + Humidity int `json:"humidity,omitempty" schema:"humidity"` + IndoorHumidity int `json:"indoorhumidity,omitempty" schema:"indoorhumidity"` + IndoorTempF float32 `json:"indoortempf,omitempty" schema:"indoortempf"` + LowBatt bool `json:"lowbatt,omitempty" schema:"lowbatt"` + MonthlyRainIn float32 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"` + RainIn float32 `json:"rainin,omitempty" schema:"rainin"` + Realtime bool `json:"realtime,omitempty" schema:"realtime"` + Rtfreq int `json:"rtfreq,omitempty" schema:"rtfreq"` + SoftwareType string `json:"softwaretype,omitempty" schema:"softwaretype"` + SolarRadiation float32 `json:"solarradiation,omitempty" schema:"solarradiation"` + Tempf float32 `json:"tempf,omitempty" schema:"tempf"` + WeeklyRainIn float32 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"` + WindChillF float32 `json:"windchillf,omitempty" schema:"windchillf"` + WindDir int `json:"winddir,omitempty" schema:"winddir"` + WindGustMPH float32 `json:"windgustmph,omitempty" schema:"windgustmph"` + WindSpeedMPH float32 `json:"windspeedmph,omitempty" schema:"windspeedmph"` + YearlyRainIn float32 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"` +} diff --git a/pkg/weather/enrich.go b/pkg/weather/enrich.go new file mode 100644 index 0000000..0b53364 --- /dev/null +++ b/pkg/weather/enrich.go @@ -0,0 +1,54 @@ +package weather + +import "math" + +// Attempts to complete missing fields that may not +// be set by a specific provider, such as DewPoint and WindChill +// +// If enrich is called repeated with the same station ID, measurements +// will be recorded to produce averages. This will be more stable +// and support scaling if Redis is available +// TODO: Implement average tracker +func (u *WeatherUpdate) Enrich() { + if u.WindChillF == 0 { + u.WindChillF = CalculateWindChill(u.TempF, u.WindSpeedMPH) + } + + if u.DewPointF == 0 { + u.DewPointF = CalculateDewPoint(u.TempF, float32(u.Humidity)) + } + + if u.BaromAbsoluteIn == 0 { + u.BaromAbsoluteIn = u.BaromRelativeIn + } +} + +func CalculateDewPoint(tempF, humidity float32) float32 { + // Convert temperature from Fahrenheit to Celsius + tempC := (tempF - 32) * 5 / 9 + + // Calculate the dew point using the Magnus-Tetens approximation + a := float32(17.27) + b := float32(237.7) + + alpha := (a*tempC)/(b+tempC) + float32(math.Log(float64(humidity)/100)) + dewPointC := (b * alpha) / (a - alpha) + + // Convert dew point back to Fahrenheit + dewPointF := (dewPointC * 9 / 5) + 32 + return dewPointF +} + +func CalculateWindChill(tempF float32, windSpeedMPH float32) float32 { + if windSpeedMPH <= 3 { + // Wind chill calculation doesn't apply for very low wind speeds + return tempF + } + + // Formula for calculating wind chill + return float32( + 35.74 + 0.6215*float64(tempF) - + 35.75*math.Pow(float64(windSpeedMPH), 0.16) + + 0.4275*float64(tempF)* + math.Pow(float64(windSpeedMPH), 0.16)) +} diff --git a/pkg/weather/metrics.go b/pkg/weather/metrics.go new file mode 100644 index 0000000..35bd7ee --- /dev/null +++ b/pkg/weather/metrics.go @@ -0,0 +1,3 @@ +package weather + +// TODO: Add OTEL Metrics diff --git a/pkg/weather/types.go b/pkg/weather/types.go new file mode 100644 index 0000000..b931958 --- /dev/null +++ b/pkg/weather/types.go @@ -0,0 +1,39 @@ +package weather + +import ( + "time" +) + +// Stable intermediate struct containing superset of fields +// between AWN and Wunderground style updates from Ambient devices +type WeatherUpdate struct { + DateUTC *time.Time + StationType string + TempF float32 + TempInsideF float32 + Humidity int + HumidityInside int + WindSpeedMPH float32 + WindGustMPH float32 + MaxDailyGust float32 + WindDir int + WindDirAVG10m int + UV int + SolarRadiation float32 + HourlyRainIn float32 + EventRainIn float32 + DailyRainIn float32 + WeeklyRainIn float32 + MonthlyRainIn float32 + YearlyRainIn float32 + TotalRainIn float32 + BattOutdoorSensor int + BattIndoorSensor int + BattRainSensor int + BaromRelativeIn float32 + BaromAbsoluteIn float32 + // These fields may be calculated + // if not otherwise set + DewPointF float32 + WindChillF float32 +}