Refactor and complete providers

This commit is contained in:
Ryan McGuire 2025-01-04 17:51:55 -05:00
parent 9094cb3d3e
commit 398cfdb77c
12 changed files with 401 additions and 109 deletions

2
go.mod
View File

@ -6,6 +6,7 @@ require (
gitea.libretechconsulting.com/rmcguire/go-app v0.1.0 gitea.libretechconsulting.com/rmcguire/go-app v0.1.0
github.com/gorilla/schema v1.4.1 github.com/gorilla/schema v1.4.1
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
go.opentelemetry.io/otel v1.33.0
golang.org/x/sys v0.29.0 golang.org/x/sys v0.29.0
) )
@ -29,7 +30,6 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // 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/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/otlpmetric/otlpmetricgrpc v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace 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 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect

81
pkg/ambient/ambient.go Normal file
View File

@ -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"))
}

View File

@ -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"))
}
}

View File

@ -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
}

View File

@ -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
}

30
pkg/provider/awn/types.go Normal file
View File

@ -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"`
}

15
pkg/provider/provider.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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"`
}

54
pkg/weather/enrich.go Normal file
View File

@ -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))
}

3
pkg/weather/metrics.go Normal file
View File

@ -0,0 +1,3 @@
package weather
// TODO: Add OTEL Metrics

39
pkg/weather/types.go Normal file
View File

@ -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
}