diff --git a/Source/Clima_Demo/Commands/RestartCommand.cs b/Source/Clima_Demo/Commands/RestartCommand.cs new file mode 100644 index 0000000..c89dfe8 --- /dev/null +++ b/Source/Clima_Demo/Commands/RestartCommand.cs @@ -0,0 +1,30 @@ +using Meadow; +using Meadow.Cloud; +using System.Threading; + +namespace Clima_Demo.Commands; + +/// +/// Restart Clima +/// +public class RestartCommand : IMeadowCommand +{ + /// + /// Delay to restart + /// + public int Delay { get; set; } + + /// + /// Initialise the command and register handler. + /// + public static void Initialise() + { + Resolver.CommandService.Subscribe(command => + { + Resolver.Log.Info($"RestartCommand: Meadow will restart in {command.Delay} seconds."); + Thread.Sleep(command.Delay * 1000); + Resolver.Device.PlatformOS.Reset(); + }); + + } +} \ No newline at end of file diff --git a/Source/Clima_Demo/MeadowApp.cs b/Source/Clima_Demo/MeadowApp.cs index b7794fa..7b4652a 100644 --- a/Source/Clima_Demo/MeadowApp.cs +++ b/Source/Clima_Demo/MeadowApp.cs @@ -1,4 +1,5 @@ -using Meadow; +using Clima_Demo.Commands; +using Meadow; using Meadow.Devices; using Meadow.Devices.Esp32.MessagePayloads; using Meadow.Hardware; @@ -31,6 +32,54 @@ public override Task Initialize() return Task.CompletedTask; } + public override Task Run() + { + var svc = Resolver.UpdateService; + + // Uncomment to clear any persisted update info. This allows installing + // the same update multiple times, such as you might do during development. + // svc.ClearUpdates(); + + svc.StateChanged += (sender, updateState) => + { + Resolver.Log.Info($"UpdateState {updateState}"); + if (updateState == UpdateState.DownloadingFile) + { + mainController?.StopUpdating(); + } + }; + + svc.RetrieveProgress += (updateService, info) => + { + short percentage = (short)((double)info.DownloadProgress / info.FileSize * 100); + + Resolver.Log.Info($"Downloading... {percentage}%"); + }; + + svc.UpdateAvailable += async (updateService, info) => + { + Resolver.Log.Info($"Update available!"); + + // Queue update for retrieval "later" + await Task.Delay(5000); + + updateService.RetrieveUpdate(info); + }; + + svc.UpdateRetrieved += async (updateService, info) => + { + Resolver.Log.Info($"Update retrieved!"); + + await Task.Delay(5000); + + updateService.ApplyUpdate(info); + }; + + RestartCommand.Initialise(); + + return Task.CompletedTask; + } + private void OnMeadowSystemError(MeadowSystemErrorInfo error, bool recommendReset, out bool forceReset) { if (error is Esp32SystemErrorInfo espError) diff --git a/Source/Clima_Demo/app.config.yaml b/Source/Clima_Demo/app.config.yaml index 55fbb50..633dfcd 100644 --- a/Source/Clima_Demo/app.config.yaml +++ b/Source/Clima_Demo/app.config.yaml @@ -23,10 +23,15 @@ MeadowCloud: Enabled: true # Enable Over-the-air Updates -# EnableUpdates: false + EnableUpdates: true # Enable Health Metrics EnableHealthMetrics: true # How often to send metrics to Meadow.Cloud - HealthMetricsIntervalMinutes: 60 + HealthMetricsIntervalMinutes: 5 + + +# Settings for Clima +Clima: + OffsetToNorth: 0.0 \ No newline at end of file diff --git a/Source/Clima_Demo/meadow.config.yaml b/Source/Clima_Demo/meadow.config.yaml index e1b04ee..dd369f3 100644 --- a/Source/Clima_Demo/meadow.config.yaml +++ b/Source/Clima_Demo/meadow.config.yaml @@ -16,7 +16,7 @@ Coprocessor: # Should the ESP32 automatically attempt to connect to an access point at startup? # If set to true, wifi.yaml credentials must be stored in the device. - AutomaticallyStartNetwork: true + AutomaticallyStartNetwork: false # Should the ESP32 automatically reconnect to the configured access point? AutomaticallyReconnect: true diff --git a/Source/Meadow.Clima/Controllers/CloudController.cs b/Source/Meadow.Clima/Controllers/CloudController.cs index 18d41ca..945bc39 100644 --- a/Source/Meadow.Clima/Controllers/CloudController.cs +++ b/Source/Meadow.Clima/Controllers/CloudController.cs @@ -2,6 +2,7 @@ using Meadow.Devices.Clima.Constants; using Meadow.Devices.Clima.Models; using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -47,21 +48,19 @@ public void LogAppStartup(string hardwareRevision) /// Logs the device information including name and location. /// /// The name of the device. - /// The latitude of the device location. + /// The latitude of the device location. /// The longitude of the device location. - public void LogDeviceInfo(string deviceName, double latitiude, double longitude) + public void LogDeviceInfo(string deviceName, double latitude, double longitude) { - var cloudEvent = new CloudEvent + Resolver.Log.Info("LogDeviceInfo: Create CloudEvent"); + CloudEvent cloudEvent = new CloudEvent { Description = "Clima Position Telemetry", Timestamp = DateTime.UtcNow, EventId = 109, + Measurements = new Dictionary { { "device_name", deviceName }, { "lat", latitude }, { "long", longitude } } }; - - cloudEvent.Measurements.Add("device_name", deviceName); - cloudEvent.Measurements.Add("lat", latitiude); - cloudEvent.Measurements.Add("long", longitude); - + SendEvent(cloudEvent); } diff --git a/Source/Meadow.Clima/Controllers/LocationController.cs b/Source/Meadow.Clima/Controllers/LocationController.cs index 9b93946..8b82911 100644 --- a/Source/Meadow.Clima/Controllers/LocationController.cs +++ b/Source/Meadow.Clima/Controllers/LocationController.cs @@ -1,6 +1,8 @@ using Meadow.Devices.Clima.Hardware; using Meadow.Peripherals.Sensors.Location.Gnss; +using Meadow.Units; using System; +using System.Threading; namespace Meadow.Devices.Clima.Controllers; @@ -21,6 +23,8 @@ public class LocationController /// public event EventHandler? PositionReceived = null; + private ManualResetEvent positionReceived = new ManualResetEvent(false); + /// /// Initializes a new instance of the class. /// @@ -31,21 +35,66 @@ public LocationController(IClimaHardware clima) { this.gnss = gnss; this.gnss.GnssDataReceived += OnGnssDataReceived; - this.gnss.StartUpdating(); } } + /// + /// Gets the current geographic position as a . + /// + /// + /// The geographic position, including latitude, longitude, and altitude, if available. + /// + /// + /// This property is updated when valid GNSS data is received. It represents the last known position + /// and remains unchanged until new valid data is processed. + /// + public GeographicCoordinate? Position { get; private set; } = default; + private void OnGnssDataReceived(object sender, IGnssResult e) { if (e is GnssPositionInfo pi) { if (pi.IsValid && pi.Position != null) { + // remember our position + Position = pi.Position; // we only need one position fix - weather stations don't move Resolver.Log.InfoIf(LogData, $"GNSS Position: lat: [{pi.Position.Latitude}], long: [{pi.Position.Longitude}]"); + positionReceived.Set(); PositionReceived?.Invoke(this, pi); - gnss?.StopUpdating(); + StopUpdating(); } } } -} \ No newline at end of file + + /// + /// Starts the GNSS sensor to begin updating location data. + /// + /// + /// This method invokes the method on the associated GNSS sensor, + /// if it is available, to start receiving GNSS data updates. + /// + public void StartUpdating(bool forced = false) + { + // start updating if forced to find new data or we don;t have current location + if (forced || !positionReceived.WaitOne(0)) + { + gnss?.StartUpdating(); + }; + } + + /// + /// Stops the GNSS sensor from updating location data. + /// + /// + /// This method halts the GNSS data updates by invoking the + /// method on the associated GNSS sensor, if it is available. + /// + public void StopUpdating() + { + // stop listening to data arriving from GNSS + gnss?.StopUpdating(); + + // TODO: can we tell GNSS sensor to stop calculating GPS location and stop sending data to reduce power consumption? + } +} diff --git a/Source/Meadow.Clima/Controllers/NetworkController.cs b/Source/Meadow.Clima/Controllers/NetworkController.cs index 4a1ffe4..530421d 100644 --- a/Source/Meadow.Clima/Controllers/NetworkController.cs +++ b/Source/Meadow.Clima/Controllers/NetworkController.cs @@ -1,4 +1,5 @@ using Meadow.Hardware; +using Meadow.Logging; using System; using System.Threading; using System.Threading.Tasks; @@ -27,17 +28,35 @@ public class NetworkController /// /// Gets a value indicating whether the network is connected. /// - public bool IsConnected { get; private set; } + public bool IsConnected => networkAdapter.IsConnected; /// /// Gets the total time the network has been down. /// - public TimeSpan DownTime { get; private set; } + public TimeSpan DownTime => lastDown == null ? TimeSpan.Zero : DateTime.UtcNow - lastDown.Value; /// /// Gets the period for triggering network down events. /// - public TimeSpan DownEventPeriod { get; private set; } + public TimeSpan DownEventPeriod { get; } = TimeSpan.FromSeconds(30); + + + /// + /// Port used for UdpLogging. + /// + /// + /// Default set in constructor is port 5100 + /// + private int UdpLoggingPort { get; set; } + + /// + /// Instance of UdpLogger. Use to remove UdpLogger if the network disconnects + /// + private UdpLogger? UdpLogger { get; set; } + + private string WifiSsid { get; set; } = "SSID"; + private string WifiPassword { get; set; } = "PASSWORD"; + /// /// Initializes a new instance of the class. @@ -73,8 +92,8 @@ public async Task ConnectToCloud() { if (!wifi.IsConnected) { - Resolver.Log.Info("Connecting to network..."); - await wifi.Connect("interwebs", "1234567890"); + Resolver.Log.Info($"Connecting to network: {WifiSsid}"); + await wifi.Connect(WifiSsid, WifiPassword); } } @@ -118,6 +137,14 @@ private void DownEventTimerProc(object _) private void OnNetworkDisconnected(INetworkAdapter sender, NetworkDisconnectionEventArgs args) { + // Remove the UdpLogger if it's in the LogProviderCollection. + if (UdpLogger != null) + { + Resolver.Log.RemoveProvider(UdpLogger); + UdpLogger.Dispose(); + UdpLogger = null; + } + lastDown = DateTimeOffset.UtcNow; downEventTimer.Change(DownEventPeriod, TimeSpan.FromMilliseconds(-1)); ConnectionStateChanged?.Invoke(this, false); @@ -150,6 +177,9 @@ private async Task ReportWiFiScan(IWiFiNetworkAdapter wifi) private void OnNetworkConnected(INetworkAdapter sender, NetworkConnectionEventArgs args) { + Resolver.Log.Info("Add UdpLogger"); + Resolver.Log.AddProvider(UdpLogger = new UdpLogger(UdpLoggingPort)); + if (sender is IWiFiNetworkAdapter wifi) { _ = ReportWiFiScan(wifi); diff --git a/Source/Meadow.Clima/Controllers/NotificationController.cs b/Source/Meadow.Clima/Controllers/NotificationController.cs index 3899520..050238e 100644 --- a/Source/Meadow.Clima/Controllers/NotificationController.cs +++ b/Source/Meadow.Clima/Controllers/NotificationController.cs @@ -81,6 +81,7 @@ public NotificationController(IRgbPwmLed? rgbLed) /// The system status to set. public void SetSystemStatus(SystemStatus status) { + Resolver.Log.Info($"SetSystemStatus = {status}"); switch (status) { case SystemStatus.LowPower: diff --git a/Source/Meadow.Clima/Controllers/PowerController.cs b/Source/Meadow.Clima/Controllers/PowerController.cs index a9a1770..826b053 100644 --- a/Source/Meadow.Clima/Controllers/PowerController.cs +++ b/Source/Meadow.Clima/Controllers/PowerController.cs @@ -35,7 +35,7 @@ public class PowerController /// /// Gets the interval at which power data is updated. /// - public TimeSpan UpdateInterval { get; } = TimeSpan.FromSeconds(5); + public TimeSpan UpdateInterval { get; private set; } = TimeSpan.FromSeconds(10); /// /// Gets the voltage level below which a battery warning is issued. @@ -59,12 +59,35 @@ public class PowerController public PowerController(IClimaHardware clima) { this.clima = clima; + } + + /// + /// Remove event handler and stop updating + /// + public void StopUpdating() + { + Resolver.Log.Info($"PowerController: Stop Updating"); + if (clima.SolarVoltageInput is { } solarVoltage) + { + solarVoltage.Updated -= SolarVoltageUpdated; + solarVoltage.StopUpdating(); + } - Initialize(); + if (clima.BatteryVoltageInput is { } batteryVoltage) + { + batteryVoltage.Updated -= BatteryVoltageUpdated; + batteryVoltage.StopUpdating(); + } } - private void Initialize() + /// + /// Add event handler and start updating + /// + /// + public void StartUpdating(TimeSpan powerControllerUpdateInterval) { + UpdateInterval = powerControllerUpdateInterval; + if (clima.SolarVoltageInput is { } solarVoltage) { solarVoltage.Updated += SolarVoltageUpdated; @@ -84,13 +107,11 @@ private void Initialize() /// A task that represents the asynchronous operation. The task result contains the power data. public Task GetPowerData() { - var data = new PowerData + return Task.FromResult(new PowerData { BatteryVoltage = clima.BatteryVoltageInput?.Voltage ?? null, SolarVoltage = clima.SolarVoltageInput?.Voltage ?? null, - }; - - return new Task(() => data); + }); } /// diff --git a/Source/Meadow.Clima/Controllers/SensorController.cs b/Source/Meadow.Clima/Controllers/SensorController.cs index ff52443..17b3b60 100644 --- a/Source/Meadow.Clima/Controllers/SensorController.cs +++ b/Source/Meadow.Clima/Controllers/SensorController.cs @@ -3,6 +3,7 @@ using Meadow.Units; using System; using System.Threading.Tasks; +using YamlDotNet.Core.Tokens; namespace Meadow.Devices.Clima.Controllers; @@ -11,7 +12,9 @@ namespace Meadow.Devices.Clima.Controllers; /// public class SensorController { + private readonly IClimaHardware clima; private readonly CircularBuffer windVaneBuffer = new CircularBuffer(12); + private readonly CircularBuffer windSpeedBuffer = new CircularBuffer(12); private readonly SensorData latestData; /// @@ -22,7 +25,7 @@ public class SensorController /// /// Gets the interval at which sensor data is updated. /// - public TimeSpan UpdateInterval { get; } = TimeSpan.FromSeconds(15); + public TimeSpan UpdateInterval { get; private set; } = TimeSpan.FromSeconds(15); /// /// Initializes a new instance of the class. @@ -31,6 +34,75 @@ public class SensorController public SensorController(IClimaHardware clima) { latestData = new SensorData(); + this.clima = clima; + + if (Resolver.App.Settings.TryGetValue("Clima.OffsetToNorth", out string offsetToNorthSetting)) + { + if (Double.TryParse(offsetToNorthSetting, out double trueNorth)) + { + OffsetToNorth = new Azimuth(trueNorth); + } + } + } + + /// + /// Stop the update events and remove event handler. + /// + public void StopUpdating() + { + if (clima.TemperatureSensor is { } temperatureSensor) + { + temperatureSensor.Updated -= TemperatureUpdated; + temperatureSensor.StopUpdating(); + } + if (clima.BarometricPressureSensor is { } pressureSensor) + { + pressureSensor.Updated -= PressureUpdated; + // barometric pressure is slow to change + pressureSensor.StopUpdating(); + } + + if (clima.HumiditySensor is { } humiditySensor) + { + humiditySensor.Updated -= HumidityUpdated; + // humidity is slow to change + humiditySensor.StopUpdating(); + } + + if (clima.CO2ConcentrationSensor is { } co2Sensor) + { + co2Sensor.Updated -= Co2Updated; + // CO2 levels are slow to change + co2Sensor.StopUpdating(); + } + + if (clima.WindVane is { } windVane) + { + windVane.Updated -= WindvaneUpdated; + windVane.StopUpdating(); + } + + if (clima.RainGauge is { } rainGuage) + { + rainGuage.Updated -= RainGaugeUpdated; + // rain does not change frequently + rainGuage.StopUpdating(); + } + + if (clima.Anemometer is { } anemometer) + { + anemometer.Updated -= AnemometerUpdated; + anemometer.StopUpdating(); + } + } + + /// + /// Add event handlers and start updating + /// + /// + public void StartUpdating(TimeSpan updateInterval) + { + UpdateInterval = updateInterval; if (clima.TemperatureSensor is { } temperatureSensor) { @@ -133,12 +205,22 @@ private void Co2Updated(object sender, IChangeResult e) private void AnemometerUpdated(object sender, IChangeResult e) { + Speed? mean = new Speed(0); lock (latestData) { - latestData.WindSpeed = e.New; + // sanity check on windspeed to avoid reporting Infinity + if (e.New.KilometersPerHour <= 250) + { + latestData.WindSpeed = e.New; + + windSpeedBuffer.Append(e.New); + latestData.WindSpeedAverage = mean = windSpeedBuffer.Mean(); + } } - Resolver.Log.InfoIf(LogSensorData, $"Anemometer: {e.New.MetersPerSecond:0.#} m/s"); + //Resolver.Log.InfoIf(true, $"Anemometer: {e.New.MetersPerSecond:0.#} m/s"); + Resolver.Log.InfoIf(LogSensorData, $"Anemometer: {e.New.KilometersPerHour:0.#} km/hr, Average = {mean?.KilometersPerHour:0.#} km/hr"); + } private void RainGaugeUpdated(object sender, IChangeResult e) @@ -151,15 +233,33 @@ private void RainGaugeUpdated(object sender, IChangeResult e) Resolver.Log.InfoIf(LogSensorData, $"Rain Gauge: {e.New.Millimeters:0.#} mm"); } + /// + /// 0 to 360 degree offset to true north updated from app.config.yaml + /// + /// + /// After install of Clima, point wind vane at true north, run Clima_Demo and record uncalibrated WindDirection. + /// Update app.config.yaml with the uncalibrated WindDirection. + /// Deploy the updated app.config.yaml and this direction will be reported as 0 Degrees. + /// + /// + /// # Settings for Clima + /// Clima: + /// OffsetToNorth: 0.0 + /// + public Azimuth OffsetToNorth { get; private set; } = new Azimuth(0.0); + private void WindvaneUpdated(object sender, IChangeResult e) { - windVaneBuffer.Append(e.New); + Azimuth newAzimuth = e.New - OffsetToNorth; + windVaneBuffer.Append(newAzimuth); + + Azimuth mean = windVaneBuffer.Mean(); lock (latestData) { - latestData.WindDirection = windVaneBuffer.Mean(); + latestData.WindDirection = mean; } - Resolver.Log.InfoIf(LogSensorData, $"Wind Vane: {e.New.DecimalDegrees} (mean: {windVaneBuffer.Mean().DecimalDegrees})"); + Resolver.Log.InfoIf(LogSensorData, $"Wind Vane: {newAzimuth}, Average: {mean.DecimalDegrees} (uncalibrated: {e.New})"); } } \ No newline at end of file diff --git a/Source/Meadow.Clima/MainController.cs b/Source/Meadow.Clima/MainController.cs index 642566e..ff0245e 100644 --- a/Source/Meadow.Clima/MainController.cs +++ b/Source/Meadow.Clima/MainController.cs @@ -50,14 +50,17 @@ public Task Initialize(IClimaHardware hardware, INetworkAdapter? networkAdapter) Resolver.Log.Info($"Running on Clima Hardware {hardware.RevisionString}"); sensorController = new SensorController(hardware); + sensorController.StartUpdating(sensorController.UpdateInterval); // TODO: consider calling after network, time etc are ready? powerController = new PowerController(hardware); + powerController.StartUpdating(powerController.UpdateInterval); // TODO: consider calling after network, time + powerController.SolarVoltageWarning += OnSolarVoltageWarning; powerController.BatteryVoltageWarning += OnBatteryVoltageWarning; - locationController = new LocationController(hardware); - locationController.PositionReceived += OnPositionReceived; + + if (networkAdapter == null) { @@ -76,6 +79,7 @@ public Task Initialize(IClimaHardware hardware, INetworkAdapter? networkAdapter) } else { + Resolver.Log.Info("Network is connected."); notificationController.SetSystemStatus(NotificationController.SystemStatus.NetworkConnected); if (Resolver.MeadowCloudService.ConnectionState == CloudConnectionState.Connecting) { @@ -87,6 +91,9 @@ public Task Initialize(IClimaHardware hardware, INetworkAdapter? networkAdapter) Resolver.MeadowCloudService.ConnectionStateChanged += OnMeadowCloudServiceConnectionStateChanged; cloudController.LogAppStartup(hardware.RevisionString); + locationController = new LocationController(hardware); + locationController.PositionReceived += OnPositionReceived; + Resolver.Device.PlatformOS.AfterWake += PlatformOS_AfterWake; if (!lowPowerMode) @@ -96,9 +103,34 @@ public Task Initialize(IClimaHardware hardware, INetworkAdapter? networkAdapter) _ = SystemPreSleepStateProc(); + Resolver.Log.Info($"Initialize hardware Task.CompletedTask"); + return Task.CompletedTask; } + /// + /// Start sensor and power controller updates + /// + public void StartUpdating() + { + Resolver.Log.Info($"Start Updating"); + sensorController.StartUpdating(sensorController.UpdateInterval); + powerController.StartUpdating(powerController.UpdateInterval); + locationController.StartUpdating(); + } + + /// + /// Stop sensor and power controller updates + /// + public void StopUpdating() + { + Resolver.Log.Info($"Stop Updating"); + sensorController.StopUpdating(); + powerController.StopUpdating(); + locationController.StopUpdating(); + sleepSimulationTimer.Change(-1, -1); // stop timer + } + private void OnPositionReceived(object sender, Peripherals.Sensors.Location.Gnss.GnssPositionInfo e) { if (e.Position != null) @@ -179,7 +211,15 @@ private void OnMeadowCloudServiceConnectionStateChanged(object sender, CloudConn { case CloudConnectionState.Connected: notificationController.SetSystemStatus(NotificationController.SystemStatus.Connected); + + locationController.StartUpdating(); break; + + case CloudConnectionState.Disconnected: + case CloudConnectionState.Unknown: + locationController.StopUpdating(); + break; + default: notificationController.SetSystemStatus(NotificationController.SystemStatus.ConnectingToCloud); break; @@ -285,4 +325,4 @@ public void LogAppStartupAfterCrash(IEnumerable crashReports) Resolver.Log.Info(report); } } -} \ No newline at end of file +} diff --git a/Source/Meadow.Clima/Meadow.Clima.csproj b/Source/Meadow.Clima/Meadow.Clima.csproj index 31623d8..230ceff 100644 --- a/Source/Meadow.Clima/Meadow.Clima.csproj +++ b/Source/Meadow.Clima/Meadow.Clima.csproj @@ -22,13 +22,14 @@ - - - - - - - - + + + + + + + + + diff --git a/Source/Meadow.Clima/Models/SensorData.cs b/Source/Meadow.Clima/Models/SensorData.cs index e67eec1..2b8c117 100644 --- a/Source/Meadow.Clima/Models/SensorData.cs +++ b/Source/Meadow.Clima/Models/SensorData.cs @@ -33,6 +33,11 @@ public class SensorData /// public Speed? WindSpeed { get; set; } + /// + /// Gets or sets the average wind speed. + /// + public Speed? WindSpeedAverage { get; set; } + /// /// Gets or sets the wind direction. /// @@ -53,10 +58,12 @@ public class SensorData /// public void Clear() { - Co2Level = null; Temperature = null; Pressure = null; + Humidity = null; + Co2Level = null; WindSpeed = null; + WindSpeedAverage = null; WindDirection = null; Rain = null; Light = null; @@ -69,10 +76,12 @@ public SensorData Copy() { return new SensorData { - Co2Level = Co2Level, Temperature = Temperature, Pressure = Pressure, + Humidity = Humidity, + Co2Level = Co2Level, WindSpeed = WindSpeed, + WindSpeedAverage = WindSpeedAverage, WindDirection = WindDirection, Rain = Rain, Light = Light, @@ -107,13 +116,17 @@ public Dictionary AsTelemetryDictionary() { d.Add(nameof(WindSpeed), WindSpeed.Value.KilometersPerHour); } + if (WindSpeedAverage != null) + { + d.Add(nameof(WindSpeedAverage), WindSpeedAverage.Value.KilometersPerHour); + } if (WindDirection != null) { d.Add(nameof(WindDirection), WindDirection.Value.DecimalDegrees); } if (Rain != null) { - d.Add(nameof(Rain), Rain.Value.Centimeters); + d.Add(nameof(Rain), Rain.Value.Millimeters); } if (Light != null) {