Compare commits

..

6 Commits

Author SHA1 Message Date
eaf2cf211d Update wunderground url
All checks were successful
Build and Publish / release (push) Successful in 2m36s
2025-01-12 19:06:45 -05:00
e8fbacfe6d Proxy updates for AWN
All checks were successful
Build and Publish / release (push) Successful in 2m53s
2025-01-12 17:49:54 -05:00
19823ea08f Weather service proxy support
All checks were successful
Build and Publish / release (push) Successful in 3m51s
2025-01-12 17:23:32 -05:00
7fc1fc9b56 Proxy support for AWN/Wunderground 2025-01-12 15:30:37 -05:00
849dbfb6ff Update go-app
All checks were successful
Build and Publish / release (push) Successful in 4m5s
2025-01-08 21:31:50 -05:00
dc2470da71 Improve metrics recording
Some checks failed
Build and Publish / release (push) Failing after 40s
2025-01-08 21:28:38 -05:00
17 changed files with 627 additions and 197 deletions

13
TODO.md
View File

@ -1,15 +1,18 @@
# TODO
- [ ] Configuration for app
- [ ] Configurable metric prefix
- [ ] Helm Chart
- [ ] Update README
- [ ] Add Grafana dashboard
- [ ] Add device name field with ID/Key mappings
- [ ] Add device type field with ID/Key mappings
- [ ] Move EVERYTHING to pointers to support nil
- [ ] Add proxy to upstream support
- [ ] Add new spans
- [ ] Fix wunderground 401
- [ ] Perform proxy calls in goroutines
## Done
- [x] Configuration for app
- [x] Configurable metric prefix
- [x] Add device name field with ID/Key mappings
- [x] Add device type field with ID/Key mappings
- [x] Move EVERYTHING to pointers to support nil
- [x] Consolidate battery status into one metric with device label
- [x] Fix shutdown
- [x] Add new fields from WS-2192

4
go.mod
View File

@ -4,11 +4,13 @@ go 1.23.4
require (
gitea.libretechconsulting.com/rmcguire/go-app v0.3.1
github.com/go-resty/resty/v2 v2.16.3
github.com/gorilla/schema v1.4.1
github.com/rs/zerolog v1.33.0
go.opentelemetry.io/otel v1.33.0
go.opentelemetry.io/otel/metric v1.33.0
golang.org/x/sys v0.29.0
k8s.io/utils v0.0.0-20241210054802-24370beab758
)
require (
@ -22,7 +24,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect

21
go.sum
View File

@ -1,5 +1,3 @@
gitea.libretechconsulting.com/rmcguire/go-app v0.3.0 h1:TSR6oEDBX+83975gmgGgU/cTFgfG999+9N/1h4RAXq0=
gitea.libretechconsulting.com/rmcguire/go-app v0.3.0/go.mod h1:wHOWh4O4AMDATQ3WEUYjq5a5bnICPBpu5G6BsNxqN38=
gitea.libretechconsulting.com/rmcguire/go-app v0.3.1 h1:jydcJ+Vv8sk+Le7nTI2+b6E7FfV+ShBJo7YdxmdaCYc=
gitea.libretechconsulting.com/rmcguire/go-app v0.3.1/go.mod h1:wHOWh4O4AMDATQ3WEUYjq5a5bnICPBpu5G6BsNxqN38=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -20,6 +18,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-resty/resty/v2 v2.16.3 h1:zacNT7lt4b8M/io2Ahj6yPypL7bqx9n1iprfQuodV+E=
github.com/go-resty/resty/v2 v2.16.3/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@ -39,8 +39,9 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -91,14 +92,10 @@ go.opentelemetry.io/otel/sdk/metric v1.33.0 h1:Gs5VK9/WUJhNXZgn8MR6ITatvAmKeIuCt
go.opentelemetry.io/otel/sdk/metric v1.33.0/go.mod h1:dL5ykHZmm1B1nVRk9dDjChwDmt81MjVp3gLkQRwKf/Q=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -108,18 +105,14 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d h1:H8tOf8XM88HvKqLTxe755haY6r1fqqzLbEnfrmLXlSA=
google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d/go.mod h1:2v7Z7gP2ZUOGsaFyxATQSRoBnKygqVq2Cwnvom7QiqY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 h1:3UsHvIr4Wc2aW4brOaSCmcxh9ksica6fHEr8P1XhkYw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4=
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -127,3 +120,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0=
k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=

View File

@ -10,6 +10,7 @@ import (
"golang.org/x/sys/unix"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/ambient"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/ambient/config"
)
const defaultMetricPrefix = "weather"
@ -19,9 +20,9 @@ func main() {
defer cncl()
// Config type for app, which implements go-app/config.AppConfig
awConfig := &ambient.AmbientLocalExporterConfig{
awConfig := &config.AmbientLocalExporterConfig{
MetricPrefix: defaultMetricPrefix,
WeatherStations: make([]ambient.WeatherStation, 0),
WeatherStations: make([]config.WeatherStation, 0),
}
// Read config and environment, set up logging, load up

View File

@ -13,6 +13,7 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/ambient/config"
"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"
@ -24,7 +25,7 @@ type AmbientWeather struct {
// when either "AmbientWeather" or "Wunderground" are selected
// in the "Custom" section of the AWNet app, or the web UI
// of an Ambient WeatherHub
config *AmbientLocalExporterConfig
config *config.AmbientLocalExporterConfig
awnProvider provider.AmbientProvider
wuProvider provider.AmbientProvider
appCtx context.Context
@ -32,8 +33,9 @@ type AmbientWeather struct {
l *zerolog.Logger
}
func New(appCtx context.Context, awConfig *AmbientLocalExporterConfig) *AmbientWeather {
func New(appCtx context.Context, awConfig *config.AmbientLocalExporterConfig) *AmbientWeather {
return &AmbientWeather{
config: awConfig,
appCtx: appCtx,
}
}
@ -43,6 +45,8 @@ func (aw *AmbientWeather) Init() *AmbientWeather {
aw.awnProvider = &awn.AWNProvider{}
aw.wuProvider = &wunderground.WUProvider{}
aw.l = zerolog.Ctx(aw.appCtx)
aw.l.Trace().Any("awConfig", aw.config).Send()
return aw
}
@ -95,13 +99,15 @@ func (aw *AmbientWeather) handleProviderRequest(
// such as dew point and wind chill
update.Enrich()
// Update metrics
// Prepare metrics if this is the first update
if aw.metrics == nil {
if aw.config.MetricPrefix != "" {
weather.MetricPrefix = aw.config.MetricPrefix
}
aw.metrics = weather.MustInitMetrics(aw.appCtx)
aw.InitMetrics()
}
// Enrich station if configured
aw.enrichStation(update)
// Update metrics
aw.metrics.Update(update)
l.Debug().
@ -109,4 +115,47 @@ func (aw *AmbientWeather) handleProviderRequest(
Any("update", update).
Msg("successfully handled update")
w.Write([]byte("ok"))
// Proxy update to one or both services if configured to do so
// Uses a weather update to allow awn to publish to wunderground and
// visa versa.
if station := update.StationConfig; station != nil {
if station.ProxyToAWN {
err := aw.awnProvider.ProxyReq(ctx, update)
if err != nil {
zerolog.Ctx(aw.appCtx).Err(err).Msg("failed to proxy to ambient weather")
return
}
zerolog.Ctx(aw.appCtx).Debug().
Str("station", station.Name).
Msg("proxied weather update to awn")
}
if station.ProxyToWunderground {
err := aw.wuProvider.ProxyReq(ctx, update)
if err != nil {
zerolog.Ctx(aw.appCtx).Err(err).Msg("failed to proxy to ambient weather")
return
}
zerolog.Ctx(aw.appCtx).Debug().
Str("station", station.Name).
Msg("proxied weather update to wunderground")
}
}
}
func (aw *AmbientWeather) InitMetrics() {
if aw.config.MetricPrefix != "" {
weather.MetricPrefix = aw.config.MetricPrefix
}
aw.metrics = weather.MustInitMetrics(aw.appCtx)
}
func (aw *AmbientWeather) enrichStation(update *weather.WeatherUpdate) {
if update != nil && update.StationID != nil && *update.StationID != "" {
for _, station := range aw.config.WeatherStations {
if *update.StationID == station.AWNPassKey || *update.StationID == station.WundergroundID {
update.StationConfig = &station
}
}
}
}

View File

@ -1,9 +1,7 @@
package ambient
package config
import (
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/config"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather"
)
// This configuration includes all config from go-app/config.AppConfig
@ -17,17 +15,22 @@ type WeatherStation struct {
Name string `yaml:"name"` // Human Friendly Name (e.g. Back Yard Weather)
Equipment string `yaml:"equipment"` // Equipment Type (e.g. WS-5000)
// One of these is required, based on the type
// set in the "Custom" weather service on your
// console, weather station, or weather hub
WundergroundID string `yaml:"wundergroundID"`
AWNPassKey string `yaml:"awnPassKey"`
// Required if proxying to awn/wu is enabled
WundergroundID string `yaml:"wundergroundID"`
WundergroundPassword string `yaml:"wundergroundPassword"`
AWNPassKey string `yaml:"awnPassKey"`
// Proxy updates to AWN or Wunderground
ProxyToAWN bool `yaml:"proxyToAWN"`
ProxyToWunderground bool `yaml:"proxyToWunderground"`
// Unreliable / unwanted metrics by name of WeatherUpdate Field
// These fields are mapped from the provider fields
DiscardMetrics []weather.WeatherUpdateField `yaml:"discardMetrics"`
// will be excluded if present in discardMetrics
//
// If anything is present in keepMetrics, it is solely applied,
// ignoring discardMetrics.
//
// Check weather.WeatherUpdateField for options
KeepMetrics []string `yaml:"keepMetrics"`
DropMetrics []string `yaml:"dropMetrics"`
}

View File

@ -13,7 +13,18 @@ import (
type AWNProvider struct{}
const providerName = "awn"
const (
providerName = "awn"
awnURL = "http://ambientweather.net/data/report"
)
// Battery Sensors
const (
BattOutdoorSensor = "OutdoorSensor"
BattIndoorSensor = "IndoorSensor"
BattRainSensor = "RainSensor"
BattCO2Sensor = "CO2Sensor"
)
func (awn *AWNProvider) Name() string {
return providerName
@ -33,14 +44,18 @@ func (awn *AWNProvider) ReqToWeather(_ context.Context, r *http.Request) (
}
func MapAwnUpdate(awnUpdate *AmbientWeatherUpdate) *weather.WeatherUpdate {
updateTime, err := time.Parse(time.DateTime, awnUpdate.DateUTC)
if err != nil {
updateTime = time.Now()
updateTime := time.Now()
if awnUpdate.DateUTC != nil {
ut, err := time.Parse(time.DateTime, *awnUpdate.DateUTC)
if err == nil {
updateTime = ut
}
}
return &weather.WeatherUpdate{
StationType: awnUpdate.StationType,
DateUTC: &updateTime,
StationID: awnUpdate.PassKey,
StationType: awnUpdate.StationType,
TempOutdoorF: awnUpdate.TempF,
HumidityOudoor: awnUpdate.Humidity,
WindSpeedMPH: awnUpdate.WindSpeedMPH,
@ -59,19 +74,19 @@ func MapAwnUpdate(awnUpdate *AmbientWeatherUpdate) *weather.WeatherUpdate {
TotalRainIn: awnUpdate.TotalRainIn,
Batteries: []weather.BatteryStatus{
{
Component: "OutdoorSensor",
Component: BattOutdoorSensor,
Status: awnUpdate.BattOut,
},
{
Component: "IndoorSensor",
Component: BattIndoorSensor,
Status: awnUpdate.BattIn,
},
{
Component: "RainSensor",
Component: BattRainSensor,
Status: awnUpdate.BattRain,
},
{
Component: "CO2Sensor",
Component: BattCO2Sensor,
Status: awnUpdate.BattCO2,
},
},

98
pkg/provider/awn/proxy.go Normal file
View File

@ -0,0 +1,98 @@
package awn
import (
"context"
"errors"
"net/url"
"time"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"github.com/go-resty/resty/v2"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather"
)
// Attempts to proxy the weather station update to awn
// SAMPLE:
// {"PASSKEY":["ABF7E052BC7325A32300ACC89112AA91"],"baromabsin":["28.895"],
// "baromrelin":["29.876"],"battin":["1"],"battout":["1"],"battrain":["1"],
// "dailyrainin":["0.000"],"dateutc":["2025-01-11 22:07:57"],"eventrainin":["0.000"],
// "hourlyrainin":["0.000"],"humidity":["76"],"humidityin":["31"],"maxdailygust":["7.83"],
// "monthlyrainin":["0.000"],"solarradiation":["14.21"],"stationtype":["WeatherHub_V1.0.1"],
// "tempf":["29.48"],"tempinf":["66.20"],"totalrainin":["0.000"],"uv":["0"],
// "weeklyrainin":["0.000"],"winddir":["66"],"winddir_avg10m":["268"],"windgustmph":["2.68"],
// "windspeedmph":["0.00"],"yearlyrainin":["0.000"]}
func (awn *AWNProvider) ProxyReq(ctx context.Context, update *weather.WeatherUpdate) error {
tracer := otel.GetTracer(ctx, "awnProvider", "proxyReq")
ctx, span := tracer.Start(ctx, "proxyToAWN")
defer span.End()
if update.StationConfig.AWNPassKey == "" {
err := errors.New("no PASSKEY set in update")
span.RecordError(err)
return err
}
params := updateToAWNParams(update)
resp, err := resty.New().R().
SetContext(ctx).
SetQueryParamsFromValues(*params).
Get(awnURL)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
span.SetAttributes(
attribute.Int("statusCode", resp.StatusCode()),
attribute.String("body", string(resp.Body())),
)
return err
}
func updateToAWNParams(update *weather.WeatherUpdate) *url.Values {
params := &url.Values{}
params.Set("dateutc", time.Now().Format(time.DateTime))
weather.SetURLVal(params, "PASSKEY", &update.StationConfig.AWNPassKey)
weather.SetURLVal(params, "baromabsin", update.BaromAbsoluteIn)
weather.SetURLVal(params, "baromrelin", update.BaromRelativeIn)
weather.SetURLVal(params, "dailyrainin", update.DailyRainIn)
weather.SetURLVal(params, "weeklyrainin", update.WeeklyRainIn)
weather.SetURLVal(params, "eventrainin", update.EventRainIn)
weather.SetURLVal(params, "hourlyrainin", update.HourlyRainIn)
weather.SetURLVal(params, "monthlyrainin", update.MonthlyRainIn)
weather.SetURLVal(params, "yearlyrainin", update.YearlyRainIn)
weather.SetURLVal(params, "totalrainin", update.TotalRainIn)
weather.SetURLVal(params, "humidity", update.HumidityOudoor)
weather.SetURLVal(params, "humidityin", update.HumidityIndoor)
weather.SetURLVal(params, "solarradiation", update.SolarRadiation)
weather.SetURLVal(params, "uv", update.UV)
weather.SetURLVal(params, "stationtype", update.StationType)
weather.SetURLVal(params, "tempf", update.TempOutdoorF)
weather.SetURLVal(params, "tempinf", update.TempIndoorF)
weather.SetURLVal(params, "winddir", update.WindDir)
weather.SetURLVal(params, "winddir_avg10m", update.WindDirAvg10m)
weather.SetURLVal(params, "windgustmph", update.WindGustMPH)
weather.SetURLVal(params, "windspeedmph", update.WindSpeedMPH)
weather.SetURLVal(params, "maxdailygust", update.MaxDailyGust)
// Batteries
for _, status := range update.Batteries {
switch status.Component {
case BattOutdoorSensor:
weather.SetURLVal(params, "battin", status.Status)
case BattIndoorSensor:
weather.SetURLVal(params, "battout", status.Status)
case BattRainSensor:
weather.SetURLVal(params, "battrain", status.Status)
case BattCO2Sensor:
weather.SetURLVal(params, "batt_co2", status.Status)
}
}
return params
}

View File

@ -1,31 +1,31 @@
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 float64 `json:"tempf,omitempty" schema:"tempf"`
Humidity int `json:"humidity,omitempty" schema:"humidity"`
WindSpeedMPH float64 `json:"windspeedmph,omitempty" schema:"windspeedmph"`
WindGustMPH float64 `json:"windgustmph,omitempty" schema:"windgustmph"`
MaxDailyGust float64 `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 float64 `json:"solarradiation,omitempty" schema:"solarradiation"`
HourlyRainIn float64 `json:"hourlyrainin,omitempty" schema:"hourlyrainin"`
EventRainIn float64 `json:"eventrainin,omitempty" schema:"eventrainin"`
DailyRainIn float64 `json:"dailyrainin,omitempty" schema:"dailyrainin"`
WeeklyRainIn float64 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"`
MonthlyRainIn float64 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"`
YearlyRainIn float64 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"`
TotalRainIn float64 `json:"totalrainin,omitempty" schema:"totalrainin"`
BattOut int `json:"battout,omitempty" schema:"battout"`
BattRain int `json:"battrain,omitempty" schema:"battrain"`
TempInF float64 `json:"tempinf,omitempty" schema:"tempinf"`
HumidityIn int `json:"humidityin,omitempty" schema:"humidityin"`
BaromRelIn float64 `json:"baromrelin,omitempty" schema:"baromrelin"`
BaromAbsIn float64 `json:"baromabsin,omitempty" schema:"baromabsin"`
BattIn int `json:"battin,omitempty" schema:"battin"`
BattCO2 int `json:"batt_co2,omitempty" schema:"batt_co2"`
PassKey *string `json:"PASSKEY,omitempty" schema:"PASSKEY"`
StationType *string `json:"stationtype,omitempty" schema:"stationtype"`
DateUTC *string `json:"dateutc,omitempty" schema:"dateutc"`
TempF *float64 `json:"tempf,omitempty" schema:"tempf"`
Humidity *int `json:"humidity,omitempty" schema:"humidity"`
WindSpeedMPH *float64 `json:"windspeedmph,omitempty" schema:"windspeedmph"`
WindGustMPH *float64 `json:"windgustmph,omitempty" schema:"windgustmph"`
MaxDailyGust *float64 `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 *float64 `json:"solarradiation,omitempty" schema:"solarradiation"`
HourlyRainIn *float64 `json:"hourlyrainin,omitempty" schema:"hourlyrainin"`
EventRainIn *float64 `json:"eventrainin,omitempty" schema:"eventrainin"`
DailyRainIn *float64 `json:"dailyrainin,omitempty" schema:"dailyrainin"`
WeeklyRainIn *float64 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"`
MonthlyRainIn *float64 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"`
YearlyRainIn *float64 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"`
TotalRainIn *float64 `json:"totalrainin,omitempty" schema:"totalrainin"`
BattOut *int `json:"battout,omitempty" schema:"battout"`
BattRain *int `json:"battrain,omitempty" schema:"battrain"`
TempInF *float64 `json:"tempinf,omitempty" schema:"tempinf"`
HumidityIn *int `json:"humidityin,omitempty" schema:"humidityin"`
BaromRelIn *float64 `json:"baromrelin,omitempty" schema:"baromrelin"`
BaromAbsIn *float64 `json:"baromabsin,omitempty" schema:"baromabsin"`
BattIn *int `json:"battin,omitempty" schema:"battin"`
BattCO2 *int `json:"batt_co2,omitempty" schema:"batt_co2"`
}

View File

@ -11,5 +11,6 @@ import (
// Ambient Weather Network HTTP requests to a stable struct
type AmbientProvider interface {
ReqToWeather(context.Context, *http.Request) (*weather.WeatherUpdate, error)
ProxyReq(context.Context, *weather.WeatherUpdate) error
Name() string
}

View File

@ -13,7 +13,10 @@ import (
type WUProvider struct{}
const providerName = "weatherunderground"
const (
providerName = "weatherunderground"
wuURL = "https://rtupdate.wunderground.com/weatherstation/updateweatherstation.php"
)
func (wu *WUProvider) Name() string {
return providerName
@ -33,16 +36,21 @@ func (wu *WUProvider) ReqToWeather(_ context.Context, r *http.Request) (
}
func MapWUUpdate(wuUpdate *WundergroundUpdate) *weather.WeatherUpdate {
updateTime, err := time.Parse(time.DateTime, wuUpdate.DateUTC)
if err != nil {
updateTime = time.Now()
updateTime := time.Now()
if wuUpdate.DateUTC != nil {
ut, err := time.Parse(time.DateTime, *wuUpdate.DateUTC)
if err == nil {
updateTime = ut
}
}
return &weather.WeatherUpdate{
StationType: wuUpdate.SoftwareType,
DateUTC: &updateTime,
TempOutdoorF: wuUpdate.Tempf,
HumidityOudoor: wuUpdate.Humidity,
StationID: wuUpdate.ID,
StationType: wuUpdate.SoftwareType,
TempOutdoorF: wuUpdate.Tempf,
HumidityOudoor: wuUpdate.Humidity,
WindSpeedMPH: wuUpdate.WindGustMPH,
WindGustMPH: wuUpdate.WindGustMPH,
WindDir: wuUpdate.WindDir,

View File

@ -0,0 +1,83 @@
package wunderground
import (
"context"
"errors"
"net/url"
"github.com/go-resty/resty/v2"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"k8s.io/utils/ptr"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather"
)
func (wu *WUProvider) ProxyReq(ctx context.Context, update *weather.WeatherUpdate) error {
tracer := otel.GetTracer(ctx, "wuProvider", "proxyReq")
ctx, span := tracer.Start(ctx, "proxyToWunderground")
defer span.End()
if update.StationConfig.WundergroundID == "" {
err := errors.New("no wunderground id set in update")
span.RecordError(err)
return err
} else if update.StationConfig.WundergroundPassword == "" {
err := errors.New("no wunderground id set in update")
span.RecordError(err)
return err
}
params := updateToWuParams(update)
resp, err := resty.New().R().
SetContext(ctx).
SetQueryParamsFromValues(*params).
Get(wuURL)
if err != nil {
span.SetStatus(codes.Error, err.Error())
span.SetAttributes(
attribute.String("query", resp.Request.QueryParam.Encode()),
)
span.RecordError(err)
}
span.SetAttributes(
attribute.Int("statusCode", resp.StatusCode()),
attribute.String("body", string(resp.Body())),
)
return err
}
func updateToWuParams(u *weather.WeatherUpdate) *url.Values {
params := &url.Values{}
weather.SetURLVal(params, "ID", &u.StationConfig.WundergroundID)
weather.SetURLVal(params, "PASSWORD", &u.StationConfig.WundergroundPassword)
params.Set("action", "updateraw")
params.Set("dateutc", "now")
weather.SetURLVal(params, "UV", u.UV)
weather.SetURLVal(params, "baromin", u.BaromRelativeIn)
weather.SetURLVal(params, "dailyrainin", u.DailyRainIn)
weather.SetURLVal(params, "dewptf", u.DewPointF)
weather.SetURLVal(params, "humidity", u.HumidityOudoor)
weather.SetURLVal(params, "indoorhumidity", u.HumidityIndoor)
weather.SetURLVal(params, "indoortempf", u.TempIndoorF)
weather.SetURLVal(params, "lowbatt", ptr.To(0))
weather.SetURLVal(params, "monthlyrainin", u.MonthlyRainIn)
weather.SetURLVal(params, "rainin", u.HourlyRainIn)
weather.SetURLVal(params, "realtime", ptr.To(1))
weather.SetURLVal(params, "rtfreq", ptr.To(60))
weather.SetURLVal(params, "softwaretype", u.StationType)
weather.SetURLVal(params, "solarradiation", u.SolarRadiation)
weather.SetURLVal(params, "tempf", u.TempOutdoorF)
weather.SetURLVal(params, "weeklyrainin", u.WeeklyRainIn)
weather.SetURLVal(params, "windchillf", u.WindChillF)
weather.SetURLVal(params, "winddir", u.WindDir)
weather.SetURLVal(params, "windgustmph", u.WindGustMPH)
weather.SetURLVal(params, "windspeedmph", u.WindSpeedMPH)
weather.SetURLVal(params, "yearlyrainin", u.YearlyRainIn)
return params
}

View File

@ -1,29 +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 float64 `json:"baromin,omitempty" schema:"baromin"`
DailyRainIn float64 `json:"dailyrainin,omitempty" schema:"dailyrainin"`
DateUTC string `json:"dateutc,omitempty" schema:"dateutc"`
DewPtF float64 `json:"dewptf,omitempty" schema:"dewptf"`
Humidity int `json:"humidity,omitempty" schema:"humidity"`
IndoorHumidity int `json:"indoorhumidity,omitempty" schema:"indoorhumidity"`
IndoorTempF float64 `json:"indoortempf,omitempty" schema:"indoortempf"`
LowBatt bool `json:"lowbatt,omitempty" schema:"lowbatt"`
MonthlyRainIn float64 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"`
RainIn float64 `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 float64 `json:"solarradiation,omitempty" schema:"solarradiation"`
Tempf float64 `json:"tempf,omitempty" schema:"tempf"`
WeeklyRainIn float64 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"`
WindChillF float64 `json:"windchillf,omitempty" schema:"windchillf"`
WindDir int `json:"winddir,omitempty" schema:"winddir"`
WindGustMPH float64 `json:"windgustmph,omitempty" schema:"windgustmph"`
WindSpeedMPH float64 `json:"windspeedmph,omitempty" schema:"windspeedmph"`
YearlyRainIn float64 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"`
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 *float64 `json:"baromin,omitempty" schema:"baromin"`
DailyRainIn *float64 `json:"dailyrainin,omitempty" schema:"dailyrainin"`
DateUTC *string `json:"dateutc,omitempty" schema:"dateutc"`
DewPtF *float64 `json:"dewptf,omitempty" schema:"dewptf"`
Humidity *int `json:"humidity,omitempty" schema:"humidity"`
IndoorHumidity *int `json:"indoorhumidity,omitempty" schema:"indoorhumidity"`
IndoorTempF *float64 `json:"indoortempf,omitempty" schema:"indoortempf"`
LowBatt *bool `json:"lowbatt,omitempty" schema:"lowbatt"`
MonthlyRainIn *float64 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"`
RainIn *float64 `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 *float64 `json:"solarradiation,omitempty" schema:"solarradiation"`
Tempf *float64 `json:"tempf,omitempty" schema:"tempf"`
WeeklyRainIn *float64 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"`
WindChillF *float64 `json:"windchillf,omitempty" schema:"windchillf"`
WindDir *int `json:"winddir,omitempty" schema:"winddir"`
WindGustMPH *float64 `json:"windgustmph,omitempty" schema:"windgustmph"`
WindSpeedMPH *float64 `json:"windspeedmph,omitempty" schema:"windspeedmph"`
YearlyRainIn *float64 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"`
}

View File

@ -1,23 +1,33 @@
package weather
import "math"
import (
"math"
"net/url"
"strconv"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/ambient/config"
)
// Attempts to complete missing fields that may not
// be set by a specific provider, such as DewPoint and WindChill
func (u *WeatherUpdate) Enrich() {
func (u *WeatherUpdate) Enrich(weatherStations ...*config.WeatherStation) {
if u == nil {
return
}
if u.WindChillF == 0 {
u.WindChillF = CalculateWindChill(u.TempOutdoorF, u.WindSpeedMPH)
if u.WindChillF == nil && u.TempOutdoorF != nil && u.WindSpeedMPH != nil {
wc := CalculateWindChill(*u.TempOutdoorF, *u.WindSpeedMPH)
u.WindChillF = &wc
}
if u.DewPointF == 0 && (u.TempOutdoorF != 0 && u.HumidityOudoor != 0) {
u.DewPointF = CalculateDewPoint(u.TempOutdoorF, float64(u.HumidityOudoor))
if u.DewPointF == nil && (u.TempOutdoorF != nil && u.HumidityOudoor != nil) {
if *u.TempOutdoorF != 0 || *u.HumidityOudoor != 0 {
dp := CalculateDewPoint(*u.TempOutdoorF, float64(*u.HumidityOudoor))
u.DewPointF = &dp
}
}
if u.BaromAbsoluteIn == 0 {
if u.BaromAbsoluteIn == nil && u.BaromRelativeIn != nil {
u.BaromAbsoluteIn = u.BaromRelativeIn
}
}
@ -49,3 +59,31 @@ func CalculateWindChill(tempF float64, windSpeedMPH float64) float64 {
35.75*math.Pow(windSpeedMPH, 0.16) +
0.4275*tempF*math.Pow(windSpeedMPH, 0.16)
}
// Helper function to set values from fields
// typically from a WeatherUpdate
func SetURLVal(vals *url.Values, key string, value any) {
if value == nil {
return
}
switch v := value.(type) {
case *float64:
if v == nil {
return
}
str := strconv.FormatFloat(*v, 'f', 4, 64)
vals.Set(key, str)
case *int:
if v == nil {
return
}
str := strconv.FormatInt(int64(*v), 10)
vals.Set(key, str)
case *string:
if v == nil {
return
}
vals.Set(key, *v)
}
}

View File

@ -5,6 +5,7 @@ import (
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/config"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
@ -41,14 +42,16 @@ type WeatherMetrics struct {
appCtx context.Context
cfg *config.AppConfig
meter metric.Meter
recorder *MetricRecorder
}
var MetricPrefix = "weather"
func MustInitMetrics(appCtx context.Context) *WeatherMetrics {
wm := &WeatherMetrics{
appCtx: appCtx,
cfg: config.MustFromCtx(appCtx),
appCtx: appCtx,
cfg: config.MustFromCtx(appCtx),
recorder: &MetricRecorder{ctx: appCtx, l: zerolog.Ctx(appCtx)},
}
wm.meter = otel.GetMeter(appCtx, "weather", "metrics")
@ -109,40 +112,53 @@ func MustInitMetrics(appCtx context.Context) *WeatherMetrics {
}
func (wm *WeatherMetrics) Update(u *WeatherUpdate) {
attributes := attribute.NewSet(
attributes := []attribute.KeyValue{
semconv.ServiceVersion(wm.cfg.Version),
attribute.String("station_type", u.StationType),
)
}
if u.StationType != nil {
attributes = append(attributes,
attribute.String("station_type", *u.StationType))
}
if u.StationConfig != nil {
if u.StationConfig.Name != "" {
attributes = append(attributes,
attribute.String("station_name", u.StationConfig.Name))
}
if u.StationConfig.Equipment != "" {
attributes = append(attributes,
attribute.String("station_equipment", u.StationConfig.Equipment))
}
}
wm.TempOutdoorF.Record(wm.appCtx, u.TempOutdoorF, metric.WithAttributeSet(attributes))
wm.TempIndoorF.Record(wm.appCtx, u.TempIndoorF, metric.WithAttributeSet(attributes))
wm.HumidityOudoor.Record(wm.appCtx, int64(u.HumidityOudoor), metric.WithAttributeSet(attributes))
wm.HumidityIndoor.Record(wm.appCtx, int64(u.HumidityIndoor), metric.WithAttributeSet(attributes))
wm.WindSpeedMPH.Record(wm.appCtx, u.WindSpeedMPH, metric.WithAttributeSet(attributes))
wm.WindGustMPH.Record(wm.appCtx, u.WindGustMPH, metric.WithAttributeSet(attributes))
wm.MaxDailyGust.Record(wm.appCtx, u.MaxDailyGust, metric.WithAttributeSet(attributes))
wm.WindDir.Record(wm.appCtx, int64(u.WindDir), metric.WithAttributeSet(attributes))
wm.WindDirAvg10m.Record(wm.appCtx, int64(u.WindDirAvg10m), metric.WithAttributeSet(attributes))
wm.UV.Record(wm.appCtx, int64(u.UV), metric.WithAttributeSet(attributes))
wm.SolarRadiation.Record(wm.appCtx, u.SolarRadiation, metric.WithAttributeSet(attributes))
wm.HourlyRainIn.Record(wm.appCtx, u.HourlyRainIn, metric.WithAttributeSet(attributes))
wm.EventRainIn.Record(wm.appCtx, u.EventRainIn, metric.WithAttributeSet(attributes))
wm.DailyRainIn.Record(wm.appCtx, u.DailyRainIn, metric.WithAttributeSet(attributes))
wm.WeeklyRainIn.Record(wm.appCtx, u.WeeklyRainIn, metric.WithAttributeSet(attributes))
wm.MonthlyRainIn.Record(wm.appCtx, u.MonthlyRainIn, metric.WithAttributeSet(attributes))
wm.YearlyRainIn.Record(wm.appCtx, u.YearlyRainIn, metric.WithAttributeSet(attributes))
wm.TotalRainIn.Record(wm.appCtx, u.TotalRainIn, metric.WithAttributeSet(attributes))
wm.BaromRelativeIn.Record(wm.appCtx, u.BaromRelativeIn, metric.WithAttributeSet(attributes))
wm.BaromAbsoluteIn.Record(wm.appCtx, u.BaromAbsoluteIn, metric.WithAttributeSet(attributes))
wm.DewPointF.Record(wm.appCtx, u.DewPointF, metric.WithAttributeSet(attributes))
wm.WindChillF.Record(wm.appCtx, u.WindChillF, metric.WithAttributeSet(attributes))
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.TempOutdoorF, FloatVal: u.TempOutdoorF, Field: FieldTempOutdoorF, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.TempIndoorF, FloatVal: u.TempIndoorF, Field: FieldTempIndoorF, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.HumidityOudoor, IntVal: u.HumidityOudoor, Field: FieldHumidityOudoor, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.HumidityIndoor, IntVal: u.HumidityIndoor, Field: FieldHumidityIndoor, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.WindSpeedMPH, FloatVal: u.WindSpeedMPH, Field: FieldWindSpeedMPH, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.WindGustMPH, FloatVal: u.WindGustMPH, Field: FieldWindGustMPH, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.MaxDailyGust, FloatVal: u.MaxDailyGust, Field: FieldMaxDailyGust, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.WindDir, IntVal: u.WindDir, Field: FieldWindDir, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.WindDirAvg10m, IntVal: u.WindDirAvg10m, Field: FieldWindDirAvg10m, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.UV, IntVal: u.UV, Field: FieldUV, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.SolarRadiation, FloatVal: u.SolarRadiation, Field: FieldSolarRadiation, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.HourlyRainIn, FloatVal: u.HourlyRainIn, Field: FieldHourlyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.EventRainIn, FloatVal: u.EventRainIn, Field: FieldEventRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.DailyRainIn, FloatVal: u.DailyRainIn, Field: FieldDailyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.WeeklyRainIn, FloatVal: u.WeeklyRainIn, Field: FieldWeeklyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.MonthlyRainIn, FloatVal: u.MonthlyRainIn, Field: FieldMonthlyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.YearlyRainIn, FloatVal: u.YearlyRainIn, Field: FieldYearlyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.TotalRainIn, FloatVal: u.TotalRainIn, Field: FieldTotalRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.BaromRelativeIn, FloatVal: u.BaromRelativeIn, Field: FieldBaromRelativeIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.BaromAbsoluteIn, FloatVal: u.BaromAbsoluteIn, Field: FieldBaromAbsoluteIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.DewPointF, FloatVal: u.DewPointF, Field: FieldDewPointF, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.WindChillF, FloatVal: u.WindChillF, Field: FieldWindChillF, Attributes: attributes, Station: u.StationConfig})
// Batteries
for _, battery := range u.Batteries {
wm.BatteryStatus.Record(wm.appCtx, int64(battery.Status),
metric.WithAttributeSet(attributes),
metric.WithAttributes(attribute.String("component", battery.Component)),
)
batAttr := attributes
batAttr = append(batAttr, attribute.String("component", battery.Component))
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.BatteryStatus, IntVal: battery.Status, Field: FieldBatteries, Attributes: batAttr, Station: u.StationConfig})
}
wm.UpdatesReceived.Add(wm.appCtx, 1)

View File

@ -0,0 +1,106 @@
package weather
import (
"context"
"errors"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/ambient/config"
)
type MetricRecorder struct {
ctx context.Context
l *zerolog.Logger
}
type RecordOpts struct {
Float64Gauge metric.Float64Gauge
Int64Gauge metric.Int64Gauge
IntVal *int
FloatVal *float64
Attributes []attribute.KeyValue
Field string
Station *config.WeatherStation
}
func (r *MetricRecorder) Record(opts *RecordOpts) {
if opts.Station != nil && !opts.keep() {
r.l.Trace().
Str("field", string(opts.Field)).
Str("station", opts.Station.Name).
Msg("Metric dropped by station config")
return
} else if opts.Int64Gauge == nil && opts.Float64Gauge == nil {
r.l.Err(errors.New("neither int nor float gauge provided")).Send()
return
}
if opts.Int64Gauge != nil {
if opts.IntVal == nil {
log := r.l.Trace().Str("field", string(opts.Field))
if opts.Station != nil {
log = log.Str("station", opts.Station.Name)
}
log.Msg("Dropping nil int metric")
return
}
r.recordInt(opts.Int64Gauge, *opts.IntVal, opts.Attributes...)
} else if opts.Float64Gauge != nil {
if opts.FloatVal == nil {
log := r.l.Trace().Str("field", string(opts.Field))
if opts.Station != nil {
log = log.Str("station", opts.Station.Name)
}
log.Msg("Dropping nil float metric")
return
}
r.recordFloat(opts.Float64Gauge, *opts.FloatVal, opts.Attributes...)
}
}
func (o *RecordOpts) keep() bool {
// If keep fields are given, only check keep fields
if len(o.Station.KeepMetrics) > 0 {
for _, f := range o.Station.KeepMetrics {
if f == o.Field {
return true
}
}
return false
}
for _, f := range o.Station.DropMetrics {
if f == o.Field {
return false
}
}
return true
}
func (r *MetricRecorder) recordInt(
m metric.Int64Gauge, value int, attributes ...attribute.KeyValue,
) {
// Prepare metric attributes
options := make([]metric.RecordOption, 0, len(attributes))
if len(attributes) > 0 {
options = append(options, metric.WithAttributes(attributes...))
}
val := int64(value)
m.Record(r.ctx, val, options...)
}
func (r *MetricRecorder) recordFloat(
m metric.Float64Gauge, value float64, attributes ...attribute.KeyValue,
) {
// Prepare metric attributes
options := make([]metric.RecordOption, 0, len(attributes))
if len(attributes) > 0 {
options = append(options, metric.WithAttributes(attributes...))
}
m.Record(r.ctx, value, options...)
}

View File

@ -2,73 +2,85 @@ package weather
import (
"time"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/ambient/config"
)
// Stable intermediate struct containing superset of fields
// between AWN and Wunderground style updates from Ambient devices
type WeatherUpdate struct {
DateUTC *time.Time
StationType string
TempOutdoorF float64
TempIndoorF float64
HumidityOudoor int
HumidityIndoor int
WindSpeedMPH float64
WindGustMPH float64
MaxDailyGust float64
WindDir int
WindDirAvg10m int
UV int
SolarRadiation float64
HourlyRainIn float64
EventRainIn float64
DailyRainIn float64
WeeklyRainIn float64
MonthlyRainIn float64
YearlyRainIn float64
TotalRainIn float64
StationConfig *config.WeatherStation
StationID *string
StationType *string
TempOutdoorF *float64
TempIndoorF *float64
HumidityOudoor *int
HumidityIndoor *int
WindSpeedMPH *float64
WindGustMPH *float64
MaxDailyGust *float64
WindDir *int
WindDirAvg10m *int
UV *int
SolarRadiation *float64
HourlyRainIn *float64
EventRainIn *float64
DailyRainIn *float64
WeeklyRainIn *float64
MonthlyRainIn *float64
YearlyRainIn *float64
TotalRainIn *float64
Batteries []BatteryStatus
BaromRelativeIn float64
BaromAbsoluteIn float64
BaromRelativeIn *float64
BaromAbsoluteIn *float64
// These fields may be calculated
// if not otherwise set
DewPointF float64
WindChillF float64
DewPointF *float64
WindChillF *float64
// First URL parameters given to AWN/Wunderground
// if proxying is enabled
}
type BatteryStatus struct {
Component string
Status int
Status *int
}
type WeatherUpdateField string
// NOTE: Annoyance to avoid string constant comparisons
// CHORE: Maintain this
// CHORE: Maintain this, used to check against
// keep and drop lists
// TODO: Use refelct/ast to generate code
const (
FieldDateUTC WeatherUpdateField = "DateUTC"
FieldStationType WeatherUpdateField = "StationType"
FieldTempOutdoorF WeatherUpdateField = "TempOutdoorF"
FieldTempIndoorF WeatherUpdateField = "TempIndoorF"
FieldHumidityOudoor WeatherUpdateField = "HumidityOudoor"
FieldHumidityIndoor WeatherUpdateField = "HumidityIndoor"
FieldWindSpeedMPH WeatherUpdateField = "WindSpeedMPH"
FieldWindGustMPH WeatherUpdateField = "WindGustMPH"
FieldMaxDailyGust WeatherUpdateField = "MaxDailyGust"
FieldWindDir WeatherUpdateField = "WindDir"
FieldWindDirAvg10m WeatherUpdateField = "WindDirAvg10m"
FieldUV WeatherUpdateField = "UV"
FieldSolarRadiation WeatherUpdateField = "SolarRadiation"
FieldHourlyRainIn WeatherUpdateField = "HourlyRainIn"
FieldEventRainIn WeatherUpdateField = "EventRainIn"
FieldDailyRainIn WeatherUpdateField = "DailyRainIn"
FieldWeeklyRainIn WeatherUpdateField = "WeeklyRainIn"
FieldMonthlyRainIn WeatherUpdateField = "MonthlyRainIn"
FieldYearlyRainIn WeatherUpdateField = "YearlyRainIn"
FieldTotalRainIn WeatherUpdateField = "TotalRainIn"
FieldBatteries WeatherUpdateField = "Batteries"
FieldBaromRelativeIn WeatherUpdateField = "BaromRelativeIn"
FieldBaromAbsoluteIn WeatherUpdateField = "BaromAbsoluteIn"
FieldDewPointF WeatherUpdateField = "DewPointF"
FieldWindChillF WeatherUpdateField = "WindChillF"
FieldDateUTC = "DateUTC"
FieldStationType = "StationType"
FieldTempOutdoorF = "TempOutdoorF"
FieldTempIndoorF = "TempIndoorF"
FieldHumidityOudoor = "HumidityOudoor"
FieldHumidityIndoor = "HumidityIndoor"
FieldWindSpeedMPH = "WindSpeedMPH"
FieldWindGustMPH = "WindGustMPH"
FieldMaxDailyGust = "MaxDailyGust"
FieldWindDir = "WindDir"
FieldWindDirAvg10m = "WindDirAvg10m"
FieldUV = "UV"
FieldSolarRadiation = "SolarRadiation"
FieldHourlyRainIn = "HourlyRainIn"
FieldEventRainIn = "EventRainIn"
FieldDailyRainIn = "DailyRainIn"
FieldWeeklyRainIn = "WeeklyRainIn"
FieldMonthlyRainIn = "MonthlyRainIn"
FieldYearlyRainIn = "YearlyRainIn"
FieldTotalRainIn = "TotalRainIn"
FieldBatteries = "Batteries"
FieldBaromRelativeIn = "BaromRelativeIn"
FieldBaromAbsoluteIn = "BaromAbsoluteIn"
FieldDewPointF = "DewPointF"
FieldWindChillF = "WindChillF"
)
func (u *WeatherUpdate) GetStationName() string {
if u.StationConfig != nil {
return u.StationConfig.Name
}
return ""
}