diff --git a/go.mod b/go.mod index 22aaa26..4241338 100644 --- a/go.mod +++ b/go.mod @@ -22,12 +22,12 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/klauspost/compress v1.18.0 // 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 + github.com/prometheus/client_golang v1.21.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect @@ -43,11 +43,11 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect - golang.org/x/net v0.35.0 // indirect + golang.org/x/net v0.36.0 // indirect golang.org/x/text v0.22.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250212204824-5a70512c5d8b // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect - google.golang.org/grpc v1.70.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 660f775..0a4aaa7 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,7 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= @@ -41,8 +42,12 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.0 h1:VD1gqscl4nYs1YxVuSdemTrSgTK github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.0/go.mod h1:4EgsQoS4TOhJizV+JTFg40qx1Ofh3XmXEQNBpgvNT40= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -63,6 +68,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= +github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= @@ -110,6 +117,8 @@ 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/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= +golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -127,12 +136,18 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250127172529-29210b9bc287 h1: google.golang.org/genproto/googleapis/api v0.0.0-20250127172529-29210b9bc287/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4= google.golang.org/genproto/googleapis/api v0.0.0-20250212204824-5a70512c5d8b h1:i+d0RZa8Hs2L/MuaOQYI+krthcxdEbEM2N+Tf3kJ4zk= google.golang.org/genproto/googleapis/api v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 h1:J1H9f+LEdWAfHcez/4cvaVBox7cOYT+IU6rgqj5x++8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk= google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k= google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= diff --git a/pkg/ambient/ambient.go b/pkg/ambient/ambient.go index a97be2b..4182f95 100644 --- a/pkg/ambient/ambient.go +++ b/pkg/ambient/ambient.go @@ -13,6 +13,7 @@ import ( "github.com/rs/zerolog" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/ambient/config" "gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/provider" @@ -67,6 +68,11 @@ func (aw *AmbientWeather) GetWundergroundHandlerFunc(appCtx context.Context) fun // 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 +// +// This will call Update on metrics, and will also proxy +// requests to AWN/Wunderground if enabled +// This is the main work performed when a weather station or +// weather hub sends an update func (aw *AmbientWeather) handleProviderRequest( p provider.AmbientProvider, w http.ResponseWriter, @@ -96,20 +102,17 @@ func (aw *AmbientWeather) handleProviderRequest( return } - // Calculate any fields that may be missing - // such as dew point and wind chill - update.Enrich() + // Perform enrichment + aw.enrichUpdate(ctx, p, update) - // Prepare metrics if this is the first update + // Update metrics + ctx, updateSpan := tracer.Start(ctx, p.Name()+".update.metrics") if aw.metrics == nil { aw.InitMetrics() } - - // Enrich station if configured - aw.enrichStation(update) - - // Update metrics aw.metrics.Update(update) + updateSpan.SetStatus(codes.Ok, "") + updateSpan.End() l.Debug(). Str("provider", p.Name()). @@ -120,44 +123,90 @@ func (aw *AmbientWeather) handleProviderRequest( // 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 { - // Perform proxy updates in parallel if enabled - var proxyWg sync.WaitGroup - - if station.ProxyToAWN { - proxyWg.Add(1) - go func() { - defer proxyWg.Done() - 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 { - proxyWg.Add(1) - go func() { - defer proxyWg.Done() - 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") - }() - } - - proxyWg.Wait() + if update.StationConfig != nil { + aw.proxyUpdate(ctx, p, update) } } +func (aw *AmbientWeather) enrichUpdate( + ctx context.Context, + p provider.AmbientProvider, + update *weather.WeatherUpdate, +) { + tracer := otel.GetTracer(aw.appCtx, p.Name()+".http.handler") + + // Calculate any fields that may be missing + // such as dew point and wind chill + ctx, enrichSpan := tracer.Start(ctx, p.Name()+".update.enrich") + update.Enrich() + + // Enrich station if configured + aw.enrichStation(update) + + // Map sensor names + update.MapSensors() + + enrichSpan.SetStatus(codes.Ok, "") + enrichSpan.End() +} + +func (aw *AmbientWeather) proxyUpdate( + ctx context.Context, + p provider.AmbientProvider, + update *weather.WeatherUpdate, +) { + var proxyWg sync.WaitGroup + + tracer := otel.GetTracer(aw.appCtx, p.Name()+".http.handler") + station := update.StationConfig + + ctx, proxySpan := tracer.Start(ctx, p.Name()+".update.proxy", trace.WithAttributes( + attribute.Bool("proxyToWunderground", station.ProxyToWunderground), + attribute.Bool("proxyToAWN", station.ProxyToAWN), + )) + defer proxySpan.End() + + // Perform proxy updates in parallel if enabled + + if station.ProxyToAWN { + proxyWg.Add(1) + go func() { + defer proxyWg.Done() + defer proxySpan.AddEvent("proxied to ambient weather network") + err := aw.awnProvider.ProxyReq(ctx, update) + if err != nil { + zerolog.Ctx(aw.appCtx).Err(err).Msg("failed to proxy to ambient weather") + proxySpan.RecordError(err) + proxySpan.SetStatus(codes.Error, err.Error()) + return + } + zerolog.Ctx(aw.appCtx).Debug(). + Str("station", station.Name). + Msg("proxied weather update to awn") + }() + } + + if station.ProxyToWunderground { + proxyWg.Add(1) + go func() { + defer proxyWg.Done() + defer proxySpan.AddEvent("proxied to wunderground") + err := aw.wuProvider.ProxyReq(ctx, update) + if err != nil { + zerolog.Ctx(aw.appCtx).Err(err).Msg("failed to proxy to ambient weather") + proxySpan.RecordError(err) + proxySpan.SetStatus(codes.Error, err.Error()) + return + } + zerolog.Ctx(aw.appCtx).Debug(). + Str("station", station.Name). + Msg("proxied weather update to wunderground") + }() + } + + proxyWg.Wait() +} + func (aw *AmbientWeather) InitMetrics() { if aw.config.MetricPrefix != "" { weather.MetricPrefix = aw.config.MetricPrefix diff --git a/pkg/ambient/config/config.go b/pkg/ambient/config/config.go index 4a2c372..f0bf6a9 100644 --- a/pkg/ambient/config/config.go +++ b/pkg/ambient/config/config.go @@ -33,4 +33,14 @@ type WeatherStation struct { // Check weather.WeatherUpdateField for options KeepMetrics []string `yaml:"keepMetrics"` DropMetrics []string `yaml:"dropMetrics"` + + // Relabels battery and sensor names + // Temp+Humidity Sensors: + // - TempHumiditySensor[1-8] + // Batteries: + // - IndoorSensor + // - OutdoorSensor + // - RainSensor + // - CO2Sensor + SensorMappings map[string]string `yaml:"sensorMappings"` } diff --git a/pkg/ambient/config/ws_map.go b/pkg/ambient/config/ws_map.go new file mode 100644 index 0000000..ff8cdc7 --- /dev/null +++ b/pkg/ambient/config/ws_map.go @@ -0,0 +1,12 @@ +package config + +// If the weather-station has a mapping, returns the new +// name for the sensor +func (ws *WeatherStation) MapSensor(sensor string) string { + for name, replacement := range ws.SensorMappings { + if name == sensor && replacement != "" { + return replacement + } + } + return sensor +} diff --git a/pkg/ambient/config/ws_map_test.go b/pkg/ambient/config/ws_map_test.go new file mode 100644 index 0000000..6d96272 --- /dev/null +++ b/pkg/ambient/config/ws_map_test.go @@ -0,0 +1,67 @@ +package config + +import "testing" + +func TestWeatherStation_MapSensor(t *testing.T) { + type fields struct { + Name string + Equipment string + WundergroundID string + WundergroundPassword string + AWNPassKey string + ProxyToAWN bool + ProxyToWunderground bool + KeepMetrics []string + DropMetrics []string + SensorMappings map[string]string + } + type args struct { + sensor string + } + tests := []struct { + name string + fields fields + args args + want string + }{ + { + name: "Check sensor mapping", + fields: fields{ + SensorMappings: map[string]string{ + "TempHumiditySensor1": "TestSensor", + }, + }, + args: args{sensor: "TempHumiditySensor1"}, + want: "TestSensor", + }, + { + name: "Check sensor no mapping", + fields: fields{ + SensorMappings: map[string]string{ + "TempHumiditySensor1": "TestSensor", + }, + }, + args: args{sensor: "TempHumiditySensor2"}, + want: "TempHumiditySensor2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ws := &WeatherStation{ + Name: tt.fields.Name, + Equipment: tt.fields.Equipment, + WundergroundID: tt.fields.WundergroundID, + WundergroundPassword: tt.fields.WundergroundPassword, + AWNPassKey: tt.fields.AWNPassKey, + ProxyToAWN: tt.fields.ProxyToAWN, + ProxyToWunderground: tt.fields.ProxyToWunderground, + KeepMetrics: tt.fields.KeepMetrics, + DropMetrics: tt.fields.DropMetrics, + SensorMappings: tt.fields.SensorMappings, + } + if got := ws.MapSensor(tt.args.sensor); got != tt.want { + t.Errorf("WeatherStation.MapSensor() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/provider/awn/provider.go b/pkg/provider/awn/provider.go index 0dd4236..dc8cd27 100644 --- a/pkg/provider/awn/provider.go +++ b/pkg/provider/awn/provider.go @@ -128,16 +128,15 @@ func MapAwnUpdate(awnUpdate *AmbientWeatherUpdate) *weather.WeatherUpdate { HumidityIndoor: awnUpdate.HumidityIn, BaromRelativeIn: awnUpdate.BaromRelIn, BaromAbsoluteIn: awnUpdate.BaromAbsIn, - // TODO: Permit mapping to config name TempHumiditySensors: []*weather.TempHumiditySensor{ - {Name: "Sensor1", TempF: awnUpdate.Temp1F, Humidity: awnUpdate.Humidity1}, - {Name: "Sensor2", TempF: awnUpdate.Temp2F, Humidity: awnUpdate.Humidity2}, - {Name: "Sensor3", TempF: awnUpdate.Temp3F, Humidity: awnUpdate.Humidity3}, - {Name: "Sensor4", TempF: awnUpdate.Temp4F, Humidity: awnUpdate.Humidity4}, - {Name: "Sensor5", TempF: awnUpdate.Temp5F, Humidity: awnUpdate.Humidity5}, - {Name: "Sensor6", TempF: awnUpdate.Temp6F, Humidity: awnUpdate.Humidity6}, - {Name: "Sensor7", TempF: awnUpdate.Temp7F, Humidity: awnUpdate.Humidity7}, - {Name: "Sensor8", TempF: awnUpdate.Temp8F, Humidity: awnUpdate.Humidity8}, + {Name: THSensor + "1", TempF: awnUpdate.Temp1F, Humidity: awnUpdate.Humidity1}, + {Name: THSensor + "2", TempF: awnUpdate.Temp2F, Humidity: awnUpdate.Humidity2}, + {Name: THSensor + "3", TempF: awnUpdate.Temp3F, Humidity: awnUpdate.Humidity3}, + {Name: THSensor + "4", TempF: awnUpdate.Temp4F, Humidity: awnUpdate.Humidity4}, + {Name: THSensor + "5", TempF: awnUpdate.Temp5F, Humidity: awnUpdate.Humidity5}, + {Name: THSensor + "6", TempF: awnUpdate.Temp6F, Humidity: awnUpdate.Humidity6}, + {Name: THSensor + "7", TempF: awnUpdate.Temp7F, Humidity: awnUpdate.Humidity7}, + {Name: THSensor + "8", TempF: awnUpdate.Temp8F, Humidity: awnUpdate.Humidity8}, }, } } diff --git a/pkg/weather/enrich.go b/pkg/weather/enrich.go index ce8feff..5cf3c4b 100644 --- a/pkg/weather/enrich.go +++ b/pkg/weather/enrich.go @@ -10,6 +10,7 @@ import ( // Attempts to complete missing fields that may not // be set by a specific provider, such as DewPoint and WindChill +// TODO: Add span func (u *WeatherUpdate) Enrich(weatherStations ...*config.WeatherStation) { if u == nil { return @@ -49,6 +50,24 @@ func (u *WeatherUpdate) Enrich(weatherStations ...*config.WeatherStation) { } } +// Swaps sensor and component names based on +// user provided configuration +func (u *WeatherUpdate) MapSensors() { + if u.StationConfig == nil { + return + } + + // Map sensor battery components + for i, batt := range u.Batteries { + u.Batteries[i].Component = u.StationConfig.MapSensor(batt.Component) + } + + // Map other sensors + for _, th := range u.TempHumiditySensors { + th.Name = u.StationConfig.MapSensor(th.Name) + } +} + func CalculateDewPoint(tempF, humidity float64) float64 { // Convert temperature from Fahrenheit to Celsius tempC := (tempF - 32) * 5 / 9