From e71d50f8f134b2ae744447597d228414f06e3a13 Mon Sep 17 00:00:00 2001 From: mplogas Date: Fri, 20 Sep 2024 19:32:47 +0200 Subject: [PATCH 01/44] flow velocity fix --- src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs b/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs index 3cf5748..d745fe3 100644 --- a/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs +++ b/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs @@ -8,7 +8,7 @@ public partial class SensorBuilder private static Sensor? BuildWaterFlowSensor(string propertyName, string alias, string propertyValue, bool isMetric = true) { return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? L2G(value) : value, isMetric ? "L/min" : "gal/min", SensorType.VolumeFlowRate) + ? new Sensor(propertyName, alias, isMetric ? value : L2G(value), isMetric ? "L/min" : "gal/min", SensorType.VolumeFlowRate) : null; } From b7ee42dbec7f37752cd75be651c15ea4a4b0572c Mon Sep 17 00:00:00 2001 From: mplogas Date: Fri, 20 Sep 2024 19:55:11 +0200 Subject: [PATCH 02/44] ac1100 reports in mA :/ --- src/Ecowitt.Controller/Mapping/SensorBuilder.cs | 2 +- src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilder.cs b/src/Ecowitt.Controller/Mapping/SensorBuilder.cs index e17f654..83215a0 100644 --- a/src/Ecowitt.Controller/Mapping/SensorBuilder.cs +++ b/src/Ecowitt.Controller/Mapping/SensorBuilder.cs @@ -379,7 +379,7 @@ public partial class SensorBuilder case "ac_voltage": return BuildVoltageSensor(propertyName, "AC Voltage", propertyValue); case "ac_current": - return BuildCurrentSensor(propertyName, "AC Current", propertyValue); + return BuildCurrentSensor(propertyName, "AC Current", propertyValue, true); case "water_status": return BuildBinarySensor(propertyName, "Water Status", propertyValue); case "water_action": diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs b/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs index d745fe3..1f4a157 100644 --- a/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs +++ b/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs @@ -20,12 +20,13 @@ public partial class SensorBuilder : null; } - private static Sensor? BuildCurrentSensor(string propertyName, string alias, string propertyValue) + private static Sensor? BuildCurrentSensor(string propertyName, string alias, string propertyValue, bool isMilliAmp = false) { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "A", SensorType.Current) + return int.TryParse(propertyValue, out var value) + ? new Sensor(propertyName, alias, isMilliAmp ? value / 1000 : value, "A", SensorType.Current) : null; - } + } + private static Sensor? BuildPowerSensor(string propertyName, string alias, string propertyValue) { From f37b12827ee50d60e8c746f07c12b7bdb3de96d7 Mon Sep 17 00:00:00 2001 From: mplogas Date: Fri, 20 Sep 2024 19:59:04 +0200 Subject: [PATCH 03/44] reverted calculation for current and rather switch units mA / A --- src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs b/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs index 1f4a157..8ae7bbe 100644 --- a/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs +++ b/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs @@ -23,7 +23,7 @@ public partial class SensorBuilder private static Sensor? BuildCurrentSensor(string propertyName, string alias, string propertyValue, bool isMilliAmp = false) { return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMilliAmp ? value / 1000 : value, "A", SensorType.Current) + ? new Sensor(propertyName, alias, value, isMilliAmp ? "mA" : "A", SensorType.Current) : null; } From 7432ec510ff613f304b666b3d7b09861612ec366 Mon Sep 17 00:00:00 2001 From: mplogas Date: Fri, 20 Sep 2024 20:10:47 +0200 Subject: [PATCH 04/44] solar radiation is a double --- src/Ecowitt.Controller/Mapping/SensorBuilder.cs | 2 +- src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilder.cs b/src/Ecowitt.Controller/Mapping/SensorBuilder.cs index 83215a0..a8a37a6 100644 --- a/src/Ecowitt.Controller/Mapping/SensorBuilder.cs +++ b/src/Ecowitt.Controller/Mapping/SensorBuilder.cs @@ -134,7 +134,7 @@ public partial class SensorBuilder case "winddir": return BuildIntSensor(propertyName, "Wind Direction", propertyValue, "°"); case "solarradiation": - return BuildIntSensor(propertyName, "Solar Radiation", propertyValue, "W/m²", SensorType.Irradiance); + return BuildDoubleSensor(propertyName, "Solar Radiation", propertyValue, "W/m²", SensorType.Irradiance); case "uv": return BuildIntSensor(propertyName, "UV Index", propertyValue); case "srain_piezo": diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs b/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs index 524f091..cca6abf 100644 --- a/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs +++ b/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs @@ -66,7 +66,7 @@ public static void CalculateGatewayAddons(ref Model.Gateway gateway, bool isMetr if (winddirection != null) { var compass = BuildStringSensor("winddir-comp", "Wind Direction (Compass)", - CalculateWindDirection((int)winddirection.Value).ToString()); + CalculateWindDirection((int)winddirection.Value)); gateway.Sensors.Add(compass); } From 28787f466dc0be52bc877eab724dc8ed1fd495bb Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Sun, 10 Nov 2024 21:20:43 +0100 Subject: [PATCH 05/44] collecting refactoring ideas --- diagrams.drawio | 105 +++++++++++++++++++++++++++++++++++++ src/Ecowitt.Controller.sln | 1 + 2 files changed, 106 insertions(+) create mode 100644 diagrams.drawio diff --git a/diagrams.drawio b/diagrams.drawio new file mode 100644 index 0000000..ece094d --- /dev/null +++ b/diagrams.drawio @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ecowitt.Controller.sln b/src/Ecowitt.Controller.sln index e2e0d50..cf529e1 100644 --- a/src/Ecowitt.Controller.sln +++ b/src/Ecowitt.Controller.sln @@ -7,6 +7,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ecowitt.Controller", "Ecowi EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution", "Solution", "{B3658F9B-BB28-4B44-A07B-793FCE094BB3}" ProjectSection(SolutionItems) = preProject + ..\diagrams.drawio = ..\diagrams.drawio ..\LICENSE = ..\LICENSE ..\README.md = ..\README.md EndProjectSection From 3617728d32457ff43edda2afcc55654ccefcad2d Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Thu, 27 Mar 2025 20:34:33 +0100 Subject: [PATCH 06/44] added statemachine.cs --- src/Ecowitt.Controller/StateMachine.cs | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/Ecowitt.Controller/StateMachine.cs diff --git a/src/Ecowitt.Controller/StateMachine.cs b/src/Ecowitt.Controller/StateMachine.cs new file mode 100644 index 0000000..00b58f5 --- /dev/null +++ b/src/Ecowitt.Controller/StateMachine.cs @@ -0,0 +1,30 @@ +using Ecowitt.Controller.Configuration; +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Store; +using Microsoft.Extensions.Options; +using SlimMessageBus; + +namespace Ecowitt.Controller; + +public class StateMachine : IConsumer, IConsumer, IConsumer +{ + public StateMachine(ILogger logger, IDeviceStore deviceStore, IHttpClientFactory httpClientFactory, IOptions ecowittOptions, IOptions controllerOptions) + { + + } + + public async Task OnHandle(SubdeviceApiCommand message) + { + throw new NotImplementedException(); + } + + public async Task OnHandle(GatewayApiData message) + { + throw new NotImplementedException(); + } + + public async Task OnHandle(SubdeviceApiAggregate message) + { + throw new NotImplementedException(); + } +} \ No newline at end of file From ee287f9b928bd127b36b059ffa045625d114ae31 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Wed, 4 Jun 2025 21:33:48 +0200 Subject: [PATCH 07/44] gitignore --- .gitignore | 2 ++ src/Ecowitt.Controller.sln | 1 + 2 files changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8a30d25..44e6e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -260,6 +260,7 @@ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak +*.bkp # SQL Server files *.mdf @@ -396,3 +397,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.idea/ diff --git a/src/Ecowitt.Controller.sln b/src/Ecowitt.Controller.sln index cf529e1..34f4a7f 100644 --- a/src/Ecowitt.Controller.sln +++ b/src/Ecowitt.Controller.sln @@ -17,6 +17,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .dockerignore = .dockerignore docker-compose.yml = docker-compose.yml Dockerfile = Dockerfile + ..\.gitignore = ..\.gitignore EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EcoWitt.Controller.Tests", "EcoWitt.Controller.Tests\EcoWitt.Controller.Tests.csproj", "{A6E36077-BD8E-4296-A628-F6914560FE02}" From 3e592ff32827bae68e6de9d9ecb4765436e8de40 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Thu, 15 May 2025 10:54:19 +0900 Subject: [PATCH 08/44] hakone --- src/Ecowitt.Controller/StateMachine.cs | 215 ++++++++++++++++++++++++- 1 file changed, 209 insertions(+), 6 deletions(-) diff --git a/src/Ecowitt.Controller/StateMachine.cs b/src/Ecowitt.Controller/StateMachine.cs index 00b58f5..2c98fd0 100644 --- a/src/Ecowitt.Controller/StateMachine.cs +++ b/src/Ecowitt.Controller/StateMachine.cs @@ -1,4 +1,6 @@ +using System.Text.Json; using Ecowitt.Controller.Configuration; +using Ecowitt.Controller.Mapping; using Ecowitt.Controller.Model; using Ecowitt.Controller.Store; using Microsoft.Extensions.Options; @@ -8,23 +10,224 @@ namespace Ecowitt.Controller; public class StateMachine : IConsumer, IConsumer, IConsumer { + private readonly ILogger _logger; + private readonly IDeviceStore _deviceStore; + private readonly IHttpClientFactory _httpClientFactory; + private readonly EcowittOptions _ecowittOptions; + private readonly ControllerOptions _controllerOptions; + public StateMachine(ILogger logger, IDeviceStore deviceStore, IHttpClientFactory httpClientFactory, IOptions ecowittOptions, IOptions controllerOptions) { - + _logger = logger; + _deviceStore = deviceStore; + _httpClientFactory = httpClientFactory; + _ecowittOptions = ecowittOptions.Value; + _controllerOptions = controllerOptions.Value; } public async Task OnHandle(SubdeviceApiCommand message) { - throw new NotImplementedException(); + _logger.LogInformation($"Received SubdeviceCommand: {message.Cmd} for device {message.Id}"); + + var gw = _deviceStore.GetGatewayBySubdeviceId(message.Id); + if(gw == null) + { + _logger.LogWarning($"Gateway not found for subdevice {message.Id}"); + return; + } + // hey compiler, this can't be null! + var subdevice = gw.Subdevices.FirstOrDefault(sd => sd.Id == message.Id); + + if (message.Cmd == Command.Start) + { + //var val = message.Duration ?? 20; + //var valType = message.Unit ?? DurationUnit.Minutes; + // // the magic "maganiFator" :D + //if(valType == DurationUnit.Liters) val *= 10; + //var alwaysOn = message.AlwaysOn.HasValue ? 1 : 0; + //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model, val: val, valType: (int)valType, alwaysOn: alwaysOn); + + //default always-on message for now + await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model); + + } else if (message.Cmd == Command.Stop) + { + await SendCommand(gw.IpAddress, "quick_stop", message.Id, (int)subdevice!.Model); + } + else + { + _logger.LogWarning($"Ignoring unsupported command {message.Cmd} for subdevice {message.Id}"); + return; + } } - public async Task OnHandle(GatewayApiData message) + public Task OnHandle(GatewayApiData message) { - throw new NotImplementedException(); + _logger.LogDebug($"Received ApiData: {message.Model} ({message.PASSKEY}) \n {message.Payload}"); + var updatedGateway = message.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); + updatedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == updatedGateway.IpAddress)?.Name ?? updatedGateway.IpAddress.Replace('.','-'); + + var storedGateway = _deviceStore.GetGateway(updatedGateway.IpAddress); + if(storedGateway == null) + { + updatedGateway.DiscoveryUpdate = true; + + foreach (var sensor in updatedGateway.Sensors) + { + sensor.DiscoveryUpdate = true; + } + if(!_deviceStore.UpsertGateway(updatedGateway)) _logger.LogWarning($"failed to add gateway {updatedGateway.IpAddress} ({updatedGateway.Model}) to the store"); + else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } + } + else + { + // no other property should update besides sensors + // and i'm stupid, because TS is required for availability :( + storedGateway.TimestampUtc = updatedGateway.TimestampUtc; + + foreach (var sensor in updatedGateway.Sensors) + { + var storedSensor = storedGateway.Sensors.FirstOrDefault(s => s.Name == sensor.Name); + if (storedSensor == null) + { + sensor.DiscoveryUpdate = true; + storedGateway.Sensors.Add(sensor); + } + else + { + storedSensor.Value = sensor.Value; + } + } + + var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); + foreach (var sensor in sensorsToRemove) + { + storedGateway.Sensors.Remove(sensor); + } + + if(!_deviceStore.UpsertGateway(storedGateway)) {_logger.LogWarning($"failed to update {storedGateway.IpAddress} ({storedGateway.Model}) in the store");} + else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } + } + + return Task.CompletedTask; } - public async Task OnHandle(SubdeviceApiAggregate message) + public Task OnHandle(SubdeviceApiAggregate message) { - throw new NotImplementedException(); + var ips = message.Subdevices.DistinctBy(sd => sd.GwIp).Select(sd => sd.GwIp); + foreach (var ip in ips) + { + var storedGateway = _deviceStore.GetGateway(ip); + if (storedGateway == null) + { + if (_ecowittOptions.AutoDiscovery) + { + _logger.LogWarning($"Gateway {ip} not found while in autodiscovery mode. Not updating subdevices. (Try turning off autodiscovery)"); + return Task.CompletedTask; + } + + storedGateway = new Gateway {IpAddress = ip}; + storedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == storedGateway.IpAddress)?.Name ?? storedGateway.IpAddress.Replace('.','-'); + storedGateway.DiscoveryUpdate = true; + } + + var subdeviceApiData = message.Subdevices.Where(sd => sd.GwIp == ip); + foreach (var data in subdeviceApiData) + { + var updatedSubDevice = data.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); + var storedSubDevice = storedGateway.Subdevices.FirstOrDefault(gwsd => gwsd.Id == updatedSubDevice.Id); + if (storedSubDevice == null) + { + updatedSubDevice.DiscoveryUpdate = true; + foreach (var sensor in updatedSubDevice.Sensors) + { + sensor.DiscoveryUpdate = true; + } + storedGateway.Subdevices.Add(updatedSubDevice); + _logger.LogInformation($"subdevice added: {data.Id} ({data.Model})"); + + + } else { + storedSubDevice.TimestampUtc = updatedSubDevice.TimestampUtc; + storedSubDevice.Availability = updatedSubDevice.Availability; + + // no update of other properties + if (storedSubDevice.Version != updatedSubDevice.Version || storedSubDevice.Devicename != updatedSubDevice.Devicename || storedSubDevice.Nickname != updatedSubDevice.Nickname) + { + storedSubDevice.Version = updatedSubDevice.Version; + storedSubDevice.Devicename = updatedSubDevice.Devicename; + storedSubDevice.Nickname = updatedSubDevice.Nickname; + storedSubDevice.DiscoveryUpdate = true; + } + + // update sensors one by one and find out if there are new ones + // if there are new ones, mark the subdevice for discovery update + foreach (var sensor in updatedSubDevice.Sensors) + { + var storedSensor = storedSubDevice.Sensors.FirstOrDefault(s => s.Name == sensor.Name); + if (storedSensor == null) + { + sensor.DiscoveryUpdate = true; + storedSubDevice.Sensors.Add(sensor); + } + else + { + storedSensor.Value = sensor.Value; + } + } + + // remove sensors that are not in the update + var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); + foreach (var sensor in sensorsToRemove) + { + storedSubDevice.Sensors.Remove(sensor); + } + + _logger.LogInformation($"subdevice updated: {data.Id} ({data.Model})"); + } + } + + _deviceStore.UpsertGateway(storedGateway); + } + + return Task.CompletedTask; + } + + public async Task SendCommand(string ipAddress, string cmd, int id, int model, int val = 0, int valType = 0, int onType = 0, int offType = 0, int alwaysOn = 1, int onTime = 0, int offTime = 0) + { + var client = _httpClientFactory.CreateClient("ecowitt-client"); + client.BaseAddress = new Uri($"http://{ipAddress}"); + + var username = _ecowittOptions.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Username; + var password = _ecowittOptions.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Password; + if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) + { + //TODO: authentication header, need to test + //client.DefaultRequestHeaders.Add(); + } + + // [{"on_type":0,"off_type":0,"always_on":0,"on_time":0,"off_time":0,"val_type":1,"val":20,"cmd":"quick_run","id":12345,"model":1}]} + dynamic payload; + switch (cmd) + { + case "quick_run": + payload = new { command = new[] { new { cmd, id, model, val, val_type = valType, on_type = onType, off_type = offType, always_on = alwaysOn, on_time = onTime, off_time = offTime } } }; + break; + case "quick_stop": + payload = new { command = new[] { new { cmd, id, model } } }; + break; + default: + _logger.LogWarning($"Unsupported command type {cmd}. Not sending command to {ipAddress} for subdevice {id}"); + return false; + } + + var sContent = new StringContent(JsonSerializer.Serialize(payload)); + var response = await client.PostAsync("parse_quick_cmd_iot", sContent); + + if (response.IsSuccessStatusCode) return true; + else { + _logger.LogWarning($"Could not send command to {ipAddress} for subdevice {id}"); + return false; + } } } \ No newline at end of file From 724af44cfc8495de79d221f1f47730e99db7b7b7 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Sat, 17 May 2025 12:53:29 +0900 Subject: [PATCH 09/44] shinkansen-kyoto: removed consumer, moved to statemachine --- .../Consumer/CommandConsumer.cs | 98 ----------- .../Consumer/DataConsumer.cs | 157 ------------------ src/Ecowitt.Controller/Program.cs | 7 +- 3 files changed, 3 insertions(+), 259 deletions(-) delete mode 100644 src/Ecowitt.Controller/Consumer/CommandConsumer.cs delete mode 100644 src/Ecowitt.Controller/Consumer/DataConsumer.cs diff --git a/src/Ecowitt.Controller/Consumer/CommandConsumer.cs b/src/Ecowitt.Controller/Consumer/CommandConsumer.cs deleted file mode 100644 index 5fda56e..0000000 --- a/src/Ecowitt.Controller/Consumer/CommandConsumer.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Store; -using Microsoft.Extensions.Options; -using System.Text.Json; -using SlimMessageBus; - -namespace Ecowitt.Controller.Consumer; - -public class CommandConsumer : IConsumer -{ - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IDeviceStore _deviceStore; - private readonly EcowittOptions _options; - - public CommandConsumer(ILogger logger, IHttpClientFactory httpClientFactory, IDeviceStore store, IOptions options) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - _deviceStore = store; - _options = options.Value; - } - - public async Task OnHandle(SubdeviceApiCommand message) - { - _logger.LogInformation($"Received SubdeviceCommand: {message.Cmd} for device {message.Id}"); - - var gw = _deviceStore.GetGatewayBySubdeviceId(message.Id); - if(gw == null) - { - _logger.LogWarning($"Gateway not found for subdevice {message.Id}"); - return; - } - // hey compiler, this can't be null! - var subdevice = gw.Subdevices.FirstOrDefault(sd => sd.Id == message.Id); - - if (message.Cmd == Command.Start) - { - //var val = message.Duration ?? 20; - //var valType = message.Unit ?? DurationUnit.Minutes; - // // the magic "maganiFator" :D - //if(valType == DurationUnit.Liters) val *= 10; - //var alwaysOn = message.AlwaysOn.HasValue ? 1 : 0; - //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model, val: val, valType: (int)valType, alwaysOn: alwaysOn); - - //default always-on message for now - await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model); - - } else if (message.Cmd == Command.Stop) - { - await SendCommand(gw.IpAddress, "quick_stop", message.Id, (int)subdevice!.Model); - } - else - { - _logger.LogWarning($"Ignoring unsupported command {message.Cmd} for subdevice {message.Id}"); - return; - } - } - - private async Task SendCommand(string ipAddress, string cmd, int id, int model, int val = 0, int valType = 0, int onType = 0, int offType = 0, int alwaysOn = 1, int onTime = 0, int offTime = 0) - { - var client = _httpClientFactory.CreateClient("ecowitt-client"); - client.BaseAddress = new Uri($"http://{ipAddress}"); - - var username = _options.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Username; - var password = _options.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Password; - if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) - { - //TODO: authentication header, need to test - //client.DefaultRequestHeaders.Add(); - } - - // [{"on_type":0,"off_type":0,"always_on":0,"on_time":0,"off_time":0,"val_type":1,"val":20,"cmd":"quick_run","id":12345,"model":1}]} - dynamic payload; - switch (cmd) - { - case "quick_run": - payload = new { command = new[] { new { cmd, id, model, val, val_type = valType, on_type = onType, off_type = offType, always_on = alwaysOn, on_time = onTime, off_time = offTime } } }; - break; - case "quick_stop": - payload = new { command = new[] { new { cmd, id, model } } }; - break; - default: - _logger.LogWarning($"Unsupported command type {cmd}. Not sending command to {ipAddress} for subdevice {id}"); - return false; - } - - var sContent = new StringContent(JsonSerializer.Serialize(payload)); - var response = await client.PostAsync("parse_quick_cmd_iot", sContent); - - if (response.IsSuccessStatusCode) return true; - else { - _logger.LogWarning($"Could not send command to {ipAddress} for subdevice {id}"); - return false; - } - } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Consumer/DataConsumer.cs b/src/Ecowitt.Controller/Consumer/DataConsumer.cs deleted file mode 100644 index 4b48d92..0000000 --- a/src/Ecowitt.Controller/Consumer/DataConsumer.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Mapping; -using Ecowitt.Controller.Store; -using Microsoft.Extensions.Options; -using SlimMessageBus; -using System.Text.Json; - -namespace Ecowitt.Controller.Consumer; - -public class DataConsumer : IConsumer, IConsumer -{ - private readonly ILogger _logger; - private readonly IDeviceStore _deviceStore; - private readonly EcowittOptions _ecowittOptions; - private readonly ControllerOptions _controllerOptions; - - public DataConsumer(ILogger logger, IDeviceStore deviceStore, IOptions ecowittOptions, IOptions controllerOptions) - { - _logger = logger; - _deviceStore = deviceStore; - _ecowittOptions = ecowittOptions.Value; - _controllerOptions = controllerOptions.Value; - } - - public Task OnHandle(GatewayApiData message) - { - _logger.LogDebug($"Received ApiData: {message.Model} ({message.PASSKEY}) \n {message.Payload}"); - var updatedGateway = message.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); - updatedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == updatedGateway.IpAddress)?.Name ?? updatedGateway.IpAddress.Replace('.','-'); - - var storedGateway = _deviceStore.GetGateway(updatedGateway.IpAddress); - if(storedGateway == null) - { - updatedGateway.DiscoveryUpdate = true; - - foreach (var sensor in updatedGateway.Sensors) - { - sensor.DiscoveryUpdate = true; - } - if(!_deviceStore.UpsertGateway(updatedGateway)) _logger.LogWarning($"failed to add gateway {updatedGateway.IpAddress} ({updatedGateway.Model}) to the store"); - else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } - } - else - { - // no other property should update besides sensors - // and i'm stupid, because TS is required for availability :( - storedGateway.TimestampUtc = updatedGateway.TimestampUtc; - - foreach (var sensor in updatedGateway.Sensors) - { - var storedSensor = storedGateway.Sensors.FirstOrDefault(s => s.Name == sensor.Name); - if (storedSensor == null) - { - sensor.DiscoveryUpdate = true; - storedGateway.Sensors.Add(sensor); - } - else - { - storedSensor.Value = sensor.Value; - } - } - - var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); - foreach (var sensor in sensorsToRemove) - { - storedGateway.Sensors.Remove(sensor); - } - - if(!_deviceStore.UpsertGateway(storedGateway)) {_logger.LogWarning($"failed to update {storedGateway.IpAddress} ({storedGateway.Model}) in the store");} - else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } - } - - return Task.CompletedTask; - } - - public Task OnHandle(SubdeviceApiAggregate message) - { - var ips = message.Subdevices.DistinctBy(sd => sd.GwIp).Select(sd => sd.GwIp); - foreach (var ip in ips) - { - var storedGateway = _deviceStore.GetGateway(ip); - if (storedGateway == null) - { - if (_ecowittOptions.AutoDiscovery) - { - _logger.LogWarning($"Gateway {ip} not found while in autodiscovery mode. Not updating subdevices. (Try turning off autodiscovery)"); - return Task.CompletedTask; - } - - storedGateway = new Gateway {IpAddress = ip}; - storedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == storedGateway.IpAddress)?.Name ?? storedGateway.IpAddress.Replace('.','-'); - storedGateway.DiscoveryUpdate = true; - } - - var subdeviceApiData = message.Subdevices.Where(sd => sd.GwIp == ip); - foreach (var data in subdeviceApiData) - { - var updatedSubDevice = data.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); - var storedSubDevice = storedGateway.Subdevices.FirstOrDefault(gwsd => gwsd.Id == updatedSubDevice.Id); - if (storedSubDevice == null) - { - updatedSubDevice.DiscoveryUpdate = true; - foreach (var sensor in updatedSubDevice.Sensors) - { - sensor.DiscoveryUpdate = true; - } - storedGateway.Subdevices.Add(updatedSubDevice); - _logger.LogInformation($"subdevice added: {data.Id} ({data.Model})"); - - - } else { - storedSubDevice.TimestampUtc = updatedSubDevice.TimestampUtc; - storedSubDevice.Availability = updatedSubDevice.Availability; - - // no update of other properties - if (storedSubDevice.Version != updatedSubDevice.Version || storedSubDevice.Devicename != updatedSubDevice.Devicename || storedSubDevice.Nickname != updatedSubDevice.Nickname) - { - storedSubDevice.Version = updatedSubDevice.Version; - storedSubDevice.Devicename = updatedSubDevice.Devicename; - storedSubDevice.Nickname = updatedSubDevice.Nickname; - storedSubDevice.DiscoveryUpdate = true; - } - - // update sensors one by one and find out if there are new ones - // if there are new ones, mark the subdevice for discovery update - foreach (var sensor in updatedSubDevice.Sensors) - { - var storedSensor = storedSubDevice.Sensors.FirstOrDefault(s => s.Name == sensor.Name); - if (storedSensor == null) - { - sensor.DiscoveryUpdate = true; - storedSubDevice.Sensors.Add(sensor); - } - else - { - storedSensor.Value = sensor.Value; - } - } - - // remove sensors that are not in the update - var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); - foreach (var sensor in sensorsToRemove) - { - storedSubDevice.Sensors.Remove(sensor); - } - - _logger.LogInformation($"subdevice updated: {data.Id} ({data.Model})"); - } - } - - _deviceStore.UpsertGateway(storedGateway); - } - - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index 39f4291..560dbbf 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -1,7 +1,6 @@ using System.Net; using System.Reflection; using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Consumer; using Ecowitt.Controller.Discovery; using Ecowitt.Controller.Model; using Ecowitt.Controller.Mqtt; @@ -66,15 +65,15 @@ public static async Task Main(string[] args) smb.Produce(x => x.DefaultTopic("subdevice-command")); smb.Consume(x => x .Topic("api-data") - .WithConsumer() + .WithConsumer() ); smb.Consume(x => x .Topic("subdevice-data") - .WithConsumer() + .WithConsumer() ); smb.Consume(x => x .Topic("subdevice-command") - .WithConsumer() + .WithConsumer() ); smb.AddServicesFromAssembly(Assembly.GetExecutingAssembly()); }); From b689aed4eb7ee226389134ec37ed2bae38c2d899 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Wed, 21 May 2025 09:09:36 +0900 Subject: [PATCH 10/44] shinkansen kyoto-hiroshima: updated architecture diagram --- diagrams.drawio | 219 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 174 insertions(+), 45 deletions(-) diff --git a/diagrams.drawio b/diagrams.drawio index ece094d..3a730dd 100644 --- a/diagrams.drawio +++ b/diagrams.drawio @@ -1,104 +1,233 @@ - - - + + + - + + + + - + - - - - - + + - + - - + + - - + + - + - - - - + - + - + - + - + - + - + - + - + - + - + - + - - - - + - + - + - + - + - - - - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 6a417d2d39a8699ffcfda121ecd65399c029fd87 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Wed, 21 May 2025 09:26:30 +0900 Subject: [PATCH 11/44] naming fixes in diagrams.drawio and message stubs in project --- diagrams.drawio | 8 ++++---- src/Ecowitt.Controller/Message/Command/DeviceCommand.cs | 6 ++++++ src/Ecowitt.Controller/Message/Config/ApiConfig.cs | 6 ++++++ .../Message/Config/HomeAssistantConfig.cs | 6 ++++++ src/Ecowitt.Controller/Message/Config/MqttConfig.cs | 6 ++++++ src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs | 6 ++++++ src/Ecowitt.Controller/Message/Data/DeviceData.cs | 6 ++++++ src/Ecowitt.Controller/Message/Data/GatewayData.cs | 6 ++++++ src/Ecowitt.Controller/Message/Data/SubdeviceData.cs | 6 ++++++ src/Ecowitt.Controller/Message/Event/ApiEvent.cs | 6 ++++++ .../Message/Event/HomeAssistantDiscoveryEvent.cs | 6 ++++++ .../Message/Event/HomeAssistantReconnectEvent.cs | 6 ++++++ .../Message/Event/HttpPublishingEvent.cs | 6 ++++++ .../Message/Event/MqttConnectionEvent.cs | 6 ++++++ 14 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 src/Ecowitt.Controller/Message/Command/DeviceCommand.cs create mode 100644 src/Ecowitt.Controller/Message/Config/ApiConfig.cs create mode 100644 src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs create mode 100644 src/Ecowitt.Controller/Message/Config/MqttConfig.cs create mode 100644 src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs create mode 100644 src/Ecowitt.Controller/Message/Data/DeviceData.cs create mode 100644 src/Ecowitt.Controller/Message/Data/GatewayData.cs create mode 100644 src/Ecowitt.Controller/Message/Data/SubdeviceData.cs create mode 100644 src/Ecowitt.Controller/Message/Event/ApiEvent.cs create mode 100644 src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs create mode 100644 src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs create mode 100644 src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs create mode 100644 src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs diff --git a/diagrams.drawio b/diagrams.drawio index 3a730dd..f72030e 100644 --- a/diagrams.drawio +++ b/diagrams.drawio @@ -140,10 +140,10 @@ - + - + @@ -184,7 +184,7 @@ - + @@ -225,7 +225,7 @@ - + diff --git a/src/Ecowitt.Controller/Message/Command/DeviceCommand.cs b/src/Ecowitt.Controller/Message/Command/DeviceCommand.cs new file mode 100644 index 0000000..14cdba9 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Command/DeviceCommand.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class DeviceCommand +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Config/ApiConfig.cs b/src/Ecowitt.Controller/Message/Config/ApiConfig.cs new file mode 100644 index 0000000..f4cb57b --- /dev/null +++ b/src/Ecowitt.Controller/Message/Config/ApiConfig.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class ApiConfig +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs b/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs new file mode 100644 index 0000000..350a260 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class HomeAssistantConfig +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Config/MqttConfig.cs b/src/Ecowitt.Controller/Message/Config/MqttConfig.cs new file mode 100644 index 0000000..1cba528 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Config/MqttConfig.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class MqttConfig +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs b/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs new file mode 100644 index 0000000..618ac5a --- /dev/null +++ b/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class SubdeviceConfig +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Data/DeviceData.cs b/src/Ecowitt.Controller/Message/Data/DeviceData.cs new file mode 100644 index 0000000..b5d37b9 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Data/DeviceData.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class DeviceData +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Data/GatewayData.cs b/src/Ecowitt.Controller/Message/Data/GatewayData.cs new file mode 100644 index 0000000..15b15d9 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Data/GatewayData.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class GatewayData +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs b/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs new file mode 100644 index 0000000..e77963e --- /dev/null +++ b/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class SubdeviceData +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/ApiEvent.cs b/src/Ecowitt.Controller/Message/Event/ApiEvent.cs new file mode 100644 index 0000000..8838f23 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Event/ApiEvent.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class ApiEvent +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs b/src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs new file mode 100644 index 0000000..eedf95b --- /dev/null +++ b/src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class HomeAssistantDiscoveryEvent +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs b/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs new file mode 100644 index 0000000..311c48c --- /dev/null +++ b/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class HomeAssistantReconnectEvent +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs b/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs new file mode 100644 index 0000000..9d1fc93 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class HttpPublishingEvent +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs b/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs new file mode 100644 index 0000000..383fa7d --- /dev/null +++ b/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model; + +public class MqttConnectionEvent +{ + +} \ No newline at end of file From ce57a61a1d5187d0ebdb84d77574024379998bf0 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Wed, 28 May 2025 13:11:46 +0900 Subject: [PATCH 12/44] tokyo hotel lobby mass commit. summarizes stuff from 7 days of refactoring --- .$diagrams.drawio.bkp | 234 +++++++++++++++++ .../.idea.Ecowitt.Controller/.idea/.gitignore | 13 + .../.idea.Ecowitt.Controller/.idea/.name | 1 + .../.idea/encodings.xml | 4 + .../.idea/indexLayout.xml | 8 + .../.idea.Ecowitt.Controller/.idea/vcs.xml | 6 + .../SubdeviceMappingTest.cs | 1 + .../Controller/DataController.cs | 1 + .../Discovery/DiscoveryPublishService.cs | 17 +- .../Ecowitt.Controller.csproj | 5 + .../Mapping/ApiDataExtension.cs | 5 +- .../Mapping/SensorBuilderAddon.cs | 32 +-- .../Message/Command/DeviceCommand.cs | 6 - .../Message/Config/ApiConfig.cs | 2 +- .../Message/Config/HomeAssistantConfig.cs | 4 +- .../Message/Config/MqttConfig.cs | 7 +- .../Message/Config/SubdeviceConfig.cs | 2 +- .../Message/Data/DeviceData.cs | 5 +- .../Message/Data/GatewayData.cs | 6 +- .../Message/Data/SubdeviceData.cs | 6 +- .../Message/Event/ApiEvent.cs | 2 +- .../Event/HomeAssistantDiscoveryEvent.cs | 2 +- .../Event/HomeAssistantReconnectEvent.cs | 2 +- .../Message/Event/HttpPublishingEvent.cs | 2 +- .../Message/Event/MqttConnectionEvent.cs | 13 +- .../SubdeviceApiAggregate.cs | 4 +- .../Model/{ => Api}/GatewayApiData.cs | 2 +- .../Model/{ => Api}/SubdeviceApiCommand.cs | 2 +- .../Model/{ => Api}/SubdeviceApiData.cs | 2 +- .../Model/{Gateway.cs => Device.cs} | 2 +- .../Model => Model/Discovery}/Availability.cs | 2 +- .../Model => Model/Discovery}/Config.cs | 2 +- .../Model => Model/Discovery}/Device.cs | 2 +- .../{ => Model}/Discovery/DiscoveryBuilder.cs | 5 +- .../Model => Model/Discovery}/Enums.cs | 2 +- .../Model => Model/Discovery}/Origin.cs | 2 +- src/Ecowitt.Controller/Mqtt/MqttService.cs | 1 + src/Ecowitt.Controller/Program.cs | 32 +-- .../Service/Http/HealthCheck.cs | 6 + .../Service/Http/HttpPublishingService.cs | 6 + .../Service/Mqtt/HealthCheck.cs | 20 ++ src/Ecowitt.Controller/Service/Mqtt/Helper.cs | 60 +++++ .../Service/Mqtt/MqttService.cs | 241 ++++++++++++++++++ src/Ecowitt.Controller/StateMachine.cs | 4 +- .../Store/DataPublishService.cs | 2 +- src/Ecowitt.Controller/Store/DeviceStore.cs | 14 +- .../Subdevice/SubdeviceService.cs | 2 + 47 files changed, 714 insertions(+), 87 deletions(-) create mode 100644 .$diagrams.drawio.bkp create mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/.gitignore create mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/.name create mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/encodings.xml create mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/indexLayout.xml create mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/vcs.xml delete mode 100644 src/Ecowitt.Controller/Message/Command/DeviceCommand.cs rename src/Ecowitt.Controller/{Model => Message}/SubdeviceApiAggregate.cs (57%) rename src/Ecowitt.Controller/Model/{ => Api}/GatewayApiData.cs (96%) rename src/Ecowitt.Controller/Model/{ => Api}/SubdeviceApiCommand.cs (89%) rename src/Ecowitt.Controller/Model/{ => Api}/SubdeviceApiData.cs (90%) rename src/Ecowitt.Controller/Model/{Gateway.cs => Device.cs} (97%) rename src/Ecowitt.Controller/{Discovery/Model => Model/Discovery}/Availability.cs (90%) rename src/Ecowitt.Controller/{Discovery/Model => Model/Discovery}/Config.cs (97%) rename src/Ecowitt.Controller/{Discovery/Model => Model/Discovery}/Device.cs (93%) rename src/Ecowitt.Controller/{ => Model}/Discovery/DiscoveryBuilder.cs (98%) rename src/Ecowitt.Controller/{Discovery/Model => Model/Discovery}/Enums.cs (80%) rename src/Ecowitt.Controller/{Discovery/Model => Model/Discovery}/Origin.cs (85%) create mode 100644 src/Ecowitt.Controller/Service/Http/HealthCheck.cs create mode 100644 src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs create mode 100644 src/Ecowitt.Controller/Service/Mqtt/HealthCheck.cs create mode 100644 src/Ecowitt.Controller/Service/Mqtt/Helper.cs create mode 100644 src/Ecowitt.Controller/Service/Mqtt/MqttService.cs diff --git a/.$diagrams.drawio.bkp b/.$diagrams.drawio.bkp new file mode 100644 index 0000000..16f96cc --- /dev/null +++ b/.$diagrams.drawio.bkp @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/.gitignore b/src/.idea/.idea.Ecowitt.Controller/.idea/.gitignore new file mode 100644 index 0000000..4110581 --- /dev/null +++ b/src/.idea/.idea.Ecowitt.Controller/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.Ecowitt.Controller.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/.name b/src/.idea/.idea.Ecowitt.Controller/.idea/.name new file mode 100644 index 0000000..85771ed --- /dev/null +++ b/src/.idea/.idea.Ecowitt.Controller/.idea/.name @@ -0,0 +1 @@ +Ecowitt.Controller \ No newline at end of file diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/encodings.xml b/src/.idea/.idea.Ecowitt.Controller/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/src/.idea/.idea.Ecowitt.Controller/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/indexLayout.xml b/src/.idea/.idea.Ecowitt.Controller/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/src/.idea/.idea.Ecowitt.Controller/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/vcs.xml b/src/.idea/.idea.Ecowitt.Controller/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/src/.idea/.idea.Ecowitt.Controller/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs b/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs index eaa87c1..6170dc7 100644 --- a/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs +++ b/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Ecowitt.Controller.Mapping; using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; using Newtonsoft.Json; namespace EcoWitt.Controller.Tests; diff --git a/src/Ecowitt.Controller/Controller/DataController.cs b/src/Ecowitt.Controller/Controller/DataController.cs index 250137c..4ca1868 100644 --- a/src/Ecowitt.Controller/Controller/DataController.cs +++ b/src/Ecowitt.Controller/Controller/DataController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; using Microsoft.AspNetCore.Mvc; using SlimMessageBus; diff --git a/src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs b/src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs index d83e28b..4729f89 100644 --- a/src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs +++ b/src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs @@ -1,12 +1,13 @@ using System.Text.Encodings.Web; using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Discovery.Model; using Ecowitt.Controller.Model; using Ecowitt.Controller.Mqtt; using Ecowitt.Controller.Store; using Microsoft.Extensions.Options; using System.Text.Json; using System.Text.Json.Serialization; +using Ecowitt.Controller.Model.Discovery; +using Device = Ecowitt.Controller.Model.Device; namespace Ecowitt.Controller.Discovery; @@ -47,7 +48,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (await timer.WaitForNextTickAsync(stoppingToken)) { - foreach (var gw in _store.GetGatewaysShort().Select(gwKvp => _store.GetGateway(gwKvp.Key)).OfType()) + foreach (var gw in _store.GetGatewaysShort().Select(gwKvp => _store.GetGateway(gwKvp.Key)).OfType()) { if (gw.DiscoveryUpdate) { @@ -88,7 +89,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) - private async Task PublishGatewayDiscovery(Gateway gw) + private async Task PublishGatewayDiscovery(Device gw) { var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType??"unknown"); var id = DiscoveryBuilder.BuildIdentifier(gw.Name, "availability"); @@ -100,7 +101,7 @@ private async Task PublishGatewayDiscovery(Gateway gw) await PublishMessage(Helper.Sanitize($"sensor/{gw.Name}"), config); } - private async Task PublishSubdeviceDiscovery(Gateway gw, Ecowitt.Controller.Model.Subdevice subdevice) + private async Task PublishSubdeviceDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice) { var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "availability"); @@ -112,7 +113,7 @@ private async Task PublishSubdeviceDiscovery(Gateway gw, Ecowitt.Controller.Mode await PublishMessage(Helper.Sanitize($"sensor/{subdevice.Nickname}"), config); } - private async Task PublishSubdeviceSwitchDiscovery(Gateway gw, Ecowitt.Controller.Model.Subdevice subdevice) + private async Task PublishSubdeviceSwitchDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice) { var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "switch"); @@ -126,7 +127,7 @@ private async Task PublishSubdeviceSwitchDiscovery(Gateway gw, Ecowitt.Controlle await PublishMessage(Helper.Sanitize($"switch/{subdevice.Nickname}"), config); } - private async Task PublishSensorDiscovery(Gateway gw, ISensor sensor) + private async Task PublishSensorDiscovery(Device gw, ISensor sensor) { var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType ?? "unknown"); @@ -135,7 +136,7 @@ private async Task PublishSensorDiscovery(Gateway gw, ISensor sensor) await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); } - private async Task PublishSensorDiscovery(Gateway gw, Ecowitt.Controller.Model.Subdevice subdevice, ISensor sensor) + private async Task PublishSensorDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice, ISensor sensor) { var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); var statetopic = sensor.SensorCategory == SensorCategory.Diagnostic ? $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceDiagnosticTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}" : $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceSensorTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}"; @@ -143,7 +144,7 @@ private async Task PublishSensorDiscovery(Gateway gw, Ecowitt.Controller.Model.S await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); } - private async Task PublishSensorDiscovery(Device device, ISensor sensor, string statetopic, string availabilityTopic) + private async Task PublishSensorDiscovery(Model.Discovery.Device device, ISensor sensor, string statetopic, string availabilityTopic) { var id = DiscoveryBuilder.BuildIdentifier($"{device.Name}_{sensor.Name}", sensor.SensorType.ToString()); var category = DiscoveryBuilder.BuildDeviceCategory(sensor.SensorType); diff --git a/src/Ecowitt.Controller/Ecowitt.Controller.csproj b/src/Ecowitt.Controller/Ecowitt.Controller.csproj index d4beea9..6ad3d2a 100644 --- a/src/Ecowitt.Controller/Ecowitt.Controller.csproj +++ b/src/Ecowitt.Controller/Ecowitt.Controller.csproj @@ -31,5 +31,10 @@ + + + + + diff --git a/src/Ecowitt.Controller/Mapping/ApiDataExtension.cs b/src/Ecowitt.Controller/Mapping/ApiDataExtension.cs index aa98c40..ba55610 100644 --- a/src/Ecowitt.Controller/Mapping/ApiDataExtension.cs +++ b/src/Ecowitt.Controller/Mapping/ApiDataExtension.cs @@ -1,5 +1,6 @@ using Ecowitt.Controller.Model; using System.Text.Json; +using Ecowitt.Controller.Model.Api; namespace Ecowitt.Controller.Mapping; @@ -76,9 +77,9 @@ public static Model.Subdevice Map(this SubdeviceApiData subdeviceApiData, bool i /// /// /// - public static Gateway Map(this GatewayApiData gatewayApiData, bool isMetric = true, bool calculateValues = true) + public static Device Map(this GatewayApiData gatewayApiData, bool isMetric = true, bool calculateValues = true) { - var result = new Gateway + var result = new Device { PASSKEY = gatewayApiData.PASSKEY, Model = gatewayApiData.Model, diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs b/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs index cca6abf..24ca72e 100644 --- a/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs +++ b/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs @@ -21,10 +21,10 @@ public static void CalculateWFC01Addons(ref Model.Subdevice subdevice) } } - public static void CalculateGatewayAddons(ref Model.Gateway gateway, bool isMetric) + public static void CalculateGatewayAddons(ref Model.Device device, bool isMetric) { - var tempin = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("tempinf", StringComparison.InvariantCultureIgnoreCase)); - var humidityin = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("humidityin", StringComparison.InvariantCultureIgnoreCase)); + var tempin = device.Sensors.FirstOrDefault(s => s.Name.Equals("tempinf", StringComparison.InvariantCultureIgnoreCase)); + var humidityin = device.Sensors.FirstOrDefault(s => s.Name.Equals("humidityin", StringComparison.InvariantCultureIgnoreCase)); if (tempin != null && humidityin != null) { @@ -32,11 +32,11 @@ public static void CalculateGatewayAddons(ref Model.Gateway gateway, bool isMetr isMetric ? CalculateDewPointMetric((double)tempin.Value, (double)humidityin.Value).ToString() : CalculateDewPointImperial((double)tempin.Value, (double)humidityin.Value).ToString(), isMetric, isMetric); - if (dewPoint != null) gateway.Sensors.Add(dewPoint); + if (dewPoint != null) device.Sensors.Add(dewPoint); } - var temp = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("tempf", StringComparison.InvariantCultureIgnoreCase)); - var humidity = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("humidity", StringComparison.InvariantCultureIgnoreCase)); + var temp = device.Sensors.FirstOrDefault(s => s.Name.Equals("tempf", StringComparison.InvariantCultureIgnoreCase)); + var humidity = device.Sensors.FirstOrDefault(s => s.Name.Equals("humidity", StringComparison.InvariantCultureIgnoreCase)); if (temp != null && humidity != null) { var dewPoint = BuildTemperatureSensor("dewpoint", "Outdoor Dewpoint", @@ -48,36 +48,36 @@ public static void CalculateGatewayAddons(ref Model.Gateway gateway, bool isMetr ? CalculateHeatIndexMetric((double)temp.Value, (double)humidity.Value).ToString() : CalculateHeatIndexImperial((double)temp.Value, (double)humidity.Value).ToString(), isMetric, isMetric); - if (dewPoint != null) gateway.Sensors.Add(dewPoint); - if (heatIndex != null) gateway.Sensors.Add(heatIndex); + if (dewPoint != null) device.Sensors.Add(dewPoint); + if (heatIndex != null) device.Sensors.Add(heatIndex); } - var windspeed = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("windspeedmph", StringComparison.InvariantCultureIgnoreCase)); + var windspeed = device.Sensors.FirstOrDefault(s => s.Name.Equals("windspeedmph", StringComparison.InvariantCultureIgnoreCase)); if (temp != null && windspeed != null) { var windChill = BuildTemperatureSensor("windchill", "Wind Chill", isMetric ? CalculateWindChillMetric((double)temp.Value, (double)windspeed.Value).ToString() : CalculateWindChillImperial((double)temp.Value, (double)windspeed.Value).ToString(), isMetric, isMetric); - if (windChill != null) gateway.Sensors.Add(windChill); + if (windChill != null) device.Sensors.Add(windChill); } - var winddirection = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("winddir", StringComparison.InvariantCultureIgnoreCase)); + var winddirection = device.Sensors.FirstOrDefault(s => s.Name.Equals("winddir", StringComparison.InvariantCultureIgnoreCase)); if (winddirection != null) { var compass = BuildStringSensor("winddir-comp", "Wind Direction (Compass)", - CalculateWindDirection((int)winddirection.Value)); - gateway.Sensors.Add(compass); + CalculateWindDirection((int)winddirection.Value).ToString()); + device.Sensors.Add(compass); } var sensorsToAdd = new List(); - var pm25 = gateway.Sensors.Where(s => s.Name.StartsWith("pm25_avg_24h") || s.Name.StartsWith("pm25_24h")); + var pm25 = device.Sensors.Where(s => s.Name.StartsWith("pm25_avg_24h") || s.Name.StartsWith("pm25_24h")); sensorsToAdd.AddRange(pm25.Select(sensor => BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm25Aqi24h((double)sensor.Value)))); - var pm10 = gateway.Sensors.Where(s => s.Name.StartsWith("pm10_avg_24h") || s.Name.StartsWith("pm10_24h")); + var pm10 = device.Sensors.Where(s => s.Name.StartsWith("pm10_avg_24h") || s.Name.StartsWith("pm10_24h")); sensorsToAdd.AddRange(pm10.Select(sensor => BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm10Aqi24h((double)sensor.Value)))); - gateway.Sensors.AddRange(sensorsToAdd); + device.Sensors.AddRange(sensorsToAdd); } // shout out to wikipedia for the formulas! <3 diff --git a/src/Ecowitt.Controller/Message/Command/DeviceCommand.cs b/src/Ecowitt.Controller/Message/Command/DeviceCommand.cs deleted file mode 100644 index 14cdba9..0000000 --- a/src/Ecowitt.Controller/Message/Command/DeviceCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Ecowitt.Controller.Model; - -public class DeviceCommand -{ - -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Config/ApiConfig.cs b/src/Ecowitt.Controller/Message/Config/ApiConfig.cs index f4cb57b..6dc61a1 100644 --- a/src/Ecowitt.Controller/Message/Config/ApiConfig.cs +++ b/src/Ecowitt.Controller/Message/Config/ApiConfig.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Message.Config; public class ApiConfig { diff --git a/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs b/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs index 350a260..f38c7e4 100644 --- a/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs +++ b/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs @@ -1,6 +1,6 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Message.Config; public class HomeAssistantConfig { - + public bool Enabled { get; set; } = true; } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Config/MqttConfig.cs b/src/Ecowitt.Controller/Message/Config/MqttConfig.cs index 1cba528..90e720b 100644 --- a/src/Ecowitt.Controller/Message/Config/MqttConfig.cs +++ b/src/Ecowitt.Controller/Message/Config/MqttConfig.cs @@ -1,6 +1,9 @@ -namespace Ecowitt.Controller.Model; +using Ecowitt.Controller.Configuration; + +namespace Ecowitt.Controller.Message.Config; public class MqttConfig { - + public bool Enabled { get; set; } = true; + public MqttOptions Configuration { get; set; } = new(); } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs b/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs index 618ac5a..a3c2324 100644 --- a/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs +++ b/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Message.Config; public class SubdeviceConfig { diff --git a/src/Ecowitt.Controller/Message/Data/DeviceData.cs b/src/Ecowitt.Controller/Message/Data/DeviceData.cs index b5d37b9..7862642 100644 --- a/src/Ecowitt.Controller/Message/Data/DeviceData.cs +++ b/src/Ecowitt.Controller/Message/Data/DeviceData.cs @@ -1,6 +1,9 @@ -namespace Ecowitt.Controller.Model; +using Ecowitt.Controller.Model; + +namespace Ecowitt.Controller.Message.Data; public class DeviceData { + public Device Device { get; set; } } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Data/GatewayData.cs b/src/Ecowitt.Controller/Message/Data/GatewayData.cs index 15b15d9..152f7e1 100644 --- a/src/Ecowitt.Controller/Message/Data/GatewayData.cs +++ b/src/Ecowitt.Controller/Message/Data/GatewayData.cs @@ -1,6 +1,8 @@ -namespace Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; + +namespace Ecowitt.Controller.Message.Data; public class GatewayData { - + public GatewayApiData Gateway { get; set; } } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs b/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs index e77963e..aca80ea 100644 --- a/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs +++ b/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs @@ -1,6 +1,8 @@ -namespace Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; + +namespace Ecowitt.Controller.Message.Data; public class SubdeviceData { - + public SubdeviceApiData SubdeviceApiData { get; set; } } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/ApiEvent.cs b/src/Ecowitt.Controller/Message/Event/ApiEvent.cs index 8838f23..b3a4065 100644 --- a/src/Ecowitt.Controller/Message/Event/ApiEvent.cs +++ b/src/Ecowitt.Controller/Message/Event/ApiEvent.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Message.Event; public class ApiEvent { diff --git a/src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs b/src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs index eedf95b..29a31a2 100644 --- a/src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs +++ b/src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Message.Event; public class HomeAssistantDiscoveryEvent { diff --git a/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs b/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs index 311c48c..d932195 100644 --- a/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs +++ b/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Message.Event; public class HomeAssistantReconnectEvent { diff --git a/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs b/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs index 9d1fc93..bf33123 100644 --- a/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs +++ b/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Message.Event; public class HttpPublishingEvent { diff --git a/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs b/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs index 383fa7d..d9adbbf 100644 --- a/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs +++ b/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs @@ -1,6 +1,15 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Message.Event; public class MqttConnectionEvent { - + public EventType EventType { get; set; } + public string? Message { get; set; } +} + +public enum EventType +{ + Connected, + Disconnected, + Error, + MessageReceived } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/SubdeviceApiAggregate.cs b/src/Ecowitt.Controller/Message/SubdeviceApiAggregate.cs similarity index 57% rename from src/Ecowitt.Controller/Model/SubdeviceApiAggregate.cs rename to src/Ecowitt.Controller/Message/SubdeviceApiAggregate.cs index d662f03..af77846 100644 --- a/src/Ecowitt.Controller/Model/SubdeviceApiAggregate.cs +++ b/src/Ecowitt.Controller/Message/SubdeviceApiAggregate.cs @@ -1,4 +1,6 @@ -namespace Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; + +namespace Ecowitt.Controller.Message; public class SubdeviceApiAggregate { diff --git a/src/Ecowitt.Controller/Model/GatewayApiData.cs b/src/Ecowitt.Controller/Model/Api/GatewayApiData.cs similarity index 96% rename from src/Ecowitt.Controller/Model/GatewayApiData.cs rename to src/Ecowitt.Controller/Model/Api/GatewayApiData.cs index 9c8288e..daec9b1 100644 --- a/src/Ecowitt.Controller/Model/GatewayApiData.cs +++ b/src/Ecowitt.Controller/Model/Api/GatewayApiData.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Model.Api; public class GatewayApiData { diff --git a/src/Ecowitt.Controller/Model/SubdeviceApiCommand.cs b/src/Ecowitt.Controller/Model/Api/SubdeviceApiCommand.cs similarity index 89% rename from src/Ecowitt.Controller/Model/SubdeviceApiCommand.cs rename to src/Ecowitt.Controller/Model/Api/SubdeviceApiCommand.cs index 9fc2357..77270b7 100644 --- a/src/Ecowitt.Controller/Model/SubdeviceApiCommand.cs +++ b/src/Ecowitt.Controller/Model/Api/SubdeviceApiCommand.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Model.Api; public class SubdeviceApiCommand { diff --git a/src/Ecowitt.Controller/Model/SubdeviceApiData.cs b/src/Ecowitt.Controller/Model/Api/SubdeviceApiData.cs similarity index 90% rename from src/Ecowitt.Controller/Model/SubdeviceApiData.cs rename to src/Ecowitt.Controller/Model/Api/SubdeviceApiData.cs index 978b8e9..67e92e6 100644 --- a/src/Ecowitt.Controller/Model/SubdeviceApiData.cs +++ b/src/Ecowitt.Controller/Model/Api/SubdeviceApiData.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Model.Api; public class SubdeviceApiData { diff --git a/src/Ecowitt.Controller/Model/Gateway.cs b/src/Ecowitt.Controller/Model/Device.cs similarity index 97% rename from src/Ecowitt.Controller/Model/Gateway.cs rename to src/Ecowitt.Controller/Model/Device.cs index 448d1b6..caaa485 100644 --- a/src/Ecowitt.Controller/Model/Gateway.cs +++ b/src/Ecowitt.Controller/Model/Device.cs @@ -2,7 +2,7 @@ namespace Ecowitt.Controller.Model; -public class Gateway +public class Device { // important properties public string IpAddress { get; set; } diff --git a/src/Ecowitt.Controller/Discovery/Model/Availability.cs b/src/Ecowitt.Controller/Model/Discovery/Availability.cs similarity index 90% rename from src/Ecowitt.Controller/Discovery/Model/Availability.cs rename to src/Ecowitt.Controller/Model/Discovery/Availability.cs index 7f79a59..d96338e 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Availability.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Availability.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public class Availability { diff --git a/src/Ecowitt.Controller/Discovery/Model/Config.cs b/src/Ecowitt.Controller/Model/Discovery/Config.cs similarity index 97% rename from src/Ecowitt.Controller/Discovery/Model/Config.cs rename to src/Ecowitt.Controller/Model/Discovery/Config.cs index fbe6033..cb69f0f 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Config.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Config.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public class Config { diff --git a/src/Ecowitt.Controller/Discovery/Model/Device.cs b/src/Ecowitt.Controller/Model/Discovery/Device.cs similarity index 93% rename from src/Ecowitt.Controller/Discovery/Model/Device.cs rename to src/Ecowitt.Controller/Model/Discovery/Device.cs index 01df009..61629f6 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Device.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Device.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public class Device { diff --git a/src/Ecowitt.Controller/Discovery/DiscoveryBuilder.cs b/src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs similarity index 98% rename from src/Ecowitt.Controller/Discovery/DiscoveryBuilder.cs rename to src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs index c119f4a..be16b9d 100644 --- a/src/Ecowitt.Controller/Discovery/DiscoveryBuilder.cs +++ b/src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs @@ -1,7 +1,4 @@ -using Ecowitt.Controller.Discovery.Model; -using Ecowitt.Controller.Model; - -namespace Ecowitt.Controller.Discovery; +namespace Ecowitt.Controller.Model.Discovery; public static class DiscoveryBuilder { diff --git a/src/Ecowitt.Controller/Discovery/Model/Enums.cs b/src/Ecowitt.Controller/Model/Discovery/Enums.cs similarity index 80% rename from src/Ecowitt.Controller/Discovery/Model/Enums.cs rename to src/Ecowitt.Controller/Model/Discovery/Enums.cs index 4b8098d..69f6473 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Enums.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Enums.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public enum EntityCategory { diff --git a/src/Ecowitt.Controller/Discovery/Model/Origin.cs b/src/Ecowitt.Controller/Model/Discovery/Origin.cs similarity index 85% rename from src/Ecowitt.Controller/Discovery/Model/Origin.cs rename to src/Ecowitt.Controller/Model/Discovery/Origin.cs index 3d0c059..f8007de 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Origin.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Origin.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public class Origin { diff --git a/src/Ecowitt.Controller/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Mqtt/MqttService.cs index 8b270d6..649a9a1 100644 --- a/src/Ecowitt.Controller/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Mqtt/MqttService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using SlimMessageBus; using System.Text.Json; +using Ecowitt.Controller.Model.Api; namespace Ecowitt.Controller.Mqtt; diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index 560dbbf..9c2d369 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -60,21 +60,23 @@ public static async Task Main(string[] args) { smb.WithProviderMemory(cfg => { cfg.EnableMessageSerialization = true; }); smb.AddJsonSerializer(); - smb.Produce(x => x.DefaultTopic("api-data")); - smb.Produce(x => x.DefaultTopic("subdevice-data")); - smb.Produce(x => x.DefaultTopic("subdevice-command")); - smb.Consume(x => x - .Topic("api-data") - .WithConsumer() - ); - smb.Consume(x => x - .Topic("subdevice-data") - .WithConsumer() - ); - smb.Consume(x => x - .Topic("subdevice-command") - .WithConsumer() - ); + + + // smb.Produce(x => x.DefaultTopic("api-data")); + // smb.Produce(x => x.DefaultTopic("subdevice-data")); + // smb.Produce(x => x.DefaultTopic("subdevice-command")); + // smb.Consume(x => x + // .Topic("api-data") + // .WithConsumer() + // ); + // smb.Consume(x => x + // .Topic("subdevice-data") + // .WithConsumer() + // ); + // smb.Consume(x => x + // .Topic("subdevice-command") + // .WithConsumer() + // ); smb.AddServicesFromAssembly(Assembly.GetExecutingAssembly()); }); diff --git a/src/Ecowitt.Controller/Service/Http/HealthCheck.cs b/src/Ecowitt.Controller/Service/Http/HealthCheck.cs new file mode 100644 index 0000000..5c2ea95 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Http/HealthCheck.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Service.Http; + +public class HealthCheck +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs new file mode 100644 index 0000000..3c64441 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Service.Http; + +public class HttpPublishingService +{ + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Mqtt/HealthCheck.cs b/src/Ecowitt.Controller/Service/Mqtt/HealthCheck.cs new file mode 100644 index 0000000..2cea0bc --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/HealthCheck.cs @@ -0,0 +1,20 @@ +using Ecowitt.Controller.Service.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Ecowitt.Controller.Service.Mqtt; + +public class HealthCheck : IHealthCheck +{ + private readonly HttpPublishingService _service; + + public HealthCheck(HttpPublishingService service) + { + _service = service; + } + + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = new CancellationToken()) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Mqtt/Helper.cs b/src/Ecowitt.Controller/Service/Mqtt/Helper.cs new file mode 100644 index 0000000..6c7a2a3 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/Helper.cs @@ -0,0 +1,60 @@ +namespace Ecowitt.Controller.Service.Mqtt; + +public static class Helper +{ + public static string BuildMqttGatewayTopic(string gwName) + { + return Sanitize(gwName); + } + + public static string BuildMqttGatewaySensorTopic(string gwName, string sensorName) + { + return Sanitize($"{gwName}/sensors/{sensorName}"); + } + + public static string BuildMqttGatewayDiagnosticTopic(string gwName, string sensorName) + { + return Sanitize($"{gwName}/diag/{sensorName}"); + } + + public static string BuildMqttSubdeviceTopic(string gwName, string subdeviceName) + { + return Sanitize($"{gwName}/subdevices/{subdeviceName}"); + } + + public static string BuildMqttSubdeviceSensorTopic(string gwName, string subdeviceName, string sensorName) + { + return Sanitize($"{gwName}/subdevices/{subdeviceName}/sensors/{sensorName}"); + } + + + public static string BuildMqttSubdeviceDiagnosticTopic(string gwName, string subdeviceName, string sensorName) + { + return Sanitize($"{gwName}/subdevices/{subdeviceName}/diag/{sensorName}"); + } + + public static string BuildMqttSubdeviceCommandTopic(string gwName, string subdeviceName) + { + return Sanitize($"{gwName}/subdevices/{subdeviceName}/cmd"); + } + + public static string BuildMqttSubdeviceCommandTopic() + { + return Sanitize($"+/subdevices/+/cmd"); + } + + public static string BuildMqttSubdeviceHACommandTopic(string gwName, string subdeviceName) + { + return Sanitize($"{gwName}/subdevices/{subdeviceName}/cmd/homeassistant"); + } + + public static string BuildMqttSubdeviceHACommandTopic() + { + return Sanitize($"+/subdevices/+/cmd/homeassistant"); + } + + public static string Sanitize(string input) + { + return input.Replace(' ', '-').ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs new file mode 100644 index 0000000..a8d2e03 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -0,0 +1,241 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Ecowitt.Controller.Configuration; +using Ecowitt.Controller.Message.Config; +using Ecowitt.Controller.Message.Data; +using Ecowitt.Controller.Message.Event; +using Ecowitt.Controller.Model; +using MQTTnet; +using MQTTnet.Client; +using SlimMessageBus; + +namespace Ecowitt.Controller.Service.Mqtt; + +public class MqttService : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer +{ + private readonly ILogger _logger; + private readonly MqttFactory _factory; + private readonly IMessageBus _messageBus; + private IMqttClient _client; + private bool _homeAssistantDiscoveryEnabled; + private bool _isConnecting = false; + private MqttConfig _mqttConfig = new (); + + + public MqttService(ILogger logger, MqttFactory factory, IMessageBus messageBus) + { + _logger = logger; + _factory = factory; + _messageBus = messageBus; + } + + public async Task OnHandle(MqttConfig message) + { + if(_client != null) + { + if(_client.IsConnected) await _client.DisconnectAsync(); + _client.ApplicationMessageReceivedAsync -= ClientOnApplicationMessageReceivedAsync; + _client.DisconnectedAsync -= ClientOnDisconnectedAsync; + _client.ConnectedAsync -= ClientOnConnectedAsync; + _client.Dispose(); + } + + if(!message.Enabled) + { + _logger.LogInformation("MQTT is disabled"); + return; + } + + _mqttConfig = message; + _client = _factory.CreateMqttClient(); + _client.ConnectedAsync += ClientOnConnectedAsync; + _client.DisconnectedAsync += ClientOnDisconnectedAsync; + _client.ApplicationMessageReceivedAsync += ClientOnApplicationMessageReceivedAsync; + var optionsBuilder = new MqttClientOptionsBuilder() + .WithTcpServer(_mqttConfig.Configuration.Host, _mqttConfig.Configuration.Port) + .WithClientId(_mqttConfig.Configuration.ClientId) + .WithCleanSession(); + if(!string.IsNullOrWhiteSpace(_mqttConfig.Configuration.User)) + optionsBuilder.WithCredentials(_mqttConfig.Configuration.User, _mqttConfig.Configuration.Password); + + await _client.ConnectAsync(optionsBuilder.Build()); + await _client.SubscribeAsync($"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceCommandTopic()}"); + } + + public async Task OnHandle(HomeAssistantConfig message) + { + _homeAssistantDiscoveryEnabled = message.Enabled; + if (_homeAssistantDiscoveryEnabled) + { + SubscribeHomeAssistantState(); + } else + { + UnsubscribeHomeAssistantState(); + } + } + + public async Task OnHandle(HomeAssistantDiscoveryEvent message) + { + EmitHomeAssistantDiscovery(); + } + + public async Task OnHandle(DeviceData message) + { + if (_client == null || !_client.IsConnected) + { + _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + return; + } + + + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + throw new NotImplementedException(); + } + + private Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) + { + throw new NotImplementedException(); + } + + private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg) + { + _logger.LogInformation("MQTT client disconnected."); + _messageBus.Publish(new MqttConnectionEvent { EventType = EventType.Disconnected }); + if (_mqttConfig.Configuration.Reconnect && !_isConnecting) + { + _logger.LogInformation("Attempting to reconnect to MQTT broker..."); + _isConnecting = true; + try + { + for (int i = 0; i < _mqttConfig.Configuration.ReconnectAttempts; i++) + { + try + { + var result = await _client.ConnectAsync(_client.Options); + if(result.ResultCode == MqttClientConnectResultCode.Success) break; + else _logger.LogWarning($"Failed to reconnect to MQTT broker. Attempt {i + 1} of {_mqttConfig.Configuration.ReconnectAttempts}. Reason: {result.ResultCode}"); + } + catch (Exception ex) + { + _logger.LogError($"Exception during MQTT reconnect attempt {i + 1} of {_mqttConfig.Configuration.ReconnectAttempts}: {ex.Message}"); + } + await Task.Delay(TimeSpan.FromSeconds(2)); + } + } + finally + { + _isConnecting = false; + } + } + } + + private Task ClientOnConnectedAsync(MqttClientConnectedEventArgs arg) + { + _logger.LogInformation("MQTT client connected."); + _messageBus.Publish(new MqttConnectionEvent + { + EventType = EventType.Connected + }); + + return Task.CompletedTask; + } + + private void SubscribeHomeAssistantState() + { + if(!_client.IsConnected || _isConnecting) + { + _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot subscribe to Home Assistant state."); + return; + } + + _client.SubscribeAsync($"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); + _logger.LogInformation("Subscribed to Home Assistant state topic: {Topic}", $"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); + } + + private void UnsubscribeHomeAssistantState() + { + if(!_client.IsConnected || _isConnecting) + { + _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot unsubscribe from Home Assistant state."); + return; + } + + _client.UnsubscribeAsync($"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); + _logger.LogInformation("Unsubscribed from Home Assistant state topic: {Topic}", $"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); + } + + private void EmitHomeAssistantDiscovery() + { + + } + + private dynamic BuildSubdevicePayload(Model.Subdevice subdevice) + { + return new + { + id = subdevice.Id, + model = subdevice.Model, + devicename = subdevice.Devicename, + nickname = subdevice.Nickname, + state = subdevice.Availability ? "online" : "offline", + ver = subdevice.Version + }; + } + + private dynamic BuildSensorPayload(ISensor s) + { + _logger.LogDebug($"Sensor {s.Name} datatype: {s.DataType}"); + return new + { + name = s.Name, + alias = s.Alias, + value = s.DataType == typeof(double) ? Math.Round(Convert.ToDouble(s.Value), _controllerOptions.Precision) : s.Value, + unit = !string.IsNullOrWhiteSpace(s.UnitOfMeasurement) ? s.UnitOfMeasurement : null + //type = s.SensorType != SensorType.None ? s.SensorType.ToString() : null + }; + } + + private dynamic BuildGatewayPayload(Device gw) + { + if (string.IsNullOrWhiteSpace(gw.Model)) + { + return new + { + ip = gw.IpAddress, + name = gw.Name + }; + } + + return new + { + ip = gw.IpAddress, + name = gw.Name, + model = gw.Model, + passkey = gw.PASSKEY, + stationType = gw.StationType, + runtime = gw.Runtime, + state = (DateTime.UtcNow - gw.TimestampUtc).TotalSeconds < _controllerOptions.PublishingInterval * 3 ? "online" : "offline", + freq = gw.Freq + }; + } + + private async Task PublishMessage(string topic, dynamic payload) + { + if (!await _mqttClient.Publish($"{_mqttOptions.BaseTopic}/{topic}", + JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping}))) + _logger.LogWarning($"Failed to publish message to topic {_mqttOptions.BaseTopic}/{topic}. Is the client connected?"); + } + + private async Task PublishAvailabilityMessage(string topic, DateTime timestamp) + { + var available = DateTime.UtcNow.Subtract(timestamp) < TimeSpan.FromSeconds(300) ? "online" : "offline"; + + if (!await _mqttClient.Publish($"{_mqttOptions.BaseTopic}/{topic}/availability", available)) + _logger.LogWarning($"Failed to publish message to topic {_mqttOptions.BaseTopic}/{topic}. Is the client connected?"); + } + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/StateMachine.cs b/src/Ecowitt.Controller/StateMachine.cs index 2c98fd0..be85980 100644 --- a/src/Ecowitt.Controller/StateMachine.cs +++ b/src/Ecowitt.Controller/StateMachine.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Ecowitt.Controller.Configuration; using Ecowitt.Controller.Mapping; +using Ecowitt.Controller.Message; using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Store; using Microsoft.Extensions.Options; using SlimMessageBus; @@ -126,7 +128,7 @@ public Task OnHandle(SubdeviceApiAggregate message) return Task.CompletedTask; } - storedGateway = new Gateway {IpAddress = ip}; + storedGateway = new Device {IpAddress = ip}; storedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == storedGateway.IpAddress)?.Name ?? storedGateway.IpAddress.Replace('.','-'); storedGateway.DiscoveryUpdate = true; } diff --git a/src/Ecowitt.Controller/Store/DataPublishService.cs b/src/Ecowitt.Controller/Store/DataPublishService.cs index 2d4c6f9..c13e0a6 100644 --- a/src/Ecowitt.Controller/Store/DataPublishService.cs +++ b/src/Ecowitt.Controller/Store/DataPublishService.cs @@ -110,7 +110,7 @@ private dynamic BuildSensorPayload(ISensor s) }; } - private dynamic BuildGatewayPayload(Gateway gw) + private dynamic BuildGatewayPayload(Device gw) { if (string.IsNullOrWhiteSpace(gw.Model)) { diff --git a/src/Ecowitt.Controller/Store/DeviceStore.cs b/src/Ecowitt.Controller/Store/DeviceStore.cs index a943b69..756d127 100644 --- a/src/Ecowitt.Controller/Store/DeviceStore.cs +++ b/src/Ecowitt.Controller/Store/DeviceStore.cs @@ -5,16 +5,16 @@ namespace Ecowitt.Controller.Store; public interface IDeviceStore { - Gateway? GetGateway(string ipAddress); - Gateway? GetGatewayBySubdeviceId(int id); - bool UpsertGateway(Gateway data); + Device? GetGateway(string ipAddress); + Device? GetGatewayBySubdeviceId(int id); + bool UpsertGateway(Device data); void Clear(); Dictionary GetGatewaysShort(); } public class DeviceStore : IDeviceStore { - private readonly ConcurrentDictionary _gateways = new(); + private readonly ConcurrentDictionary _gateways = new(); private readonly ILogger _logger; public DeviceStore(ILogger logger) @@ -27,17 +27,17 @@ public Dictionary GetGatewaysShort() return _gateways.ToArray().ToDictionary(gateway => gateway.Key, gateway => gateway.Value.Model); } - public Gateway? GetGateway(string ipAddress) + public Device? GetGateway(string ipAddress) { return _gateways.TryGetValue(ipAddress, out var gateway) ? gateway : null; } - public Gateway? GetGatewayBySubdeviceId(int id) + public Device? GetGatewayBySubdeviceId(int id) { return _gateways.Values.FirstOrDefault(gw => gw.Subdevices.Any(sd => sd.Id == id)); } - public bool UpsertGateway(Gateway data) + public bool UpsertGateway(Device data) { if (_gateways.TryGetValue(data.IpAddress, out var gateway)) { diff --git a/src/Ecowitt.Controller/Subdevice/SubdeviceService.cs b/src/Ecowitt.Controller/Subdevice/SubdeviceService.cs index 2e86b63..2fd8b0d 100644 --- a/src/Ecowitt.Controller/Subdevice/SubdeviceService.cs +++ b/src/Ecowitt.Controller/Subdevice/SubdeviceService.cs @@ -1,6 +1,8 @@ using System.Text.Json; using Ecowitt.Controller.Configuration; +using Ecowitt.Controller.Message; using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Store; using Microsoft.Extensions.Options; using SlimMessageBus; From 47453b839be31446bba868db9a37c136c1a02547 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Wed, 4 Jun 2025 23:40:26 +0200 Subject: [PATCH 13/44] fixes and cleanup for new mqtt service class. mqttoptions & controlleroptions need to be mapped to mqttconfig in statemachine before sending --- .../Configuration/MqttOptions.cs | 1 + .../Ecowitt.Controller.csproj | 1 - .../Message/Config/HomeAssistantConfig.cs | 6 - .../Message/Config/MqttConfig.cs | 16 +- .../Mqtt/{Helper.cs => MqttPathBuilder.cs} | 2 +- .../Service/Mqtt/MqttPayloadBuilder.cs | 63 +++++ .../Service/Mqtt/MqttService.cs | 216 +++++++++--------- 7 files changed, 192 insertions(+), 113 deletions(-) delete mode 100644 src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs rename src/Ecowitt.Controller/Service/Mqtt/{Helper.cs => MqttPathBuilder.cs} (98%) create mode 100644 src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs diff --git a/src/Ecowitt.Controller/Configuration/MqttOptions.cs b/src/Ecowitt.Controller/Configuration/MqttOptions.cs index 2f787f7..999d287 100644 --- a/src/Ecowitt.Controller/Configuration/MqttOptions.cs +++ b/src/Ecowitt.Controller/Configuration/MqttOptions.cs @@ -10,4 +10,5 @@ public class MqttOptions public string ClientId { get; set; } = "ecowitt-controller"; public bool Reconnect { get; set; } = true; public int ReconnectAttempts { get; set; } = 2; + } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Ecowitt.Controller.csproj b/src/Ecowitt.Controller/Ecowitt.Controller.csproj index 6ad3d2a..2b06675 100644 --- a/src/Ecowitt.Controller/Ecowitt.Controller.csproj +++ b/src/Ecowitt.Controller/Ecowitt.Controller.csproj @@ -33,7 +33,6 @@ - diff --git a/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs b/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs deleted file mode 100644 index f38c7e4..0000000 --- a/src/Ecowitt.Controller/Message/Config/HomeAssistantConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Ecowitt.Controller.Message.Config; - -public class HomeAssistantConfig -{ - public bool Enabled { get; set; } = true; -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Config/MqttConfig.cs b/src/Ecowitt.Controller/Message/Config/MqttConfig.cs index 90e720b..aaff816 100644 --- a/src/Ecowitt.Controller/Message/Config/MqttConfig.cs +++ b/src/Ecowitt.Controller/Message/Config/MqttConfig.cs @@ -5,5 +5,19 @@ namespace Ecowitt.Controller.Message.Config; public class MqttConfig { public bool Enabled { get; set; } = true; - public MqttOptions Configuration { get; set; } = new(); + public string Host { get; set; } + public int Port { get; set; } = 1883; + public string User { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string BaseTopic { get; set; } = "ecowitt"; + public string ClientId { get; set; } = "ecowitt-controller"; + public bool Reconnect { get; set; } = true; + public int ReconnectAttempts { get; set; } = 2; + public int Precision { get; set; } = 2; + public Units Units { get; set; } = Units.Metric; + public int PublishingInterval { get; set; } = 60; // 0 or lower immediately publish all data + public bool HomeAssistantDiscovery { get; set; } = true; + + public string CmdTopic { get; } = "cmd"; + public string HeartbeatTopic { get; } = "heartbeat"; } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Mqtt/Helper.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttPathBuilder.cs similarity index 98% rename from src/Ecowitt.Controller/Service/Mqtt/Helper.cs rename to src/Ecowitt.Controller/Service/Mqtt/MqttPathBuilder.cs index 6c7a2a3..4827306 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/Helper.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttPathBuilder.cs @@ -1,6 +1,6 @@ namespace Ecowitt.Controller.Service.Mqtt; -public static class Helper +public static class MqttPathBuilder { public static string BuildMqttGatewayTopic(string gwName) { diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs new file mode 100644 index 0000000..90d216b --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs @@ -0,0 +1,63 @@ +using Ecowitt.Controller.Model; + +namespace Ecowitt.Controller.Service.Mqtt; + +public static class MqttPayloadBuilder +{ + public static dynamic BuildSubdevicePayload(Model.Subdevice subdevice) + { + return new + { + id = subdevice.Id, + model = subdevice.Model, + devicename = subdevice.Devicename, + nickname = subdevice.Nickname, + state = subdevice.Availability ? "online" : "offline", + ver = subdevice.Version + }; + } + + public static dynamic BuildSensorPayload(ISensor s, int? precision) + { + // _logger.LogDebug($"Sensor {s.Name} datatype: {s.DataType}"); // Logging removed, can be done by caller + return new + { + name = s.Name, + alias = s.Alias, + value = s.DataType == typeof(double) ? Math.Round(Convert.ToDouble(s.Value), precision ?? 2) : s.Value, + unit = !string.IsNullOrWhiteSpace(s.UnitOfMeasurement) ? s.UnitOfMeasurement : null + //type = s.SensorType != SensorType.None ? s.SensorType.ToString() : null + }; + } + + public static dynamic BuildGatewayPayload(Device gw, int? publishingInterval) + { + if (string.IsNullOrWhiteSpace(gw.Model)) + { + return new + { + ip = gw.IpAddress, + name = gw.Name + }; + } + + var state = "offline"; // Default state + if (publishingInterval.HasValue) + { + // Determine state based on last update time and publishing interval + state = (DateTime.UtcNow - gw.TimestampUtc).TotalSeconds < publishingInterval.Value * 3 ? "online" : "offline"; + } + + return new + { + ip = gw.IpAddress, + name = gw.Name, + model = gw.Model, + passkey = gw.PASSKEY, + stationType = gw.StationType, + runtime = gw.Runtime, + state = state, + freq = gw.Freq + }; + } +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index a8d2e03..9fef932 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -1,26 +1,23 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; -using Ecowitt.Controller.Configuration; using Ecowitt.Controller.Message.Config; using Ecowitt.Controller.Message.Data; using Ecowitt.Controller.Message.Event; -using Ecowitt.Controller.Model; using MQTTnet; using MQTTnet.Client; using SlimMessageBus; namespace Ecowitt.Controller.Service.Mqtt; -public class MqttService : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer +public class MqttService : BackgroundService, IConsumer, IConsumer, IConsumer { private readonly ILogger _logger; private readonly MqttFactory _factory; private readonly IMessageBus _messageBus; - private IMqttClient _client; - private bool _homeAssistantDiscoveryEnabled; - private bool _isConnecting = false; - private MqttConfig _mqttConfig = new (); + private IMqttClient? _client; + private bool _isConnecting; + private MqttConfig? _mqttConfig; public MqttService(ILogger logger, MqttFactory factory, IMessageBus messageBus) @@ -34,45 +31,46 @@ public async Task OnHandle(MqttConfig message) { if(_client != null) { - if(_client.IsConnected) await _client.DisconnectAsync(); - _client.ApplicationMessageReceivedAsync -= ClientOnApplicationMessageReceivedAsync; - _client.DisconnectedAsync -= ClientOnDisconnectedAsync; - _client.ConnectedAsync -= ClientOnConnectedAsync; - _client.Dispose(); + try + { + if (_mqttConfig is { HomeAssistantDiscovery: true }) UnsubscribeHomeAssistantState(); + + if(_client.IsConnected) await _client.DisconnectAsync(); + _client.ApplicationMessageReceivedAsync -= ClientOnApplicationMessageReceivedAsync; + _client.DisconnectedAsync -= ClientOnDisconnectedAsync; + _client.ConnectedAsync -= ClientOnConnectedAsync; + _client.Dispose(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while disconnecting, unregistering or disposing MQTT client."); + return; + } } + _mqttConfig = message; + if(!message.Enabled) { _logger.LogInformation("MQTT is disabled"); return; } - _mqttConfig = message; _client = _factory.CreateMqttClient(); _client.ConnectedAsync += ClientOnConnectedAsync; _client.DisconnectedAsync += ClientOnDisconnectedAsync; _client.ApplicationMessageReceivedAsync += ClientOnApplicationMessageReceivedAsync; var optionsBuilder = new MqttClientOptionsBuilder() - .WithTcpServer(_mqttConfig.Configuration.Host, _mqttConfig.Configuration.Port) - .WithClientId(_mqttConfig.Configuration.ClientId) + .WithTcpServer(_mqttConfig.Host, _mqttConfig.Port) + .WithClientId(_mqttConfig.ClientId) .WithCleanSession(); - if(!string.IsNullOrWhiteSpace(_mqttConfig.Configuration.User)) - optionsBuilder.WithCredentials(_mqttConfig.Configuration.User, _mqttConfig.Configuration.Password); + if(!string.IsNullOrWhiteSpace(_mqttConfig.User)) + optionsBuilder.WithCredentials(_mqttConfig.User, _mqttConfig.Password); await _client.ConnectAsync(optionsBuilder.Build()); - await _client.SubscribeAsync($"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceCommandTopic()}"); - } - - public async Task OnHandle(HomeAssistantConfig message) - { - _homeAssistantDiscoveryEnabled = message.Enabled; - if (_homeAssistantDiscoveryEnabled) - { - SubscribeHomeAssistantState(); - } else - { - UnsubscribeHomeAssistantState(); - } + await _client.SubscribeAsync($"{_mqttConfig.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceCommandTopic()}"); + + if(_mqttConfig.HomeAssistantDiscovery) SubscribeHomeAssistantState(); } public async Task OnHandle(HomeAssistantDiscoveryEvent message) @@ -80,48 +78,88 @@ public async Task OnHandle(HomeAssistantDiscoveryEvent message) EmitHomeAssistantDiscovery(); } - public async Task OnHandle(DeviceData message) + public Task OnHandle(DeviceData message) { if (_client == null || !_client.IsConnected) { _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); - return; } - + return Task.CompletedTask; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - throw new NotImplementedException(); + _logger.LogInformation("Starting MqttService"); + + using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); + try + { + while(await timer.WaitForNextTickAsync(stoppingToken)) + { + if (_client is { IsConnected: true } && _mqttConfig != null) + { + await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", + JsonSerializer.Serialize(new { service = DateTime.UtcNow })); + _logger.LogInformation("Sent heartbeat"); + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Stopping MqttService"); + } } private Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) { - throw new NotImplementedException(); + var payload = arg.ApplicationMessage.ConvertPayloadToString(); + var topic = arg.ApplicationMessage.Topic; + _logger.LogDebug($"Message received for topic {topic}: {payload}"); + + if (topic.EndsWith("homeassistant")) + { + if (int.TryParse(topic.Split('/')[3], out var result)) + { + //var cmd = payload.Equals("ON", StringComparison.InvariantCultureIgnoreCase) ? Command.Start : Command.Stop; //I know, everything that's not "ON" is "OFF" + //await _messageBus.Publish(new SubdeviceApiCommand() { Cmd = cmd, Id = result }); + } + else + { + _logger.LogWarning("Invalid subdevice id in topic {Topic}", topic); + } + } + else + { + // var cmd = JsonSerializer.Deserialize(e.Payload); + // await _messageBus.Publish(cmd); + } + + return Task.CompletedTask; + } private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg) { _logger.LogInformation("MQTT client disconnected."); - _messageBus.Publish(new MqttConnectionEvent { EventType = EventType.Disconnected }); - if (_mqttConfig.Configuration.Reconnect && !_isConnecting) + await _messageBus.Publish(new MqttConnectionEvent { EventType = EventType.Disconnected }); + if (_mqttConfig is { Reconnect: true } && !_isConnecting) { _logger.LogInformation("Attempting to reconnect to MQTT broker..."); _isConnecting = true; try { - for (int i = 0; i < _mqttConfig.Configuration.ReconnectAttempts; i++) + for (var i = 0; i < _mqttConfig.ReconnectAttempts; i++) { try { - var result = await _client.ConnectAsync(_client.Options); + var result = await _client?.ConnectAsync(_client.Options)!; if(result.ResultCode == MqttClientConnectResultCode.Success) break; - else _logger.LogWarning($"Failed to reconnect to MQTT broker. Attempt {i + 1} of {_mqttConfig.Configuration.ReconnectAttempts}. Reason: {result.ResultCode}"); + else _logger.LogWarning($"Failed to reconnect to MQTT broker. Attempt {i + 1} of {_mqttConfig.ReconnectAttempts}. Reason: {result.ResultCode}"); } catch (Exception ex) { - _logger.LogError($"Exception during MQTT reconnect attempt {i + 1} of {_mqttConfig.Configuration.ReconnectAttempts}: {ex.Message}"); + _logger.LogError($"Exception during MQTT reconnect attempt {i + 1} of {_mqttConfig.ReconnectAttempts}: {ex.Message}"); } await Task.Delay(TimeSpan.FromSeconds(2)); } @@ -133,39 +171,37 @@ private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg } } - private Task ClientOnConnectedAsync(MqttClientConnectedEventArgs arg) + private async Task ClientOnConnectedAsync(MqttClientConnectedEventArgs arg) { _logger.LogInformation("MQTT client connected."); - _messageBus.Publish(new MqttConnectionEvent + await _messageBus.Publish(new MqttConnectionEvent { EventType = EventType.Connected }); - - return Task.CompletedTask; } private void SubscribeHomeAssistantState() { - if(!_client.IsConnected || _isConnecting) + if(_client != null && (!_client.IsConnected || _isConnecting)) { _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot subscribe to Home Assistant state."); return; } - _client.SubscribeAsync($"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); - _logger.LogInformation("Subscribed to Home Assistant state topic: {Topic}", $"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); + _client.SubscribeAsync($"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); + _logger.LogInformation("Subscribed to Home Assistant state topic: {Topic}", $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); } private void UnsubscribeHomeAssistantState() { - if(!_client.IsConnected || _isConnecting) + if(_client != null && (!_client.IsConnected || _isConnecting)) { _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot unsubscribe from Home Assistant state."); return; } - _client.UnsubscribeAsync($"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); - _logger.LogInformation("Unsubscribed from Home Assistant state topic: {Topic}", $"{_mqttConfig.Configuration.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); + _client.UnsubscribeAsync($"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); + _logger.LogInformation("Unsubscribed from Home Assistant state topic: {Topic}", $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); } private void EmitHomeAssistantDiscovery() @@ -173,69 +209,41 @@ private void EmitHomeAssistantDiscovery() } - private dynamic BuildSubdevicePayload(Model.Subdevice subdevice) - { - return new - { - id = subdevice.Id, - model = subdevice.Model, - devicename = subdevice.Devicename, - nickname = subdevice.Nickname, - state = subdevice.Availability ? "online" : "offline", - ver = subdevice.Version - }; - } - - private dynamic BuildSensorPayload(ISensor s) - { - _logger.LogDebug($"Sensor {s.Name} datatype: {s.DataType}"); - return new - { - name = s.Name, - alias = s.Alias, - value = s.DataType == typeof(double) ? Math.Round(Convert.ToDouble(s.Value), _controllerOptions.Precision) : s.Value, - unit = !string.IsNullOrWhiteSpace(s.UnitOfMeasurement) ? s.UnitOfMeasurement : null - //type = s.SensorType != SensorType.None ? s.SensorType.ToString() : null - }; - } - - private dynamic BuildGatewayPayload(Device gw) - { - if (string.IsNullOrWhiteSpace(gw.Model)) - { - return new - { - ip = gw.IpAddress, - name = gw.Name - }; - } - - return new - { - ip = gw.IpAddress, - name = gw.Name, - model = gw.Model, - passkey = gw.PASSKEY, - stationType = gw.StationType, - runtime = gw.Runtime, - state = (DateTime.UtcNow - gw.TimestampUtc).TotalSeconds < _controllerOptions.PublishingInterval * 3 ? "online" : "offline", - freq = gw.Freq - }; - } + private async Task PublishMessage(string topic, dynamic payload) { - if (!await _mqttClient.Publish($"{_mqttOptions.BaseTopic}/{topic}", + if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}", JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping}))) - _logger.LogWarning($"Failed to publish message to topic {_mqttOptions.BaseTopic}/{topic}. Is the client connected?"); + _logger.LogWarning($"Failed to publish message to topic {_mqttConfig?.BaseTopic}/{topic}. Is the client connected?"); } private async Task PublishAvailabilityMessage(string topic, DateTime timestamp) { var available = DateTime.UtcNow.Subtract(timestamp) < TimeSpan.FromSeconds(300) ? "online" : "offline"; - if (!await _mqttClient.Publish($"{_mqttOptions.BaseTopic}/{topic}/availability", available)) - _logger.LogWarning($"Failed to publish message to topic {_mqttOptions.BaseTopic}/{topic}. Is the client connected?"); + if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}/availability", available)) + _logger.LogWarning($"Failed to publish message to topic {_mqttConfig?.BaseTopic}/{topic}. Is the client connected?"); } + private async Task Publish(string topic, string message) + { + var mqttPayload = new MqttApplicationMessageBuilder() + .WithTopic(topic) + .WithPayload(message) + .Build(); + + if (_client is { IsConnected: true }) + { + var result = await _client.PublishAsync(mqttPayload); + if (result.IsSuccess) + { + _logger.LogDebug($"Message {message} published to topic {topic}"); + return true; + } + else _logger.LogWarning($"Failed to publish message {message} to topic {topic}. Reason: {result.ReasonCode}"); + } + else _logger.LogWarning($"Can't publish message {message} to topic {topic}. Client not connected."); + return false; + } } \ No newline at end of file From 66ecdc78e04478522519f7ac619c79757e9d4c5f Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 15 Jul 2025 23:35:44 +0200 Subject: [PATCH 14/44] updated diagrams.drawio --- diagrams.drawio | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/diagrams.drawio b/diagrams.drawio index f72030e..e586c96 100644 --- a/diagrams.drawio +++ b/diagrams.drawio @@ -1,6 +1,6 @@ - + - + @@ -25,9 +25,24 @@ - + + + + + + + + + + + + + + + + From 92220d60ee5cd5a72fb9af6f50b8436ecff87ec0 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 15 Jul 2025 23:36:47 +0200 Subject: [PATCH 15/44] some more refactoring of the new MqttService.cs and the StateMachine.cs --- .../Event/HomeAssistantReconnectEvent.cs | 6 - .../Message/Event/HomeAssistantStatusEvent.cs | 13 ++ src/Ecowitt.Controller/Program.cs | 21 +-- .../Service/Mqtt/MqttService.cs | 35 +++-- .../Orchestrator}/StateMachine.cs | 131 ++++++++++++------ 5 files changed, 140 insertions(+), 66 deletions(-) delete mode 100644 src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs create mode 100644 src/Ecowitt.Controller/Message/Event/HomeAssistantStatusEvent.cs rename src/Ecowitt.Controller/{ => Service/Orchestrator}/StateMachine.cs (67%) diff --git a/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs b/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs deleted file mode 100644 index d932195..0000000 --- a/src/Ecowitt.Controller/Message/Event/HomeAssistantReconnectEvent.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Ecowitt.Controller.Message.Event; - -public class HomeAssistantReconnectEvent -{ - -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/HomeAssistantStatusEvent.cs b/src/Ecowitt.Controller/Message/Event/HomeAssistantStatusEvent.cs new file mode 100644 index 0000000..d0f579a --- /dev/null +++ b/src/Ecowitt.Controller/Message/Event/HomeAssistantStatusEvent.cs @@ -0,0 +1,13 @@ +namespace Ecowitt.Controller.Message.Event; + +public class HomeAssistantStatusEvent +{ + public HomeAssistantStatusType Status { get; set; } +} + +public enum HomeAssistantStatusType +{ + Unknown, + Online, + Offline +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index 9c2d369..8a2c1fb 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -1,11 +1,11 @@ using System.Net; using System.Reflection; using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Discovery; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Mqtt; +using Ecowitt.Controller.Message.Config; +using Ecowitt.Controller.Message.Data; +using Ecowitt.Controller.Message.Event; +using Ecowitt.Controller.Service.Mqtt; using Ecowitt.Controller.Store; -using Ecowitt.Controller.Subdevice; using MQTTnet; using Polly; using Polly.Contrib.WaitAndRetry; @@ -60,6 +60,9 @@ public static async Task Main(string[] args) { smb.WithProviderMemory(cfg => { cfg.EnableMessageSerialization = true; }); smb.AddJsonSerializer(); + smb.Consume(x => x.Topic("config-mqtt").WithConsumer()); + smb.Consume(x => x.Topic("home-assistant-discovery").WithConsumer()); + smb.Consume(x => x.Topic("device-data").WithConsumer()); // smb.Produce(x => x.DefaultTopic("api-data")); @@ -81,12 +84,12 @@ public static async Task Main(string[] args) }); builder.Services.AddTransient(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); + // + // builder.Services.AddHostedService(); + // builder.Services.AddHostedService(); + // builder.Services.AddHostedService(); + // builder.Services.AddHostedService(); builder.Services.AddControllers(); //builder.Services.AddEndpointsApiExplorer(); diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index 9fef932..c16e758 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -4,6 +4,7 @@ using Ecowitt.Controller.Message.Config; using Ecowitt.Controller.Message.Data; using Ecowitt.Controller.Message.Event; +using Ecowitt.Controller.Model.Api; using MQTTnet; using MQTTnet.Client; using SlimMessageBus; @@ -111,18 +112,26 @@ await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", } } - private Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) + private async Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) { var payload = arg.ApplicationMessage.ConvertPayloadToString(); var topic = arg.ApplicationMessage.Topic; _logger.LogDebug($"Message received for topic {topic}: {payload}"); - - if (topic.EndsWith("homeassistant")) + + if (topic.Equals("homeassistant/status", StringComparison.OrdinalIgnoreCase)) + { + // home assistant status topic + if (payload.Equals("online", StringComparison.OrdinalIgnoreCase)) + await _messageBus.Publish(new HomeAssistantStatusEvent() { Status = HomeAssistantStatusType.Online }); + else if (payload.Equals("offline", StringComparison.OrdinalIgnoreCase)) await _messageBus.Publish(new HomeAssistantStatusEvent { Status = HomeAssistantStatusType.Offline }); + else await _messageBus.Publish(new HomeAssistantStatusEvent { Status = HomeAssistantStatusType.Unknown }); + } else if (topic.EndsWith("cmd/homeassistant")) { + // commands coming from home assistant if (int.TryParse(topic.Split('/')[3], out var result)) { - //var cmd = payload.Equals("ON", StringComparison.InvariantCultureIgnoreCase) ? Command.Start : Command.Stop; //I know, everything that's not "ON" is "OFF" - //await _messageBus.Publish(new SubdeviceApiCommand() { Cmd = cmd, Id = result }); + var cmd = payload.Equals("ON", StringComparison.InvariantCultureIgnoreCase) ? Command.Start : Command.Stop; //I know, everything that's not "ON" is "OFF" + await _messageBus.Publish(new SubdeviceApiCommand() { Cmd = cmd, Id = result }); } else { @@ -131,12 +140,18 @@ private Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageRecei } else { - // var cmd = JsonSerializer.Deserialize(e.Payload); - // await _messageBus.Publish(cmd); + // direct commands via mqtt + try + { + var cmd = JsonSerializer.Deserialize(payload); + await _messageBus.Publish(cmd); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } } - - return Task.CompletedTask; - } private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg) diff --git a/src/Ecowitt.Controller/StateMachine.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs similarity index 67% rename from src/Ecowitt.Controller/StateMachine.cs rename to src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs index be85980..de4c41a 100644 --- a/src/Ecowitt.Controller/StateMachine.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs @@ -2,29 +2,77 @@ using Ecowitt.Controller.Configuration; using Ecowitt.Controller.Mapping; using Ecowitt.Controller.Message; +using Ecowitt.Controller.Message.Config; using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Store; using Microsoft.Extensions.Options; using SlimMessageBus; -namespace Ecowitt.Controller; +namespace Ecowitt.Controller.Service.Orchestrator; -public class StateMachine : IConsumer, IConsumer, IConsumer +public class StateMachine : BackgroundService, IConsumer, IConsumer, IConsumer { private readonly ILogger _logger; private readonly IDeviceStore _deviceStore; - private readonly IHttpClientFactory _httpClientFactory; private readonly EcowittOptions _ecowittOptions; private readonly ControllerOptions _controllerOptions; + private readonly MqttOptions _mqttOptions; + private readonly IMessageBus _messageBus; - public StateMachine(ILogger logger, IDeviceStore deviceStore, IHttpClientFactory httpClientFactory, IOptions ecowittOptions, IOptions controllerOptions) + public StateMachine(ILogger logger, IDeviceStore deviceStore, IMessageBus messageBus, IOptions mqttOptions, IOptions ecowittOptions, IOptions controllerOptions) { _logger = logger; _deviceStore = deviceStore; - _httpClientFactory = httpClientFactory; + _mqttOptions = mqttOptions.Value; _ecowittOptions = ecowittOptions.Value; _controllerOptions = controllerOptions.Value; + _messageBus = messageBus; + } + + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Starting Orchestrator"); + + var mqttConfig = new MqttConfig + { + Host = _mqttOptions.Host, + Port = _mqttOptions.Port, + ClientId = _mqttOptions.ClientId, + BaseTopic = _mqttOptions.BaseTopic, + ReconnectAttempts = _mqttOptions.ReconnectAttempts, + HomeAssistantDiscovery = _controllerOptions.HomeAssistantDiscovery, + Precision = _controllerOptions.Precision, + PublishingInterval = _controllerOptions.PublishingInterval, + Units = _controllerOptions.Units + }; + if (!string.IsNullOrWhiteSpace(_mqttOptions.User)) + { + mqttConfig.User = _mqttOptions.User; + mqttConfig.Password = _mqttOptions.Password; + } + + await _messageBus.Publish(mqttConfig); + + + // using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); + // try + // { + // while(await timer.WaitForNextTickAsync(stoppingToken)) + // { + // if (_client is { IsConnected: true } && _mqttConfig != null) + // { + // await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", + // JsonSerializer.Serialize(new { service = DateTime.UtcNow })); + // _logger.LogInformation("Sent heartbeat"); + // } + // } + // } + // catch (OperationCanceledException) + // { + // _logger.LogInformation("Stopping MqttService"); + // } } public async Task OnHandle(SubdeviceApiCommand message) @@ -195,41 +243,42 @@ public Task OnHandle(SubdeviceApiAggregate message) return Task.CompletedTask; } - public async Task SendCommand(string ipAddress, string cmd, int id, int model, int val = 0, int valType = 0, int onType = 0, int offType = 0, int alwaysOn = 1, int onTime = 0, int offTime = 0) - { - var client = _httpClientFactory.CreateClient("ecowitt-client"); - client.BaseAddress = new Uri($"http://{ipAddress}"); - - var username = _ecowittOptions.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Username; - var password = _ecowittOptions.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Password; - if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) - { - //TODO: authentication header, need to test - //client.DefaultRequestHeaders.Add(); - } - - // [{"on_type":0,"off_type":0,"always_on":0,"on_time":0,"off_time":0,"val_type":1,"val":20,"cmd":"quick_run","id":12345,"model":1}]} - dynamic payload; - switch (cmd) - { - case "quick_run": - payload = new { command = new[] { new { cmd, id, model, val, val_type = valType, on_type = onType, off_type = offType, always_on = alwaysOn, on_time = onTime, off_time = offTime } } }; - break; - case "quick_stop": - payload = new { command = new[] { new { cmd, id, model } } }; - break; - default: - _logger.LogWarning($"Unsupported command type {cmd}. Not sending command to {ipAddress} for subdevice {id}"); - return false; - } - - var sContent = new StringContent(JsonSerializer.Serialize(payload)); - var response = await client.PostAsync("parse_quick_cmd_iot", sContent); + // public async Task SendCommand(string ipAddress, string cmd, int id, int model, int val = 0, int valType = 0, int onType = 0, int offType = 0, int alwaysOn = 1, int onTime = 0, int offTime = 0) + // { + // var client = _httpClientFactory.CreateClient("ecowitt-client"); + // client.BaseAddress = new Uri($"http://{ipAddress}"); + // + // var username = _ecowittOptions.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Username; + // var password = _ecowittOptions.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Password; + // if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) + // { + // //TODO: authentication header, need to test + // //client.DefaultRequestHeaders.Add(); + // } + // + // // [{"on_type":0,"off_type":0,"always_on":0,"on_time":0,"off_time":0,"val_type":1,"val":20,"cmd":"quick_run","id":12345,"model":1}]} + // dynamic payload; + // switch (cmd) + // { + // case "quick_run": + // payload = new { command = new[] { new { cmd, id, model, val, val_type = valType, on_type = onType, off_type = offType, always_on = alwaysOn, on_time = onTime, off_time = offTime } } }; + // break; + // case "quick_stop": + // payload = new { command = new[] { new { cmd, id, model } } }; + // break; + // default: + // _logger.LogWarning($"Unsupported command type {cmd}. Not sending command to {ipAddress} for subdevice {id}"); + // return false; + // } + // + // var sContent = new StringContent(JsonSerializer.Serialize(payload)); + // var response = await client.PostAsync("parse_quick_cmd_iot", sContent); + // + // if (response.IsSuccessStatusCode) return true; + // else { + // _logger.LogWarning($"Could not send command to {ipAddress} for subdevice {id}"); + // return false; + // } + // } - if (response.IsSuccessStatusCode) return true; - else { - _logger.LogWarning($"Could not send command to {ipAddress} for subdevice {id}"); - return false; - } - } } \ No newline at end of file From 5ff783a71df8b1dda8c5f60d09daa6c65f06b752 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Thu, 17 Jul 2025 23:22:11 +0200 Subject: [PATCH 16/44] updated diagram --- .$diagrams.drawio.bkp | 234 ------------------------------------------ .gitignore | 1 + diagrams.drawio | 48 +++++++-- 3 files changed, 42 insertions(+), 241 deletions(-) delete mode 100644 .$diagrams.drawio.bkp diff --git a/.$diagrams.drawio.bkp b/.$diagrams.drawio.bkp deleted file mode 100644 index 16f96cc..0000000 --- a/.$diagrams.drawio.bkp +++ /dev/null @@ -1,234 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.gitignore b/.gitignore index 44e6e4a..19c6171 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml .idea/ +/.$diagrams.drawio.bkp diff --git a/diagrams.drawio b/diagrams.drawio index e586c96..de15417 100644 --- a/diagrams.drawio +++ b/diagrams.drawio @@ -1,9 +1,22 @@ - + + + + + + + + + + + + + + @@ -28,17 +41,17 @@ - + - + - + @@ -96,6 +109,21 @@ + + + + + + + + + + + + + + + @@ -105,9 +133,6 @@ - - - @@ -120,6 +145,15 @@ + + + + + + + + + From c04b62bd3f38a50e560051d4b5619686a665363e Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Thu, 17 Jul 2025 23:22:21 +0200 Subject: [PATCH 17/44] docs --- docs/api.md | 0 docs/mqtt.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/mqtt.md diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/mqtt.md b/docs/mqtt.md new file mode 100644 index 0000000..e69de29 From 0d1f6557b3d3a86ee78801e95f9402eafe2c0b5b Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Thu, 17 Jul 2025 23:22:57 +0200 Subject: [PATCH 18/44] updated gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 19c6171..961d3c9 100644 --- a/.gitignore +++ b/.gitignore @@ -399,3 +399,5 @@ FodyWeavers.xsd *.sln.iml .idea/ /.$diagrams.drawio.bkp +/.continue/mcpServers +/.vscode From 167610588a772d0f89ceb6dae3588cbf67bfdf0f Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Thu, 17 Jul 2025 23:23:48 +0200 Subject: [PATCH 19/44] communication statemachine <-> mqttservice --- src/Ecowitt.Controller/Ecowitt.Controller.sln | 24 ++++ .../Message/Event/MqttConnectionEvent.cs | 4 +- .../Message/Event/MqttServiceEvent.cs | 17 +++ src/Ecowitt.Controller/Program.cs | 16 +++ .../Service/Mqtt/MqttService.cs | 41 ++++-- .../Service/Orchestrator/StateMachine.cs | 132 ++++++++++++++---- src/Ecowitt.Controller/appsettings.json | 3 +- 7 files changed, 201 insertions(+), 36 deletions(-) create mode 100644 src/Ecowitt.Controller/Ecowitt.Controller.sln create mode 100644 src/Ecowitt.Controller/Message/Event/MqttServiceEvent.cs diff --git a/src/Ecowitt.Controller/Ecowitt.Controller.sln b/src/Ecowitt.Controller/Ecowitt.Controller.sln new file mode 100644 index 0000000..13dfddf --- /dev/null +++ b/src/Ecowitt.Controller/Ecowitt.Controller.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecowitt.Controller", "Ecowitt.Controller.csproj", "{56154C56-94B0-8CD8-9B5A-3C7D1D373C63}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {56154C56-94B0-8CD8-9B5A-3C7D1D373C63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56154C56-94B0-8CD8-9B5A-3C7D1D373C63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56154C56-94B0-8CD8-9B5A-3C7D1D373C63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56154C56-94B0-8CD8-9B5A-3C7D1D373C63}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {33608E75-F9C7-4C7D-8B46-A65DAD5ACDF7} + EndGlobalSection +EndGlobal diff --git a/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs b/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs index d9adbbf..eebcf94 100644 --- a/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs +++ b/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs @@ -2,11 +2,11 @@ namespace Ecowitt.Controller.Message.Event; public class MqttConnectionEvent { - public EventType EventType { get; set; } + public MqttConnectionEventType EventType { get; set; } public string? Message { get; set; } } -public enum EventType +public enum MqttConnectionEventType { Connected, Disconnected, diff --git a/src/Ecowitt.Controller/Message/Event/MqttServiceEvent.cs b/src/Ecowitt.Controller/Message/Event/MqttServiceEvent.cs new file mode 100644 index 0000000..bdce3e5 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Event/MqttServiceEvent.cs @@ -0,0 +1,17 @@ +namespace Ecowitt.Controller.Message.Event +{ + public class MqttServiceEvent + { + public MqttServiceEventType EventType { get; set; } = MqttServiceEventType.Unknown; + public string? Message { get; set; } + } + + public enum MqttServiceEventType + { + Unknown, + Started, + Stopped, + Heartbeat, + Error + } +} diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index 8a2c1fb..e649aa1 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -5,6 +5,7 @@ using Ecowitt.Controller.Message.Data; using Ecowitt.Controller.Message.Event; using Ecowitt.Controller.Service.Mqtt; +using Ecowitt.Controller.Service.Orchestrator; using Ecowitt.Controller.Store; using MQTTnet; using Polly; @@ -60,9 +61,22 @@ public static async Task Main(string[] args) { smb.WithProviderMemory(cfg => { cfg.EnableMessageSerialization = true; }); smb.AddJsonSerializer(); + // statemachine -> mqttservice + smb.Produce(x => x.DefaultTopic("config-mqtt")); + smb.Produce(x => x.DefaultTopic("home-assistant-discovery")); + smb.Produce(x => x.DefaultTopic("device-data")); smb.Consume(x => x.Topic("config-mqtt").WithConsumer()); smb.Consume(x => x.Topic("home-assistant-discovery").WithConsumer()); smb.Consume(x => x.Topic("device-data").WithConsumer()); + + // mqttservice -> statemachine + smb.Produce(x => x.DefaultTopic("mqtt-service-event")); + smb.Produce(x => x.DefaultTopic("mqtt-connection-event")); + smb.Consume(x => x.Topic("mqtt-service-event").WithConsumer()); + smb.Consume(x => x.Topic("mqtt-connection-event").WithConsumer()); + + + // smb.Produce(x => x.DefaultTopic("api-data")); @@ -83,6 +97,8 @@ public static async Task Main(string[] args) smb.AddServicesFromAssembly(Assembly.GetExecutingAssembly()); }); + builder.Services.AddHostedService(); + builder.Services.AddTransient(); builder.Services.AddHostedService(); // diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index c16e758..18b9ffe 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -11,7 +11,7 @@ namespace Ecowitt.Controller.Service.Mqtt; -public class MqttService : BackgroundService, IConsumer, IConsumer, IConsumer +public class MqttService : BackgroundService, IHostedLifecycleService, IConsumer, IConsumer, IConsumer { private readonly ILogger _logger; private readonly MqttFactory _factory; @@ -91,9 +91,7 @@ public Task OnHandle(DeviceData message) protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Starting MqttService"); - - using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); try { while(await timer.WaitForNextTickAsync(stoppingToken)) @@ -102,6 +100,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", JsonSerializer.Serialize(new { service = DateTime.UtcNow })); + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Heartbeat }, cancellationToken: stoppingToken); _logger.LogInformation("Sent heartbeat"); } } @@ -109,9 +108,30 @@ await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", catch (OperationCanceledException) { _logger.LogInformation("Stopping MqttService"); + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Stopped }, cancellationToken: stoppingToken); } } + public async Task StartedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Started }, cancellationToken: cancellationToken); + } + + public Task StartingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public async Task StoppedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Stopped }, cancellationToken: cancellationToken); + } + + public Task StoppingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + private async Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) { var payload = arg.ApplicationMessage.ConvertPayloadToString(); @@ -157,7 +177,7 @@ private async Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessag private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg) { _logger.LogInformation("MQTT client disconnected."); - await _messageBus.Publish(new MqttConnectionEvent { EventType = EventType.Disconnected }); + await _messageBus.Publish(new MqttConnectionEvent { EventType = MqttConnectionEventType.Disconnected }); if (_mqttConfig is { Reconnect: true } && !_isConnecting) { _logger.LogInformation("Attempting to reconnect to MQTT broker..."); @@ -191,7 +211,7 @@ private async Task ClientOnConnectedAsync(MqttClientConnectedEventArgs arg) _logger.LogInformation("MQTT client connected."); await _messageBus.Publish(new MqttConnectionEvent { - EventType = EventType.Connected + EventType = MqttConnectionEventType.Connected }); } @@ -202,9 +222,10 @@ private void SubscribeHomeAssistantState() _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot subscribe to Home Assistant state."); return; } - - _client.SubscribeAsync($"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); - _logger.LogInformation("Subscribed to Home Assistant state topic: {Topic}", $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); + + var topic = "homeassistant/state"; + _client.SubscribeAsync(topic); + _logger.LogInformation($"Subscribed to Home Assistant state topic: {topic}"); } private void UnsubscribeHomeAssistantState() @@ -261,4 +282,6 @@ private async Task Publish(string topic, string message) else _logger.LogWarning($"Can't publish message {message} to topic {topic}. Client not connected."); return false; } + + } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs index de4c41a..4b0f3b5 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs @@ -1,17 +1,18 @@ -using System.Text.Json; using Ecowitt.Controller.Configuration; using Ecowitt.Controller.Mapping; using Ecowitt.Controller.Message; using Ecowitt.Controller.Message.Config; +using Ecowitt.Controller.Message.Event; using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Store; using Microsoft.Extensions.Options; using SlimMessageBus; +using System.Text.Json; namespace Ecowitt.Controller.Service.Orchestrator; -public class StateMachine : BackgroundService, IConsumer, IConsumer, IConsumer +public class StateMachine : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer { private readonly ILogger _logger; private readonly IDeviceStore _deviceStore; @@ -19,6 +20,8 @@ public class StateMachine : BackgroundService, IConsumer, I private readonly ControllerOptions _controllerOptions; private readonly MqttOptions _mqttOptions; private readonly IMessageBus _messageBus; + private MqttServiceEventType _lastServiceState = MqttServiceEventType.Unknown; + //private readonly TaskCompletionSource _mqttServiceStarted = new TaskCompletionSource(); public StateMachine(ILogger logger, IDeviceStore deviceStore, IMessageBus messageBus, IOptions mqttOptions, IOptions ecowittOptions, IOptions controllerOptions) { @@ -30,11 +33,39 @@ public StateMachine(ILogger logger, IDeviceStore deviceStore, IMes _messageBus = messageBus; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Starting Orchestrator"); - + + // emit initial MQTT configuration with a 1s timeout + // this is to ensure that the MQTT service has time to start + + // Wait for MQTT service to start with timeout + // for whatever reasons this did not work as expected, so it'S back to dumb task.delay(). + //try + //{ + // await _mqttServiceStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), stoppingToken); + // _logger.LogInformation("MQTT service started signal received"); + // await EmitMqttConfig(); + //} + //catch (TimeoutException) + //{ + // _logger.LogWarning("Timeout waiting for MQTT service to start"); + // await EmitMqttConfig(); // Try to proceed anyway + //} + //catch (OperationCanceledException) + //{ + // _logger.LogInformation("Cancellation requested while waiting for MQTT service"); + //} + + + _logger.LogInformation("Emitting initial MQTT configuration"); + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + await EmitMqttConfig(); + } + + private async Task EmitMqttConfig() + { var mqttConfig = new MqttConfig { Host = _mqttOptions.Host, @@ -52,27 +83,60 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) mqttConfig.User = _mqttOptions.User; mqttConfig.Password = _mqttOptions.Password; } - + await _messageBus.Publish(mqttConfig); + } + public Task OnHandle(MqttServiceEvent message) + { + switch(message.EventType) + { + case MqttServiceEventType.Started: + _logger.LogInformation("MQTT Service started"); + _lastServiceState = MqttServiceEventType.Started; + //_mqttServiceStarted.TrySetResult(true); + break; + case MqttServiceEventType.Stopped: + _logger.LogWarning("MQTT Service stopped"); + _lastServiceState = MqttServiceEventType.Stopped; + break; + case MqttServiceEventType.Error: + _logger.LogError($"MQTT Service error: {message.Message}"); + _lastServiceState = MqttServiceEventType.Error; + break; + case MqttServiceEventType.Heartbeat: + _logger.LogDebug("MQTT Service heartbeat received"); + _lastServiceState = MqttServiceEventType.Heartbeat; + break; + case MqttServiceEventType.Unknown: + default: + _logger.LogWarning($"Unknown MQTT Service event: {message.EventType}"); + break; + } - // using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); - // try - // { - // while(await timer.WaitForNextTickAsync(stoppingToken)) - // { - // if (_client is { IsConnected: true } && _mqttConfig != null) - // { - // await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", - // JsonSerializer.Serialize(new { service = DateTime.UtcNow })); - // _logger.LogInformation("Sent heartbeat"); - // } - // } - // } - // catch (OperationCanceledException) - // { - // _logger.LogInformation("Stopping MqttService"); - // } + return Task.CompletedTask; + } + + public Task OnHandle(MqttConnectionEvent message) + { + switch (message.EventType) + { + case MqttConnectionEventType.Connected: + _logger.LogInformation("MQTT Client connected"); + break; + case MqttConnectionEventType.Disconnected: + _logger.LogWarning("MQTT Client disconnected"); + break; + case MqttConnectionEventType.Error: + _logger.LogInformation($"MQTT Client error {message.Message}"); + break; + case MqttConnectionEventType.MessageReceived: + default: + _logger.LogInformation($"MQTT Connection event received: {message.EventType}"); + break; + } + + return Task.CompletedTask; } public async Task OnHandle(SubdeviceApiCommand message) @@ -98,11 +162,11 @@ public async Task OnHandle(SubdeviceApiCommand message) //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model, val: val, valType: (int)valType, alwaysOn: alwaysOn); //default always-on message for now - await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model); + //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model); } else if (message.Cmd == Command.Stop) { - await SendCommand(gw.IpAddress, "quick_stop", message.Id, (int)subdevice!.Model); + //await SendCommand(gw.IpAddress, "quick_stop", message.Id, (int)subdevice!.Model); } else { @@ -281,4 +345,24 @@ public Task OnHandle(SubdeviceApiAggregate message) // } // } + + public Task StartedAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task StartingAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task StoppedAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task StoppingAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } } \ No newline at end of file diff --git a/src/Ecowitt.Controller/appsettings.json b/src/Ecowitt.Controller/appsettings.json index f345f52..c2100b4 100644 --- a/src/Ecowitt.Controller/appsettings.json +++ b/src/Ecowitt.Controller/appsettings.json @@ -3,7 +3,8 @@ "MinimumLevel": { "Default": "Information", "Override": { - "Ecowitt.Controller.Consumer": "Debug" + "Ecowitt.Controller.Consumer": "Debug", + "Ecowitt.Controller.Service": "Debug" } } }, From 66e26207327efe34c72cfee1b636d073ebbc9466 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 22 Jul 2025 22:48:19 +0200 Subject: [PATCH 20/44] mqtt topic doc --- docs/mqtt.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/mqtt.md b/docs/mqtt.md index e69de29..1436e66 100644 --- a/docs/mqtt.md +++ b/docs/mqtt.md @@ -0,0 +1,13 @@ +# Topic definition + +## Listen + +- Topic: `homeassistant/status` + +## Publish + +- Topic: `ec/` +- Topic: `ec//status` +- Topic: `ec/heartbeat` + + From 9dca38c275f746236817c09c7545c301f3f3153e Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 22 Jul 2025 22:49:18 +0200 Subject: [PATCH 21/44] new mqttservice! --- src/Ecowitt.Controller.sln | 10 ++++-- src/Ecowitt.Controller/Program.cs | 22 +++++++++++-- .../Service/Mqtt/MqttService.cs | 32 +++++++++++-------- .../Service/Orchestrator/StateMachine.cs | 21 +++++++++++- 4 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/Ecowitt.Controller.sln b/src/Ecowitt.Controller.sln index 34f4a7f..b2626fb 100644 --- a/src/Ecowitt.Controller.sln +++ b/src/Ecowitt.Controller.sln @@ -12,16 +12,22 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution", "Solution", "{B3 ..\README.md = ..\README.md EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C746A790-DCB6-46A6-A74E-DF1A4FD6994F}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{C746A790-DCB6-46A6-A74E-DF1A4FD6994F}" ProjectSection(SolutionItems) = preProject .dockerignore = .dockerignore + ..\.gitignore = ..\.gitignore docker-compose.yml = docker-compose.yml Dockerfile = Dockerfile - ..\.gitignore = ..\.gitignore EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EcoWitt.Controller.Tests", "EcoWitt.Controller.Tests\EcoWitt.Controller.Tests.csproj", "{A6E36077-BD8E-4296-A628-F6914560FE02}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + ..\docs\api.md = ..\docs\api.md + ..\docs\mqtt.md = ..\docs\mqtt.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index e649aa1..b8e9c88 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -22,6 +22,11 @@ namespace Ecowitt.Controller; public class Program { + public class ServiceProviderAccessor + { + public IServiceProvider ServiceProvider { get; set; } + } + public static async Task Main(string[] args) { var builder = WebApplication.CreateBuilder(args); @@ -55,12 +60,21 @@ public static async Task Main(string[] args) .MinimumLevel.Warning() .ReadFrom.Configuration(builder.Configuration)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + var sp = builder.Services.BuildServiceProvider(); builder.Services.AddSlimMessageBus(smb => { + + smb.WithProviderMemory(cfg => { cfg.EnableMessageSerialization = true; }); smb.AddJsonSerializer(); + smb.WithDependencyResolver(sp); + // statemachine -> mqttservice smb.Produce(x => x.DefaultTopic("config-mqtt")); smb.Produce(x => x.DefaultTopic("home-assistant-discovery")); @@ -72,8 +86,10 @@ public static async Task Main(string[] args) // mqttservice -> statemachine smb.Produce(x => x.DefaultTopic("mqtt-service-event")); smb.Produce(x => x.DefaultTopic("mqtt-connection-event")); + smb.Produce(x => x.DefaultTopic("home-assistant-status")); smb.Consume(x => x.Topic("mqtt-service-event").WithConsumer()); smb.Consume(x => x.Topic("mqtt-connection-event").WithConsumer()); + smb.Consume(x => x.Topic("home-assistant-status").WithConsumer()); @@ -97,10 +113,10 @@ public static async Task Main(string[] args) smb.AddServicesFromAssembly(Assembly.GetExecutingAssembly()); }); - builder.Services.AddHostedService(); - + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddTransient(); - builder.Services.AddHostedService(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); // // builder.Services.AddHostedService(); // builder.Services.AddHostedService(); diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index 18b9ffe..f4dfde3 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -19,7 +19,8 @@ public class MqttService : BackgroundService, IHostedLifecycleService, IConsumer private IMqttClient? _client; private bool _isConnecting; private MqttConfig? _mqttConfig; - + private Guid _serviceId = Guid.NewGuid(); + private const string HaStatusTopic = "homeassistant/status"; public MqttService(ILogger logger, MqttFactory factory, IMessageBus messageBus) { @@ -30,11 +31,12 @@ public MqttService(ILogger logger, MqttFactory factory, IMessageBus public async Task OnHandle(MqttConfig message) { + _logger.LogInformation($"{_serviceId}: handle mqttConfig"); if(_client != null) { try { - if (_mqttConfig is { HomeAssistantDiscovery: true }) UnsubscribeHomeAssistantState(); + if (_mqttConfig is { HomeAssistantDiscovery: true }) await UnsubscribeHomeAssistant(); if(_client.IsConnected) await _client.DisconnectAsync(); _client.ApplicationMessageReceivedAsync -= ClientOnApplicationMessageReceivedAsync; @@ -71,7 +73,7 @@ public async Task OnHandle(MqttConfig message) await _client.ConnectAsync(optionsBuilder.Build()); await _client.SubscribeAsync($"{_mqttConfig.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceCommandTopic()}"); - if(_mqttConfig.HomeAssistantDiscovery) SubscribeHomeAssistantState(); + if(_mqttConfig.HomeAssistantDiscovery) await SubscribeHomeAssistant(); } public async Task OnHandle(HomeAssistantDiscoveryEvent message) @@ -91,17 +93,18 @@ public Task OnHandle(DeviceData message) protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); try { - while(await timer.WaitForNextTickAsync(stoppingToken)) + _logger.LogInformation($"{_serviceId}: handle heartbeat"); + while (await timer.WaitForNextTickAsync(stoppingToken)) { if (_client is { IsConnected: true } && _mqttConfig != null) { await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", JsonSerializer.Serialize(new { service = DateTime.UtcNow })); await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Heartbeat }, cancellationToken: stoppingToken); - _logger.LogInformation("Sent heartbeat"); + _logger.LogDebug("Sent heartbeat"); } } } @@ -215,7 +218,7 @@ await _messageBus.Publish(new MqttConnectionEvent }); } - private void SubscribeHomeAssistantState() + private async Task SubscribeHomeAssistant() { if(_client != null && (!_client.IsConnected || _isConnecting)) { @@ -223,21 +226,22 @@ private void SubscribeHomeAssistantState() return; } - var topic = "homeassistant/state"; - _client.SubscribeAsync(topic); - _logger.LogInformation($"Subscribed to Home Assistant state topic: {topic}"); + await _client.SubscribeAsync(HaStatusTopic); + await _client.SubscribeAsync($"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); + _logger.LogInformation("Subscribed to Home Assistant"); } - private void UnsubscribeHomeAssistantState() + private async Task UnsubscribeHomeAssistant() { if(_client != null && (!_client.IsConnected || _isConnecting)) { _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot unsubscribe from Home Assistant state."); return; } - - _client.UnsubscribeAsync($"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); - _logger.LogInformation("Unsubscribed from Home Assistant state topic: {Topic}", $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); + + await _client.UnsubscribeAsync(HaStatusTopic); + await _client.UnsubscribeAsync($"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); + _logger.LogInformation("Unsubscribed from Home Assistant"); } private void EmitHomeAssistantDiscovery() diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs index 4b0f3b5..28afdce 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs @@ -12,7 +12,7 @@ namespace Ecowitt.Controller.Service.Orchestrator; -public class StateMachine : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer +public class StateMachine : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer { private readonly ILogger _logger; private readonly IDeviceStore _deviceStore; @@ -139,6 +139,25 @@ public Task OnHandle(MqttConnectionEvent message) return Task.CompletedTask; } + public Task OnHandle(HomeAssistantStatusEvent message) + { + switch (message.Status) + { + case HomeAssistantStatusType.Online: + _logger.LogInformation("HomeAssistant is online"); + break; + case HomeAssistantStatusType.Offline: + _logger.LogInformation("HomeAssistant is offline"); + break; + case HomeAssistantStatusType.Unknown: + default: + _logger.LogInformation("Unknown HomeAssistant status received"); + break; + } + + return Task.CompletedTask; + } + public async Task OnHandle(SubdeviceApiCommand message) { _logger.LogInformation($"Received SubdeviceCommand: {message.Cmd} for device {message.Id}"); From 54230dde6f2c8a50e922efb46095346b19489fc2 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 22 Jul 2025 23:11:26 +0200 Subject: [PATCH 22/44] improved readability/maintainability via partial classes --- .../Service/Mqtt/MqttService.Consumer.cs | 72 +++++ .../Service/Mqtt/MqttService.Events.cs | 115 ++++++++ .../Service/Mqtt/MqttService.cs | 169 +---------- .../Orchestrator/StateMachine.ConsumerHttp.cs | 183 ++++++++++++ .../Orchestrator/StateMachine.ConsumerMqtt.cs | 81 ++++++ .../Service/Orchestrator/StateMachine.cs | 264 +----------------- 6 files changed, 455 insertions(+), 429 deletions(-) create mode 100644 src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs create mode 100644 src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs create mode 100644 src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs create mode 100644 src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs new file mode 100644 index 0000000..fb609a8 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs @@ -0,0 +1,72 @@ +using Ecowitt.Controller.Message.Config; +using Ecowitt.Controller.Message.Data; +using Ecowitt.Controller.Message.Event; +using MQTTnet.Client; + +namespace Ecowitt.Controller.Service.Mqtt +{ + public partial class MqttService + { + public async Task OnHandle(MqttConfig message) + { + _logger.LogInformation($"{_serviceId}: handle mqttConfig"); + if (_client != null) + { + try + { + if (_mqttConfig is { HomeAssistantDiscovery: true }) await UnsubscribeHomeAssistant(); + + if (_client.IsConnected) await _client.DisconnectAsync(); + _client.ApplicationMessageReceivedAsync -= ClientOnApplicationMessageReceivedAsync; + _client.DisconnectedAsync -= ClientOnDisconnectedAsync; + _client.ConnectedAsync -= ClientOnConnectedAsync; + _client.Dispose(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while disconnecting, unregistering or disposing MQTT client."); + return; + } + } + + _mqttConfig = message; + + if (!message.Enabled) + { + _logger.LogInformation("MQTT is disabled"); + return; + } + + _client = _factory.CreateMqttClient(); + _client.ConnectedAsync += ClientOnConnectedAsync; + _client.DisconnectedAsync += ClientOnDisconnectedAsync; + _client.ApplicationMessageReceivedAsync += ClientOnApplicationMessageReceivedAsync; + var optionsBuilder = new MqttClientOptionsBuilder() + .WithTcpServer(_mqttConfig.Host, _mqttConfig.Port) + .WithClientId(_mqttConfig.ClientId) + .WithCleanSession(); + if (!string.IsNullOrWhiteSpace(_mqttConfig.User)) + optionsBuilder.WithCredentials(_mqttConfig.User, _mqttConfig.Password); + + await _client.ConnectAsync(optionsBuilder.Build()); + await _client.SubscribeAsync($"{_mqttConfig.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceCommandTopic()}"); + + if (_mqttConfig.HomeAssistantDiscovery) await SubscribeHomeAssistant(); + } + + public async Task OnHandle(HomeAssistantDiscoveryEvent message) + { + EmitHomeAssistantDiscovery(); + } + + public Task OnHandle(DeviceData message) + { + if (_client == null || !_client.IsConnected) + { + _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + } + + return Task.CompletedTask; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs new file mode 100644 index 0000000..940c08b --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs @@ -0,0 +1,115 @@ +using Ecowitt.Controller.Message.Event; +using Ecowitt.Controller.Model.Api; +using MQTTnet; +using MQTTnet.Client; +using System.Text.Json; + +namespace Ecowitt.Controller.Service.Mqtt +{ + public partial class MqttService + { + public async Task StartedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Started }, cancellationToken: cancellationToken); + } + + public Task StartingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public async Task StoppedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Stopped }, cancellationToken: cancellationToken); + } + + public Task StoppingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) + { + var payload = arg.ApplicationMessage.ConvertPayloadToString(); + var topic = arg.ApplicationMessage.Topic; + _logger.LogDebug($"Message received for topic {topic}: {payload}"); + + if (topic.Equals(HaStatusTopic, StringComparison.OrdinalIgnoreCase)) + { + // home assistant status topic + if (payload.Equals("online", StringComparison.OrdinalIgnoreCase)) + await _messageBus.Publish(new HomeAssistantStatusEvent() { Status = HomeAssistantStatusType.Online }); + else if (payload.Equals("offline", StringComparison.OrdinalIgnoreCase)) await _messageBus.Publish(new HomeAssistantStatusEvent { Status = HomeAssistantStatusType.Offline }); + else await _messageBus.Publish(new HomeAssistantStatusEvent { Status = HomeAssistantStatusType.Unknown }); + } + else if (topic.EndsWith("cmd/homeassistant")) + { + // commands coming from home assistant + if (int.TryParse(topic.Split('/')[3], out var result)) + { + var cmd = payload.Equals("ON", StringComparison.InvariantCultureIgnoreCase) ? Command.Start : Command.Stop; //I know, everything that's not "ON" is "OFF" + await _messageBus.Publish(new SubdeviceApiCommand() { Cmd = cmd, Id = result }); + } + else + { + _logger.LogWarning("Invalid subdevice id in topic {Topic}", topic); + } + } + else + { + // direct commands via mqtt + try + { + var cmd = JsonSerializer.Deserialize(payload); + await _messageBus.Publish(cmd); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + } + + private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg) + { + _logger.LogInformation("MQTT client disconnected."); + await _messageBus.Publish(new MqttConnectionEvent { EventType = MqttConnectionEventType.Disconnected }); + if (_mqttConfig is { Reconnect: true } && !_isConnecting) + { + _logger.LogInformation("Attempting to reconnect to MQTT broker..."); + _isConnecting = true; + try + { + for (var i = 0; i < _mqttConfig.ReconnectAttempts; i++) + { + try + { + var result = await _client?.ConnectAsync(_client.Options)!; + if (result.ResultCode == MqttClientConnectResultCode.Success) break; + else _logger.LogWarning($"Failed to reconnect to MQTT broker. Attempt {i + 1} of {_mqttConfig.ReconnectAttempts}. Reason: {result.ResultCode}"); + } + catch (Exception ex) + { + _logger.LogError($"Exception during MQTT reconnect attempt {i + 1} of {_mqttConfig.ReconnectAttempts}: {ex.Message}"); + } + await Task.Delay(TimeSpan.FromSeconds(2)); + } + } + finally + { + _isConnecting = false; + } + } + } + + private async Task ClientOnConnectedAsync(MqttClientConnectedEventArgs arg) + { + _logger.LogInformation("MQTT client connected."); + await _messageBus.Publish(new MqttConnectionEvent + { + EventType = MqttConnectionEventType.Connected + }); + } + } +} diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index f4dfde3..6f473ce 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -11,7 +11,7 @@ namespace Ecowitt.Controller.Service.Mqtt; -public class MqttService : BackgroundService, IHostedLifecycleService, IConsumer, IConsumer, IConsumer +public partial class MqttService : BackgroundService, IHostedLifecycleService, IConsumer, IConsumer, IConsumer { private readonly ILogger _logger; private readonly MqttFactory _factory; @@ -29,68 +29,6 @@ public MqttService(ILogger logger, MqttFactory factory, IMessageBus _messageBus = messageBus; } - public async Task OnHandle(MqttConfig message) - { - _logger.LogInformation($"{_serviceId}: handle mqttConfig"); - if(_client != null) - { - try - { - if (_mqttConfig is { HomeAssistantDiscovery: true }) await UnsubscribeHomeAssistant(); - - if(_client.IsConnected) await _client.DisconnectAsync(); - _client.ApplicationMessageReceivedAsync -= ClientOnApplicationMessageReceivedAsync; - _client.DisconnectedAsync -= ClientOnDisconnectedAsync; - _client.ConnectedAsync -= ClientOnConnectedAsync; - _client.Dispose(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while disconnecting, unregistering or disposing MQTT client."); - return; - } - } - - _mqttConfig = message; - - if(!message.Enabled) - { - _logger.LogInformation("MQTT is disabled"); - return; - } - - _client = _factory.CreateMqttClient(); - _client.ConnectedAsync += ClientOnConnectedAsync; - _client.DisconnectedAsync += ClientOnDisconnectedAsync; - _client.ApplicationMessageReceivedAsync += ClientOnApplicationMessageReceivedAsync; - var optionsBuilder = new MqttClientOptionsBuilder() - .WithTcpServer(_mqttConfig.Host, _mqttConfig.Port) - .WithClientId(_mqttConfig.ClientId) - .WithCleanSession(); - if(!string.IsNullOrWhiteSpace(_mqttConfig.User)) - optionsBuilder.WithCredentials(_mqttConfig.User, _mqttConfig.Password); - - await _client.ConnectAsync(optionsBuilder.Build()); - await _client.SubscribeAsync($"{_mqttConfig.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceCommandTopic()}"); - - if(_mqttConfig.HomeAssistantDiscovery) await SubscribeHomeAssistant(); - } - - public async Task OnHandle(HomeAssistantDiscoveryEvent message) - { - EmitHomeAssistantDiscovery(); - } - - public Task OnHandle(DeviceData message) - { - if (_client == null || !_client.IsConnected) - { - _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); - } - - return Task.CompletedTask; - } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); @@ -115,108 +53,7 @@ await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", } } - public async Task StartedAsync(CancellationToken cancellationToken) - { - await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Started }, cancellationToken: cancellationToken); - } - - public Task StartingAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public async Task StoppedAsync(CancellationToken cancellationToken) - { - await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Stopped }, cancellationToken: cancellationToken); - } - - public Task StoppingAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - private async Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) - { - var payload = arg.ApplicationMessage.ConvertPayloadToString(); - var topic = arg.ApplicationMessage.Topic; - _logger.LogDebug($"Message received for topic {topic}: {payload}"); - - if (topic.Equals("homeassistant/status", StringComparison.OrdinalIgnoreCase)) - { - // home assistant status topic - if (payload.Equals("online", StringComparison.OrdinalIgnoreCase)) - await _messageBus.Publish(new HomeAssistantStatusEvent() { Status = HomeAssistantStatusType.Online }); - else if (payload.Equals("offline", StringComparison.OrdinalIgnoreCase)) await _messageBus.Publish(new HomeAssistantStatusEvent { Status = HomeAssistantStatusType.Offline }); - else await _messageBus.Publish(new HomeAssistantStatusEvent { Status = HomeAssistantStatusType.Unknown }); - } else if (topic.EndsWith("cmd/homeassistant")) - { - // commands coming from home assistant - if (int.TryParse(topic.Split('/')[3], out var result)) - { - var cmd = payload.Equals("ON", StringComparison.InvariantCultureIgnoreCase) ? Command.Start : Command.Stop; //I know, everything that's not "ON" is "OFF" - await _messageBus.Publish(new SubdeviceApiCommand() { Cmd = cmd, Id = result }); - } - else - { - _logger.LogWarning("Invalid subdevice id in topic {Topic}", topic); - } - } - else - { - // direct commands via mqtt - try - { - var cmd = JsonSerializer.Deserialize(payload); - await _messageBus.Publish(cmd); - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } - } - } - - private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg) - { - _logger.LogInformation("MQTT client disconnected."); - await _messageBus.Publish(new MqttConnectionEvent { EventType = MqttConnectionEventType.Disconnected }); - if (_mqttConfig is { Reconnect: true } && !_isConnecting) - { - _logger.LogInformation("Attempting to reconnect to MQTT broker..."); - _isConnecting = true; - try - { - for (var i = 0; i < _mqttConfig.ReconnectAttempts; i++) - { - try - { - var result = await _client?.ConnectAsync(_client.Options)!; - if(result.ResultCode == MqttClientConnectResultCode.Success) break; - else _logger.LogWarning($"Failed to reconnect to MQTT broker. Attempt {i + 1} of {_mqttConfig.ReconnectAttempts}. Reason: {result.ResultCode}"); - } - catch (Exception ex) - { - _logger.LogError($"Exception during MQTT reconnect attempt {i + 1} of {_mqttConfig.ReconnectAttempts}: {ex.Message}"); - } - await Task.Delay(TimeSpan.FromSeconds(2)); - } - } - finally - { - _isConnecting = false; - } - } - } - - private async Task ClientOnConnectedAsync(MqttClientConnectedEventArgs arg) - { - _logger.LogInformation("MQTT client connected."); - await _messageBus.Publish(new MqttConnectionEvent - { - EventType = MqttConnectionEventType.Connected - }); - } + private async Task SubscribeHomeAssistant() { @@ -248,8 +85,6 @@ private void EmitHomeAssistantDiscovery() { } - - private async Task PublishMessage(string topic, dynamic payload) { diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs new file mode 100644 index 0000000..44f482f --- /dev/null +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs @@ -0,0 +1,183 @@ +using Ecowitt.Controller.Configuration; +using Ecowitt.Controller.Mapping; +using Ecowitt.Controller.Message; +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; +using System.Text.Json; + +namespace Ecowitt.Controller.Service.Orchestrator +{ + public partial class StateMachine + { + public async Task OnHandle(SubdeviceApiCommand message) + { + _logger.LogInformation($"Received SubdeviceCommand: {message.Cmd} for device {message.Id}"); + + var gw = _deviceStore.GetGatewayBySubdeviceId(message.Id); + if (gw == null) + { + _logger.LogWarning($"Gateway not found for subdevice {message.Id}"); + return; + } + // hey compiler, this can't be null! + var subdevice = gw.Subdevices.FirstOrDefault(sd => sd.Id == message.Id); + + if (message.Cmd == Command.Start) + { + //var val = message.Duration ?? 20; + //var valType = message.Unit ?? DurationUnit.Minutes; + // // the magic "maganiFator" :D + //if(valType == DurationUnit.Liters) val *= 10; + //var alwaysOn = message.AlwaysOn.HasValue ? 1 : 0; + //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model, val: val, valType: (int)valType, alwaysOn: alwaysOn); + + //default always-on message for now + //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model); + + } + else if (message.Cmd == Command.Stop) + { + //await SendCommand(gw.IpAddress, "quick_stop", message.Id, (int)subdevice!.Model); + } + else + { + _logger.LogWarning($"Ignoring unsupported command {message.Cmd} for subdevice {message.Id}"); + return; + } + } + + public Task OnHandle(GatewayApiData message) + { + _logger.LogDebug($"Received ApiData: {message.Model} ({message.PASSKEY}) \n {message.Payload}"); + var updatedGateway = message.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); + updatedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == updatedGateway.IpAddress)?.Name ?? updatedGateway.IpAddress.Replace('.', '-'); + + var storedGateway = _deviceStore.GetGateway(updatedGateway.IpAddress); + if (storedGateway == null) + { + updatedGateway.DiscoveryUpdate = true; + + foreach (var sensor in updatedGateway.Sensors) + { + sensor.DiscoveryUpdate = true; + } + if (!_deviceStore.UpsertGateway(updatedGateway)) _logger.LogWarning($"failed to add gateway {updatedGateway.IpAddress} ({updatedGateway.Model}) to the store"); + else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } + } + else + { + // no other property should update besides sensors + // and i'm stupid, because TS is required for availability :( + storedGateway.TimestampUtc = updatedGateway.TimestampUtc; + + foreach (var sensor in updatedGateway.Sensors) + { + var storedSensor = storedGateway.Sensors.FirstOrDefault(s => s.Name == sensor.Name); + if (storedSensor == null) + { + sensor.DiscoveryUpdate = true; + storedGateway.Sensors.Add(sensor); + } + else + { + storedSensor.Value = sensor.Value; + } + } + + var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); + foreach (var sensor in sensorsToRemove) + { + storedGateway.Sensors.Remove(sensor); + } + + if (!_deviceStore.UpsertGateway(storedGateway)) { _logger.LogWarning($"failed to update {storedGateway.IpAddress} ({storedGateway.Model}) in the store"); } + else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } + } + + return Task.CompletedTask; + } + + public Task OnHandle(SubdeviceApiAggregate message) + { + var ips = message.Subdevices.DistinctBy(sd => sd.GwIp).Select(sd => sd.GwIp); + foreach (var ip in ips) + { + var storedGateway = _deviceStore.GetGateway(ip); + if (storedGateway == null) + { + if (_ecowittOptions.AutoDiscovery) + { + _logger.LogWarning($"Gateway {ip} not found while in autodiscovery mode. Not updating subdevices. (Try turning off autodiscovery)"); + return Task.CompletedTask; + } + + storedGateway = new Device { IpAddress = ip }; + storedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == storedGateway.IpAddress)?.Name ?? storedGateway.IpAddress.Replace('.', '-'); + storedGateway.DiscoveryUpdate = true; + } + + var subdeviceApiData = message.Subdevices.Where(sd => sd.GwIp == ip); + foreach (var data in subdeviceApiData) + { + var updatedSubDevice = data.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); + var storedSubDevice = storedGateway.Subdevices.FirstOrDefault(gwsd => gwsd.Id == updatedSubDevice.Id); + if (storedSubDevice == null) + { + updatedSubDevice.DiscoveryUpdate = true; + foreach (var sensor in updatedSubDevice.Sensors) + { + sensor.DiscoveryUpdate = true; + } + storedGateway.Subdevices.Add(updatedSubDevice); + _logger.LogInformation($"subdevice added: {data.Id} ({data.Model})"); + + + } + else + { + storedSubDevice.TimestampUtc = updatedSubDevice.TimestampUtc; + storedSubDevice.Availability = updatedSubDevice.Availability; + + // no update of other properties + if (storedSubDevice.Version != updatedSubDevice.Version || storedSubDevice.Devicename != updatedSubDevice.Devicename || storedSubDevice.Nickname != updatedSubDevice.Nickname) + { + storedSubDevice.Version = updatedSubDevice.Version; + storedSubDevice.Devicename = updatedSubDevice.Devicename; + storedSubDevice.Nickname = updatedSubDevice.Nickname; + storedSubDevice.DiscoveryUpdate = true; + } + + // update sensors one by one and find out if there are new ones + // if there are new ones, mark the subdevice for discovery update + foreach (var sensor in updatedSubDevice.Sensors) + { + var storedSensor = storedSubDevice.Sensors.FirstOrDefault(s => s.Name == sensor.Name); + if (storedSensor == null) + { + sensor.DiscoveryUpdate = true; + storedSubDevice.Sensors.Add(sensor); + } + else + { + storedSensor.Value = sensor.Value; + } + } + + // remove sensors that are not in the update + var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); + foreach (var sensor in sensorsToRemove) + { + storedSubDevice.Sensors.Remove(sensor); + } + + _logger.LogInformation($"subdevice updated: {data.Id} ({data.Model})"); + } + } + + _deviceStore.UpsertGateway(storedGateway); + } + + return Task.CompletedTask; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs new file mode 100644 index 0000000..1f7fd07 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs @@ -0,0 +1,81 @@ +using Ecowitt.Controller.Message.Event; + +namespace Ecowitt.Controller.Service.Orchestrator +{ + public partial class StateMachine + { + public Task OnHandle(MqttServiceEvent message) + { + switch (message.EventType) + { + case MqttServiceEventType.Started: + _logger.LogInformation("MQTT Service started"); + _lastServiceState = MqttServiceEventType.Started; + //_mqttServiceStarted.TrySetResult(true); + break; + case MqttServiceEventType.Stopped: + _logger.LogWarning("MQTT Service stopped"); + _lastServiceState = MqttServiceEventType.Stopped; + break; + case MqttServiceEventType.Error: + _logger.LogError($"MQTT Service error: {message.Message}"); + _lastServiceState = MqttServiceEventType.Error; + break; + case MqttServiceEventType.Heartbeat: + _logger.LogDebug("MQTT Service heartbeat received"); + _lastServiceState = MqttServiceEventType.Heartbeat; + break; + case MqttServiceEventType.Unknown: + default: + _logger.LogWarning($"Unknown MQTT Service event: {message.EventType}"); + break; + } + + return Task.CompletedTask; + } + + public Task OnHandle(MqttConnectionEvent message) + { + switch (message.EventType) + { + case MqttConnectionEventType.Connected: + _logger.LogInformation("MQTT Client connected"); + break; + case MqttConnectionEventType.Disconnected: + _logger.LogWarning("MQTT Client disconnected"); + break; + case MqttConnectionEventType.Error: + _logger.LogInformation($"MQTT Client error {message.Message}"); + break; + case MqttConnectionEventType.MessageReceived: + default: + _logger.LogInformation($"MQTT Connection event received: {message.EventType}"); + break; + } + + return Task.CompletedTask; + } + + public Task OnHandle(HomeAssistantStatusEvent message) + { + switch (message.Status) + { + case HomeAssistantStatusType.Online: + _logger.LogInformation("HomeAssistant is online"); + break; + case HomeAssistantStatusType.Offline: + _logger.LogInformation("HomeAssistant is offline"); + break; + case HomeAssistantStatusType.Unknown: + default: + _logger.LogInformation("Unknown HomeAssistant status received"); + break; + } + + return Task.CompletedTask; + } + + + + } +} diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs index 28afdce..bb76e85 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs @@ -1,18 +1,15 @@ using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Mapping; using Ecowitt.Controller.Message; using Ecowitt.Controller.Message.Config; using Ecowitt.Controller.Message.Event; -using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Store; using Microsoft.Extensions.Options; using SlimMessageBus; -using System.Text.Json; namespace Ecowitt.Controller.Service.Orchestrator; -public class StateMachine : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer +public partial class StateMachine : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer { private readonly ILogger _logger; private readonly IDeviceStore _deviceStore; @@ -87,244 +84,7 @@ private async Task EmitMqttConfig() await _messageBus.Publish(mqttConfig); } - public Task OnHandle(MqttServiceEvent message) - { - switch(message.EventType) - { - case MqttServiceEventType.Started: - _logger.LogInformation("MQTT Service started"); - _lastServiceState = MqttServiceEventType.Started; - //_mqttServiceStarted.TrySetResult(true); - break; - case MqttServiceEventType.Stopped: - _logger.LogWarning("MQTT Service stopped"); - _lastServiceState = MqttServiceEventType.Stopped; - break; - case MqttServiceEventType.Error: - _logger.LogError($"MQTT Service error: {message.Message}"); - _lastServiceState = MqttServiceEventType.Error; - break; - case MqttServiceEventType.Heartbeat: - _logger.LogDebug("MQTT Service heartbeat received"); - _lastServiceState = MqttServiceEventType.Heartbeat; - break; - case MqttServiceEventType.Unknown: - default: - _logger.LogWarning($"Unknown MQTT Service event: {message.EventType}"); - break; - } - - return Task.CompletedTask; - } - - public Task OnHandle(MqttConnectionEvent message) - { - switch (message.EventType) - { - case MqttConnectionEventType.Connected: - _logger.LogInformation("MQTT Client connected"); - break; - case MqttConnectionEventType.Disconnected: - _logger.LogWarning("MQTT Client disconnected"); - break; - case MqttConnectionEventType.Error: - _logger.LogInformation($"MQTT Client error {message.Message}"); - break; - case MqttConnectionEventType.MessageReceived: - default: - _logger.LogInformation($"MQTT Connection event received: {message.EventType}"); - break; - } - - return Task.CompletedTask; - } - - public Task OnHandle(HomeAssistantStatusEvent message) - { - switch (message.Status) - { - case HomeAssistantStatusType.Online: - _logger.LogInformation("HomeAssistant is online"); - break; - case HomeAssistantStatusType.Offline: - _logger.LogInformation("HomeAssistant is offline"); - break; - case HomeAssistantStatusType.Unknown: - default: - _logger.LogInformation("Unknown HomeAssistant status received"); - break; - } - - return Task.CompletedTask; - } - - public async Task OnHandle(SubdeviceApiCommand message) - { - _logger.LogInformation($"Received SubdeviceCommand: {message.Cmd} for device {message.Id}"); - - var gw = _deviceStore.GetGatewayBySubdeviceId(message.Id); - if(gw == null) - { - _logger.LogWarning($"Gateway not found for subdevice {message.Id}"); - return; - } - // hey compiler, this can't be null! - var subdevice = gw.Subdevices.FirstOrDefault(sd => sd.Id == message.Id); - - if (message.Cmd == Command.Start) - { - //var val = message.Duration ?? 20; - //var valType = message.Unit ?? DurationUnit.Minutes; - // // the magic "maganiFator" :D - //if(valType == DurationUnit.Liters) val *= 10; - //var alwaysOn = message.AlwaysOn.HasValue ? 1 : 0; - //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model, val: val, valType: (int)valType, alwaysOn: alwaysOn); - - //default always-on message for now - //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model); - - } else if (message.Cmd == Command.Stop) - { - //await SendCommand(gw.IpAddress, "quick_stop", message.Id, (int)subdevice!.Model); - } - else - { - _logger.LogWarning($"Ignoring unsupported command {message.Cmd} for subdevice {message.Id}"); - return; - } - } - - public Task OnHandle(GatewayApiData message) - { - _logger.LogDebug($"Received ApiData: {message.Model} ({message.PASSKEY}) \n {message.Payload}"); - var updatedGateway = message.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); - updatedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == updatedGateway.IpAddress)?.Name ?? updatedGateway.IpAddress.Replace('.','-'); - - var storedGateway = _deviceStore.GetGateway(updatedGateway.IpAddress); - if(storedGateway == null) - { - updatedGateway.DiscoveryUpdate = true; - - foreach (var sensor in updatedGateway.Sensors) - { - sensor.DiscoveryUpdate = true; - } - if(!_deviceStore.UpsertGateway(updatedGateway)) _logger.LogWarning($"failed to add gateway {updatedGateway.IpAddress} ({updatedGateway.Model}) to the store"); - else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } - } - else - { - // no other property should update besides sensors - // and i'm stupid, because TS is required for availability :( - storedGateway.TimestampUtc = updatedGateway.TimestampUtc; - - foreach (var sensor in updatedGateway.Sensors) - { - var storedSensor = storedGateway.Sensors.FirstOrDefault(s => s.Name == sensor.Name); - if (storedSensor == null) - { - sensor.DiscoveryUpdate = true; - storedGateway.Sensors.Add(sensor); - } - else - { - storedSensor.Value = sensor.Value; - } - } - - var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); - foreach (var sensor in sensorsToRemove) - { - storedGateway.Sensors.Remove(sensor); - } - - if(!_deviceStore.UpsertGateway(storedGateway)) {_logger.LogWarning($"failed to update {storedGateway.IpAddress} ({storedGateway.Model}) in the store");} - else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } - } - - return Task.CompletedTask; - } - - public Task OnHandle(SubdeviceApiAggregate message) - { - var ips = message.Subdevices.DistinctBy(sd => sd.GwIp).Select(sd => sd.GwIp); - foreach (var ip in ips) - { - var storedGateway = _deviceStore.GetGateway(ip); - if (storedGateway == null) - { - if (_ecowittOptions.AutoDiscovery) - { - _logger.LogWarning($"Gateway {ip} not found while in autodiscovery mode. Not updating subdevices. (Try turning off autodiscovery)"); - return Task.CompletedTask; - } - - storedGateway = new Device {IpAddress = ip}; - storedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == storedGateway.IpAddress)?.Name ?? storedGateway.IpAddress.Replace('.','-'); - storedGateway.DiscoveryUpdate = true; - } - - var subdeviceApiData = message.Subdevices.Where(sd => sd.GwIp == ip); - foreach (var data in subdeviceApiData) - { - var updatedSubDevice = data.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); - var storedSubDevice = storedGateway.Subdevices.FirstOrDefault(gwsd => gwsd.Id == updatedSubDevice.Id); - if (storedSubDevice == null) - { - updatedSubDevice.DiscoveryUpdate = true; - foreach (var sensor in updatedSubDevice.Sensors) - { - sensor.DiscoveryUpdate = true; - } - storedGateway.Subdevices.Add(updatedSubDevice); - _logger.LogInformation($"subdevice added: {data.Id} ({data.Model})"); - - - } else { - storedSubDevice.TimestampUtc = updatedSubDevice.TimestampUtc; - storedSubDevice.Availability = updatedSubDevice.Availability; - - // no update of other properties - if (storedSubDevice.Version != updatedSubDevice.Version || storedSubDevice.Devicename != updatedSubDevice.Devicename || storedSubDevice.Nickname != updatedSubDevice.Nickname) - { - storedSubDevice.Version = updatedSubDevice.Version; - storedSubDevice.Devicename = updatedSubDevice.Devicename; - storedSubDevice.Nickname = updatedSubDevice.Nickname; - storedSubDevice.DiscoveryUpdate = true; - } - - // update sensors one by one and find out if there are new ones - // if there are new ones, mark the subdevice for discovery update - foreach (var sensor in updatedSubDevice.Sensors) - { - var storedSensor = storedSubDevice.Sensors.FirstOrDefault(s => s.Name == sensor.Name); - if (storedSensor == null) - { - sensor.DiscoveryUpdate = true; - storedSubDevice.Sensors.Add(sensor); - } - else - { - storedSensor.Value = sensor.Value; - } - } - - // remove sensors that are not in the update - var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); - foreach (var sensor in sensorsToRemove) - { - storedSubDevice.Sensors.Remove(sensor); - } - - _logger.LogInformation($"subdevice updated: {data.Id} ({data.Model})"); - } - } - - _deviceStore.UpsertGateway(storedGateway); - } - - return Task.CompletedTask; - } + // public async Task SendCommand(string ipAddress, string cmd, int id, int model, int val = 0, int valType = 0, int onType = 0, int offType = 0, int alwaysOn = 1, int onTime = 0, int offTime = 0) // { @@ -364,24 +124,4 @@ public Task OnHandle(SubdeviceApiAggregate message) // } // } - - public Task StartedAsync(CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task StartingAsync(CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task StoppedAsync(CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task StoppingAsync(CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } } \ No newline at end of file From ff864a5cd011c6fa4263917bca0047eda3e7f91e Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 22 Jul 2025 23:29:43 +0200 Subject: [PATCH 23/44] removed guid that was used to test-log instances --- src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs | 1 - src/Ecowitt.Controller/Service/Mqtt/MqttService.cs | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs index fb609a8..e762930 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs @@ -9,7 +9,6 @@ public partial class MqttService { public async Task OnHandle(MqttConfig message) { - _logger.LogInformation($"{_serviceId}: handle mqttConfig"); if (_client != null) { try diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index 6f473ce..e0abf31 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -19,7 +19,6 @@ public partial class MqttService : BackgroundService, IHostedLifecycleService, I private IMqttClient? _client; private bool _isConnecting; private MqttConfig? _mqttConfig; - private Guid _serviceId = Guid.NewGuid(); private const string HaStatusTopic = "homeassistant/status"; public MqttService(ILogger logger, MqttFactory factory, IMessageBus messageBus) @@ -34,7 +33,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); try { - _logger.LogInformation($"{_serviceId}: handle heartbeat"); while (await timer.WaitForNextTickAsync(stoppingToken)) { if (_client is { IsConnected: true } && _mqttConfig != null) From a68172a7adebcc6ccb00167250af0a45c909a807 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 29 Jul 2025 23:14:17 +0200 Subject: [PATCH 24/44] renaming and cleanup of unused classes --- .../Discovery/DiscoveryPublishService.cs | 2 +- .../Store/DataPublishService.cs | 1 + .../Subdevice/SubdeviceService.cs | 2 +- .../Message/Config/ApiConfig.cs | 6 ------ .../Message/Config/SubdeviceConfig.cs | 6 ------ src/Ecowitt.Controller/Message/Event/ApiEvent.cs | 6 ------ .../Message/Event/HttpPublishingEvent.cs | 6 ------ .../Message/Event/HttpServiceEvent.cs | 16 ++++++++++++++++ .../Message/SubdeviceApiAggregate.cs | 8 -------- .../Service/Http/HealthCheck.cs | 6 ------ .../Orchestrator}/DeviceStore.cs | 2 +- 11 files changed, 20 insertions(+), 41 deletions(-) rename {src/Ecowitt.Controller => old}/Discovery/DiscoveryPublishService.cs (99%) rename {src/Ecowitt.Controller => old}/Store/DataPublishService.cs (99%) rename {src/Ecowitt.Controller => old}/Subdevice/SubdeviceService.cs (99%) delete mode 100644 src/Ecowitt.Controller/Message/Config/ApiConfig.cs delete mode 100644 src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs delete mode 100644 src/Ecowitt.Controller/Message/Event/ApiEvent.cs delete mode 100644 src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs create mode 100644 src/Ecowitt.Controller/Message/Event/HttpServiceEvent.cs delete mode 100644 src/Ecowitt.Controller/Message/SubdeviceApiAggregate.cs delete mode 100644 src/Ecowitt.Controller/Service/Http/HealthCheck.cs rename src/Ecowitt.Controller/{Store => Service/Orchestrator}/DeviceStore.cs (96%) diff --git a/src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs b/old/Discovery/DiscoveryPublishService.cs similarity index 99% rename from src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs rename to old/Discovery/DiscoveryPublishService.cs index 4729f89..57cb4d6 100644 --- a/src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs +++ b/old/Discovery/DiscoveryPublishService.cs @@ -2,12 +2,12 @@ using Ecowitt.Controller.Configuration; using Ecowitt.Controller.Model; using Ecowitt.Controller.Mqtt; -using Ecowitt.Controller.Store; using Microsoft.Extensions.Options; using System.Text.Json; using System.Text.Json.Serialization; using Ecowitt.Controller.Model.Discovery; using Device = Ecowitt.Controller.Model.Device; +using Ecowitt.Controller.Service.Orchestrator; namespace Ecowitt.Controller.Discovery; diff --git a/src/Ecowitt.Controller/Store/DataPublishService.cs b/old/Store/DataPublishService.cs similarity index 99% rename from src/Ecowitt.Controller/Store/DataPublishService.cs rename to old/Store/DataPublishService.cs index c13e0a6..d1533e8 100644 --- a/src/Ecowitt.Controller/Store/DataPublishService.cs +++ b/old/Store/DataPublishService.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using System.Text.Json; using System.Text.Json.Serialization; +using Ecowitt.Controller.Service.Orchestrator; namespace Ecowitt.Controller.Store; diff --git a/src/Ecowitt.Controller/Subdevice/SubdeviceService.cs b/old/Subdevice/SubdeviceService.cs similarity index 99% rename from src/Ecowitt.Controller/Subdevice/SubdeviceService.cs rename to old/Subdevice/SubdeviceService.cs index 2fd8b0d..e37409a 100644 --- a/src/Ecowitt.Controller/Subdevice/SubdeviceService.cs +++ b/old/Subdevice/SubdeviceService.cs @@ -3,7 +3,7 @@ using Ecowitt.Controller.Message; using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; -using Ecowitt.Controller.Store; +using Ecowitt.Controller.Service.Orchestrator; using Microsoft.Extensions.Options; using SlimMessageBus; diff --git a/src/Ecowitt.Controller/Message/Config/ApiConfig.cs b/src/Ecowitt.Controller/Message/Config/ApiConfig.cs deleted file mode 100644 index 6dc61a1..0000000 --- a/src/Ecowitt.Controller/Message/Config/ApiConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Ecowitt.Controller.Message.Config; - -public class ApiConfig -{ - -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs b/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs deleted file mode 100644 index a3c2324..0000000 --- a/src/Ecowitt.Controller/Message/Config/SubdeviceConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Ecowitt.Controller.Message.Config; - -public class SubdeviceConfig -{ - -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/ApiEvent.cs b/src/Ecowitt.Controller/Message/Event/ApiEvent.cs deleted file mode 100644 index b3a4065..0000000 --- a/src/Ecowitt.Controller/Message/Event/ApiEvent.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Ecowitt.Controller.Message.Event; - -public class ApiEvent -{ - -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs b/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs deleted file mode 100644 index bf33123..0000000 --- a/src/Ecowitt.Controller/Message/Event/HttpPublishingEvent.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Ecowitt.Controller.Message.Event; - -public class HttpPublishingEvent -{ - -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Event/HttpServiceEvent.cs b/src/Ecowitt.Controller/Message/Event/HttpServiceEvent.cs new file mode 100644 index 0000000..3f267fc --- /dev/null +++ b/src/Ecowitt.Controller/Message/Event/HttpServiceEvent.cs @@ -0,0 +1,16 @@ +namespace Ecowitt.Controller.Message.Event; + +public class HttpServiceEvent +{ + public HttpServiceEventType EventType { get; set; } = HttpServiceEventType.Unknown; + public string? Message { get; set; } + +} + +public enum HttpServiceEventType +{ + Unknown, + Started, + Stopped, + Error +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/SubdeviceApiAggregate.cs b/src/Ecowitt.Controller/Message/SubdeviceApiAggregate.cs deleted file mode 100644 index af77846..0000000 --- a/src/Ecowitt.Controller/Message/SubdeviceApiAggregate.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Ecowitt.Controller.Model.Api; - -namespace Ecowitt.Controller.Message; - -public class SubdeviceApiAggregate -{ - public List Subdevices { get; set; } = new(); -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Http/HealthCheck.cs b/src/Ecowitt.Controller/Service/Http/HealthCheck.cs deleted file mode 100644 index 5c2ea95..0000000 --- a/src/Ecowitt.Controller/Service/Http/HealthCheck.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Ecowitt.Controller.Service.Http; - -public class HealthCheck -{ - -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Store/DeviceStore.cs b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs similarity index 96% rename from src/Ecowitt.Controller/Store/DeviceStore.cs rename to src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs index 756d127..5f2c70a 100644 --- a/src/Ecowitt.Controller/Store/DeviceStore.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using Ecowitt.Controller.Model; -namespace Ecowitt.Controller.Store; +namespace Ecowitt.Controller.Service.Orchestrator; public interface IDeviceStore { From f70cc225209770223a6353dd84964eca679f0087 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 29 Jul 2025 23:15:29 +0200 Subject: [PATCH 25/44] moved to partial classes and added httppublishingservice (shitty name, i agree). most of the data now arrives in the state machine and device store. --- src/Ecowitt.Controller.sln | 7 + .../Controller/DataController.cs | 3 +- .../Ecowitt.Controller.csproj | 45 ++++--- .../Message/Config/HttpConfig.cs | 35 +++++ .../Message/Data/SubdeviceApiAggregate.cs | 8 ++ .../Model/Api/GatewayApiData.cs | 8 +- src/Ecowitt.Controller/Program.cs | 31 +++-- .../Http/HttpPublishingService.Consumer.cs | 15 +++ .../Http/HttpPublishingService.Events.cs | 64 +++++++++ .../Service/Http/HttpPublishingService.cs | 126 +++++++++++++++++- .../Orchestrator/StateMachine.ConsumerHttp.cs | 33 ++++- .../Orchestrator/StateMachine.ConsumerMqtt.cs | 8 +- .../Service/Orchestrator/StateMachine.cs | 44 +++--- src/Ecowitt.Controller/appsettings.json | 4 +- 14 files changed, 357 insertions(+), 74 deletions(-) create mode 100644 src/Ecowitt.Controller/Message/Config/HttpConfig.cs create mode 100644 src/Ecowitt.Controller/Message/Data/SubdeviceApiAggregate.cs create mode 100644 src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs create mode 100644 src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs diff --git a/src/Ecowitt.Controller.sln b/src/Ecowitt.Controller.sln index b2626fb..f59151b 100644 --- a/src/Ecowitt.Controller.sln +++ b/src/Ecowitt.Controller.sln @@ -28,6 +28,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{02EA681E-C ..\docs\mqtt.md = ..\docs\mqtt.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "old_classes", "old_classes", "{F45EBE6B-D610-44B2-B7ED-B96A65472093}" + ProjectSection(SolutionItems) = preProject + ..\old\Store\DataPublishService.cs = ..\old\Store\DataPublishService.cs + ..\old\Discovery\DiscoveryPublishService.cs = ..\old\Discovery\DiscoveryPublishService.cs + ..\old\Subdevice\SubdeviceService.cs = ..\old\Subdevice\SubdeviceService.cs + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/src/Ecowitt.Controller/Controller/DataController.cs b/src/Ecowitt.Controller/Controller/DataController.cs index 4ca1868..670bd00 100644 --- a/src/Ecowitt.Controller/Controller/DataController.cs +++ b/src/Ecowitt.Controller/Controller/DataController.cs @@ -1,8 +1,7 @@ -using System.Text.Json; -using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; using Microsoft.AspNetCore.Mvc; using SlimMessageBus; +using System.Text.Json; namespace Ecowitt.Controller.Controller; diff --git a/src/Ecowitt.Controller/Ecowitt.Controller.csproj b/src/Ecowitt.Controller/Ecowitt.Controller.csproj index 2b06675..e194e8d 100644 --- a/src/Ecowitt.Controller/Ecowitt.Controller.csproj +++ b/src/Ecowitt.Controller/Ecowitt.Controller.csproj @@ -9,30 +9,33 @@ - - - - - - - - - - - - - - - - - - - - + + + + - + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ecowitt.Controller/Message/Config/HttpConfig.cs b/src/Ecowitt.Controller/Message/Config/HttpConfig.cs new file mode 100644 index 0000000..bac6d55 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Config/HttpConfig.cs @@ -0,0 +1,35 @@ +namespace Ecowitt.Controller.Message.Config; + +public class HttpConfig +{ + public List Hosts { get; set; } = new List(); + public int PollingInterval { get; set; } = 5; + public bool AutoDiscovery { get; set; } +} + +public class HttpHost +{ + public HttpHost() + { + + } + public HttpHost(string host) + { + Host = host; + } + + public HttpHost(string host, string user, string password) + { + Host = host; + User = user; + Password = password; + } + + public string Host { get; set; } + public int Port { get; set; } = 80; + public string User { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Protocol { get; set; } = "http"; // or "https" + public string BaseUrl => $"{Protocol}://{Host}"; + public string Passkey { get; set; } = string.Empty; +} diff --git a/src/Ecowitt.Controller/Message/Data/SubdeviceApiAggregate.cs b/src/Ecowitt.Controller/Message/Data/SubdeviceApiAggregate.cs new file mode 100644 index 0000000..8d6d8a2 --- /dev/null +++ b/src/Ecowitt.Controller/Message/Data/SubdeviceApiAggregate.cs @@ -0,0 +1,8 @@ +using Ecowitt.Controller.Model.Api; + +namespace Ecowitt.Controller.Message.Data; + +public class SubdeviceApiAggregate +{ + public List Subdevices { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Api/GatewayApiData.cs b/src/Ecowitt.Controller/Model/Api/GatewayApiData.cs index daec9b1..56a7389 100644 --- a/src/Ecowitt.Controller/Model/Api/GatewayApiData.cs +++ b/src/Ecowitt.Controller/Model/Api/GatewayApiData.cs @@ -2,12 +2,12 @@ public class GatewayApiData { - public string PASSKEY { get; set; } - public string StationType { get; set; } + public string PASSKEY { get; set; } = string.Empty; + public string StationType { get; set; } = string.Empty; public int Runtime { get; set; } public DateTime DateUtc { get; set; } - public string Freq { get; set; } - public string Model { get; set; } + public string Freq { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; public string? IpAddress { get; set; } public DateTime TimestampUtc { get; set; } = DateTime.UtcNow; diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index b8e9c88..12064fb 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -4,9 +4,10 @@ using Ecowitt.Controller.Message.Config; using Ecowitt.Controller.Message.Data; using Ecowitt.Controller.Message.Event; +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Service.Http; using Ecowitt.Controller.Service.Mqtt; using Ecowitt.Controller.Service.Orchestrator; -using Ecowitt.Controller.Store; using MQTTnet; using Polly; using Polly.Contrib.WaitAndRetry; @@ -22,11 +23,6 @@ namespace Ecowitt.Controller; public class Program { - public class ServiceProviderAccessor - { - public IServiceProvider ServiceProvider { get; set; } - } - public static async Task Main(string[] args) { var builder = WebApplication.CreateBuilder(args); @@ -63,6 +59,7 @@ public static async Task Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); var sp = builder.Services.BuildServiceProvider(); @@ -91,10 +88,21 @@ public static async Task Main(string[] args) smb.Consume(x => x.Topic("mqtt-connection-event").WithConsumer()); smb.Consume(x => x.Topic("home-assistant-status").WithConsumer()); + // controller -> statemachine + smb.Produce(x => x.DefaultTopic("gw-api-data")); + smb.Consume(x => x.Topic("gw-api-data")); + + // statemachine -> HttpPublishingService + smb.Produce(x => x.DefaultTopic("config-http")); + smb.Consume(x => x.Topic("config-http").WithConsumer()); + + // HttpPublishingService -> statemachine + smb.Produce(x => x.DefaultTopic("subdevice-api-data")); + smb.Produce(x => x.DefaultTopic("http-service-event")); + smb.Consume(x => x.Topic("subdevice-api-data").WithConsumer()); + smb.Consume(x => x.Topic("http-service-event").WithConsumer()); - - // smb.Produce(x => x.DefaultTopic("api-data")); // smb.Produce(x => x.DefaultTopic("subdevice-data")); // smb.Produce(x => x.DefaultTopic("subdevice-command")); @@ -113,10 +121,13 @@ public static async Task Main(string[] args) smb.AddServicesFromAssembly(Assembly.GetExecutingAssembly()); }); - builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(s => s.GetRequiredService()); builder.Services.AddTransient(); - builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(s => s.GetRequiredService()); + + builder.Services.AddHostedService(s => s.GetRequiredService()); + // // builder.Services.AddHostedService(); // builder.Services.AddHostedService(); diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs new file mode 100644 index 0000000..005e0d0 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs @@ -0,0 +1,15 @@ +using Ecowitt.Controller.Message.Config; + +namespace Ecowitt.Controller.Service.Http +{ + public partial class HttpPublishingService + { + public Task OnHandle(HttpConfig message) + { + _logger.LogDebug("HttpConfig received"); + _config = message ?? throw new ArgumentNullException(nameof(message), "HttpConfig cannot be null"); + + return Task.CompletedTask; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs new file mode 100644 index 0000000..d90312e --- /dev/null +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs @@ -0,0 +1,64 @@ +using Ecowitt.Controller.Message.Data; +using Ecowitt.Controller.Message.Event; + +namespace Ecowitt.Controller.Service.Http +{ + public partial class HttpPublishingService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Starting SubdeviceService"); + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_config.PollingInterval)); + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + foreach (var host in _config.Hosts) + { + try + { + var data = await GetSubdeviceData(host, stoppingToken); + if(data.Count == 0) + { + _logger.LogWarning($"No subdevice data received from {host.BaseUrl}"); + } + + var aggregate = new SubdeviceApiAggregate(); + aggregate.Subdevices.AddRange(data); + await _messageBus.Publish(aggregate,cancellationToken: stoppingToken); + } + catch (Exception e) + { + _logger.LogError($"Failed to get subdevicedata for {host.BaseUrl}", e); + } + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Stopping SubdeviceService"); + } + } + + public async Task StartedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new HttpServiceEvent { EventType = HttpServiceEventType.Started }, cancellationToken: cancellationToken); + } + + public Task StartingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public async Task StoppedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new HttpServiceEvent { EventType = HttpServiceEventType.Stopped }, cancellationToken: cancellationToken); + } + + public Task StoppingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs index 3c64441..1e84e67 100644 --- a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs @@ -1,6 +1,128 @@ +using Ecowitt.Controller.Message.Config; +using Ecowitt.Controller.Model.Api; +using SlimMessageBus; +using System.Text.Json; + namespace Ecowitt.Controller.Service.Http; -public class HttpPublishingService +public partial class HttpPublishingService : BackgroundService, IHostedLifecycleService, IConsumer { - + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly IMessageBus _messageBus; + private HttpConfig _config = new HttpConfig(); + + public HttpPublishingService(ILogger logger, IMessageBus messageBus, IHttpClientFactory httpClientFactory) + { + _logger = logger; + _messageBus = messageBus; + _httpClientFactory = httpClientFactory; + } + + + private async Task> GetSubdeviceData(HttpHost host, CancellationToken cancellationToken) + { + _logger.LogInformation($"Polling subdevices from {host.Host}"); + var subdevices = new List(); + try + { + subdevices.AddRange(await GetSubdevicesOverview(host, cancellationToken)); + foreach (var subdevice in subdevices) + { + var payload = await GetSubDeviceApiPayload(host, subdevice.Id, subdevice.Model, cancellationToken); + subdevice.Payload = payload; + await Task.Delay(350, cancellationToken); // delay to not overload the gateway + } + } + catch (Exception e) + { + _logger.LogError(e, $"Exception while trying to get subdevices from {host.Host}"); + } + + return subdevices; + + } + + private async Task> GetSubdevicesOverview(HttpHost host, CancellationToken cancellationToken) + { + var subdevices = new List(); + using var client = CreateHttpClient(host); + + try + { + var response = await client.GetAsync("get_iot_device_list", cancellationToken); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var jsonDocument = JsonDocument.Parse(content); + var elements = jsonDocument.RootElement.GetProperty("command"); + foreach (var element in elements.EnumerateArray()) + { + + var subdevice = new SubdeviceApiData + { + Id = element.GetProperty("id").GetInt32(), + Model = element.GetProperty("model").GetInt32(), + Version = element.GetProperty("ver").GetInt32(), + RfnetState = element.GetProperty("rfnet_state").GetInt32(), + Battery = element.GetProperty("battery").GetInt32(), + Signal = element.GetProperty("signal").GetInt32(), + GwIp = host.Host, + TimestampUtc = DateTime.UtcNow + }; + _logger.LogInformation($"Subdevice: {subdevice.Id} ({subdevice.Model})"); + subdevices.Add(subdevice); + } + } + else + { + _logger.LogWarning($"Failed to get subdevices from {host.Host}"); + } + } + catch (Exception e) + { + _logger.LogError(e, $"Exception while trying to get subdevices from {host.Host}"); + } + + + return subdevices; + } + + private async Task GetSubDeviceApiPayload(HttpHost host, int subdeviceId, int model, CancellationToken cancellationToken) + { + using var client = CreateHttpClient(host); + try + { + var payload = new { command = new[] { new { cmd = "read_device", id = subdeviceId, model } } }; + var sContent = new StringContent(JsonSerializer.Serialize(payload)); + var response = await client.PostAsync("parse_quick_cmd_iot", sContent, cancellationToken); + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync(cancellationToken); + } + else + { + _logger.LogWarning($"Could not get payload from {host.Host} for subdevice {subdeviceId}"); + } + } + catch (Exception e) + { + _logger.LogError(e, $"Exception while trying to get payload from {host.Host} for subdevice {subdeviceId}"); + } + + return string.Empty; + } + + private HttpClient CreateHttpClient(HttpHost host) + { + var client = _httpClientFactory.CreateClient("ecowitt-client"); + client.BaseAddress = new Uri(host.BaseUrl); + + if (!string.IsNullOrWhiteSpace(host.User) && !string.IsNullOrWhiteSpace(host.Password)) + { + //TODO: authentication header, need to test + //client.DefaultRequestHeaders.Add(); + } + return client; + } } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs index 44f482f..b210176 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs @@ -1,13 +1,15 @@ using Ecowitt.Controller.Configuration; using Ecowitt.Controller.Mapping; -using Ecowitt.Controller.Message; +using Ecowitt.Controller.Message.Data; using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; using System.Text.Json; +using Ecowitt.Controller.Message.Event; +using SlimMessageBus; namespace Ecowitt.Controller.Service.Orchestrator { - public partial class StateMachine + public partial class StateMachine : IConsumer { public async Task OnHandle(SubdeviceApiCommand message) { @@ -179,5 +181,32 @@ public Task OnHandle(SubdeviceApiAggregate message) return Task.CompletedTask; } + + + public Task OnHandle(HttpServiceEvent message) + { + switch (message.EventType) + { + case HttpServiceEventType.Started: + _logger.LogInformation("HTTP Service started"); + _lastHttpServiceState = HttpServiceEventType.Started; + break; + case HttpServiceEventType.Stopped: + _logger.LogWarning("HTTP Service stopped"); + _lastHttpServiceState = HttpServiceEventType.Stopped; + break; + case HttpServiceEventType.Error: + _logger.LogError($"HTTP Service error: {message.Message}"); + _lastHttpServiceState = HttpServiceEventType.Error; + break; + case HttpServiceEventType.Unknown: + default: + _logger.LogWarning($"Unknown HTTP Service event: {message.EventType}"); + _lastHttpServiceState = HttpServiceEventType.Unknown; + break; + } + + return Task.CompletedTask; + } } } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs index 1f7fd07..78e4b16 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs @@ -10,20 +10,20 @@ public Task OnHandle(MqttServiceEvent message) { case MqttServiceEventType.Started: _logger.LogInformation("MQTT Service started"); - _lastServiceState = MqttServiceEventType.Started; + _lastMqttServiceState = MqttServiceEventType.Started; //_mqttServiceStarted.TrySetResult(true); break; case MqttServiceEventType.Stopped: _logger.LogWarning("MQTT Service stopped"); - _lastServiceState = MqttServiceEventType.Stopped; + _lastMqttServiceState = MqttServiceEventType.Stopped; break; case MqttServiceEventType.Error: _logger.LogError($"MQTT Service error: {message.Message}"); - _lastServiceState = MqttServiceEventType.Error; + _lastMqttServiceState = MqttServiceEventType.Error; break; case MqttServiceEventType.Heartbeat: _logger.LogDebug("MQTT Service heartbeat received"); - _lastServiceState = MqttServiceEventType.Heartbeat; + _lastMqttServiceState = MqttServiceEventType.Heartbeat; break; case MqttServiceEventType.Unknown: default: diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs index bb76e85..c5fc7ea 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs @@ -1,9 +1,8 @@ using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Message; using Ecowitt.Controller.Message.Config; +using Ecowitt.Controller.Message.Data; using Ecowitt.Controller.Message.Event; using Ecowitt.Controller.Model.Api; -using Ecowitt.Controller.Store; using Microsoft.Extensions.Options; using SlimMessageBus; @@ -17,8 +16,8 @@ public partial class StateMachine : BackgroundService, IConsumer _mqttServiceStarted = new TaskCompletionSource(); + private MqttServiceEventType _lastMqttServiceState = MqttServiceEventType.Unknown; + private HttpServiceEventType _lastHttpServiceState = HttpServiceEventType.Unknown; public StateMachine(ILogger logger, IDeviceStore deviceStore, IMessageBus messageBus, IOptions mqttOptions, IOptions ecowittOptions, IOptions controllerOptions) { @@ -34,31 +33,22 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Starting Orchestrator"); - // emit initial MQTT configuration with a 1s timeout - // this is to ensure that the MQTT service has time to start - - // Wait for MQTT service to start with timeout - // for whatever reasons this did not work as expected, so it'S back to dumb task.delay(). - //try - //{ - // await _mqttServiceStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), stoppingToken); - // _logger.LogInformation("MQTT service started signal received"); - // await EmitMqttConfig(); - //} - //catch (TimeoutException) - //{ - // _logger.LogWarning("Timeout waiting for MQTT service to start"); - // await EmitMqttConfig(); // Try to proceed anyway - //} - //catch (OperationCanceledException) - //{ - // _logger.LogInformation("Cancellation requested while waiting for MQTT service"); - //} - - - _logger.LogInformation("Emitting initial MQTT configuration"); + _logger.LogInformation("Emitting initial configuration"); await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); await EmitMqttConfig(); + await EmitHttpConfig(); + } + + private async Task EmitHttpConfig() + { + var httpConfig = new HttpConfig + { + Hosts = _ecowittOptions.Gateways.Select(gw => new HttpHost(gw.Ip, gw.Username, gw.Password)).ToList(), + PollingInterval = _ecowittOptions.PollingInterval, + AutoDiscovery = _ecowittOptions.AutoDiscovery + }; + + await _messageBus.Publish(httpConfig); } private async Task EmitMqttConfig() diff --git a/src/Ecowitt.Controller/appsettings.json b/src/Ecowitt.Controller/appsettings.json index c2100b4..956c467 100644 --- a/src/Ecowitt.Controller/appsettings.json +++ b/src/Ecowitt.Controller/appsettings.json @@ -24,8 +24,8 @@ "autodiscovery": false, "gateways": [ { - "name": "garten", - "ip": "192.168.103.162" + "name": "gw3000", + "ip": "192.168.56.138" } ] }, From 8b51b8e1139aa211092b1ae3b6cb2e60cf02e855 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 29 Jul 2025 23:18:17 +0200 Subject: [PATCH 26/44] forgot to move the old mqtt stuff to the old folder --- {src/Ecowitt.Controller => old}/Mqtt/Helper.cs | 0 {src/Ecowitt.Controller => old}/Mqtt/MqttClient.cs | 0 {src/Ecowitt.Controller => old}/Mqtt/MqttService.cs | 0 src/Ecowitt.Controller/Ecowitt.Controller.csproj | 4 ++++ 4 files changed, 4 insertions(+) rename {src/Ecowitt.Controller => old}/Mqtt/Helper.cs (100%) rename {src/Ecowitt.Controller => old}/Mqtt/MqttClient.cs (100%) rename {src/Ecowitt.Controller => old}/Mqtt/MqttService.cs (100%) diff --git a/src/Ecowitt.Controller/Mqtt/Helper.cs b/old/Mqtt/Helper.cs similarity index 100% rename from src/Ecowitt.Controller/Mqtt/Helper.cs rename to old/Mqtt/Helper.cs diff --git a/src/Ecowitt.Controller/Mqtt/MqttClient.cs b/old/Mqtt/MqttClient.cs similarity index 100% rename from src/Ecowitt.Controller/Mqtt/MqttClient.cs rename to old/Mqtt/MqttClient.cs diff --git a/src/Ecowitt.Controller/Mqtt/MqttService.cs b/old/Mqtt/MqttService.cs similarity index 100% rename from src/Ecowitt.Controller/Mqtt/MqttService.cs rename to old/Mqtt/MqttService.cs diff --git a/src/Ecowitt.Controller/Ecowitt.Controller.csproj b/src/Ecowitt.Controller/Ecowitt.Controller.csproj index e194e8d..91e6eb2 100644 --- a/src/Ecowitt.Controller/Ecowitt.Controller.csproj +++ b/src/Ecowitt.Controller/Ecowitt.Controller.csproj @@ -9,9 +9,13 @@ + + + + From 81889d03c33e7417f3fab54028f2b63fb592e817 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 29 Jul 2025 23:26:00 +0200 Subject: [PATCH 27/44] consolidated the model. --- src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs | 2 +- src/Ecowitt.Controller/Message/Data/GatewayData.cs | 8 -------- src/Ecowitt.Controller/Message/Data/SubdeviceData.cs | 8 -------- .../{ => Model}/Configuration/ControllerOptions.cs | 2 +- .../{ => Model}/Configuration/EcowittOptions.cs | 2 +- .../{ => Model}/Configuration/MqttOptions.cs | 2 +- .../{ => Model}/Mapping/ApiDataExtension.cs | 7 ++++--- .../{ => Model}/Mapping/SensorBuilder.cs | 0 .../{ => Model}/Mapping/SensorBuilderAddon.cs | 0 .../{ => Model}/Mapping/SensorBuilderLogic.cs | 0 .../{ => Model}/Message/Config/HttpConfig.cs | 2 +- .../{ => Model}/Message/Config/MqttConfig.cs | 4 ++-- .../{ => Model}/Message/Data/DeviceData.cs | 2 +- .../{ => Model}/Message/Data/SubdeviceApiAggregate.cs | 2 +- .../Message/Event/HomeAssistantDiscoveryEvent.cs | 2 +- .../Message/Event/HomeAssistantStatusEvent.cs | 2 +- .../{ => Model}/Message/Event/HttpServiceEvent.cs | 2 +- .../{ => Model}/Message/Event/MqttConnectionEvent.cs | 2 +- .../{ => Model}/Message/Event/MqttServiceEvent.cs | 2 +- src/Ecowitt.Controller/Program.cs | 8 ++++---- .../Service/Http/HttpPublishingService.Consumer.cs | 2 +- .../Service/Http/HttpPublishingService.Events.cs | 4 ++-- .../Service/Http/HttpPublishingService.cs | 2 +- .../Service/Mqtt/MqttService.Consumer.cs | 6 +++--- .../Service/Mqtt/MqttService.Events.cs | 4 ++-- src/Ecowitt.Controller/Service/Mqtt/MqttService.cs | 6 +++--- .../Service/Orchestrator/StateMachine.ConsumerHttp.cs | 10 +++++----- .../Service/Orchestrator/StateMachine.ConsumerMqtt.cs | 2 +- .../Service/Orchestrator/StateMachine.cs | 8 ++++---- 29 files changed, 44 insertions(+), 59 deletions(-) delete mode 100644 src/Ecowitt.Controller/Message/Data/GatewayData.cs delete mode 100644 src/Ecowitt.Controller/Message/Data/SubdeviceData.cs rename src/Ecowitt.Controller/{ => Model}/Configuration/ControllerOptions.cs (85%) rename src/Ecowitt.Controller/{ => Model}/Configuration/EcowittOptions.cs (91%) rename src/Ecowitt.Controller/{ => Model}/Configuration/MqttOptions.cs (89%) rename src/Ecowitt.Controller/{ => Model}/Mapping/ApiDataExtension.cs (94%) rename src/Ecowitt.Controller/{ => Model}/Mapping/SensorBuilder.cs (100%) rename src/Ecowitt.Controller/{ => Model}/Mapping/SensorBuilderAddon.cs (100%) rename src/Ecowitt.Controller/{ => Model}/Mapping/SensorBuilderLogic.cs (100%) rename src/Ecowitt.Controller/{ => Model}/Message/Config/HttpConfig.cs (94%) rename src/Ecowitt.Controller/{ => Model}/Message/Config/MqttConfig.cs (89%) rename src/Ecowitt.Controller/{ => Model}/Message/Data/DeviceData.cs (68%) rename src/Ecowitt.Controller/{ => Model}/Message/Data/SubdeviceApiAggregate.cs (77%) rename src/Ecowitt.Controller/{ => Model}/Message/Event/HomeAssistantDiscoveryEvent.cs (50%) rename src/Ecowitt.Controller/{ => Model}/Message/Event/HomeAssistantStatusEvent.cs (77%) rename src/Ecowitt.Controller/{ => Model}/Message/Event/HttpServiceEvent.cs (83%) rename src/Ecowitt.Controller/{ => Model}/Message/Event/MqttConnectionEvent.cs (82%) rename src/Ecowitt.Controller/{ => Model}/Message/Event/MqttServiceEvent.cs (86%) diff --git a/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs b/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs index 6170dc7..1c10b55 100644 --- a/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs +++ b/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs @@ -1,7 +1,7 @@ using System.Diagnostics; -using Ecowitt.Controller.Mapping; using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Mapping; using Newtonsoft.Json; namespace EcoWitt.Controller.Tests; diff --git a/src/Ecowitt.Controller/Message/Data/GatewayData.cs b/src/Ecowitt.Controller/Message/Data/GatewayData.cs deleted file mode 100644 index 152f7e1..0000000 --- a/src/Ecowitt.Controller/Message/Data/GatewayData.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Ecowitt.Controller.Model.Api; - -namespace Ecowitt.Controller.Message.Data; - -public class GatewayData -{ - public GatewayApiData Gateway { get; set; } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs b/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs deleted file mode 100644 index aca80ea..0000000 --- a/src/Ecowitt.Controller/Message/Data/SubdeviceData.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Ecowitt.Controller.Model.Api; - -namespace Ecowitt.Controller.Message.Data; - -public class SubdeviceData -{ - public SubdeviceApiData SubdeviceApiData { get; set; } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Configuration/ControllerOptions.cs b/src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs similarity index 85% rename from src/Ecowitt.Controller/Configuration/ControllerOptions.cs rename to src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs index b37e85e..4a6bb39 100644 --- a/src/Ecowitt.Controller/Configuration/ControllerOptions.cs +++ b/src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Configuration; +namespace Ecowitt.Controller.Model.Configuration; public class ControllerOptions { diff --git a/src/Ecowitt.Controller/Configuration/EcowittOptions.cs b/src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs similarity index 91% rename from src/Ecowitt.Controller/Configuration/EcowittOptions.cs rename to src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs index bcd3cb3..5a5c986 100644 --- a/src/Ecowitt.Controller/Configuration/EcowittOptions.cs +++ b/src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Configuration; +namespace Ecowitt.Controller.Model.Configuration; public class EcowittOptions { diff --git a/src/Ecowitt.Controller/Configuration/MqttOptions.cs b/src/Ecowitt.Controller/Model/Configuration/MqttOptions.cs similarity index 89% rename from src/Ecowitt.Controller/Configuration/MqttOptions.cs rename to src/Ecowitt.Controller/Model/Configuration/MqttOptions.cs index 999d287..be9e266 100644 --- a/src/Ecowitt.Controller/Configuration/MqttOptions.cs +++ b/src/Ecowitt.Controller/Model/Configuration/MqttOptions.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Configuration; +namespace Ecowitt.Controller.Model.Configuration; public class MqttOptions { diff --git a/src/Ecowitt.Controller/Mapping/ApiDataExtension.cs b/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs similarity index 94% rename from src/Ecowitt.Controller/Mapping/ApiDataExtension.cs rename to src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs index ba55610..5d79de4 100644 --- a/src/Ecowitt.Controller/Mapping/ApiDataExtension.cs +++ b/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs @@ -1,8 +1,9 @@ using Ecowitt.Controller.Model; using System.Text.Json; using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Mapping; -namespace Ecowitt.Controller.Mapping; +namespace Ecowitt.Controller.Model.Mapping; public static class ApiDataExtension { @@ -12,9 +13,9 @@ public static class ApiDataExtension /// /// /// - public static Model.Subdevice Map(this SubdeviceApiData subdeviceApiData, bool isMetric = true, bool calculateValues = true) + public static Subdevice Map(this SubdeviceApiData subdeviceApiData, bool isMetric = true, bool calculateValues = true) { - var result = new Model.Subdevice + var result = new Subdevice { Id = subdeviceApiData.Id, Model = (SubdeviceModel)subdeviceApiData.Model, diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilder.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs similarity index 100% rename from src/Ecowitt.Controller/Mapping/SensorBuilder.cs rename to src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilderAddon.cs similarity index 100% rename from src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs rename to src/Ecowitt.Controller/Model/Mapping/SensorBuilderAddon.cs diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilderLogic.cs similarity index 100% rename from src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs rename to src/Ecowitt.Controller/Model/Mapping/SensorBuilderLogic.cs diff --git a/src/Ecowitt.Controller/Message/Config/HttpConfig.cs b/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs similarity index 94% rename from src/Ecowitt.Controller/Message/Config/HttpConfig.cs rename to src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs index bac6d55..53a3424 100644 --- a/src/Ecowitt.Controller/Message/Config/HttpConfig.cs +++ b/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Message.Config; +namespace Ecowitt.Controller.Model.Message.Config; public class HttpConfig { diff --git a/src/Ecowitt.Controller/Message/Config/MqttConfig.cs b/src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs similarity index 89% rename from src/Ecowitt.Controller/Message/Config/MqttConfig.cs rename to src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs index aaff816..10e0b0f 100644 --- a/src/Ecowitt.Controller/Message/Config/MqttConfig.cs +++ b/src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs @@ -1,6 +1,6 @@ -using Ecowitt.Controller.Configuration; +using Ecowitt.Controller.Model.Configuration; -namespace Ecowitt.Controller.Message.Config; +namespace Ecowitt.Controller.Model.Message.Config; public class MqttConfig { diff --git a/src/Ecowitt.Controller/Message/Data/DeviceData.cs b/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs similarity index 68% rename from src/Ecowitt.Controller/Message/Data/DeviceData.cs rename to src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs index 7862642..843ad97 100644 --- a/src/Ecowitt.Controller/Message/Data/DeviceData.cs +++ b/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs @@ -1,6 +1,6 @@ using Ecowitt.Controller.Model; -namespace Ecowitt.Controller.Message.Data; +namespace Ecowitt.Controller.Model.Message.Data; public class DeviceData { diff --git a/src/Ecowitt.Controller/Message/Data/SubdeviceApiAggregate.cs b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceApiAggregate.cs similarity index 77% rename from src/Ecowitt.Controller/Message/Data/SubdeviceApiAggregate.cs rename to src/Ecowitt.Controller/Model/Message/Data/SubdeviceApiAggregate.cs index 8d6d8a2..da8aaae 100644 --- a/src/Ecowitt.Controller/Message/Data/SubdeviceApiAggregate.cs +++ b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceApiAggregate.cs @@ -1,6 +1,6 @@ using Ecowitt.Controller.Model.Api; -namespace Ecowitt.Controller.Message.Data; +namespace Ecowitt.Controller.Model.Message.Data; public class SubdeviceApiAggregate { diff --git a/src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs similarity index 50% rename from src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs rename to src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs index 29a31a2..8ce16d9 100644 --- a/src/Ecowitt.Controller/Message/Event/HomeAssistantDiscoveryEvent.cs +++ b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Message.Event; +namespace Ecowitt.Controller.Model.Message.Event; public class HomeAssistantDiscoveryEvent { diff --git a/src/Ecowitt.Controller/Message/Event/HomeAssistantStatusEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantStatusEvent.cs similarity index 77% rename from src/Ecowitt.Controller/Message/Event/HomeAssistantStatusEvent.cs rename to src/Ecowitt.Controller/Model/Message/Event/HomeAssistantStatusEvent.cs index d0f579a..bd40f41 100644 --- a/src/Ecowitt.Controller/Message/Event/HomeAssistantStatusEvent.cs +++ b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantStatusEvent.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Message.Event; +namespace Ecowitt.Controller.Model.Message.Event; public class HomeAssistantStatusEvent { diff --git a/src/Ecowitt.Controller/Message/Event/HttpServiceEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/HttpServiceEvent.cs similarity index 83% rename from src/Ecowitt.Controller/Message/Event/HttpServiceEvent.cs rename to src/Ecowitt.Controller/Model/Message/Event/HttpServiceEvent.cs index 3f267fc..7bebe93 100644 --- a/src/Ecowitt.Controller/Message/Event/HttpServiceEvent.cs +++ b/src/Ecowitt.Controller/Model/Message/Event/HttpServiceEvent.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Message.Event; +namespace Ecowitt.Controller.Model.Message.Event; public class HttpServiceEvent { diff --git a/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/MqttConnectionEvent.cs similarity index 82% rename from src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs rename to src/Ecowitt.Controller/Model/Message/Event/MqttConnectionEvent.cs index eebcf94..cf1e035 100644 --- a/src/Ecowitt.Controller/Message/Event/MqttConnectionEvent.cs +++ b/src/Ecowitt.Controller/Model/Message/Event/MqttConnectionEvent.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Message.Event; +namespace Ecowitt.Controller.Model.Message.Event; public class MqttConnectionEvent { diff --git a/src/Ecowitt.Controller/Message/Event/MqttServiceEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/MqttServiceEvent.cs similarity index 86% rename from src/Ecowitt.Controller/Message/Event/MqttServiceEvent.cs rename to src/Ecowitt.Controller/Model/Message/Event/MqttServiceEvent.cs index bdce3e5..c7fb45c 100644 --- a/src/Ecowitt.Controller/Message/Event/MqttServiceEvent.cs +++ b/src/Ecowitt.Controller/Model/Message/Event/MqttServiceEvent.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Message.Event +namespace Ecowitt.Controller.Model.Message.Event { public class MqttServiceEvent { diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index 12064fb..1c3f899 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -1,10 +1,10 @@ using System.Net; using System.Reflection; -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Message.Config; -using Ecowitt.Controller.Message.Data; -using Ecowitt.Controller.Message.Event; using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Configuration; +using Ecowitt.Controller.Model.Message.Config; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; using Ecowitt.Controller.Service.Http; using Ecowitt.Controller.Service.Mqtt; using Ecowitt.Controller.Service.Orchestrator; diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs index 005e0d0..b7ec588 100644 --- a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs @@ -1,4 +1,4 @@ -using Ecowitt.Controller.Message.Config; +using Ecowitt.Controller.Model.Message.Config; namespace Ecowitt.Controller.Service.Http { diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs index d90312e..da133f6 100644 --- a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs @@ -1,5 +1,5 @@ -using Ecowitt.Controller.Message.Data; -using Ecowitt.Controller.Message.Event; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; namespace Ecowitt.Controller.Service.Http { diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs index 1e84e67..f2c20b4 100644 --- a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs @@ -1,5 +1,5 @@ -using Ecowitt.Controller.Message.Config; using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Message.Config; using SlimMessageBus; using System.Text.Json; diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs index e762930..05ffb57 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs @@ -1,6 +1,6 @@ -using Ecowitt.Controller.Message.Config; -using Ecowitt.Controller.Message.Data; -using Ecowitt.Controller.Message.Event; +using Ecowitt.Controller.Model.Message.Config; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; using MQTTnet.Client; namespace Ecowitt.Controller.Service.Mqtt diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs index 940c08b..8f25424 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs @@ -1,5 +1,5 @@ -using Ecowitt.Controller.Message.Event; -using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Message.Event; using MQTTnet; using MQTTnet.Client; using System.Text.Json; diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index e0abf31..b84c126 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -1,10 +1,10 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; -using Ecowitt.Controller.Message.Config; -using Ecowitt.Controller.Message.Data; -using Ecowitt.Controller.Message.Event; using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Message.Config; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; using MQTTnet; using MQTTnet.Client; using SlimMessageBus; diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs index b210176..c0fc777 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs @@ -1,11 +1,11 @@ -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Mapping; -using Ecowitt.Controller.Message.Data; -using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; using System.Text.Json; -using Ecowitt.Controller.Message.Event; using SlimMessageBus; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; +using Ecowitt.Controller.Model.Mapping; +using Ecowitt.Controller.Model.Configuration; namespace Ecowitt.Controller.Service.Orchestrator { diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs index 78e4b16..cfca0e4 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs @@ -1,4 +1,4 @@ -using Ecowitt.Controller.Message.Event; +using Ecowitt.Controller.Model.Message.Event; namespace Ecowitt.Controller.Service.Orchestrator { diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs index c5fc7ea..ff84ad2 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs @@ -1,8 +1,8 @@ -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Message.Config; -using Ecowitt.Controller.Message.Data; -using Ecowitt.Controller.Message.Event; using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Configuration; +using Ecowitt.Controller.Model.Message.Config; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; using Microsoft.Extensions.Options; using SlimMessageBus; From a286694367efd692ef68371185633f512eeb6698 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Thu, 31 Jul 2025 22:54:05 +0200 Subject: [PATCH 28/44] added wfc02 compatibility --- .../Model/Mapping/ApiDataExtension.cs | 9 +- .../Model/Mapping/SensorBuilder.cs | 121 +++--------------- 2 files changed, 24 insertions(+), 106 deletions(-) diff --git a/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs b/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs index 5d79de4..a2bacd1 100644 --- a/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs +++ b/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs @@ -1,7 +1,8 @@ -using Ecowitt.Controller.Model; -using System.Text.Json; -using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Mapping; +using Ecowitt.Controller.Model.Api; +using System.Text.Json; +using Serilog; +using Serilog.Core; namespace Ecowitt.Controller.Model.Mapping; @@ -55,6 +56,8 @@ public static Subdevice Map(this SubdeviceApiData subdeviceApiData, bool isMetri if (sensor != null) { result.Sensors.Add(sensor); + Log.Debug("Mapped sensor {SensorName} with value {SensorValue} for subdevice {SubdeviceId}", + sensor.Name, sensor.Value, result.Id); } } diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs index a8a37a6..f48986e 100644 --- a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs @@ -1,109 +1,6 @@ using Ecowitt.Controller.Model; using Serilog; -/* all currently available sensors. their naming scheme is not consistent, so we have to map them manually -PASSKEY -stationtype -runtime -dateutc -tempinf -humidityin -baromrelin -baromabsin -tempf -humidity -winddir -windspeedmph -windgustmph -maxdailygust -solarradiation -uv -rainratein -eventrainin -hourlyrainin -dailyrainin -weeklyrainin -monthlyrainin -yearlyrainin -totalrainin -srain_piezo -rrain_piezo -erain_piezo -hrain_piezo -drain_piezo -wrain_piezo -mrain_piezo -yrain_piezo -ws90cap_volt -ws90_ver -tempf1,….,,tempf8 -humidity1,….,humidity8 -soilmoisture1,….., soilmoisture8 -soilad1,…., soilad8 -pm25_ch1, …., pm25_ch4 -pm25_avg_24h_ch1,…..,pm25_avg_24h_ch4 -tf_co2 -humi_co2 -pm1_co2 -pm1_24h_co2 -pm25_co2 -pm25_24h_co2 -pm4_co2 -pm4_24h_co2 -pm10_co2 -pm10_24h_co2 -co2 -co2_24h -lightning_num -lightning -lightning_time -leak_ch1 …., leak_ch4 -tf_ch1, …., tf_ch8 -leafwetness_ch1,…, leafwetness_ch8 -console_batt -wh65batt -wh80batt -wh26batt -batt1,….,batt8 -soilbatt1,…,soilbatt8 -pm25batt1, …, pm25batt4 -wh57batt -leakbatt1, …,leakbatt4 -tf_batt1, …, tf_batt8 -co2_batt -leaf_batt1, …, leaf_batt8 -wh90batt -freq -model -interval -ac_status -warning -always_on -val_type -val -run_time -rssi -gw_rssi -timeutc -publish_time -ac_action -ac_running -plan_status -elect_total -happen_elect -realtime_power -ac_voltage -ac_current -water_status -water_action -water_running -water_total -happen_water -flow_velocity -water_temp -wfc01batt -*/ - namespace Ecowitt.Controller.Mapping { public partial class SensorBuilder @@ -387,11 +284,13 @@ public partial class SensorBuilder case "water_running": return BuildBinarySensor(propertyName, "Running", propertyValue); case "water_total": + case "wfc02_total": return BuildWaterConsumptionSensor(propertyName, "Total Water", propertyValue, isMetric, true); case "happen_water": //new Sensor("Daily Consumption", isMetric ? (double?)device.water_total - (double?)device.happen_water : L2G(device.water_total) - L2G(device.happen_water), isMetric ? "L" : "gal", SensorType.Volume, SensorState.Measurement)); return BuildWaterConsumptionSensor(propertyName, "Last Planned Consumption", propertyValue, isMetric); case "flow_velocity": + case "wfc02_flow_velocity": return BuildWaterFlowSensor(propertyName, "Flow Velocity", propertyValue, isMetric); case "water_temp": return BuildTemperatureSensor(propertyName, "Water Temperature", propertyValue, isMetric, true); @@ -399,6 +298,22 @@ public partial class SensorBuilder return BuildBatterySensor(propertyName, "WFC01 Battery", propertyValue, true); case "heap": return BuildIntSensor(propertyName, "Gateway Heap", propertyValue, "byte", isDiag: true); + case "wfc02_status": + return BuildIntSensor(propertyName, "WFC02 Status", propertyValue, isDiag: true); + case "wfc02rssi": + return BuildIntSensor(propertyName, "WFC02 RSSI", propertyValue, "/5", SensorType.SignalStrength, true); + case "wfc02batt": + return BuildIntSensor(propertyName, "WFC02 Battery", propertyValue, "%", SensorType.Battery, true); + case "capacity": + return BuildIntSensor(propertyName, "Capacity", propertyValue, "byte", isDiag: true); + case "transition": + return BuildIntSensor(propertyName, "Transition", propertyValue, isDiag: true); + case "flowmeter": + return BuildBinarySensor(propertyName, "Flowmeter Available", propertyValue, isDiag: true); + case "valve": + return BuildBinarySensor(propertyName, "Valve Available", propertyValue, isDiag: true); + case "wfc02_position": + return BuildIntSensor(propertyName, "wfc02-position", propertyValue, isDiag: true); case "PASSKEY": case "stationtype": case "runtime": From d25079442e5d41d8b45fa43b03814b2b2be6bc72 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Thu, 31 Jul 2025 23:11:03 +0200 Subject: [PATCH 29/44] updated readme --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 499c57b..bd24829 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ +*so you found the hidden refactoring branch? sweet, follow me on this journey, there diagrams, api spec and mqtt spec. and a lot of almost working code.* + ## Supported Devices ### Gateways +- **GW3000**: The new one. yes i'm already using it. - **GW2000**: The GW2000 is a displayless console/gateway available since April 2022. It offers an extended range of functions, including a browser interface (WebUI) for display and configuration. It supports both Ethernet and WLAN connections, though it's recommended not to use both interfaces simultaneously to avoid blocking the web interface. The GW2000 firmware supports various sensors, including the WFC01 IoT sensor. - **GW1200**: This gateway supports bidirectional communication necessary for controlling intelligent components like switches and water valves. It supports a WebUI and also supports the WS View Plus app for configuration . - **GW1x00**: The GW1100 is similar to the GW1200 but does not support a WebUI and no subdevices. It is configured and displayed via the WS View Plus app. The GW1100 includes a temperature/humidity sensor for indoor measurements and supports local data display through applications like PWT (Personal Weather Tablet). @@ -9,7 +12,8 @@ ### Subdevices - **AC1100 Smart Plug**: The AC1100 is a switchable socket that can be controlled manually, time-controlled, or based on measured values from the weather station. It requires an IoT-enabled console (e.g., GW2000, GW1200) and the Ecowitt app for automatic operation. It supports different regions with corresponding plugs and maximum wattages . - **WFC01 Intelligent Water Timer (WittFlow)**: The WFC01 is a timer-controlled or sensor-measurement-dependent water valve. It features a built-in liquid flow sensor and a temperature sensor for the liquid. The valve can be controlled via the Ecowitt app, and it supports various operating modes, including manual, plan, and smart modes . The device is waterproof and dustproof to IP66 standards and built from corrosion-resistant materials . - +- **WFC02 Intelligent Water Valve**: Similar to the WFC01, the WFC02 is a water valve and comes with an optional flow sensor. more later i'm satill testing it. + ### Compatibility - **Weather Stations**: Fine Offset and its clones (Ecowitt, Froggit, Ambient Weather...) are generally supported if your gateway/weather station supports custom weather station uploads in Ecowitt format. - **Web API Devices**: If your device offers a web API (e.g., GW2000, GW1200), this tool offers bidirectional communication, allowing you to control actors like the AC1100 smart plug or the WFC01 water valve . From 85f6be12be2916b25796314153cf433ab7674358 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Fri, 1 Aug 2025 23:11:45 +0200 Subject: [PATCH 30/44] fixed ns for mapping --- src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs | 4 +--- .../{SensorBuilderAddon.cs => SensorBuilder.Addon.cs} | 5 +---- .../{SensorBuilderLogic.cs => SensorBuilder.Builder.cs} | 5 ++--- src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs | 7 ++++--- 4 files changed, 8 insertions(+), 13 deletions(-) rename src/Ecowitt.Controller/Model/Mapping/{SensorBuilderAddon.cs => SensorBuilder.Addon.cs} (98%) rename src/Ecowitt.Controller/Model/Mapping/{SensorBuilderLogic.cs => SensorBuilder.Builder.cs} (98%) diff --git a/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs b/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs index a2bacd1..619c22d 100644 --- a/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs +++ b/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs @@ -1,8 +1,6 @@ -using Ecowitt.Controller.Mapping; using Ecowitt.Controller.Model.Api; -using System.Text.Json; using Serilog; -using Serilog.Core; +using System.Text.Json; namespace Ecowitt.Controller.Model.Mapping; diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilderAddon.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs similarity index 98% rename from src/Ecowitt.Controller/Model/Mapping/SensorBuilderAddon.cs rename to src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs index 24ca72e..b658339 100644 --- a/src/Ecowitt.Controller/Model/Mapping/SensorBuilderAddon.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs @@ -1,7 +1,4 @@ -using System.Transactions; -using Ecowitt.Controller.Model; - -namespace Ecowitt.Controller.Mapping +namespace Ecowitt.Controller.Model.Mapping { public partial class SensorBuilder { diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilderLogic.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs similarity index 98% rename from src/Ecowitt.Controller/Model/Mapping/SensorBuilderLogic.cs rename to src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs index 8ae7bbe..4ccdf31 100644 --- a/src/Ecowitt.Controller/Model/Mapping/SensorBuilderLogic.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs @@ -1,7 +1,6 @@ -using Ecowitt.Controller.Model; -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; -namespace Ecowitt.Controller.Mapping +namespace Ecowitt.Controller.Model.Mapping { public partial class SensorBuilder { diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs index f48986e..cc7992a 100644 --- a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs @@ -1,7 +1,8 @@ -using Ecowitt.Controller.Model; -using Serilog; +using Serilog; -namespace Ecowitt.Controller.Mapping +// TODO: read this from a json config file + +namespace Ecowitt.Controller.Model.Mapping { public partial class SensorBuilder { From 39b30514aadd57d3ee6f5dd5b6396857ed0f1d9e Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 26 Aug 2025 22:11:30 +0200 Subject: [PATCH 31/44] added storage logging for debugging purposes --- .../Service/Orchestrator/DeviceStore.cs | 6 +++--- .../Service/Orchestrator/StateMachine.ConsumerHttp.cs | 4 ++++ .../Service/Orchestrator/StateMachine.cs | 11 ++++++++++- src/Ecowitt.Controller/appsettings.json | 3 +-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs index 5f2c70a..a02310a 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs @@ -9,7 +9,7 @@ public interface IDeviceStore Device? GetGatewayBySubdeviceId(int id); bool UpsertGateway(Device data); void Clear(); - Dictionary GetGatewaysShort(); + Dictionary GetGatewaysShort(); } public class DeviceStore : IDeviceStore @@ -22,9 +22,9 @@ public DeviceStore(ILogger logger) _logger = logger; } - public Dictionary GetGatewaysShort() + public Dictionary GetGatewaysShort() { - return _gateways.ToArray().ToDictionary(gateway => gateway.Key, gateway => gateway.Value.Model); + return _gateways.ToDictionary(gateway => gateway.Key, gateway => gateway.Value.Model); } public Device? GetGateway(string ipAddress) diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs index c0fc777..94f2e19 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs @@ -96,6 +96,8 @@ public Task OnHandle(GatewayApiData message) else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } } + LogStorageState(); + return Task.CompletedTask; } @@ -179,6 +181,8 @@ public Task OnHandle(SubdeviceApiAggregate message) _deviceStore.UpsertGateway(storedGateway); } + LogStorageState(); + return Task.CompletedTask; } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs index ff84ad2..2eb1d92 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Model.Configuration; using Ecowitt.Controller.Model.Message.Config; @@ -74,7 +75,15 @@ private async Task EmitMqttConfig() await _messageBus.Publish(mqttConfig); } - + private void LogStorageState() + { + foreach (var gw in _deviceStore.GetGatewaysShort()) + { + _logger.LogInformation($"Gateway {gw.Key} - {gw.Value}"); + var gateway = _deviceStore.GetGateway(gw.Key); + _logger.LogDebug($"Storage dump: \n {JsonSerializer.Serialize(gateway)}"); + } + } // public async Task SendCommand(string ipAddress, string cmd, int id, int model, int val = 0, int valType = 0, int onType = 0, int offType = 0, int alwaysOn = 1, int onTime = 0, int offTime = 0) // { diff --git a/src/Ecowitt.Controller/appsettings.json b/src/Ecowitt.Controller/appsettings.json index 956c467..e48ced4 100644 --- a/src/Ecowitt.Controller/appsettings.json +++ b/src/Ecowitt.Controller/appsettings.json @@ -3,8 +3,7 @@ "MinimumLevel": { "Default": "Information", "Override": { - "Ecowitt.Controller.Consumer": "Debug", - "Ecowitt.Controller.Service": "Debug" + "Ecowitt.Controller.Service.Orchestrator": "Debug" } } }, From cabccfec261d18485c37735c19bc42e98d3138cb Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Wed, 27 Aug 2025 00:41:31 +0200 Subject: [PATCH 32/44] a delta is emitted to the mqtt service only. in theory. --- .../Model/Message/Data/DeviceData.cs | 7 +-- .../Model/Message/Data/DeviceDataFull.cs | 7 +++ .../Model/Message/Data/SubdeviceData.cs | 11 ++++ .../Model/Message/Data/SubdeviceDataFull.cs | 7 +++ src/Ecowitt.Controller/Model/Sensor.cs | 28 +++++++++- .../Service/Orchestrator/DeNoiser.cs | 56 +++++++++++++++++++ .../Orchestrator/StateMachine.ConsumerHttp.cs | 43 +++++++++----- .../Service/Orchestrator/StateMachine.cs | 45 ++++++++++++++- 8 files changed, 183 insertions(+), 21 deletions(-) create mode 100644 src/Ecowitt.Controller/Model/Message/Data/DeviceDataFull.cs create mode 100644 src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs create mode 100644 src/Ecowitt.Controller/Model/Message/Data/SubdeviceDataFull.cs create mode 100644 src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs diff --git a/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs b/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs index 843ad97..841bb2e 100644 --- a/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs +++ b/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs @@ -1,9 +1,8 @@ -using Ecowitt.Controller.Model; - namespace Ecowitt.Controller.Model.Message.Data; public class DeviceData { - public Device Device { get; set; } - + public string GatewayId { get; set; } = string.Empty; + public List ChangedSensors { get; set; } = new(); + public DateTime Timestamp { get; set; } } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Data/DeviceDataFull.cs b/src/Ecowitt.Controller/Model/Message/Data/DeviceDataFull.cs new file mode 100644 index 0000000..9623ba8 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Data/DeviceDataFull.cs @@ -0,0 +1,7 @@ +namespace Ecowitt.Controller.Model.Message.Data +{ + public class DeviceDataFull : DeviceData + { + public Device Device { get; set; } + } +} diff --git a/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs new file mode 100644 index 0000000..2bec21d --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs @@ -0,0 +1,11 @@ +namespace Ecowitt.Controller.Model.Message.Data +{ + public class SubdeviceData + { + public string GatewayId { get; set; } = string.Empty; + public int SubdeviceId { get; set; } + public List ChangedSensors { get; set; } = new(); + public DateTime Timestamp { get; set; } + + } +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Data/SubdeviceDataFull.cs b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceDataFull.cs new file mode 100644 index 0000000..dbbcf5e --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceDataFull.cs @@ -0,0 +1,7 @@ +namespace Ecowitt.Controller.Model.Message.Data +{ + public class SubdeviceDataFull : SubdeviceData + { + public Subdevice Subdevice { get; set; } + } +} diff --git a/src/Ecowitt.Controller/Model/Sensor.cs b/src/Ecowitt.Controller/Model/Sensor.cs index 1539aaa..dd58b68 100644 --- a/src/Ecowitt.Controller/Model/Sensor.cs +++ b/src/Ecowitt.Controller/Model/Sensor.cs @@ -1,3 +1,4 @@ +using Newtonsoft.Json.Linq; using System.Text.Json.Serialization; namespace Ecowitt.Controller.Model; @@ -21,10 +22,16 @@ public interface ISensor public interface ISensor : ISensor { new T Value { get; set; } + public bool HasChanged { get; } + void ResetChangeFlag(); } public class Sensor : ISensor { + private int _lastValueHash; + private T _value; + + public bool HasChanged { get; private set; } public string Name { get; } public string Alias { get; } public DateTime TimestampUtc { get; set; } @@ -33,8 +40,23 @@ public class Sensor : ISensor public SensorClass SensorClass { get; } public SensorCategory SensorCategory { get; } public string UnitOfMeasurement { get; } - public T Value { get; set; } - + //public T Value { get; set; } + public T Value + { + get => _value; + set + { + var newHash = value?.GetHashCode() ?? 0; + HasChanged = _lastValueHash != newHash; + _lastValueHash = newHash; + _value = value; + if (HasChanged) + { + TimestampUtc = DateTime.UtcNow; + } + } + } + object ISensor.Value { get => Value; @@ -71,6 +93,8 @@ public Sensor(string name, string alias, T value, string unitOfMeasurement = "", Value = value; TimestampUtc = DateTime.UtcNow; } + + public void ResetChangeFlag() => HasChanged = false; } /// diff --git a/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs b/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs new file mode 100644 index 0000000..2884bea --- /dev/null +++ b/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs @@ -0,0 +1,56 @@ +using Ecowitt.Controller.Model; + +namespace Ecowitt.Controller.Service.Orchestrator +{ + public static class DeNoiserHelper + { + private static readonly Dictionary ToleranceMap = new() + { + { SensorType.Temperature, 0.1 }, + { SensorType.Humidity, 1.0 }, + { SensorType.Pressure, 0.5 }, + { SensorType.Battery, 1.0 } + }; + + public static bool HasSignificantChange(ISensor sensor, T newValue) + { + if (sensor.Value == null || newValue == null) + return !Equals(sensor.Value, newValue); + + if (typeof(T) == typeof(double) || typeof(T) == typeof(float)) + { + var tolerance = ToleranceMap.GetValueOrDefault(sensor.SensorType, 0.01); + var oldVal = Convert.ToDouble(sensor.Value); + var newVal = Convert.ToDouble(newValue); + return Math.Abs(oldVal - newVal) >= tolerance; + } + + return !Equals(sensor.Value, newValue); + } + public static bool HasSignificantChange(ISensor sensor, object newValue) + { + if (sensor.Value == null || newValue == null) + return !Equals(sensor.Value, newValue); + + // Handle numeric types with tolerance + if (sensor.DataType == typeof(double) || sensor.DataType == typeof(float)) + { + var tolerance = ToleranceMap.GetValueOrDefault(sensor.SensorType, 0.01); + var oldVal = Convert.ToDouble(sensor.Value); + var newVal = Convert.ToDouble(newValue); + return Math.Abs(oldVal - newVal) >= tolerance; + } + + // Handle integer types with tolerance + if (sensor.DataType == typeof(int) || sensor.DataType == typeof(long)) + { + var tolerance = ToleranceMap.GetValueOrDefault(sensor.SensorType, 1.0); + var oldVal = Convert.ToDouble(sensor.Value); + var newVal = Convert.ToDouble(newValue); + return Math.Abs(oldVal - newVal) >= tolerance; + } + + return !Equals(sensor.Value, newValue); + } + } +} diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs index 94f2e19..f3bf361 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs @@ -48,7 +48,7 @@ public async Task OnHandle(SubdeviceApiCommand message) } } - public Task OnHandle(GatewayApiData message) + public async Task OnHandle(GatewayApiData message) { _logger.LogDebug($"Received ApiData: {message.Model} ({message.PASSKEY}) \n {message.Payload}"); var updatedGateway = message.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); @@ -63,14 +63,18 @@ public Task OnHandle(GatewayApiData message) { sensor.DiscoveryUpdate = true; } - if (!_deviceStore.UpsertGateway(updatedGateway)) _logger.LogWarning($"failed to add gateway {updatedGateway.IpAddress} ({updatedGateway.Model}) to the store"); - else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } + if (_deviceStore.UpsertGateway(updatedGateway)) + { + _logger.LogDebug($"gateway added: {JsonSerializer.Serialize(storedGateway)})"); + await EmitGatewayFull(updatedGateway); + } + else _logger.LogWarning($"failed to add gateway {updatedGateway.IpAddress} ({updatedGateway.Model}) to the store"); } else { - // no other property should update besides sensors - // and i'm stupid, because TS is required for availability :( + // no other property should update besides sensors - it seems fw isn't reported by the GW storedGateway.TimestampUtc = updatedGateway.TimestampUtc; + var changedSensors = new List(); foreach (var sensor in updatedGateway.Sensors) { @@ -79,29 +83,33 @@ public Task OnHandle(GatewayApiData message) { sensor.DiscoveryUpdate = true; storedGateway.Sensors.Add(sensor); + changedSensors.Add(sensor); } - else + else if (DeNoiserHelper.HasSignificantChange(storedSensor, sensor.Value)) { storedSensor.Value = sensor.Value; + changedSensors.Add(storedSensor); } } + await EmitGatewayChanged(changedSensors, storedGateway.IpAddress); + var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); foreach (var sensor in sensorsToRemove) { storedGateway.Sensors.Remove(sensor); } + // TODO: emit removed gateway sensors to mqtt (maybe... is it really needed?) + if (!_deviceStore.UpsertGateway(storedGateway)) { _logger.LogWarning($"failed to update {storedGateway.IpAddress} ({storedGateway.Model}) in the store"); } else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } } LogStorageState(); - - return Task.CompletedTask; } - public Task OnHandle(SubdeviceApiAggregate message) + public async Task OnHandle(SubdeviceApiAggregate message) { var ips = message.Subdevices.DistinctBy(sd => sd.GwIp).Select(sd => sd.GwIp); foreach (var ip in ips) @@ -112,7 +120,7 @@ public Task OnHandle(SubdeviceApiAggregate message) if (_ecowittOptions.AutoDiscovery) { _logger.LogWarning($"Gateway {ip} not found while in autodiscovery mode. Not updating subdevices. (Try turning off autodiscovery)"); - return Task.CompletedTask; + return; } storedGateway = new Device { IpAddress = ip }; @@ -135,7 +143,7 @@ public Task OnHandle(SubdeviceApiAggregate message) storedGateway.Subdevices.Add(updatedSubDevice); _logger.LogInformation($"subdevice added: {data.Id} ({data.Model})"); - + await EmitSubdeviceFull(updatedSubDevice); } else { @@ -149,10 +157,13 @@ public Task OnHandle(SubdeviceApiAggregate message) storedSubDevice.Devicename = updatedSubDevice.Devicename; storedSubDevice.Nickname = updatedSubDevice.Nickname; storedSubDevice.DiscoveryUpdate = true; + + await EmitSubdeviceFull(storedSubDevice); } // update sensors one by one and find out if there are new ones // if there are new ones, mark the subdevice for discovery update + var changedSensors = new List(); foreach (var sensor in updatedSubDevice.Sensors) { var storedSensor = storedSubDevice.Sensors.FirstOrDefault(s => s.Name == sensor.Name); @@ -160,13 +171,17 @@ public Task OnHandle(SubdeviceApiAggregate message) { sensor.DiscoveryUpdate = true; storedSubDevice.Sensors.Add(sensor); + changedSensors.Add(sensor); } - else + else if (DeNoiserHelper.HasSignificantChange(storedSensor, sensor.Value)) { storedSensor.Value = sensor.Value; + changedSensors.Add(storedSensor); } } + await EmitSubdeviceChanged(changedSensors, storedGateway.IpAddress, storedSubDevice.Id); + // remove sensors that are not in the update var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); foreach (var sensor in sensorsToRemove) @@ -174,6 +189,8 @@ public Task OnHandle(SubdeviceApiAggregate message) storedSubDevice.Sensors.Remove(sensor); } + // TODO: emit removed subdevice sensors to mqtt (maybe... is it really needed?) + _logger.LogInformation($"subdevice updated: {data.Id} ({data.Model})"); } } @@ -182,8 +199,6 @@ public Task OnHandle(SubdeviceApiAggregate message) } LogStorageState(); - - return Task.CompletedTask; } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs index 2eb1d92..d073b9f 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Model.Configuration; using Ecowitt.Controller.Model.Message.Config; @@ -84,7 +85,49 @@ private void LogStorageState() _logger.LogDebug($"Storage dump: \n {JsonSerializer.Serialize(gateway)}"); } } - + + private async Task EmitGatewayFull(Device device) + { + await _messageBus.Publish(new DeviceDataFull + { + Device = device, + GatewayId = device.IpAddress, + Timestamp = DateTime.UtcNow + }); + } + + private async Task EmitSubdeviceFull(Subdevice subdevice) + { + await _messageBus.Publish(new SubdeviceDataFull + { + Subdevice = subdevice, + GatewayId = subdevice.GwIp, + SubdeviceId = subdevice.Id, + Timestamp = DateTime.UtcNow + }); + } + + private async Task EmitGatewayChanged(List sensorsChanged, string gatewayId) + { + await _messageBus.Publish(new DeviceData() + { + ChangedSensors = sensorsChanged, + GatewayId = gatewayId, + Timestamp = DateTime.UtcNow + }); + } + + private async Task EmitSubdeviceChanged(List sensorsChanged, string gatewayId, int subdeviceId) + { + await _messageBus.Publish(new SubdeviceData() + { + ChangedSensors = sensorsChanged, + GatewayId = gatewayId, + SubdeviceId = subdeviceId, + Timestamp = DateTime.UtcNow + }); + } + // public async Task SendCommand(string ipAddress, string cmd, int id, int model, int val = 0, int valType = 0, int onType = 0, int offType = 0, int alwaysOn = 1, int onTime = 0, int offTime = 0) // { // var client = _httpClientFactory.CreateClient("ecowitt-client"); From f1d20bc706a6dd1adb521a88abe319f977efd109 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Wed, 27 Aug 2025 22:49:49 +0200 Subject: [PATCH 33/44] end-to-end and then a random newtonsoft jsonparsing error appeared --- src/Ecowitt.Controller/ISensorConverter.cs | 114 ++++++++++++++++++ .../Model/Message/Data/DeviceData.cs | 1 + .../Model/Message/Data/SubdeviceData.cs | 1 + src/Ecowitt.Controller/Model/Sensor.cs | 5 +- src/Ecowitt.Controller/Model/Subdevice.cs | 3 +- src/Ecowitt.Controller/Program.cs | 38 ++++-- .../Service/Mqtt/MqttPayloadBuilder.cs | 12 +- .../Service/Mqtt/MqttService.Consumer.cs | 72 ++++++++++- .../Service/Mqtt/MqttService.Publisher.cs | 81 +++++++++++++ .../Service/Mqtt/MqttService.cs | 37 +----- ...umerHttp.cs => Dispatcher.ConsumerHttp.cs} | 4 +- ...umerMqtt.cs => Dispatcher.ConsumerMqtt.cs} | 2 +- .../{StateMachine.cs => Dispatcher.cs} | 10 +- 13 files changed, 309 insertions(+), 71 deletions(-) create mode 100644 src/Ecowitt.Controller/ISensorConverter.cs create mode 100644 src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs rename src/Ecowitt.Controller/Service/Orchestrator/{StateMachine.ConsumerHttp.cs => Dispatcher.ConsumerHttp.cs} (99%) rename src/Ecowitt.Controller/Service/Orchestrator/{StateMachine.ConsumerMqtt.cs => Dispatcher.ConsumerMqtt.cs} (98%) rename src/Ecowitt.Controller/Service/Orchestrator/{StateMachine.cs => Dispatcher.cs} (90%) diff --git a/src/Ecowitt.Controller/ISensorConverter.cs b/src/Ecowitt.Controller/ISensorConverter.cs new file mode 100644 index 0000000..f4af54b --- /dev/null +++ b/src/Ecowitt.Controller/ISensorConverter.cs @@ -0,0 +1,114 @@ +using Ecowitt.Controller.Model; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Ecowitt.Controller +{ + public class SensorConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, ISensor? value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + + //// Create a new serializer without this converter to avoid recursion + //var tempSerializer = new JsonSerializer + //{ + // NullValueHandling = serializer.NullValueHandling, + // DefaultValueHandling = serializer.DefaultValueHandling, + // DateFormatHandling = serializer.DateFormatHandling, + // DateTimeZoneHandling = serializer.DateTimeZoneHandling, + // FloatFormatHandling = serializer.FloatFormatHandling, + // Formatting = serializer.Formatting + //}; + + //// Copy all converters except this one + //foreach (var converter in serializer.Converters) + //{ + // if (converter.GetType() != typeof(SensorConverter)) + // { + // tempSerializer.Converters.Add(converter); + // } + //} + + //// Serialize with the temp serializer to avoid recursion + //var json = JObject.FromObject(value, tempSerializer); + //json["$type"] = value.GetType().AssemblyQualifiedName; + //json.WriteTo(writer); + + // Manually create JSON object to avoid recursion + writer.WriteStartObject(); + + writer.WritePropertyName("Name"); + writer.WriteValue(value.Name); + + writer.WritePropertyName("Alias"); + writer.WriteValue(value.Alias); + + writer.WritePropertyName("TimestampUtc"); + writer.WriteValue(value.TimestampUtc); + + writer.WritePropertyName("SensorType"); + writer.WriteValue((int)value.SensorType); + + writer.WritePropertyName("SensorState"); + writer.WriteValue((int)value.SensorState); + + writer.WritePropertyName("SensorClass"); + writer.WriteValue((int)value.SensorClass); + + writer.WritePropertyName("SensorCategory"); + writer.WriteValue((int)value.SensorCategory); + + writer.WritePropertyName("UnitOfMeasurement"); + writer.WriteValue(value.UnitOfMeasurement); + + writer.WritePropertyName("Value"); + serializer.Serialize(writer, value.Value); + + writer.WritePropertyName("DataType"); + writer.WriteValue(value.DataType.AssemblyQualifiedName); + + writer.WritePropertyName("DiscoveryUpdate"); + writer.WriteValue(value.DiscoveryUpdate); + + writer.WriteEndObject(); + } + + public override ISensor? ReadJson(JsonReader reader, Type objectType, ISensor? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + return null; + + var json = JObject.Load(reader); + + // Get the DataType to determine what Sensor to create + var dataTypeToken = json["DataType"]; + if (dataTypeToken == null) + throw new JsonSerializationException("DataType property is required for ISensor deserialization"); + + var dataTypeString = dataTypeToken.Value(); + var dataType = Type.GetType(dataTypeString!); + + if (dataType == null) + throw new JsonSerializationException($"Could not resolve type: {dataTypeString}"); + + // Create the appropriate Sensor type + var sensorType = typeof(Sensor<>).MakeGenericType(dataType); + + // Remove the DataType from JSON since it's not part of the actual object + json.Remove("DataType"); + json.Remove("HasChanged"); // Remove since it's JsonIgnore and computed + + return (ISensor)json.ToObject(sensorType, serializer)!; + } + + public new bool CanConvert(Type objectType) + { + return objectType == typeof(ISensor) || objectType.IsAssignableFrom(typeof(ISensor)); + } + } +} diff --git a/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs b/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs index 841bb2e..0b9e5f7 100644 --- a/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs +++ b/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs @@ -3,6 +3,7 @@ namespace Ecowitt.Controller.Model.Message.Data; public class DeviceData { public string GatewayId { get; set; } = string.Empty; + public string GatewayName { get; set; } = string.Empty; public List ChangedSensors { get; set; } = new(); public DateTime Timestamp { get; set; } } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs index 2bec21d..2f2a9f7 100644 --- a/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs +++ b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs @@ -3,6 +3,7 @@ public class SubdeviceData { public string GatewayId { get; set; } = string.Empty; + public string GatewayName { get; set; } = string.Empty; public int SubdeviceId { get; set; } public List ChangedSensors { get; set; } = new(); public DateTime Timestamp { get; set; } diff --git a/src/Ecowitt.Controller/Model/Sensor.cs b/src/Ecowitt.Controller/Model/Sensor.cs index dd58b68..5317baf 100644 --- a/src/Ecowitt.Controller/Model/Sensor.cs +++ b/src/Ecowitt.Controller/Model/Sensor.cs @@ -14,16 +14,15 @@ public interface ISensor public SensorCategory SensorCategory { get; } public string UnitOfMeasurement { get; } public object Value { get; set; } - [JsonIgnore] public Type DataType { get; } public bool DiscoveryUpdate { get; set; } + public bool HasChanged { get; } + void ResetChangeFlag(); } public interface ISensor : ISensor { new T Value { get; set; } - public bool HasChanged { get; } - void ResetChangeFlag(); } public class Sensor : ISensor diff --git a/src/Ecowitt.Controller/Model/Subdevice.cs b/src/Ecowitt.Controller/Model/Subdevice.cs index a609818..828f961 100644 --- a/src/Ecowitt.Controller/Model/Subdevice.cs +++ b/src/Ecowitt.Controller/Model/Subdevice.cs @@ -18,5 +18,6 @@ public enum SubdeviceModel { Unknown = 0, WFC01 = 1, - AC1100 = 2 + AC1100 = 2, + WFC02 = 3 } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index 1c3f899..c127998 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -9,6 +9,7 @@ using Ecowitt.Controller.Service.Mqtt; using Ecowitt.Controller.Service.Orchestrator; using MQTTnet; +using Newtonsoft.Json; using Polly; using Polly.Contrib.WaitAndRetry; using Polly.Extensions.Http; @@ -60,7 +61,7 @@ public static async Task Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var sp = builder.Services.BuildServiceProvider(); @@ -69,24 +70,30 @@ public static async Task Main(string[] args) smb.WithProviderMemory(cfg => { cfg.EnableMessageSerialization = true; }); - smb.AddJsonSerializer(); + smb.AddJsonSerializer(jsonSerializerSettings: JsonSettings); smb.WithDependencyResolver(sp); // statemachine -> mqttservice smb.Produce(x => x.DefaultTopic("config-mqtt")); smb.Produce(x => x.DefaultTopic("home-assistant-discovery")); smb.Produce(x => x.DefaultTopic("device-data")); + smb.Produce(x => x.DefaultTopic("device-data-full")); + smb.Produce(x => x.DefaultTopic("subdevice-data")); + smb.Produce(x => x.DefaultTopic("subdevice-data-full")); smb.Consume(x => x.Topic("config-mqtt").WithConsumer()); smb.Consume(x => x.Topic("home-assistant-discovery").WithConsumer()); smb.Consume(x => x.Topic("device-data").WithConsumer()); - + smb.Consume(x => x.Topic("device-data-full").WithConsumer()); + smb.Consume(x => x.Topic("subdevice-data").WithConsumer()); + smb.Consume(x => x.Topic("subdevice-data-full").WithConsumer()); + // mqttservice -> statemachine smb.Produce(x => x.DefaultTopic("mqtt-service-event")); smb.Produce(x => x.DefaultTopic("mqtt-connection-event")); smb.Produce(x => x.DefaultTopic("home-assistant-status")); - smb.Consume(x => x.Topic("mqtt-service-event").WithConsumer()); - smb.Consume(x => x.Topic("mqtt-connection-event").WithConsumer()); - smb.Consume(x => x.Topic("home-assistant-status").WithConsumer()); + smb.Consume(x => x.Topic("mqtt-service-event").WithConsumer()); + smb.Consume(x => x.Topic("mqtt-connection-event").WithConsumer()); + smb.Consume(x => x.Topic("home-assistant-status").WithConsumer()); // controller -> statemachine smb.Produce(x => x.DefaultTopic("gw-api-data")); @@ -99,8 +106,8 @@ public static async Task Main(string[] args) // HttpPublishingService -> statemachine smb.Produce(x => x.DefaultTopic("subdevice-api-data")); smb.Produce(x => x.DefaultTopic("http-service-event")); - smb.Consume(x => x.Topic("subdevice-api-data").WithConsumer()); - smb.Consume(x => x.Topic("http-service-event").WithConsumer()); + smb.Consume(x => x.Topic("subdevice-api-data").WithConsumer()); + smb.Consume(x => x.Topic("http-service-event").WithConsumer()); // smb.Produce(x => x.DefaultTopic("api-data")); @@ -108,20 +115,20 @@ public static async Task Main(string[] args) // smb.Produce(x => x.DefaultTopic("subdevice-command")); // smb.Consume(x => x // .Topic("api-data") - // .WithConsumer() + // .WithConsumer() // ); // smb.Consume(x => x // .Topic("subdevice-data") - // .WithConsumer() + // .WithConsumer() // ); // smb.Consume(x => x // .Topic("subdevice-command") - // .WithConsumer() + // .WithConsumer() // ); smb.AddServicesFromAssembly(Assembly.GetExecutingAssembly()); }); - builder.Services.AddHostedService(s => s.GetRequiredService()); + builder.Services.AddHostedService(s => s.GetRequiredService()); builder.Services.AddTransient(); builder.Services.AddHostedService(s => s.GetRequiredService()); @@ -176,4 +183,11 @@ private static IAsyncPolicy GetRetryPolicy(int retries) //.WaitAndRetryAsync(retries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); .WaitAndRetryAsync(delay); } + + private static readonly JsonSerializerSettings JsonSettings = new() + { + Converters = { new SensorConverter() }, + TypeNameHandling = TypeNameHandling.Auto, + NullValueHandling = NullValueHandling.Ignore + }; } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs index 90d216b..26f4c09 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs @@ -30,7 +30,7 @@ public static dynamic BuildSensorPayload(ISensor s, int? precision) }; } - public static dynamic BuildGatewayPayload(Device gw, int? publishingInterval) + public static dynamic BuildGatewayPayload(Device gw) { if (string.IsNullOrWhiteSpace(gw.Model)) { @@ -40,14 +40,6 @@ public static dynamic BuildGatewayPayload(Device gw, int? publishingInterval) name = gw.Name }; } - - var state = "offline"; // Default state - if (publishingInterval.HasValue) - { - // Determine state based on last update time and publishing interval - state = (DateTime.UtcNow - gw.TimestampUtc).TotalSeconds < publishingInterval.Value * 3 ? "online" : "offline"; - } - return new { ip = gw.IpAddress, @@ -56,7 +48,7 @@ public static dynamic BuildGatewayPayload(Device gw, int? publishingInterval) passkey = gw.PASSKEY, stationType = gw.StationType, runtime = gw.Runtime, - state = state, + state = "online", freq = gw.Freq }; } diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs index 05ffb57..951e16c 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs @@ -1,4 +1,5 @@ -using Ecowitt.Controller.Model.Message.Config; +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Message.Config; using Ecowitt.Controller.Model.Message.Data; using Ecowitt.Controller.Model.Message.Event; using MQTTnet.Client; @@ -58,14 +59,79 @@ public async Task OnHandle(HomeAssistantDiscoveryEvent message) EmitHomeAssistantDiscovery(); } - public Task OnHandle(DeviceData message) + public async Task OnHandle(DeviceData message) { if (_client == null || !_client.IsConnected) { _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + return; + } + + if (string.IsNullOrWhiteSpace(message.GatewayName)) + { + _logger.LogWarning("Gateway name is empty. Cannot publish device data."); + return; + } + + await PublishSensors(message.ChangedSensors, message.GatewayName); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttGatewayTopic(message.GatewayName), DateTime.UtcNow); + } + + public async Task OnHandle(DeviceDataFull message) + { + if (_client == null || !_client.IsConnected) + { + _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + return; + } + var gateway = message.Device; + if (string.IsNullOrWhiteSpace(gateway.Name)) + { + _logger.LogWarning("Gateway name is empty. Cannot publish device data."); + return; + } + + await PublishGateway(gateway); + await PublishSensors(gateway.Sensors, gateway.Name); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttGatewayTopic(gateway.Name), gateway.TimestampUtc); + } + + public async Task OnHandle(SubdeviceData message) + { + if (_client == null || !_client.IsConnected) + { + _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + return; + } + + if (string.IsNullOrWhiteSpace(message.GatewayName)) + { + _logger.LogWarning("Gateway name is empty. Cannot publish device data."); + return; + } + + await PublishSubdeviceSensors(message.ChangedSensors, message.GatewayName, message.SubdeviceId); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttSubdeviceTopic(message.GatewayName, message.SubdeviceId.ToString()), DateTime.UtcNow); + } + + public async Task OnHandle(SubdeviceDataFull message) + { + if (_client == null || !_client.IsConnected) + { + _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + return; + } + + if (string.IsNullOrWhiteSpace(message.GatewayName)) + { + _logger.LogWarning("Gateway name is empty. Cannot publish device data."); + return; } - return Task.CompletedTask; + var subdevice = message.Subdevice; + await PublishSubdevice(subdevice, message.GatewayName); + await PublishSubdeviceSensors(message.ChangedSensors, message.GatewayName, message.SubdeviceId); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttSubdeviceTopic(message.GatewayName, message.SubdeviceId.ToString()), DateTime.UtcNow); } } } diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs new file mode 100644 index 0000000..dc97f49 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs @@ -0,0 +1,81 @@ +using Ecowitt.Controller.Model; +using MQTTnet; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ecowitt.Controller.Service.Mqtt +{ + public partial class MqttService + { + private async Task PublishSubdevice(Subdevice subdevice, string gatewayName) + { + var payload = MqttPayloadBuilder.BuildSubdevicePayload(subdevice); + await PublishMessage(MqttPathBuilder.BuildMqttSubdeviceTopic(gatewayName, subdevice.Id.ToString()), payload); + } + + private async Task PublishSubdeviceSensors(List sensors, string gatewayName, int subdeviceId) + { + var precision = _mqttConfig?.Precision ?? 2; + foreach (var sensor in sensors) + { + var payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision); + var topic = sensor.SensorCategory == SensorCategory.Diagnostic ? MqttPathBuilder.BuildMqttSubdeviceDiagnosticTopic(gatewayName, subdeviceId.ToString(), sensor.Alias) : MqttPathBuilder.BuildMqttSubdeviceSensorTopic(gatewayName, subdeviceId.ToString(), sensor.Alias); + await PublishMessage(topic, payload); + } + } + + private async Task PublishGateway(Device gateway) + { + var payload = MqttPayloadBuilder.BuildGatewayPayload(gateway); + await PublishMessage(MqttPathBuilder.BuildMqttGatewayTopic(gateway.Name), payload); + } + + private async Task PublishSensors(List sensors, string gatewayName ) + { + var precision = _mqttConfig?.Precision ?? 2; + foreach (var sensor in sensors) + { + var payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision); + var topic = sensor.SensorCategory == SensorCategory.Diagnostic ? MqttPathBuilder.BuildMqttGatewayDiagnosticTopic(gatewayName, sensor.Alias) : MqttPathBuilder.BuildMqttGatewaySensorTopic(gatewayName, sensor.Alias); + await PublishMessage(topic, payload); + } + } + + private async Task PublishMessage(string topic, dynamic payload) + { + if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}", + JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }))) + _logger.LogWarning($"Failed to publish message to topic {_mqttConfig?.BaseTopic}/{topic}. Is the client connected?"); + } + + private async Task PublishAvailabilityMessage(string topic, DateTime timestamp) + { + var available = DateTime.UtcNow.Subtract(timestamp) < TimeSpan.FromSeconds(300) ? "online" : "offline"; + + if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}/availability", available)) + _logger.LogWarning($"Failed to publish message to topic {_mqttConfig?.BaseTopic}/{topic}. Is the client connected?"); + } + + private async Task Publish(string topic, string message) + { + var mqttPayload = new MqttApplicationMessageBuilder() + .WithTopic(topic) + .WithPayload(message) + .Build(); + + if (_client is { IsConnected: true }) + { + var result = await _client.PublishAsync(mqttPayload); + if (result.IsSuccess) + { + _logger.LogDebug($"Message {message} published to topic {topic}"); + return true; + } + else _logger.LogWarning($"Failed to publish message {message} to topic {topic}. Reason: {result.ReasonCode}"); + } + else _logger.LogWarning($"Can't publish message {message} to topic {topic}. Client not connected."); + return false; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index b84c126..fe8c21b 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -11,7 +11,7 @@ namespace Ecowitt.Controller.Service.Mqtt; -public partial class MqttService : BackgroundService, IHostedLifecycleService, IConsumer, IConsumer, IConsumer +public partial class MqttService : BackgroundService, IHostedLifecycleService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer { private readonly ILogger _logger; private readonly MqttFactory _factory; @@ -84,41 +84,8 @@ private void EmitHomeAssistantDiscovery() } - private async Task PublishMessage(string topic, dynamic payload) - { - if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}", - JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping}))) - _logger.LogWarning($"Failed to publish message to topic {_mqttConfig?.BaseTopic}/{topic}. Is the client connected?"); - } - - private async Task PublishAvailabilityMessage(string topic, DateTime timestamp) - { - var available = DateTime.UtcNow.Subtract(timestamp) < TimeSpan.FromSeconds(300) ? "online" : "offline"; - - if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}/availability", available)) - _logger.LogWarning($"Failed to publish message to topic {_mqttConfig?.BaseTopic}/{topic}. Is the client connected?"); - } - - private async Task Publish(string topic, string message) - { - var mqttPayload = new MqttApplicationMessageBuilder() - .WithTopic(topic) - .WithPayload(message) - .Build(); + - if (_client is { IsConnected: true }) - { - var result = await _client.PublishAsync(mqttPayload); - if (result.IsSuccess) - { - _logger.LogDebug($"Message {message} published to topic {topic}"); - return true; - } - else _logger.LogWarning($"Failed to publish message {message} to topic {topic}. Reason: {result.ReasonCode}"); - } - else _logger.LogWarning($"Can't publish message {message} to topic {topic}. Client not connected."); - return false; - } } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs similarity index 99% rename from src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs rename to src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs index f3bf361..4735fa3 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerHttp.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs @@ -9,7 +9,7 @@ namespace Ecowitt.Controller.Service.Orchestrator { - public partial class StateMachine : IConsumer + public partial class Dispatcher : IConsumer { public async Task OnHandle(SubdeviceApiCommand message) { @@ -92,7 +92,7 @@ public async Task OnHandle(GatewayApiData message) } } - await EmitGatewayChanged(changedSensors, storedGateway.IpAddress); + await EmitGatewayChanged(changedSensors, storedGateway.IpAddress, storedGateway.Name); var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); foreach (var sensor in sensorsToRemove) diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs similarity index 98% rename from src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs rename to src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs index cfca0e4..d6fac44 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.ConsumerMqtt.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs @@ -2,7 +2,7 @@ namespace Ecowitt.Controller.Service.Orchestrator { - public partial class StateMachine + public partial class Dispatcher { public Task OnHandle(MqttServiceEvent message) { diff --git a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs similarity index 90% rename from src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs rename to src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs index d073b9f..5ce1709 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/StateMachine.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs @@ -10,9 +10,9 @@ namespace Ecowitt.Controller.Service.Orchestrator; -public partial class StateMachine : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer +public partial class Dispatcher : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IDeviceStore _deviceStore; private readonly EcowittOptions _ecowittOptions; private readonly ControllerOptions _controllerOptions; @@ -21,7 +21,7 @@ public partial class StateMachine : BackgroundService, IConsumer logger, IDeviceStore deviceStore, IMessageBus messageBus, IOptions mqttOptions, IOptions ecowittOptions, IOptions controllerOptions) + public Dispatcher(ILogger logger, IDeviceStore deviceStore, IMessageBus messageBus, IOptions mqttOptions, IOptions ecowittOptions, IOptions controllerOptions) { _logger = logger; _deviceStore = deviceStore; @@ -92,6 +92,7 @@ await _messageBus.Publish(new DeviceDataFull { Device = device, GatewayId = device.IpAddress, + GatewayName = device.Name, Timestamp = DateTime.UtcNow }); } @@ -107,12 +108,13 @@ await _messageBus.Publish(new SubdeviceDataFull }); } - private async Task EmitGatewayChanged(List sensorsChanged, string gatewayId) + private async Task EmitGatewayChanged(List sensorsChanged, string gatewayId, string gatewayName) { await _messageBus.Publish(new DeviceData() { ChangedSensors = sensorsChanged, GatewayId = gatewayId, + GatewayName = gatewayName, Timestamp = DateTime.UtcNow }); } From 735d87a81b4f907d04be5226ed949e7009f914b6 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Thu, 4 Sep 2025 23:13:52 +0200 Subject: [PATCH 34/44] data is now emitted to mqtt again. after inital full emit, only changes are emitted --- .../SensorMappingTest.cs | 21 +- src/Ecowitt.Controller/ISensorConverter.cs | 114 -------- .../Model/Mapping/SensorBuilder.Addon.cs | 67 ++--- .../Model/Mapping/SensorBuilder.Builder.cs | 268 ++++++++++-------- .../Model/Mapping/SensorBuilder.cs | 37 +-- src/Ecowitt.Controller/Model/Sensor.cs | 151 +++++++--- src/Ecowitt.Controller/Program.cs | 40 --- .../Service/Mqtt/MqttPayloadBuilder.cs | 9 +- .../Service/Mqtt/MqttService.Consumer.cs | 2 +- .../Service/Orchestrator/DeNoiser.cs | 82 +++--- .../Orchestrator/Dispatcher.ConsumerHttp.cs | 16 +- .../Service/Orchestrator/Dispatcher.cs | 10 +- 12 files changed, 369 insertions(+), 448 deletions(-) delete mode 100644 src/Ecowitt.Controller/ISensorConverter.cs diff --git a/src/EcoWitt.Controller.Tests/SensorMappingTest.cs b/src/EcoWitt.Controller.Tests/SensorMappingTest.cs index ac2ec1a..eb06f46 100644 --- a/src/EcoWitt.Controller.Tests/SensorMappingTest.cs +++ b/src/EcoWitt.Controller.Tests/SensorMappingTest.cs @@ -1,5 +1,4 @@ using Ecowitt.Controller.Model; -using Newtonsoft.Json; namespace EcoWitt.Controller.Tests; @@ -13,27 +12,26 @@ public void Setup() [Test] public void TestSensorCreation() { - // arrange act assert List list = new(); - Sensor sensorBool = new("Test", true, "", SensorType.None, SensorState.Measurement); - Assert.That(sensorBool.DataType, Is.EqualTo(typeof(bool))); + var sensorBool = new Sensor("Test", true, SensorDataType.Boolean, sensorType: SensorType.None, sensorState: SensorState.Measurement); + Assert.That(sensorBool.DataType, Is.EqualTo(SensorDataType.Boolean)); Assert.That(sensorBool.Value, Is.EqualTo(true)); Assert.That(sensorBool.Name, Is.EqualTo("Test")); - Sensor sensorInt = new("Test", 1, "", SensorType.None, SensorState.Measurement); - Assert.That(sensorInt.DataType, Is.EqualTo(typeof(int))); + var sensorInt = new Sensor("Test", 1, SensorDataType.Integer, sensorType: SensorType.None, sensorState: SensorState.Measurement); + Assert.That(sensorInt.DataType, Is.EqualTo(SensorDataType.Integer)); Assert.That(sensorInt.Value, Is.EqualTo(1)); Assert.That(sensorInt.Name, Is.EqualTo("Test")); - Sensor sensorDouble = new("Test", 1.0, "", SensorType.Temperature, SensorState.Measurement); - Assert.That(sensorDouble.DataType, Is.EqualTo(typeof(double))); + var sensorDouble = new Sensor("Test", 1.0, SensorDataType.Double, sensorType: SensorType.Temperature, sensorState: SensorState.Measurement); + Assert.That(sensorDouble.DataType, Is.EqualTo(SensorDataType.Double)); Assert.That(sensorDouble.Value, Is.EqualTo(1.0)); Assert.That(sensorDouble.SensorType, Is.EqualTo(SensorType.Temperature)); Assert.That(sensorDouble.Name, Is.EqualTo("Test")); - Sensor sensorString = new("Test", "10", "GBps", SensorType.DataRate, SensorState.Measurement); - Assert.That(sensorString.DataType, Is.EqualTo(typeof(string))); + var sensorString = new Sensor("Test", "10", SensorDataType.String, unitOfMeasurement: "GBps", sensorType: SensorType.DataRate, sensorState: SensorState.Measurement); + Assert.That(sensorString.DataType, Is.EqualTo(SensorDataType.String)); Assert.That(sensorString.Value, Is.EqualTo("10")); Assert.That(sensorString.UnitOfMeasurement, Is.EqualTo("GBps")); Assert.That(sensorString.Name, Is.EqualTo("Test")); @@ -43,8 +41,5 @@ public void TestSensorCreation() list.Add(sensorDouble); list.Add(sensorString); Assert.That(list.Count, Is.EqualTo(4)); - } - - } \ No newline at end of file diff --git a/src/Ecowitt.Controller/ISensorConverter.cs b/src/Ecowitt.Controller/ISensorConverter.cs deleted file mode 100644 index f4af54b..0000000 --- a/src/Ecowitt.Controller/ISensorConverter.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Ecowitt.Controller.Model; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Ecowitt.Controller -{ - public class SensorConverter : JsonConverter - { - public override void WriteJson(JsonWriter writer, ISensor? value, JsonSerializer serializer) - { - if (value == null) - { - writer.WriteNull(); - return; - } - - //// Create a new serializer without this converter to avoid recursion - //var tempSerializer = new JsonSerializer - //{ - // NullValueHandling = serializer.NullValueHandling, - // DefaultValueHandling = serializer.DefaultValueHandling, - // DateFormatHandling = serializer.DateFormatHandling, - // DateTimeZoneHandling = serializer.DateTimeZoneHandling, - // FloatFormatHandling = serializer.FloatFormatHandling, - // Formatting = serializer.Formatting - //}; - - //// Copy all converters except this one - //foreach (var converter in serializer.Converters) - //{ - // if (converter.GetType() != typeof(SensorConverter)) - // { - // tempSerializer.Converters.Add(converter); - // } - //} - - //// Serialize with the temp serializer to avoid recursion - //var json = JObject.FromObject(value, tempSerializer); - //json["$type"] = value.GetType().AssemblyQualifiedName; - //json.WriteTo(writer); - - // Manually create JSON object to avoid recursion - writer.WriteStartObject(); - - writer.WritePropertyName("Name"); - writer.WriteValue(value.Name); - - writer.WritePropertyName("Alias"); - writer.WriteValue(value.Alias); - - writer.WritePropertyName("TimestampUtc"); - writer.WriteValue(value.TimestampUtc); - - writer.WritePropertyName("SensorType"); - writer.WriteValue((int)value.SensorType); - - writer.WritePropertyName("SensorState"); - writer.WriteValue((int)value.SensorState); - - writer.WritePropertyName("SensorClass"); - writer.WriteValue((int)value.SensorClass); - - writer.WritePropertyName("SensorCategory"); - writer.WriteValue((int)value.SensorCategory); - - writer.WritePropertyName("UnitOfMeasurement"); - writer.WriteValue(value.UnitOfMeasurement); - - writer.WritePropertyName("Value"); - serializer.Serialize(writer, value.Value); - - writer.WritePropertyName("DataType"); - writer.WriteValue(value.DataType.AssemblyQualifiedName); - - writer.WritePropertyName("DiscoveryUpdate"); - writer.WriteValue(value.DiscoveryUpdate); - - writer.WriteEndObject(); - } - - public override ISensor? ReadJson(JsonReader reader, Type objectType, ISensor? existingValue, bool hasExistingValue, JsonSerializer serializer) - { - if (reader.TokenType == JsonToken.Null) - return null; - - var json = JObject.Load(reader); - - // Get the DataType to determine what Sensor to create - var dataTypeToken = json["DataType"]; - if (dataTypeToken == null) - throw new JsonSerializationException("DataType property is required for ISensor deserialization"); - - var dataTypeString = dataTypeToken.Value(); - var dataType = Type.GetType(dataTypeString!); - - if (dataType == null) - throw new JsonSerializationException($"Could not resolve type: {dataTypeString}"); - - // Create the appropriate Sensor type - var sensorType = typeof(Sensor<>).MakeGenericType(dataType); - - // Remove the DataType from JSON since it's not part of the actual object - json.Remove("DataType"); - json.Remove("HasChanged"); // Remove since it's JsonIgnore and computed - - return (ISensor)json.ToObject(sensorType, serializer)!; - } - - public new bool CanConvert(Type objectType) - { - return objectType == typeof(ISensor) || objectType.IsAssignableFrom(typeof(ISensor)); - } - } -} diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs index b658339..eac7522 100644 --- a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs @@ -6,75 +6,76 @@ public static void CalculateWFC01Addons(ref Model.Subdevice subdevice) { if (subdevice.Model == SubdeviceModel.WFC01) { - var waterTotal = subdevice.Sensors.FirstOrDefault(s => - s.Name.Equals("water_total", StringComparison.InvariantCultureIgnoreCase)); - var waterHappen = subdevice.Sensors.FirstOrDefault(s => - s.Name.Equals("happen_water", StringComparison.InvariantCultureIgnoreCase)); + var waterTotal = subdevice.Sensors.FirstOrDefault(s => s.Name.Equals("water_total", StringComparison.InvariantCultureIgnoreCase)); + var waterHappen = subdevice.Sensors.FirstOrDefault(s => s.Name.Equals("happen_water", StringComparison.InvariantCultureIgnoreCase)); if (waterTotal != null && waterHappen != null) { - waterHappen.Value = (double)waterTotal.Value - (double)waterHappen.Value; + // re-calc delta + var newVal = waterTotal is Sensor wt ? wt.AsDouble() - (waterHappen is Sensor wh ? wh.AsDouble() : 0d) : 0d; + if (waterHappen is Sensor whSensor) + { + whSensor.Value = newVal; + } } } } public static void CalculateGatewayAddons(ref Model.Device device, bool isMetric) { - var tempin = device.Sensors.FirstOrDefault(s => s.Name.Equals("tempinf", StringComparison.InvariantCultureIgnoreCase)); - var humidityin = device.Sensors.FirstOrDefault(s => s.Name.Equals("humidityin", StringComparison.InvariantCultureIgnoreCase)); + var tempin = device.Sensors.FirstOrDefault(s => s.Name.Equals("tempinf", StringComparison.InvariantCultureIgnoreCase)) as Sensor; + var humidityin = device.Sensors.FirstOrDefault(s => s.Name.Equals("humidityin", StringComparison.InvariantCultureIgnoreCase)) as Sensor; if (tempin != null && humidityin != null) { - var dewPoint = BuildTemperatureSensor("dewpointin", "Indoor Dewpoint", - isMetric - ? CalculateDewPointMetric((double)tempin.Value, (double)humidityin.Value).ToString() - : CalculateDewPointImperial((double)tempin.Value, (double)humidityin.Value).ToString(), isMetric, isMetric); + var dewPointVal = isMetric ? CalculateDewPointMetric(tempin.AsDouble(), humidityin.AsDouble()) : CalculateDewPointImperial(tempin.AsDouble(), humidityin.AsDouble()); + var dewPoint = BuildTemperatureSensor("dewpointin", "Indoor Dewpoint", dewPointVal.ToString(), isMetric, isMetric); if (dewPoint != null) device.Sensors.Add(dewPoint); } - var temp = device.Sensors.FirstOrDefault(s => s.Name.Equals("tempf", StringComparison.InvariantCultureIgnoreCase)); - var humidity = device.Sensors.FirstOrDefault(s => s.Name.Equals("humidity", StringComparison.InvariantCultureIgnoreCase)); + var temp = device.Sensors.FirstOrDefault(s => s.Name.Equals("tempf", StringComparison.InvariantCultureIgnoreCase)) as Sensor; + var humidity = device.Sensors.FirstOrDefault(s => s.Name.Equals("humidity", StringComparison.InvariantCultureIgnoreCase)) as Sensor; if (temp != null && humidity != null) { - var dewPoint = BuildTemperatureSensor("dewpoint", "Outdoor Dewpoint", - isMetric - ? CalculateDewPointMetric((double)temp.Value, (double)humidity.Value).ToString() - : CalculateDewPointImperial((double)temp.Value, (double)humidity.Value).ToString(), isMetric, isMetric); - var heatIndex = BuildTemperatureSensor("heatindex", "Heat Index", - isMetric - ? CalculateHeatIndexMetric((double)temp.Value, (double)humidity.Value).ToString() - : CalculateHeatIndexImperial((double)temp.Value, (double)humidity.Value).ToString(), isMetric, isMetric); - + var dewPointVal = isMetric ? CalculateDewPointMetric(temp.AsDouble(), humidity.AsDouble()) : CalculateDewPointImperial(temp.AsDouble(), humidity.AsDouble()); + var heatIndexVal = isMetric ? CalculateHeatIndexMetric(temp.AsDouble(), humidity.AsDouble()) : CalculateHeatIndexImperial(temp.AsDouble(), humidity.AsDouble()); + var dewPoint = BuildTemperatureSensor("dewpoint", "Outdoor Dewpoint", dewPointVal.ToString(), isMetric, isMetric); + var heatIndex = BuildTemperatureSensor("heatindex", "Heat Index", heatIndexVal.ToString(), isMetric, isMetric); if (dewPoint != null) device.Sensors.Add(dewPoint); if (heatIndex != null) device.Sensors.Add(heatIndex); } - var windspeed = device.Sensors.FirstOrDefault(s => s.Name.Equals("windspeedmph", StringComparison.InvariantCultureIgnoreCase)); + var windspeed = device.Sensors.FirstOrDefault(s => s.Name.Equals("windspeedmph", StringComparison.InvariantCultureIgnoreCase)) as Sensor; if (temp != null && windspeed != null) { - var windChill = BuildTemperatureSensor("windchill", "Wind Chill", - isMetric - ? CalculateWindChillMetric((double)temp.Value, (double)windspeed.Value).ToString() - : CalculateWindChillImperial((double)temp.Value, (double)windspeed.Value).ToString(), isMetric, isMetric); + var windChillVal = isMetric ? CalculateWindChillMetric(temp.AsDouble(), windspeed.AsDouble()) : CalculateWindChillImperial(temp.AsDouble(), windspeed.AsDouble()); + var windChill = BuildTemperatureSensor("windchill", "Wind Chill", windChillVal.ToString(), isMetric, isMetric); if (windChill != null) device.Sensors.Add(windChill); } - var winddirection = device.Sensors.FirstOrDefault(s => s.Name.Equals("winddir", StringComparison.InvariantCultureIgnoreCase)); + var winddirection = device.Sensors.FirstOrDefault(s => s.Name.Equals("winddir", StringComparison.InvariantCultureIgnoreCase)) as Sensor; if (winddirection != null) { - var compass = BuildStringSensor("winddir-comp", "Wind Direction (Compass)", - CalculateWindDirection((int)winddirection.Value).ToString()); + var compass = BuildStringSensor("winddir-comp", "Wind Direction (Compass)", CalculateWindDirection(winddirection.AsInt())); device.Sensors.Add(compass); } var sensorsToAdd = new List(); var pm25 = device.Sensors.Where(s => s.Name.StartsWith("pm25_avg_24h") || s.Name.StartsWith("pm25_24h")); - sensorsToAdd.AddRange(pm25.Select(sensor => BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm25Aqi24h((double)sensor.Value)))); + sensorsToAdd.AddRange(pm25.Select(sensor => + { + var s = sensor as Sensor; + return BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm25Aqi24h(s?.AsDouble() ?? 0)); + })); var pm10 = device.Sensors.Where(s => s.Name.StartsWith("pm10_avg_24h") || s.Name.StartsWith("pm10_24h")); - sensorsToAdd.AddRange(pm10.Select(sensor => BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm10Aqi24h((double)sensor.Value)))); + sensorsToAdd.AddRange(pm10.Select(sensor => + { + var s = sensor as Sensor; + return BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm10Aqi24h(s?.AsDouble() ?? 0)); + })); - device.Sensors.AddRange(sensorsToAdd); + device.Sensors.AddRange(sensorsToAdd.Where(s => s != null)); } // shout out to wikipedia for the formulas! <3 diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs index 4ccdf31..9129877 100644 --- a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs @@ -1,233 +1,249 @@ -using System.Text.RegularExpressions; +using System.Globalization; +using System.Text.RegularExpressions; +using Serilog; namespace Ecowitt.Controller.Model.Mapping { public partial class SensorBuilder { - private static Sensor? BuildWaterFlowSensor(string propertyName, string alias, string propertyValue, bool isMetric = true) + private static readonly HashSet InvalidTokens = new(StringComparer.InvariantCultureIgnoreCase) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? value : L2G(value), isMetric ? "L/min" : "gal/min", SensorType.VolumeFlowRate) + "-","--","na","n/a","nan","null","none","" + }; + + private static bool TryParseDouble(string? raw, out double value, string? prop = null) + { + value = default; + if (string.IsNullOrWhiteSpace(raw)) return false; + raw = raw.Trim(); + if (InvalidTokens.Contains(raw)) return false; + if (raw.Contains(',') && !raw.Contains('.') && raw.IndexOf(',') == raw.LastIndexOf(',')) + { + raw = raw.Replace(',', '.'); + } + var ok = double.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out value); + if (!ok) + { + Log.Debug("Could not parse double value '{Raw}' for sensor property {Property}", raw, prop ?? ""); + } + return ok; + } + + private static bool TryParseInt(string? raw, out int value, string? prop = null) + { + value = default; + if (string.IsNullOrWhiteSpace(raw)) return false; + raw = raw.Trim(); + if (InvalidTokens.Contains(raw)) return false; + if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) return true; + if (double.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var dbl)) + { + if (dbl % 1 == 0 && dbl <= int.MaxValue && dbl >= int.MinValue) + { + value = (int)dbl; + return true; + } + } + Log.Debug("Could not parse int value '{Raw}' for sensor property {Property}", raw, prop ?? ""); + return false; + } + + private static bool IsInvalidString(string? raw) => string.IsNullOrWhiteSpace(raw) || InvalidTokens.Contains(raw.Trim()); + + private static Sensor? BuildWaterFlowSensor(string propertyName, string alias, string propertyValue, bool isMetric = true) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? value : L2G(value), SensorDataType.Double, isMetric ? "L/min" : "gal/min", SensorType.VolumeFlowRate) : null; } - private static Sensor? BuildWaterConsumptionSensor(string propertyName, string alias, string propertyValue, + private static Sensor? BuildWaterConsumptionSensor(string propertyName, string alias, string propertyValue, bool isMetric = true, bool isTotal = false) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? value : L2G(value), isMetric ? "L" : "gal", SensorType.Water, isTotal ? SensorState.TotalIncreasing : SensorState.Measurement) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? value : L2G(value), SensorDataType.Double, isMetric ? "L" : "gal", SensorType.Water, isTotal ? SensorState.TotalIncreasing : SensorState.Measurement) : null; } - private static Sensor? BuildCurrentSensor(string propertyName, string alias, string propertyValue, bool isMilliAmp = false) + private static Sensor? BuildCurrentSensor(string propertyName, string alias, string propertyValue, bool isMilliAmp = false) { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, isMilliAmp ? "mA" : "A", SensorType.Current) + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, isMilliAmp ? "mA" : "A", SensorType.Current) : null; } - private static Sensor? BuildPowerSensor(string propertyName, string alias, string propertyValue) + private static Sensor? BuildPowerSensor(string propertyName, string alias, string propertyValue) { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "W", SensorType.Power) + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, "W", SensorType.Power) : null; } - private static Sensor? BuildConsumptionSensor(string propertyName, string alias, string propertyValue, bool isTotal = false) + private static Sensor? BuildConsumptionSensor(string propertyName, string alias, string propertyValue, bool isTotal = false) { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "Wh", SensorType.Energy, isTotal ? SensorState.TotalIncreasing : SensorState.Measurement) + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, "Wh", SensorType.Energy, isTotal ? SensorState.TotalIncreasing : SensorState.Measurement) : null; } - private static Sensor? BuildDistanceSensor(string propertyName, string alias, string propertyValue, + private static Sensor? BuildDistanceSensor(string propertyName, string alias, string propertyValue, bool isMetric = true) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? value : K2M(value), isMetric ? "km" : "miles", SensorType.Distance) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? value : K2M(value), SensorDataType.Double, isMetric ? "km" : "miles", SensorType.Distance) : null; } - private static Sensor? BuildBinarySensor(string propertyName, string alias, string propertyValue, bool isDiag = true) + private static Sensor? BuildBinarySensor(string propertyName, string alias, string propertyValue, bool isDiag = true) { + if (propertyValue == null) return null; bool value; - switch (propertyValue) + switch (propertyValue.Trim().ToLowerInvariant()) { case "0": case "false": - value = false; - break; + case "off": + case "no": + value = false; break; case "1": case "true": - value = true; - break; + case "on": + case "yes": + value = true; break; default: + Log.Debug("Could not parse boolean value '{Raw}' for sensor property {Property}", propertyValue, propertyName); return null; } - - return new Sensor(propertyName, alias, value, sensorClass: SensorClass.BinarySensor, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config); + return new Sensor(propertyName, alias, value, SensorDataType.Boolean, sensorType: SensorType.None, sensorClass: SensorClass.BinarySensor, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config); } - private static Sensor? BuildBatterySensor(string propertyName, string alias, string propertyValue, bool withMultiplier = false ) + private static Sensor? BuildBatterySensor(string propertyName, string alias, string propertyValue, bool withMultiplier = false ) { - const int multiplier = 20; // I forsee this will change in the future with new sensors - if (int.TryParse(propertyValue, out var value)) + if (!TryParseInt(propertyValue, out var value, propertyName)) return null; + if (withMultiplier) { - if (withMultiplier) - { - value = value * multiplier; - if(value > 100) value = 100; //fix for the 120% battery level when powered by USB - } - - return new Sensor(propertyName, alias, value, "%", SensorType.Battery, - sensorCategory: SensorCategory.Diagnostic); - } - return null; + value *= 20; // multiplier + } + if (value > 100) value = 100; + if (value < 0) value = 0; + return new Sensor(propertyName, alias, value, SensorDataType.Integer, "%", SensorType.Battery, sensorCategory: SensorCategory.Diagnostic); } - private static Sensor? BuildPPMSensor(string propertyName, string alias, string propertyValue, SensorType sensorType, bool isTotal = false) + private static Sensor? BuildPPMSensor(string propertyName, string alias, string propertyValue, SensorType sensorType, bool isTotal = false) { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "ppm", sensorType, isTotal ? SensorState.Total : SensorState.Measurement) + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, "ppm", sensorType, isTotal ? SensorState.Total : SensorState.Measurement) : null; } - private static Sensor? BuildParticleSensor(string propertyName, string alias, string propertyValue, SensorType sensorType, bool isMetric = true, bool isTotal = false) + private static Sensor? BuildParticleSensor(string propertyName, string alias, string propertyValue, SensorType sensorType, bool isMetric = true, bool isTotal = false) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "µg/m³", sensorType, isTotal ? SensorState.Total : SensorState.Measurement) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Double, "µg/m³", sensorType, isTotal ? SensorState.Total : SensorState.Measurement) : null; } - private static Sensor? BuildVoltageSensor(string propertyName, string alias, string propertyValue, bool isDiag = false) + private static Sensor? BuildVoltageSensor(string propertyName, string alias, string propertyValue, bool isDiag = false) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "V", SensorType.Voltage, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Double, "V", SensorType.Voltage, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) : null; } - private static Sensor? BuildRainRateSensor(string propertyName, string alias, string propertyValue, bool isMetric) + private static Sensor? BuildRainRateSensor(string propertyName, string alias, string propertyValue, bool isMetric) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? I2M(value) : value, isMetric ? "mm/h" : "in/h", SensorType.PrecipitationIntensity) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? I2M(value) : value, SensorDataType.Double, isMetric ? "mm/h" : "in/h", SensorType.PrecipitationIntensity) : null; } - private static Sensor? BuildRainSensor(string propertyName, string alias, string propertyValue, bool isMetric) + private static Sensor? BuildRainSensor(string propertyName, string alias, string propertyValue, bool isMetric) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? I2M(value) : value, isMetric ? "mm" : "in", SensorType.Precipitation) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? I2M(value) : value, SensorDataType.Double, isMetric ? "mm" : "in", SensorType.Precipitation) : null; } - private static Sensor? BuildWindSpeedSensor(string propertyName, string alias, string propertyValue, bool isMetric) + private static Sensor? BuildWindSpeedSensor(string propertyName, string alias, string propertyValue, bool isMetric) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? M2K(value) : value, isMetric ? "km/h" : "mph", SensorType.WindSpeed) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? M2K(value) : value, SensorDataType.Double, isMetric ? "km/h" : "mph", SensorType.WindSpeed) : null; } - private static Sensor? BuildPressureSensor(string propertyName, string alias, string propertyValue, bool isMetric) + private static Sensor? BuildPressureSensor(string propertyName, string alias, string propertyValue, bool isMetric) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? IM2HP(value) : value, isMetric ? "hPa" : "inHg", SensorType.Pressure) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? IM2HP(value) : value, SensorDataType.Double, isMetric ? "hPa" : "inHg", SensorType.Pressure) : null; } - private static Sensor? BuildHumiditySensor(string propertyName, string alias, string propertyValue) + private static Sensor? BuildHumiditySensor(string propertyName, string alias, string propertyValue) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "%", SensorType.Humidity) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Double, "%", SensorType.Humidity) : null; } - private static Sensor? BuildTemperatureSensor(string propertyName, string alias, string propertyValue, bool isMetric, bool startMetric = false) + private static Sensor? BuildTemperatureSensor(string propertyName, string alias, string propertyValue, bool isMetric, bool startMetric = false) { - if (double.TryParse(propertyValue, out var value)) + if (!TryParseDouble(propertyValue, out var value, propertyName)) return null; + var unit = isMetric ? "°C" : "F"; + if (startMetric != isMetric) { - var unit = isMetric ? "°C" : "F"; - if (startMetric != isMetric) - { - value = startMetric ? C2F(value) : F2C(value); - } - - return new Sensor(propertyName, alias, value, unit, SensorType.Temperature); + value = startMetric ? C2F(value) : F2C(value); } - - return null; + return new Sensor(propertyName, alias, value, SensorDataType.Double, unit, SensorType.Temperature); } - private static Sensor? BuildDoubleSensor(string propertyName, string alias, string propertyValue, string unit = "", SensorType type = SensorType.None, bool isDiag = false) + private static Sensor? BuildDoubleSensor(string propertyName, string alias, string propertyValue, string unit = "", SensorType type = SensorType.None, bool isDiag = false) { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias,value, unit, type, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Double, unit, type, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) : null; } - private static Sensor? BuildDateTimeSensor(string propertyName, string alias, string propertyValue) + private static Sensor? BuildDateTimeSensor(string propertyName, string alias, string propertyValue) { - return long.TryParse(propertyValue, out var ts) - ? new Sensor(propertyName, alias, DateTimeOffset.FromUnixTimeSeconds(ts).UtcDateTime) - : null; + if (long.TryParse(propertyValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ts)) + { + return new Sensor(propertyName, alias, DateTimeOffset.FromUnixTimeSeconds(ts).UtcDateTime, SensorDataType.DateTime); + } + if (DateTime.TryParse(propertyValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt)) + { + return new Sensor(propertyName, alias, DateTime.SpecifyKind(dt, DateTimeKind.Utc), SensorDataType.DateTime); + } + Log.Debug("Could not parse datetime value '{Raw}' for sensor property {Property}", propertyValue, propertyName); + return null; } - private static Sensor? BuildIntSensor(string propertyName, string alias, string propertyValue, string unit = "", SensorType type = SensorType.None, bool isDiag = false) + private static Sensor? BuildIntSensor(string propertyName, string alias, string propertyValue, string unit = "", SensorType type = SensorType.None, bool isDiag = false) { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, unit, type, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, unit, type, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) : null; } - private static Sensor BuildStringSensor(string propertyName, string alias, string propertyValue, bool isDiag = false) + private static Sensor? BuildStringSensor(string propertyName, string alias, string propertyValue, bool isDiag = false) { - return new Sensor(propertyName, alias, propertyValue, unitOfMeasurement: string.Empty, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config); + if (IsInvalidString(propertyValue)) return null; + return new Sensor(propertyName, alias, propertyValue, SensorDataType.String, string.Empty, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config); } - // well, that (https://learn.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-source-generators?pivots=dotnet-8-0) doesn't work - //[GeneratedRegex(@"(\D*)(\d+)", RegexOptions.IgnoreCase)] - //private static partial Regex SensorNumberRegex(); - // so we're doing it old school private static int GetNumber(string propertyName) { const string pattern = @"^[a-zA-Z_0-9]*(\d+)$"; var m = Regex.Match(propertyName, pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); - return m.Success ? int.Parse(m.Groups[1].Value) : -1; + return m.Success ? int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture) : -1; } - private static double K2M(double result) - { - return result * 0.621371; - } - - private static double IM2HP(double im) - { - return im * 33.86388; - } - - private static double F2C(double fahrenheit) - { - return ((fahrenheit - 32) * 5 / 9); - } - - private static double C2F(double celsius) - { - return celsius * 9 / 5 + 32; - } - - private static double M2K(double mph) - { - return mph * 1.60934; - } - - private static double I2M(double inches) - { - return inches * 25.4; - } - - private static double L2G(double liters) - { - return liters * 0.264172; - } + private static double K2M(double result) => result * 0.621371; + private static double IM2HP(double im) => im * 33.86388; + private static double F2C(double fahrenheit) => (fahrenheit - 32) * 5 / 9; + private static double C2F(double celsius) => celsius * 9 / 5 + 32; + private static double M2K(double mph) => mph * 1.60934; + private static double I2M(double inches) => inches * 25.4; + private static double L2G(double liters) => liters * 0.264172; } } diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs index cc7992a..880c0d3 100644 --- a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs @@ -1,7 +1,5 @@ using Serilog; -// TODO: read this from a json config file - namespace Ecowitt.Controller.Model.Mapping { public partial class SensorBuilder @@ -26,7 +24,6 @@ public partial class SensorBuilder return BuildWindSpeedSensor(propertyName, "Wind Speed", propertyValue, isMetric); case "windgustmph": return BuildWindSpeedSensor(propertyName, "Wind Gust", propertyValue, isMetric); - //return BuildDoubleSensor(propertyName, "Wind Gust", propertyValue, "km/h", SensorType.WindSpeed); case "maxdailygust": return BuildWindSpeedSensor(propertyName, "Max Daily Gust", propertyValue, isMetric); case "winddir": @@ -148,7 +145,6 @@ public partial class SensorBuilder case "leak_ch2": case "leak_ch3": case "leak_ch4": - // maybe it's bool, I don't have this sensor number = GetNumber(propertyName); return BuildIntSensor(propertyName, $"Leak Channel {number}", propertyValue); case "tf_ch1": @@ -169,20 +165,15 @@ public partial class SensorBuilder case "leafwetness_ch6": case "leafwetness_ch7": case "leafwetness_ch8": - // maybe it's double, I don't have this sensor number = GetNumber(propertyName); return BuildIntSensor(propertyName, $"Leaf Wetness {number}", propertyValue, "%"); case "console_batt": - // maybe it's voltage, I don't have this sensor return BuildBatterySensor(propertyName, "Console Battery", propertyValue); case "wh65batt": - // maybe it's voltage, I don't have this sensor return BuildBatterySensor(propertyName, "WH65 Battery", propertyValue); case "wh80batt": - // maybe it's voltage, I don't have this sensor return BuildBatterySensor(propertyName, "WH80 Battery", propertyValue); case "wh26batt": - // maybe it's voltage, I don't have this sensor return BuildBatterySensor(propertyName, "WH26 Battery", propertyValue); case "batt1": case "batt2": @@ -192,10 +183,8 @@ public partial class SensorBuilder case "batt6": case "batt7": case "batt8": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"Battery {number}", propertyValue); - //return BuildVoltageSensor(propertyName, $"Battery {number}", propertyValue, isDiag: true); case "soilbatt1": case "soilbatt2": case "soilbatt3": @@ -205,12 +194,11 @@ public partial class SensorBuilder case "soilbatt7": case "soilbatt8": number = GetNumber(propertyName); - return BuildVoltageSensor(propertyName, $"Soil Battery {number}", propertyValue, isDiag: true); + return BuildVoltageSensor(propertyName, $"Soil Battery {number}", propertyValue, true); case "pm25batt1": case "pm25batt2": case "pm25batt3": case "pm25batt4": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"PM2.5 Battery {number}", propertyValue); case "wh57batt": @@ -219,7 +207,6 @@ public partial class SensorBuilder case "leakbatt2": case "leakbatt3": case "leakbatt4": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"Leak Battery {number}", propertyValue); case "tf_batt1": @@ -230,7 +217,6 @@ public partial class SensorBuilder case "tf_batt6": case "tf_batt7": case "tf_batt8": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"Temperature Battery {number}", propertyValue); case "co2_batt": @@ -243,7 +229,6 @@ public partial class SensorBuilder case "leaf_batt6": case "leaf_batt7": case "leaf_batt8": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"Leaf Battery {number}", propertyValue); case "wh90batt": @@ -288,7 +273,6 @@ public partial class SensorBuilder case "wfc02_total": return BuildWaterConsumptionSensor(propertyName, "Total Water", propertyValue, isMetric, true); case "happen_water": - //new Sensor("Daily Consumption", isMetric ? (double?)device.water_total - (double?)device.happen_water : L2G(device.water_total) - L2G(device.happen_water), isMetric ? "L" : "gal", SensorType.Volume, SensorState.Measurement)); return BuildWaterConsumptionSensor(propertyName, "Last Planned Consumption", propertyValue, isMetric); case "flow_velocity": case "wfc02_flow_velocity": @@ -315,29 +299,10 @@ public partial class SensorBuilder return BuildBinarySensor(propertyName, "Valve Available", propertyValue, isDiag: true); case "wfc02_position": return BuildIntSensor(propertyName, "wfc02-position", propertyValue, isDiag: true); - case "PASSKEY": - case "stationtype": - case "runtime": - case "dateutc": - case "freq": - case "model": - case "ws90_ver": - case "interval": - case "rssi": - case "timeutc": - case "publish_time": - case "id": - case "nickname": - case "devicename": - case "version": default: Log.Information($"Ignored property {propertyName}."); return null; } } - - } - - } diff --git a/src/Ecowitt.Controller/Model/Sensor.cs b/src/Ecowitt.Controller/Model/Sensor.cs index 5317baf..47cf59d 100644 --- a/src/Ecowitt.Controller/Model/Sensor.cs +++ b/src/Ecowitt.Controller/Model/Sensor.cs @@ -1,46 +1,53 @@ -using Newtonsoft.Json.Linq; -using System.Text.Json.Serialization; + + +using Newtonsoft.Json; namespace Ecowitt.Controller.Model; -public interface ISensor +public enum SensorDataType { - public string Name { get; } - public string Alias { get; } - public DateTime TimestampUtc { get; set; } - public SensorType SensorType { get; } - public SensorState SensorState { get; } - public SensorClass SensorClass { get; } - public SensorCategory SensorCategory { get; } - public string UnitOfMeasurement { get; } - public object Value { get; set; } - public Type DataType { get; } - public bool DiscoveryUpdate { get; set; } - public bool HasChanged { get; } - void ResetChangeFlag(); + Integer, + Double, + Boolean, + String, + DateTime } -public interface ISensor : ISensor +public interface ISensor { - new T Value { get; set; } + string Name { get; } + string Alias { get; } + DateTime TimestampUtc { get; } + SensorType SensorType { get; } + SensorState SensorState { get; } + SensorClass SensorClass { get; } + SensorCategory SensorCategory { get; } + string UnitOfMeasurement { get; } + SensorDataType DataType { get; } + object? Value { get; set; } + bool DiscoveryUpdate { get; set; } + bool HasChanged { get; } + void ResetChangeFlag(); } -public class Sensor : ISensor +public sealed class Sensor : ISensor { private int _lastValueHash; - private T _value; + private object? _value; - public bool HasChanged { get; private set; } public string Name { get; } public string Alias { get; } - public DateTime TimestampUtc { get; set; } + public DateTime TimestampUtc { get; private set; } public SensorType SensorType { get; } public SensorState SensorState { get; } public SensorClass SensorClass { get; } public SensorCategory SensorCategory { get; } public string UnitOfMeasurement { get; } - //public T Value { get; set; } - public T Value + public SensorDataType DataType { get; } + public bool DiscoveryUpdate { get; set; } + public bool HasChanged { get; private set; } + + public object? Value { get => _value; set @@ -56,44 +63,105 @@ public T Value } } - object ISensor.Value - { - get => Value; - set => Value = (T)value; - } - - public Type DataType => typeof(T); - public bool DiscoveryUpdate { get; set; } - - public Sensor(string name, T value, string unitOfMeasurement = "", SensorType sensorType = SensorType.None, SensorState sensorState = SensorState.Measurement, - SensorClass sensorClass = SensorClass.Sensor, SensorCategory sensorCategory = SensorCategory.Config) + [JsonConstructor] + private Sensor(string name, + string alias, + object? value, + SensorDataType dataType, + string unitOfMeasurement, + SensorType sensorType, + SensorState sensorState, + SensorClass sensorClass, + SensorCategory sensorCategory, + bool discoveryUpdate, + DateTime timestampUtc) { Name = name; - Alias = name; + Alias = alias; + DataType = dataType; SensorType = sensorType; SensorState = sensorState; SensorClass = sensorClass; SensorCategory = sensorCategory; UnitOfMeasurement = unitOfMeasurement; - Value = value; - TimestampUtc = DateTime.UtcNow; + DiscoveryUpdate = discoveryUpdate; + TimestampUtc = timestampUtc == default ? DateTime.UtcNow : DateTime.SpecifyKind(timestampUtc, DateTimeKind.Utc); + + // set value & hash without flagging change + _value = CoerceValue(value, dataType); + _lastValueHash = _value?.GetHashCode() ?? 0; + HasChanged = false; } - public Sensor(string name, string alias, T value, string unitOfMeasurement = "", SensorType sensorType = SensorType.None, SensorState sensorState = SensorState.Measurement, - SensorClass sensorClass = SensorClass.Sensor, SensorCategory sensorCategory = SensorCategory.Config) + public Sensor(string name, + string alias, + object? value, + SensorDataType dataType, + string unitOfMeasurement = "", + SensorType sensorType = SensorType.None, + SensorState sensorState = SensorState.Measurement, + SensorClass sensorClass = SensorClass.Sensor, + SensorCategory sensorCategory = SensorCategory.Config) { Name = name; Alias = alias; + DataType = dataType; SensorType = sensorType; SensorState = sensorState; SensorClass = sensorClass; SensorCategory = sensorCategory; UnitOfMeasurement = unitOfMeasurement; - Value = value; TimestampUtc = DateTime.UtcNow; + Value = value; // invokes setter => sets hash & timestamp } + public Sensor(string name, + object? value, + SensorDataType dataType, + string unitOfMeasurement = "", + SensorType sensorType = SensorType.None, + SensorState sensorState = SensorState.Measurement, + SensorClass sensorClass = SensorClass.Sensor, + SensorCategory sensorCategory = SensorCategory.Config) + : this(name, name, value, dataType, unitOfMeasurement, sensorType, sensorState, sensorClass, sensorCategory) + { } + public void ResetChangeFlag() => HasChanged = false; + + // Helper typed accessors (optional usage) + public double AsDouble() => DataType switch + { + SensorDataType.Double => Convert.ToDouble(Value), + SensorDataType.Integer => Convert.ToDouble(Value), + _ => throw new InvalidCastException($"Sensor {Name} value is not numeric") + }; + public int AsInt() => DataType == SensorDataType.Integer ? Convert.ToInt32(Value) : (int)Math.Round(AsDouble()); + public bool AsBool() => DataType == SensorDataType.Boolean ? (bool)(Value ?? false) : throw new InvalidCastException($"Sensor {Name} value is not boolean"); + public string AsString() => Value?.ToString() ?? string.Empty; + public DateTime AsDateTime() => DataType == SensorDataType.DateTime ? (DateTime)(Value ?? DateTime.MinValue) : throw new InvalidCastException($"Sensor {Name} value is not DateTime"); + + private static object? CoerceValue(object? raw, SensorDataType dt) + { + if (raw == null) return null; + try + { + return dt switch + { + SensorDataType.Integer => Convert.ToInt32(raw), + SensorDataType.Double => Convert.ToDouble(raw), + SensorDataType.Boolean => Convert.ToBoolean(raw), + SensorDataType.String => raw.ToString(), + SensorDataType.DateTime => raw is DateTime dtv + ? DateTime.SpecifyKind(dtv, DateTimeKind.Utc) + : DateTime.SpecifyKind(Convert.ToDateTime(raw), DateTimeKind.Utc), + _ => raw + }; + } + catch + { + return raw; // fallback, avoid hard failure + } + } } /// @@ -209,7 +277,6 @@ public enum SensorType WindSpeed } - /// /// from: https://developers.home-assistant.io/blog/2021/09/20/state_class_total/ /// There are 3 defined state classes: diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index c127998..3b1abd7 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -67,8 +67,6 @@ public static async Task Main(string[] args) builder.Services.AddSlimMessageBus(smb => { - - smb.WithProviderMemory(cfg => { cfg.EnableMessageSerialization = true; }); smb.AddJsonSerializer(jsonSerializerSettings: JsonSettings); smb.WithDependencyResolver(sp); @@ -109,22 +107,6 @@ public static async Task Main(string[] args) smb.Consume(x => x.Topic("subdevice-api-data").WithConsumer()); smb.Consume(x => x.Topic("http-service-event").WithConsumer()); - - // smb.Produce(x => x.DefaultTopic("api-data")); - // smb.Produce(x => x.DefaultTopic("subdevice-data")); - // smb.Produce(x => x.DefaultTopic("subdevice-command")); - // smb.Consume(x => x - // .Topic("api-data") - // .WithConsumer() - // ); - // smb.Consume(x => x - // .Topic("subdevice-data") - // .WithConsumer() - // ); - // smb.Consume(x => x - // .Topic("subdevice-command") - // .WithConsumer() - // ); smb.AddServicesFromAssembly(Assembly.GetExecutingAssembly()); }); @@ -132,29 +114,15 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddHostedService(s => s.GetRequiredService()); - builder.Services.AddHostedService(s => s.GetRequiredService()); - // - // builder.Services.AddHostedService(); - // builder.Services.AddHostedService(); - // builder.Services.AddHostedService(); - // builder.Services.AddHostedService(); - builder.Services.AddControllers(); - //builder.Services.AddEndpointsApiExplorer(); - //builder.Services.AddSwaggerGen(); var app = builder.Build(); app.UseSerilogRequestLogging(options => { - // Customize the message template options.MessageTemplate = "Handled {RequestPath}"; - - // Emit debug-level events instead of the defaults options.GetLevel = (httpContext, elapsed, ex) => LogEventLevel.Debug; - - // Attach additional properties to the request completion event options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); @@ -162,12 +130,6 @@ public static async Task Main(string[] args) }; }); - //if (app.Environment.IsDevelopment()) - //{ - // app.UseSwagger(); - // app.UseSwaggerUI(); - //} - app.MapControllers(); await app.RunAsync(); @@ -180,13 +142,11 @@ private static IAsyncPolicy GetRetryPolicy(int retries) return HttpPolicyExtensions .HandleTransientHttpError() .OrResult(msg => msg.StatusCode == HttpStatusCode.NotFound) - //.WaitAndRetryAsync(retries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); .WaitAndRetryAsync(delay); } private static readonly JsonSerializerSettings JsonSettings = new() { - Converters = { new SensorConverter() }, TypeNameHandling = TypeNameHandling.Auto, NullValueHandling = NullValueHandling.Ignore }; diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs index 26f4c09..5510b97 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs @@ -19,14 +19,17 @@ public static dynamic BuildSubdevicePayload(Model.Subdevice subdevice) public static dynamic BuildSensorPayload(ISensor s, int? precision) { - // _logger.LogDebug($"Sensor {s.Name} datatype: {s.DataType}"); // Logging removed, can be done by caller + object? valueOut = s.Value; + if (s.DataType == SensorDataType.Double) + { + try { valueOut = Math.Round(Convert.ToDouble(s.Value), precision ?? 2); } catch { /* ignore */ } + } return new { name = s.Name, alias = s.Alias, - value = s.DataType == typeof(double) ? Math.Round(Convert.ToDouble(s.Value), precision ?? 2) : s.Value, + value = valueOut, unit = !string.IsNullOrWhiteSpace(s.UnitOfMeasurement) ? s.UnitOfMeasurement : null - //type = s.SensorType != SensorType.None ? s.SensorType.ToString() : null }; } diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs index 951e16c..4ff357b 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs @@ -130,7 +130,7 @@ public async Task OnHandle(SubdeviceDataFull message) var subdevice = message.Subdevice; await PublishSubdevice(subdevice, message.GatewayName); - await PublishSubdeviceSensors(message.ChangedSensors, message.GatewayName, message.SubdeviceId); + await PublishSubdeviceSensors(message.Subdevice.Sensors, message.GatewayName, message.SubdeviceId); await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttSubdeviceTopic(message.GatewayName, message.SubdeviceId.ToString()), DateTime.UtcNow); } } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs b/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs index 2884bea..b96a2fb 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs @@ -1,56 +1,70 @@ using Ecowitt.Controller.Model; +using Serilog; namespace Ecowitt.Controller.Service.Orchestrator { public static class DeNoiserHelper { - private static readonly Dictionary ToleranceMap = new() + // Base tolerances per SensorType (can be extended) + private static readonly Dictionary TypeTolerance = new() { { SensorType.Temperature, 0.1 }, { SensorType.Humidity, 1.0 }, { SensorType.Pressure, 0.5 }, - { SensorType.Battery, 1.0 } + { SensorType.Battery, 1.0 }, + { SensorType.WindSpeed, 0.2 }, + { SensorType.PrecipitationIntensity, 0.1 }, + { SensorType.Distance, 1.0 } }; - public static bool HasSignificantChange(ISensor sensor, T newValue) - { - if (sensor.Value == null || newValue == null) - return !Equals(sensor.Value, newValue); - - if (typeof(T) == typeof(double) || typeof(T) == typeof(float)) - { - var tolerance = ToleranceMap.GetValueOrDefault(sensor.SensorType, 0.01); - var oldVal = Convert.ToDouble(sensor.Value); - var newVal = Convert.ToDouble(newValue); - return Math.Abs(oldVal - newVal) >= tolerance; - } + private const double DefaultDoubleTolerance = 0.01; + private const double DefaultIntegerTolerance = 1.0; - return !Equals(sensor.Value, newValue); - } - public static bool HasSignificantChange(ISensor sensor, object newValue) + public static bool HasSignificantChange(ISensor sensor, object? newValue) { - if (sensor.Value == null || newValue == null) - return !Equals(sensor.Value, newValue); + if (sensor.Value == null && newValue == null) return false; + if (sensor.Value == null || newValue == null) return true; // one is null => changed - // Handle numeric types with tolerance - if (sensor.DataType == typeof(double) || sensor.DataType == typeof(float)) + try { - var tolerance = ToleranceMap.GetValueOrDefault(sensor.SensorType, 0.01); - var oldVal = Convert.ToDouble(sensor.Value); - var newVal = Convert.ToDouble(newValue); - return Math.Abs(oldVal - newVal) >= tolerance; + switch (sensor.DataType) + { + case SensorDataType.Double: + { + var oldVal = Convert.ToDouble(sensor.Value); + var newVal = Convert.ToDouble(newValue); + var tol = GetTolerance(sensor.SensorType, DefaultDoubleTolerance); + var changed = Math.Abs(oldVal - newVal) >= tol; + Log.Verbose("DeNoiser double check {Name}: old={Old} new={New} tol={Tol} changed={Changed}", sensor.Name, oldVal, newVal, tol, changed); + return changed; + } + case SensorDataType.Integer: + { + var oldVal = Convert.ToDouble(sensor.Value); // cast to double for diff + var newVal = Convert.ToDouble(newValue); + var tol = GetTolerance(sensor.SensorType, DefaultIntegerTolerance); + var changed = Math.Abs(oldVal - newVal) >= tol; + Log.Verbose("DeNoiser int check {Name}: old={Old} new={New} tol={Tol} changed={Changed}", sensor.Name, oldVal, newVal, tol, changed); + return changed; + } + case SensorDataType.Boolean: + case SensorDataType.String: + case SensorDataType.DateTime: + var eq = Equals(sensor.Value, newValue); + Log.Verbose("DeNoiser equality check {Name}: old={Old} new={New} changed={Changed}", sensor.Name, sensor.Value, newValue, !eq); + return !eq; + default: + return !Equals(sensor.Value, newValue); + } } - - // Handle integer types with tolerance - if (sensor.DataType == typeof(int) || sensor.DataType == typeof(long)) + catch (Exception ex) { - var tolerance = ToleranceMap.GetValueOrDefault(sensor.SensorType, 1.0); - var oldVal = Convert.ToDouble(sensor.Value); - var newVal = Convert.ToDouble(newValue); - return Math.Abs(oldVal - newVal) >= tolerance; + Log.Debug(ex, "DeNoiser parse error for sensor {Name} (treating as changed)", sensor.Name); + return true; } - - return !Equals(sensor.Value, newValue); } + + private static double GetTolerance(SensorType type, double fallback) + => TypeTolerance.TryGetValue(type, out var t) ? t : fallback; } } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs index 4735fa3..718c8cf 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs @@ -92,7 +92,8 @@ public async Task OnHandle(GatewayApiData message) } } - await EmitGatewayChanged(changedSensors, storedGateway.IpAddress, storedGateway.Name); + if(changedSensors.Count > 0) await EmitGatewayChanged(changedSensors, storedGateway.IpAddress, storedGateway.Name); + else _logger.LogInformation($"no changes for gateway {storedGateway.IpAddress} ({storedGateway.Model})"); var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); foreach (var sensor in sensorsToRemove) @@ -106,7 +107,7 @@ public async Task OnHandle(GatewayApiData message) else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } } - LogStorageState(); + //LogStorageState(); } public async Task OnHandle(SubdeviceApiAggregate message) @@ -126,6 +127,7 @@ public async Task OnHandle(SubdeviceApiAggregate message) storedGateway = new Device { IpAddress = ip }; storedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == storedGateway.IpAddress)?.Name ?? storedGateway.IpAddress.Replace('.', '-'); storedGateway.DiscoveryUpdate = true; + _deviceStore.UpsertGateway(storedGateway); } var subdeviceApiData = message.Subdevices.Where(sd => sd.GwIp == ip); @@ -143,6 +145,7 @@ public async Task OnHandle(SubdeviceApiAggregate message) storedGateway.Subdevices.Add(updatedSubDevice); _logger.LogInformation($"subdevice added: {data.Id} ({data.Model})"); + _deviceStore.UpsertGateway(storedGateway); await EmitSubdeviceFull(updatedSubDevice); } else @@ -164,6 +167,7 @@ public async Task OnHandle(SubdeviceApiAggregate message) // update sensors one by one and find out if there are new ones // if there are new ones, mark the subdevice for discovery update var changedSensors = new List(); + var flushData = false; foreach (var sensor in updatedSubDevice.Sensors) { var storedSensor = storedSubDevice.Sensors.FirstOrDefault(s => s.Name == sensor.Name); @@ -172,6 +176,7 @@ public async Task OnHandle(SubdeviceApiAggregate message) sensor.DiscoveryUpdate = true; storedSubDevice.Sensors.Add(sensor); changedSensors.Add(sensor); + flushData = true; } else if (DeNoiserHelper.HasSignificantChange(storedSensor, sensor.Value)) { @@ -179,8 +184,9 @@ public async Task OnHandle(SubdeviceApiAggregate message) changedSensors.Add(storedSensor); } } - - await EmitSubdeviceChanged(changedSensors, storedGateway.IpAddress, storedSubDevice.Id); + if (flushData) _deviceStore.UpsertGateway(storedGateway); + if (changedSensors.Count > 0) await EmitSubdeviceChanged(changedSensors, storedGateway.IpAddress, storedSubDevice.Id); + else _logger.LogInformation($"no changes for subdevice {data.Id} ({data.Model})"); // remove sensors that are not in the update var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); @@ -198,7 +204,7 @@ public async Task OnHandle(SubdeviceApiAggregate message) _deviceStore.UpsertGateway(storedGateway); } - LogStorageState(); + //LogStorageState(); } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs index 5ce1709..b9f6989 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs @@ -38,7 +38,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogInformation("Emitting initial configuration"); await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); await EmitMqttConfig(); - await EmitHttpConfig(); + if(_ecowittOptions.Gateways.Count > 0) await EmitHttpConfig(); } private async Task EmitHttpConfig() @@ -99,10 +99,14 @@ await _messageBus.Publish(new DeviceDataFull private async Task EmitSubdeviceFull(Subdevice subdevice) { + var gateway = _deviceStore.GetGateway(subdevice.GwIp); + var gatewayName = gateway != null ? gateway.Name : subdevice.GwIp; + await _messageBus.Publish(new SubdeviceDataFull { Subdevice = subdevice, GatewayId = subdevice.GwIp, + GatewayName = gatewayName, SubdeviceId = subdevice.Id, Timestamp = DateTime.UtcNow }); @@ -121,11 +125,15 @@ await _messageBus.Publish(new DeviceData() private async Task EmitSubdeviceChanged(List sensorsChanged, string gatewayId, int subdeviceId) { + var gateway = _deviceStore.GetGatewayBySubdeviceId(subdeviceId); + var gatewayName = gateway != null ? gateway.Name : gatewayId; + await _messageBus.Publish(new SubdeviceData() { ChangedSensors = sensorsChanged, GatewayId = gatewayId, SubdeviceId = subdeviceId, + GatewayName = gatewayName, Timestamp = DateTime.UtcNow }); } From 25bf9cb1e0410ed485ec56b5749444f9c0d9d2e3 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Sat, 6 Sep 2025 00:25:01 +0200 Subject: [PATCH 35/44] HA discovery added, ha restart triggers re-emit of sensor data. ha discovery topics are now retained. dynamically removing subdevices and sensors need to be implemented --- .../Controller/DataController.cs | 4 +- src/Ecowitt.Controller/Model/Device.cs | 2 +- .../Model/Discovery/DiscoveryBuilder.cs | 2 +- .../Model/Mapping/SensorBuilder.cs | 2 +- .../Event/HomeAssistantDiscoveryEvent.cs | 4 +- .../Http/HttpPublishingService.Events.cs | 4 +- .../Service/Http/HttpPublishingService.cs | 14 +- .../Service/Mqtt/MqttService.Consumer.cs | 6 +- .../Mqtt/MqttService.DiscoveryPublisher.cs | 124 ++++++++++++++++++ .../Service/Mqtt/MqttService.Events.cs | 6 +- .../Service/Mqtt/MqttService.Publisher.cs | 16 ++- .../Service/Mqtt/MqttService.cs | 21 ++- .../Service/Orchestrator/DeviceStore.cs | 4 +- .../Orchestrator/Dispatcher.ConsumerHttp.cs | 69 ++++++---- .../Orchestrator/Dispatcher.ConsumerMqtt.cs | 30 +++-- .../Service/Orchestrator/Dispatcher.cs | 28 +++- src/Ecowitt.Controller/appsettings.json | 2 +- 17 files changed, 266 insertions(+), 72 deletions(-) create mode 100644 src/Ecowitt.Controller/Service/Mqtt/MqttService.DiscoveryPublisher.cs diff --git a/src/Ecowitt.Controller/Controller/DataController.cs b/src/Ecowitt.Controller/Controller/DataController.cs index 670bd00..7e76c0f 100644 --- a/src/Ecowitt.Controller/Controller/DataController.cs +++ b/src/Ecowitt.Controller/Controller/DataController.cs @@ -53,7 +53,7 @@ public async Task PostData([FromForm] GatewayApiData data) else { - _logger.LogInformation($"Received data from IP {ip} ({data.StationType})."); + _logger.LogInformation("Received data from IP {Ip} ({DataStationType}).", ip, data.StationType); data.IpAddress = ip; } @@ -61,7 +61,7 @@ public async Task PostData([FromForm] GatewayApiData data) //write forms key/values into Payload property as json data.Payload = JsonSerializer.Serialize(Request.Form.Select(kvp => new { name = kvp.Key, value = kvp.Value[0] })); - _logger.LogDebug($"Request form keys: {string.Join(", ", Request.Form.Keys)}"); + _logger.LogDebug("Request form keys: {Join}", string.Join(", ", Request.Form.Keys)); await _messageBus.Publish(data); diff --git a/src/Ecowitt.Controller/Model/Device.cs b/src/Ecowitt.Controller/Model/Device.cs index caaa485..a5ab51a 100644 --- a/src/Ecowitt.Controller/Model/Device.cs +++ b/src/Ecowitt.Controller/Model/Device.cs @@ -7,7 +7,7 @@ public class Device // important properties public string IpAddress { get; set; } public string Name { get; set; } - public DateTime TimestampUtc { get; set; } + public DateTime TimestampUtc { get; set; } //= DateTime.UtcNow; public List Subdevices { get; set; } = new(); public bool DiscoveryUpdate { get; set; } diff --git a/src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs b/src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs index be16b9d..84a067d 100644 --- a/src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs +++ b/src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs @@ -51,7 +51,7 @@ public static Origin BuildOrigin() return new Origin { Name = "Ecowitt Controller", - Sw = "v0.0.1", + Sw = "v2.0.0", Url = "https://github.com/mplogas/ecowitt-controller" }; } diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs index 880c0d3..af2cc19 100644 --- a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs @@ -300,7 +300,7 @@ public partial class SensorBuilder case "wfc02_position": return BuildIntSensor(propertyName, "wfc02-position", propertyValue, isDiag: true); default: - Log.Information($"Ignored property {propertyName}."); + Log.Information("Ignored property {PropertyName}.", propertyName); return null; } } diff --git a/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs index 8ce16d9..08378f2 100644 --- a/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs +++ b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs @@ -1,6 +1,6 @@ namespace Ecowitt.Controller.Model.Message.Event; -public class HomeAssistantDiscoveryEvent +public class HomeAssistantDiscoveryEvent(Model.Device device) { - + public Device Device { get; set; } = device; } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs index da133f6..55badc7 100644 --- a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs @@ -21,7 +21,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var data = await GetSubdeviceData(host, stoppingToken); if(data.Count == 0) { - _logger.LogWarning($"No subdevice data received from {host.BaseUrl}"); + _logger.LogWarning("No subdevice data received from {HostBaseUrl}", host.BaseUrl); } var aggregate = new SubdeviceApiAggregate(); @@ -30,7 +30,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception e) { - _logger.LogError($"Failed to get subdevicedata for {host.BaseUrl}", e); + _logger.LogError(e, "Failed to get subdevicedata for {HostBaseUrl}", host.BaseUrl); } } } diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs index f2c20b4..a6e4b21 100644 --- a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs @@ -22,7 +22,7 @@ public HttpPublishingService(ILogger logger, IMessageBus private async Task> GetSubdeviceData(HttpHost host, CancellationToken cancellationToken) { - _logger.LogInformation($"Polling subdevices from {host.Host}"); + _logger.LogInformation("Polling subdevices from {HostHost}", host.Host); var subdevices = new List(); try { @@ -36,7 +36,7 @@ private async Task> GetSubdeviceData(HttpHost host, Cance } catch (Exception e) { - _logger.LogError(e, $"Exception while trying to get subdevices from {host.Host}"); + _logger.LogError(e, "Exception while trying to get subdevices from {HostHost}", host.Host); } return subdevices; @@ -70,18 +70,18 @@ private async Task> GetSubdevicesOverview(HttpHost host, GwIp = host.Host, TimestampUtc = DateTime.UtcNow }; - _logger.LogInformation($"Subdevice: {subdevice.Id} ({subdevice.Model})"); + _logger.LogInformation("Subdevice: {SubdeviceId} ({SubdeviceModel})", subdevice.Id, subdevice.Model); subdevices.Add(subdevice); } } else { - _logger.LogWarning($"Failed to get subdevices from {host.Host}"); + _logger.LogWarning("Failed to get subdevices from {HostHost}", host.Host); } } catch (Exception e) { - _logger.LogError(e, $"Exception while trying to get subdevices from {host.Host}"); + _logger.LogError(e, "Exception while trying to get subdevices from {HostHost}", host.Host); } @@ -102,12 +102,12 @@ private async Task GetSubDeviceApiPayload(HttpHost host, int subdeviceId } else { - _logger.LogWarning($"Could not get payload from {host.Host} for subdevice {subdeviceId}"); + _logger.LogWarning("Could not get payload from {HostHost} for subdevice {SubdeviceId}", host.Host, subdeviceId); } } catch (Exception e) { - _logger.LogError(e, $"Exception while trying to get payload from {host.Host} for subdevice {subdeviceId}"); + _logger.LogError(e, "Exception while trying to get payload from {HostHost} for subdevice {SubdeviceId}", host.Host, subdeviceId); } return string.Empty; diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs index 4ff357b..8ee60eb 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs @@ -56,7 +56,7 @@ public async Task OnHandle(MqttConfig message) public async Task OnHandle(HomeAssistantDiscoveryEvent message) { - EmitHomeAssistantDiscovery(); + await EmitHomeAssistantDiscovery(message.Device); } public async Task OnHandle(DeviceData message) @@ -74,7 +74,7 @@ public async Task OnHandle(DeviceData message) } await PublishSensors(message.ChangedSensors, message.GatewayName); - await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttGatewayTopic(message.GatewayName), DateTime.UtcNow); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttGatewayTopic(message.GatewayName), message.Timestamp); } public async Task OnHandle(DeviceDataFull message) @@ -93,7 +93,7 @@ public async Task OnHandle(DeviceDataFull message) await PublishGateway(gateway); await PublishSensors(gateway.Sensors, gateway.Name); - await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttGatewayTopic(gateway.Name), gateway.TimestampUtc); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttGatewayTopic(gateway.Name), message.Timestamp); } public async Task OnHandle(SubdeviceData message) diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.DiscoveryPublisher.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.DiscoveryPublisher.cs new file mode 100644 index 0000000..a4f404c --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.DiscoveryPublisher.cs @@ -0,0 +1,124 @@ + +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Discovery; +using MQTTnet; +using MQTTnet.Protocol; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Device = Ecowitt.Controller.Model.Device; + +namespace Ecowitt.Controller.Service.Mqtt +{ + public partial class MqttService + { + private async Task PublishGatewayDiscovery(Device gw) + { + var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType ?? "unknown"); + var id = DiscoveryBuilder.BuildIdentifier(gw.Name, "availability"); + //var statetopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewayTopic(gw.Name)}"; + var availabilityTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewayTopic(gw.Name)}/availability"; + + var config = DiscoveryBuilder.BuildGatewayConfig(device, _origin, "Availability", id, availabilityTopic, availabilityTopic); + + await PublishDiscoveryMessage(MqttPathBuilder.Sanitize($"sensor/{gw.Name}"), config); + } + + private async Task PublishSubdeviceDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice) + { + var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); + var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "availability"); + //var statetopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}"; + var availabilityTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/availability"; + + var config = DiscoveryBuilder.BuildGatewayConfig(device, _origin, "Availability", id, availabilityTopic, availabilityTopic); + + await PublishDiscoveryMessage(MqttPathBuilder.Sanitize($"sensor/{subdevice.Nickname}"), config); + } + + private async Task PublishSubdeviceSwitchDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice) + { + var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); + var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "switch"); + var statetopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/diag/running"; + var valueTemplate = "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}"; + var cmdTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic(gw.Name, subdevice.Id.ToString())}"; + + var config = + DiscoveryBuilder.BuildSwitchConfig(device, _origin, "switch", id, statetopic, cmdTopic, valueTemplate: valueTemplate); + + await PublishDiscoveryMessage(MqttPathBuilder.Sanitize($"switch/{subdevice.Nickname}"), config); + } + + private async Task PublishSensorDiscovery(Device gw, ISensor sensor) + { + var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType ?? "unknown"); + + var statetopic = sensor.SensorCategory == SensorCategory.Diagnostic ? $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewayDiagnosticTopic(gw.Name, sensor.Alias)}" : $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewaySensorTopic(gw.Name, sensor.Alias)}"; + var availabilityTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewayTopic(gw.Name)}/availability"; + await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); + } + + private async Task PublishSensorDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice, ISensor sensor) + { + var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); + var statetopic = sensor.SensorCategory == SensorCategory.Diagnostic ? $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceDiagnosticTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}" : $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceSensorTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}"; + var availabilityTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/availability"; + await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); + } + + private async Task PublishSensorDiscovery(Model.Discovery.Device device, ISensor sensor, string statetopic, string availabilityTopic) + { + var id = DiscoveryBuilder.BuildIdentifier($"{device.Name}_{sensor.Name}", sensor.SensorType.ToString()); + var category = DiscoveryBuilder.BuildDeviceCategory(sensor.SensorType); + + var valueTemplate = sensor.SensorClass == SensorClass.BinarySensor + ? "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}" + : "{{ value_json.value }}"; + //var valueTemplate = "{{ value_json.value }}"; + + var config = sensor.SensorCategory == SensorCategory.Diagnostic + ? DiscoveryBuilder.BuildSensorConfig(device, _origin, sensor.Alias, id, category, statetopic, valueTemplate: valueTemplate, unitOfMeasurement: sensor.UnitOfMeasurement, sensorCategory: sensor.SensorCategory.ToString().ToLower(), isBinarySensor: sensor.SensorClass == SensorClass.BinarySensor) + : DiscoveryBuilder.BuildSensorConfig(device, _origin, sensor.Alias, id, category, statetopic, valueTemplate: valueTemplate, unitOfMeasurement: sensor.UnitOfMeasurement, isBinarySensor: sensor.SensorClass == SensorClass.BinarySensor); + + var sensorClassTopic = BuildSensorClassTopic(sensor.SensorClass); + + await PublishDiscoveryMessage(MqttPathBuilder.Sanitize($"{sensorClassTopic}/{device.Name}_{sensor.Name}"), config); + } + + private async Task PublishDiscoveryMessage(string topic, Config config) + { + if (_client is { IsConnected: true }) + { + topic = $"homeassistant/{topic}/config"; + + if (config.DeviceClass != null && + config.DeviceClass.Equals("none", StringComparison.InvariantCultureIgnoreCase)) + config.DeviceClass = null; + + var payload = JsonSerializer.Serialize(config, + new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + await Publish(topic, payload, true); + } + } + + public async Task RemoveDiscoveryMessage(string topic, bool retain = false) + { + await Publish(topic, string.Empty, true); + } + + private string BuildSensorClassTopic(SensorClass sc) + { + return sc switch + { + SensorClass.BinarySensor => "binary_sensor", + _ => "sensor" + }; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs index 8f25424..a8466fb 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs @@ -32,7 +32,7 @@ private async Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessag { var payload = arg.ApplicationMessage.ConvertPayloadToString(); var topic = arg.ApplicationMessage.Topic; - _logger.LogDebug($"Message received for topic {topic}: {payload}"); + _logger.LogDebug("Message received for topic {Topic}: {Payload}", topic, payload); if (topic.Equals(HaStatusTopic, StringComparison.OrdinalIgnoreCase)) { @@ -87,11 +87,11 @@ private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg { var result = await _client?.ConnectAsync(_client.Options)!; if (result.ResultCode == MqttClientConnectResultCode.Success) break; - else _logger.LogWarning($"Failed to reconnect to MQTT broker. Attempt {i + 1} of {_mqttConfig.ReconnectAttempts}. Reason: {result.ResultCode}"); + else _logger.LogWarning("Failed to reconnect to MQTT broker. Attempt {I} of {MqttConfigReconnectAttempts}. Reason: {MqttClientConnectResultCode}", i + 1, _mqttConfig.ReconnectAttempts, result.ResultCode); } catch (Exception ex) { - _logger.LogError($"Exception during MQTT reconnect attempt {i + 1} of {_mqttConfig.ReconnectAttempts}: {ex.Message}"); + _logger.LogError("Exception during MQTT reconnect attempt {I} of {MqttConfigReconnectAttempts}: {ExMessage}", i + 1, _mqttConfig.ReconnectAttempts, ex.Message); } await Task.Delay(TimeSpan.FromSeconds(2)); } diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs index dc97f49..0a209ee 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs @@ -3,6 +3,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using MQTTnet.Protocol; namespace Ecowitt.Controller.Service.Mqtt { @@ -46,7 +47,7 @@ private async Task PublishMessage(string topic, dynamic payload) { if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}", JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }))) - _logger.LogWarning($"Failed to publish message to topic {_mqttConfig?.BaseTopic}/{topic}. Is the client connected?"); + _logger.LogWarning("Failed to publish message to topic {MqttConfigBaseTopic}/{Topic}. Is the client connected?", _mqttConfig?.BaseTopic, topic); } private async Task PublishAvailabilityMessage(string topic, DateTime timestamp) @@ -54,14 +55,16 @@ private async Task PublishAvailabilityMessage(string topic, DateTime timestamp) var available = DateTime.UtcNow.Subtract(timestamp) < TimeSpan.FromSeconds(300) ? "online" : "offline"; if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}/availability", available)) - _logger.LogWarning($"Failed to publish message to topic {_mqttConfig?.BaseTopic}/{topic}. Is the client connected?"); + _logger.LogWarning("Failed to publish message to topic {MqttConfigBaseTopic}/{Topic}. Is the client connected?", _mqttConfig?.BaseTopic, topic); } - private async Task Publish(string topic, string message) + private async Task Publish(string topic, string message, bool retain = false) { var mqttPayload = new MqttApplicationMessageBuilder() .WithTopic(topic) .WithPayload(message) + .WithRetainFlag(retain) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .Build(); if (_client is { IsConnected: true }) @@ -69,12 +72,13 @@ private async Task Publish(string topic, string message) var result = await _client.PublishAsync(mqttPayload); if (result.IsSuccess) { - _logger.LogDebug($"Message {message} published to topic {topic}"); + _logger.LogInformation("Published message to topic {topic}", topic); + _logger.LogDebug("Message {message} ", message); return true; } - else _logger.LogWarning($"Failed to publish message {message} to topic {topic}. Reason: {result.ReasonCode}"); + else _logger.LogWarning("Failed to publish message {Message} to topic {Topic}. Reason: {MqttClientPublishReasonCode}", message, topic, result.ReasonCode); } - else _logger.LogWarning($"Can't publish message {message} to topic {topic}. Client not connected."); + else _logger.LogWarning("Can't publish message {Message} to topic {Topic}. Client not connected.", message, topic); return false; } } diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index fe8c21b..0d24ca6 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Discovery; using Ecowitt.Controller.Model.Message.Config; using Ecowitt.Controller.Model.Message.Data; using Ecowitt.Controller.Model.Message.Event; @@ -19,13 +20,16 @@ public partial class MqttService : BackgroundService, IHostedLifecycleService, I private IMqttClient? _client; private bool _isConnecting; private MqttConfig? _mqttConfig; + private readonly Origin _origin; private const string HaStatusTopic = "homeassistant/status"; + public MqttService(ILogger logger, MqttFactory factory, IMessageBus messageBus) { _logger = logger; _factory = factory; _messageBus = messageBus; + _origin = DiscoveryBuilder.BuildOrigin(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -79,9 +83,22 @@ private async Task UnsubscribeHomeAssistant() _logger.LogInformation("Unsubscribed from Home Assistant"); } - private void EmitHomeAssistantDiscovery() + private async Task EmitHomeAssistantDiscovery(Model.Device gateway) { - + await PublishGatewayDiscovery(gateway); + foreach (var sensor in gateway.Sensors) + { + await PublishSensorDiscovery(gateway, sensor); + } + + foreach (var subdevice in gateway.Subdevices) + { + await PublishSubdeviceDiscovery(gateway, subdevice); + foreach (var sensor in subdevice.Sensors) + { + await PublishSensorDiscovery(gateway, subdevice, sensor); + } + } } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs index a02310a..3aa7633 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs @@ -41,12 +41,12 @@ public bool UpsertGateway(Device data) { if (_gateways.TryGetValue(data.IpAddress, out var gateway)) { - _logger.LogInformation($"Updateing Gateway {data.IpAddress} ({data.Model})"); + _logger.LogInformation("Updateing Gateway {DataIpAddress} ({DataModel})", data.IpAddress, data.Model); return _gateways.TryUpdate(data.IpAddress, data, gateway); } else { - _logger.LogInformation($"Adding Gateway {data.IpAddress} ({data.Model})"); + _logger.LogInformation("Adding Gateway {DataIpAddress} ({DataModel})", data.IpAddress, data.Model); return _gateways.TryAdd(data.IpAddress, data); } } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs index 718c8cf..6422bd7 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs @@ -13,12 +13,12 @@ public partial class Dispatcher : IConsumer { public async Task OnHandle(SubdeviceApiCommand message) { - _logger.LogInformation($"Received SubdeviceCommand: {message.Cmd} for device {message.Id}"); + _logger.LogInformation("Received SubdeviceCommand: {MessageCmd} for device {MessageId}", message.Cmd, message.Id); var gw = _deviceStore.GetGatewayBySubdeviceId(message.Id); if (gw == null) { - _logger.LogWarning($"Gateway not found for subdevice {message.Id}"); + _logger.LogWarning("Gateway not found for subdevice {MessageId}", message.Id); return; } // hey compiler, this can't be null! @@ -43,39 +43,46 @@ public async Task OnHandle(SubdeviceApiCommand message) } else { - _logger.LogWarning($"Ignoring unsupported command {message.Cmd} for subdevice {message.Id}"); + _logger.LogWarning("Ignoring unsupported command {MessageCmd} for subdevice {MessageId}", message.Cmd, message.Id); return; } } public async Task OnHandle(GatewayApiData message) { - _logger.LogDebug($"Received ApiData: {message.Model} ({message.PASSKEY}) \n {message.Payload}"); + _logger.LogDebug("Received ApiData: {MessageModel} ({MessagePasskey}) \n {MessagePayload}", message.Model, message.PASSKEY, message.Payload); var updatedGateway = message.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); updatedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == updatedGateway.IpAddress)?.Name ?? updatedGateway.IpAddress.Replace('.', '-'); var storedGateway = _deviceStore.GetGateway(updatedGateway.IpAddress); if (storedGateway == null) { - updatedGateway.DiscoveryUpdate = true; + updatedGateway.DiscoveryUpdate = true; foreach (var sensor in updatedGateway.Sensors) { sensor.DiscoveryUpdate = true; } + + var firstGateway = _deviceStore.GetGatewaysShort().Count == 0; if (_deviceStore.UpsertGateway(updatedGateway)) { - _logger.LogDebug($"gateway added: {JsonSerializer.Serialize(storedGateway)})"); + _logger.LogDebug("gateway added: {Serialize})", JsonSerializer.Serialize(storedGateway)); + if (_ecowittOptions is { AutoDiscovery: true, Gateways.Count: 0 } && firstGateway) + { + await EmitHttpConfig(); + } + await EmitHomeAssistantDiscovery(updatedGateway); await EmitGatewayFull(updatedGateway); } - else _logger.LogWarning($"failed to add gateway {updatedGateway.IpAddress} ({updatedGateway.Model}) to the store"); + else _logger.LogWarning("failed to add gateway {UpdatedGatewayIpAddress} ({UpdatedGatewayModel}) to the store", updatedGateway.IpAddress, updatedGateway.Model); } else { // no other property should update besides sensors - it seems fw isn't reported by the GW storedGateway.TimestampUtc = updatedGateway.TimestampUtc; var changedSensors = new List(); - + var emitDiscovery = false; foreach (var sensor in updatedGateway.Sensors) { var storedSensor = storedGateway.Sensors.FirstOrDefault(s => s.Name == sensor.Name); @@ -84,6 +91,7 @@ public async Task OnHandle(GatewayApiData message) sensor.DiscoveryUpdate = true; storedGateway.Sensors.Add(sensor); changedSensors.Add(sensor); + emitDiscovery = true; } else if (DeNoiserHelper.HasSignificantChange(storedSensor, sensor.Value)) { @@ -93,18 +101,24 @@ public async Task OnHandle(GatewayApiData message) } if(changedSensors.Count > 0) await EmitGatewayChanged(changedSensors, storedGateway.IpAddress, storedGateway.Name); - else _logger.LogInformation($"no changes for gateway {storedGateway.IpAddress} ({storedGateway.Model})"); + else _logger.LogInformation("no changes for gateway {StoredGatewayIpAddress} ({StoredGatewayModel})", storedGateway.IpAddress, storedGateway.Model); var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); + if(sensorsToRemove.Count > 0) emitDiscovery = true; foreach (var sensor in sensorsToRemove) { storedGateway.Sensors.Remove(sensor); } - // TODO: emit removed gateway sensors to mqtt (maybe... is it really needed?) - - if (!_deviceStore.UpsertGateway(storedGateway)) { _logger.LogWarning($"failed to update {storedGateway.IpAddress} ({storedGateway.Model}) in the store"); } - else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } + if (!_deviceStore.UpsertGateway(storedGateway)) + { + _logger.LogWarning("failed to update {StoredGatewayIpAddress} ({StoredGatewayModel}) in the store", storedGateway.IpAddress, storedGateway.Model); + } + else + { + if(emitDiscovery) await EmitHomeAssistantDiscovery(storedGateway); + _logger.LogDebug("gateway updated: {Serialize})", JsonSerializer.Serialize(storedGateway)); + } } //LogStorageState(); @@ -120,14 +134,16 @@ public async Task OnHandle(SubdeviceApiAggregate message) { if (_ecowittOptions.AutoDiscovery) { - _logger.LogWarning($"Gateway {ip} not found while in autodiscovery mode. Not updating subdevices. (Try turning off autodiscovery)"); + _logger.LogWarning("Gateway {Ip} not found while in autodiscovery mode. Not updating subdevices. (Try turning off autodiscovery)", ip); return; } storedGateway = new Device { IpAddress = ip }; storedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == storedGateway.IpAddress)?.Name ?? storedGateway.IpAddress.Replace('.', '-'); + storedGateway.TimestampUtc = DateTime.UtcNow; storedGateway.DiscoveryUpdate = true; _deviceStore.UpsertGateway(storedGateway); + await EmitHomeAssistantDiscovery(storedGateway); } var subdeviceApiData = message.Subdevices.Where(sd => sd.GwIp == ip); @@ -143,8 +159,9 @@ public async Task OnHandle(SubdeviceApiAggregate message) sensor.DiscoveryUpdate = true; } storedGateway.Subdevices.Add(updatedSubDevice); - _logger.LogInformation($"subdevice added: {data.Id} ({data.Model})"); + _logger.LogInformation("subdevice added: {DataId} ({DataModel})", data.Id, data.Model); + await EmitHomeAssistantDiscovery(storedGateway); _deviceStore.UpsertGateway(storedGateway); await EmitSubdeviceFull(updatedSubDevice); } @@ -184,24 +201,26 @@ public async Task OnHandle(SubdeviceApiAggregate message) changedSensors.Add(storedSensor); } } - if (flushData) _deviceStore.UpsertGateway(storedGateway); + if (changedSensors.Count > 0) await EmitSubdeviceChanged(changedSensors, storedGateway.IpAddress, storedSubDevice.Id); - else _logger.LogInformation($"no changes for subdevice {data.Id} ({data.Model})"); + else _logger.LogInformation("no changes for subdevice {DataId} ({DataModel})", data.Id, data.Model); - // remove sensors that are not in the update var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); + if (sensorsToRemove.Count > 0) flushData = true; foreach (var sensor in sensorsToRemove) { storedSubDevice.Sensors.Remove(sensor); } + + if (flushData) + { + _deviceStore.UpsertGateway(storedGateway); + await EmitHomeAssistantDiscovery(storedGateway); + } - // TODO: emit removed subdevice sensors to mqtt (maybe... is it really needed?) - - _logger.LogInformation($"subdevice updated: {data.Id} ({data.Model})"); + _logger.LogInformation("subdevice updated: {DataId} ({DataModel})", data.Id, data.Model); } } - - _deviceStore.UpsertGateway(storedGateway); } //LogStorageState(); @@ -221,12 +240,12 @@ public Task OnHandle(HttpServiceEvent message) _lastHttpServiceState = HttpServiceEventType.Stopped; break; case HttpServiceEventType.Error: - _logger.LogError($"HTTP Service error: {message.Message}"); + _logger.LogError("HTTP Service error: {MessageMessage}", message.Message); _lastHttpServiceState = HttpServiceEventType.Error; break; case HttpServiceEventType.Unknown: default: - _logger.LogWarning($"Unknown HTTP Service event: {message.EventType}"); + _logger.LogWarning("Unknown HTTP Service event: {HttpServiceEventType}", message.EventType); _lastHttpServiceState = HttpServiceEventType.Unknown; break; } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs index d6fac44..be97f65 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs @@ -1,4 +1,5 @@ -using Ecowitt.Controller.Model.Message.Event; +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Message.Event; namespace Ecowitt.Controller.Service.Orchestrator { @@ -18,7 +19,7 @@ public Task OnHandle(MqttServiceEvent message) _lastMqttServiceState = MqttServiceEventType.Stopped; break; case MqttServiceEventType.Error: - _logger.LogError($"MQTT Service error: {message.Message}"); + _logger.LogError("MQTT Service error: {MessageMessage}", message.Message); _lastMqttServiceState = MqttServiceEventType.Error; break; case MqttServiceEventType.Heartbeat: @@ -27,7 +28,7 @@ public Task OnHandle(MqttServiceEvent message) break; case MqttServiceEventType.Unknown: default: - _logger.LogWarning($"Unknown MQTT Service event: {message.EventType}"); + _logger.LogWarning("Unknown MQTT Service event: {MqttServiceEventType}", message.EventType); break; } @@ -45,34 +46,43 @@ public Task OnHandle(MqttConnectionEvent message) _logger.LogWarning("MQTT Client disconnected"); break; case MqttConnectionEventType.Error: - _logger.LogInformation($"MQTT Client error {message.Message}"); + _logger.LogInformation("MQTT Client error {MessageMessage}", message.Message); break; case MqttConnectionEventType.MessageReceived: default: - _logger.LogInformation($"MQTT Connection event received: {message.EventType}"); + _logger.LogInformation("MQTT Connection event received: {MqttConnectionEventType}", message.EventType); break; } return Task.CompletedTask; } - public Task OnHandle(HomeAssistantStatusEvent message) + public async Task OnHandle(HomeAssistantStatusEvent message) { switch (message.Status) { case HomeAssistantStatusType.Online: - _logger.LogInformation("HomeAssistant is online"); + _logger.LogWarning("HomeAssistant is online"); + foreach (var gateway in _deviceStore.GetGatewaysShort().Select(kvp => _deviceStore.GetGateway(kvp.Key)).OfType()) + { + //await EmitHomeAssistantDiscovery(gateway); + await EmitGatewayFull(gateway); + foreach (var subdevice in gateway.Subdevices) + { + await EmitSubdeviceFull(subdevice); + } + + _logger.LogInformation("Re-emitted device {deviceIp}", gateway.IpAddress); + } break; case HomeAssistantStatusType.Offline: - _logger.LogInformation("HomeAssistant is offline"); + _logger.LogWarning("HomeAssistant is offline"); break; case HomeAssistantStatusType.Unknown: default: _logger.LogInformation("Unknown HomeAssistant status received"); break; } - - return Task.CompletedTask; } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs index b9f6989..0639820 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs @@ -39,6 +39,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); await EmitMqttConfig(); if(_ecowittOptions.Gateways.Count > 0) await EmitHttpConfig(); + + + } + + private async Task EmitHomeAssistantDiscovery(Device device) + { + var discoveryEvent = new HomeAssistantDiscoveryEvent(device); + await _messageBus.Publish(discoveryEvent); } private async Task EmitHttpConfig() @@ -53,6 +61,18 @@ private async Task EmitHttpConfig() await _messageBus.Publish(httpConfig); } + private async Task EmitHttpConfig(List hosts) + { + var httpConfig = new HttpConfig + { + Hosts = hosts, + PollingInterval = _ecowittOptions.PollingInterval, + AutoDiscovery = _ecowittOptions.AutoDiscovery + }; + + await _messageBus.Publish(httpConfig); + } + private async Task EmitMqttConfig() { var mqttConfig = new MqttConfig @@ -80,9 +100,9 @@ private void LogStorageState() { foreach (var gw in _deviceStore.GetGatewaysShort()) { - _logger.LogInformation($"Gateway {gw.Key} - {gw.Value}"); + _logger.LogInformation("Gateway {GwKey} - {GwValue}", gw.Key, gw.Value); var gateway = _deviceStore.GetGateway(gw.Key); - _logger.LogDebug($"Storage dump: \n {JsonSerializer.Serialize(gateway)}"); + _logger.LogDebug("Storage dump: \n {Serialize}", JsonSerializer.Serialize(gateway)); } } @@ -93,7 +113,7 @@ await _messageBus.Publish(new DeviceDataFull Device = device, GatewayId = device.IpAddress, GatewayName = device.Name, - Timestamp = DateTime.UtcNow + Timestamp = device.TimestampUtc }); } @@ -108,7 +128,7 @@ await _messageBus.Publish(new SubdeviceDataFull GatewayId = subdevice.GwIp, GatewayName = gatewayName, SubdeviceId = subdevice.Id, - Timestamp = DateTime.UtcNow + Timestamp = subdevice.TimestampUtc }); } diff --git a/src/Ecowitt.Controller/appsettings.json b/src/Ecowitt.Controller/appsettings.json index e48ced4..bf15d23 100644 --- a/src/Ecowitt.Controller/appsettings.json +++ b/src/Ecowitt.Controller/appsettings.json @@ -3,7 +3,7 @@ "MinimumLevel": { "Default": "Information", "Override": { - "Ecowitt.Controller.Service.Orchestrator": "Debug" + //"Ecowitt.Controller.Service.Orchestrator": "Debug" } } }, From 1311388e1130ad03d15aa3c6429c1bc258e38a07 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Mon, 2 Mar 2026 22:36:19 +0100 Subject: [PATCH 36/44] Phase 1: dead code cleanup, docs refresh, unit tests Remove unused code: ConcurrentSensors, GatewayOptions.Passkey, HttpHost.Passkey, HealthCheck.cs, old/ directory (superseded), in-source docs (moved to docs/). Fix solarradiation to use BuildDoubleSensor (value can be decimal). Upgrade both projects to net10.0, add InternalsVisibleTo for test access. Add 7 test files (150 tests) covering SensorBuilder, SensorBuilder.Addon, DeNoiserHelper, MqttPathBuilder, MqttPayloadBuilder, DiscoveryBuilder, and ApiDataExtension. Update docs/api.md, docs/mqtt.md, README.md, CLAUDE.md. --- CLAUDE.md | 81 ++++ README.md | 192 +++++----- docs/api.md | 165 ++++++++ docs/mqtt.md | 174 ++++++++- old/Discovery/DiscoveryPublishService.cs | 189 --------- old/Mqtt/Helper.cs | 62 --- old/Mqtt/MqttClient.cs | 153 -------- old/Mqtt/MqttService.cs | 102 ----- old/Store/DataPublishService.cs | 152 -------- old/Subdevice/SubdeviceService.cs | 178 --------- .../ApiDataExtensionTest.cs | 215 +++++++++++ src/EcoWitt.Controller.Tests/DeNoiserTest.cs | 134 +++++++ .../DiscoveryBuilderTest.cs | 152 ++++++++ .../EcoWitt.Controller.Tests.csproj | 2 +- .../MqttPathBuilderTest.cs | 93 +++++ .../MqttPayloadBuilderTest.cs | 158 ++++++++ .../SensorBuilderAddonTest.cs | 162 ++++++++ .../SensorBuilderTest.cs | 359 ++++++++++++++++++ .../SubdeviceMappingTest.cs | 4 +- .../Ecowitt.Controller.csproj | 10 +- .../Model/Configuration/EcowittOptions.cs | 1 - src/Ecowitt.Controller/Model/Device.cs | 4 - .../Model/Message/Config/HttpConfig.cs | 1 - .../Service/Mqtt/HealthCheck.cs | 20 - .../ha_discovery_payload.md | 184 --------- src/Ecowitt.Controller/mqtt.md | 17 - 26 files changed, 1795 insertions(+), 1169 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 old/Discovery/DiscoveryPublishService.cs delete mode 100644 old/Mqtt/Helper.cs delete mode 100644 old/Mqtt/MqttClient.cs delete mode 100644 old/Mqtt/MqttService.cs delete mode 100644 old/Store/DataPublishService.cs delete mode 100644 old/Subdevice/SubdeviceService.cs create mode 100644 src/EcoWitt.Controller.Tests/ApiDataExtensionTest.cs create mode 100644 src/EcoWitt.Controller.Tests/DeNoiserTest.cs create mode 100644 src/EcoWitt.Controller.Tests/DiscoveryBuilderTest.cs create mode 100644 src/EcoWitt.Controller.Tests/MqttPathBuilderTest.cs create mode 100644 src/EcoWitt.Controller.Tests/MqttPayloadBuilderTest.cs create mode 100644 src/EcoWitt.Controller.Tests/SensorBuilderAddonTest.cs create mode 100644 src/EcoWitt.Controller.Tests/SensorBuilderTest.cs delete mode 100644 src/Ecowitt.Controller/Service/Mqtt/HealthCheck.cs delete mode 100644 src/Ecowitt.Controller/ha_discovery_payload.md delete mode 100644 src/Ecowitt.Controller/mqtt.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..010164b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Ecowitt Controller is a .NET 8 / ASP.NET Core application that bridges Ecowitt weather stations and IoT subdevices (AC1100 smart plugs, WFC01/WFC02 water valves) to MQTT, with Home Assistant auto-discovery support. It receives weather data via HTTP POST from Ecowitt gateways and polls subdevices via their HTTP API, then publishes everything over MQTT. + +## Build & Run Commands + +All commands run from `src/`: + +```bash +# Build +dotnet build Ecowitt.Controller.sln + +# Run +dotnet run --project Ecowitt.Controller/Ecowitt.Controller.csproj + +# Run release +dotnet run -c Release --project Ecowitt.Controller/Ecowitt.Controller.csproj + +# Run tests (NUnit) +dotnet test EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj + +# Run a single test +dotnet test EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj --filter "FullyQualifiedName~TestMethodName" + +# Docker build (from src/) +docker build -t ecowitt-controller . +``` + +Note: the test project casing is `EcoWitt.Controller.Tests` (capital W) while the main project is `Ecowitt.Controller`. + +## Architecture + +The system uses three BackgroundServices communicating through an **in-memory message bus** (SlimMessageBus): + +### Data Flow + +1. **DataController** (`Controller/DataController.cs`) — ASP.NET endpoint at `POST /data/report` receives form-encoded weather data from Ecowitt gateways, publishes `GatewayApiData` onto the bus. + +2. **Dispatcher** (`Service/Orchestrator/Dispatcher*.cs`) — Central orchestrator (partial class split across files). Consumes messages from both HTTP and MQTT services, manages the `DeviceStore`, performs change detection via `DeNoiserHelper`, and emits data/discovery messages. The `ConsumerHttp` partial handles gateway and subdevice API data; the `ConsumerMqtt` partial handles MQTT lifecycle and Home Assistant status events. + +3. **MqttService** (`Service/Mqtt/MqttService*.cs`) — Partial class split across files for consumer, publisher, discovery, and events. Connects to MQTT broker, publishes sensor data and Home Assistant discovery payloads, subscribes to HA status topic for re-emission on HA restart. + +4. **HttpPublishingService** (`Service/Http/HttpPublishingService*.cs`) — Polls Ecowitt gateway HTTP APIs for subdevice data on a configurable interval, publishes `SubdeviceApiAggregate` onto the bus. + +### Message Bus Topics (SlimMessageBus) + +Messages are defined in `Model/Message/` (Config, Data, Event subdirs). The bus wiring is in `Program.cs`. Key flows: +- Dispatcher → MqttService: `MqttConfig`, `DeviceData`, `DeviceDataFull`, `SubdeviceData`, `SubdeviceDataFull`, `HomeAssistantDiscoveryEvent` +- MqttService → Dispatcher: `MqttServiceEvent`, `MqttConnectionEvent`, `HomeAssistantStatusEvent` +- Dispatcher → HttpPublishingService: `HttpConfig` +- HttpPublishingService → Dispatcher: `SubdeviceApiAggregate`, `HttpServiceEvent` + +### Key Model Layer + +- **Device/Subdevice/Sensor** (`Model/Device.cs`, `Model/Subdevice.cs`, `Model/Sensor.cs`) — Domain model. `ISensor` has typed value accessors and change tracking via hash comparison. +- **SensorBuilder** (`Model/Mapping/SensorBuilder*.cs`) — Partial class that maps raw Ecowitt property names (e.g., `tempinf`, `baromrelin`) to typed `Sensor` objects with unit conversion (imperial→metric). This is the main mapping to extend when adding new sensor types. +- **ApiDataExtension** (`Model/Mapping/ApiDataExtension.cs`) — Extension methods that convert API DTOs to domain objects. +- **DiscoveryBuilder** (`Model/Discovery/`) — Builds Home Assistant MQTT discovery payloads. +- **DeNoiserHelper** (`Service/Orchestrator/DeNoiser.cs`) — Filters insignificant sensor value changes using configurable tolerances per sensor type. +- **DeviceStore** (`Service/Orchestrator/DeviceStore.cs`) — Thread-safe in-memory store (`ConcurrentDictionary`) for gateway/subdevice state. + +### Configuration + +Three option classes bound from `appsettings.json` sections in `Model/Configuration/`: +- `ecowitt` → `EcowittOptions` (gateways, polling interval, autodiscovery) +- `mqtt` → `MqttOptions` (broker connection) +- `controller` → `ControllerOptions` (units, precision, publishing interval, HA discovery toggle) + +Config is loaded from `/config/appsettings.json` (Docker) or the content root (bare-metal). + +## Conventions + +- Partial classes are used extensively for service separation (consumers, publishers, events in separate files). +- Logging uses Serilog with structured logging templates (not string interpolation). +- HTTP retry policy uses Polly with decorrelated jitter backoff. +- The Ecowitt gateway HTTP API endpoints used: `get_iot_device_list`, `parse_quick_cmd_iot`. +- SensorType/SensorState/SensorCategory enums align with Home Assistant device classes. \ No newline at end of file diff --git a/README.md b/README.md index bd24829..5ff0f13 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,49 @@ -*so you found the hidden refactoring branch? sweet, follow me on this journey, there diagrams, api spec and mqtt spec. and a lot of almost working code.* - -## Supported Devices - -### Gateways -- **GW3000**: The new one. yes i'm already using it. -- **GW2000**: The GW2000 is a displayless console/gateway available since April 2022. It offers an extended range of functions, including a browser interface (WebUI) for display and configuration. It supports both Ethernet and WLAN connections, though it's recommended not to use both interfaces simultaneously to avoid blocking the web interface. The GW2000 firmware supports various sensors, including the WFC01 IoT sensor. -- **GW1200**: This gateway supports bidirectional communication necessary for controlling intelligent components like switches and water valves. It supports a WebUI and also supports the WS View Plus app for configuration . -- **GW1x00**: The GW1100 is similar to the GW1200 but does not support a WebUI and no subdevices. It is configured and displayed via the WS View Plus app. The GW1100 includes a temperature/humidity sensor for indoor measurements and supports local data display through applications like PWT (Personal Weather Tablet). -- **Other Compatible Gateways**: WN1980, WS3800, and WS39x0 also support IoT sensors and bidirectional communication . - -### Subdevices -- **AC1100 Smart Plug**: The AC1100 is a switchable socket that can be controlled manually, time-controlled, or based on measured values from the weather station. It requires an IoT-enabled console (e.g., GW2000, GW1200) and the Ecowitt app for automatic operation. It supports different regions with corresponding plugs and maximum wattages . -- **WFC01 Intelligent Water Timer (WittFlow)**: The WFC01 is a timer-controlled or sensor-measurement-dependent water valve. It features a built-in liquid flow sensor and a temperature sensor for the liquid. The valve can be controlled via the Ecowitt app, and it supports various operating modes, including manual, plan, and smart modes . The device is waterproof and dustproof to IP66 standards and built from corrosion-resistant materials . -- **WFC02 Intelligent Water Valve**: Similar to the WFC01, the WFC02 is a water valve and comes with an optional flow sensor. more later i'm satill testing it. - -### Compatibility -- **Weather Stations**: Fine Offset and its clones (Ecowitt, Froggit, Ambient Weather...) are generally supported if your gateway/weather station supports custom weather station uploads in Ecowitt format. -- **Web API Devices**: If your device offers a web API (e.g., GW2000, GW1200), this tool offers bidirectional communication, allowing you to control actors like the AC1100 smart plug or the WFC01 water valve . -## Why -Ecowitt offers great and reliable weather stations that can be run entirely locally. As a Home Assistant user, I used Ecowitt2Mqtt to collect my weather data. Unfortunately, it doesn't support subdevices (yet), and my Python skills aren't sufficient to extend it. However, I have experience with .NET, IoT, and I enjoy challenges. This project was created to fill that gap and provide a solution for controlling Ecowitt subdevices from Home Assistant. Additionally, the decoupled architecture can serve as a blueprint for future similar projects. -## Features -- **Multi-Gateway, Multi-Subdevices Support:** The system supports multiple gateways and subdevices, allowing for extensive scalability and flexibility . -- **Automatic Discovery:** Newly added sensors and subdevices are automatically detected. This feature can be turned off to allow for manual gateway definitions . -- **Bidirectional Communication:** Enables data retrieval such as battery state and sensor status from subdevices, and also allows for control commands to be sent . -- **MQTT-Based Integration:** Weather data is exposed via MQTT, facilitating integration with other IoT systems . -- **Home Assistant Compatibility:** Supports device discovery messages to the Home Assistant topic, enabling seamless integration with Home Assistant through MQTT . -- **Highly Configurable:** Offers extensive configuration options including discovery settings, polling schedules, HTTP timeouts, and alternative discovery topics . -- **Metric and Freedom Units Support:** Transforms data points to metric units by default, with the option to turn this off if imperial units are preferred . -- **Dynamic Property Discovery:** The system flexibly discovers and publishes new sensor properties by default, ensuring up-to-date data availability . -## Roadmap -- **InfluxDB Storage:** Store states directly in InfluxDB without going through Home Assistant first. This will enable more efficient data storage and retrieval, allowing for advanced data analysis and visualization using tools like Grafana. -- **Calculated Properties:** Implement calculated properties such as dewpoint, wind direction (NESW), windchill, etc. These calculated metrics will provide more comprehensive weather data insights, enhancing the utility of the collected data for various applications. -## Getting started -To begin, you will need a running MQTT broker along with its IP address or hostname and port number. While TLS and credentials are optional, they are highly recommended for enhanced security. -## Ecowitt Controller (this tool) -If you intend to run the Ecowitt Controller in a Docker container, you will need to create a new `.json` file named `appsettings.json`. For a minimal setup, you only need to specify the IP address of your MQTT broker. All other settings will be automatically populated with default values. -### Minimal Configuration - ``` json +# Ecowitt Controller + +A .NET 8 bridge that connects Ecowitt weather stations and IoT subdevices to MQTT, with native Home Assistant auto-discovery. + +## Features + +- Multi-gateway, multi-subdevice support +- Automatic discovery of new sensors and subdevices +- Bidirectional communication with subdevices (AC1100, WFC01, WFC02) +- Home Assistant MQTT discovery (devices, sensors, switches) +- Metric/imperial unit conversion +- Change-detection filtering to reduce MQTT noise + +## Supported Devices + +**Gateways:** GW3000, GW2000, GW1200, GW1100, WN1980, WS3800, WS39x0 + +**Subdevices** (require IoT-capable gateway like GW2000/GW1200): +- **AC1100** — Smart plug with power monitoring +- **WFC01** — Water timer with flow sensor and temperature +- **WFC02** — Water valve with optional flow sensor + +**Weather Stations:** Any Fine Offset compatible station (Ecowitt, Froggit, Ambient Weather, ...) that supports custom Ecowitt protocol uploads. + +## Getting Started + +**Prerequisites:** A running MQTT broker (e.g. Mosquitto). + +### Configuration + +Create an `appsettings.json`. Minimal setup — just point it at your MQTT broker: + +```json { "mqtt": { - "host": "" + "host": "192.168.1.50" } } -``` +``` -For a more detailed configuration, including MQTT credentials and polling intervals, see the full configuration example below. This example also enables debug logging. -### Full Config +Full configuration with all options and their defaults: -``` json +```json { "Serilog": { - "MinimumLevel": "Debug" + "MinimumLevel": "Warning" }, "mqtt": { "host": "", @@ -60,72 +53,89 @@ For a more detailed configuration, including MQTT credentials and polling interv "basetopic": "ecowitt", "clientId": "ecowitt-controller", "reconnect": true, - "reconnectAttemps": 5 + "reconnectAttempts": 2 }, "ecowitt": { - "pollingInterval": 30, //seconds + "pollingInterval": 30, "autodiscovery": true, + "calculateValues": true, "retries": 2, "gateways": [ { "name": "weatherstation_01", "ip": "192.168.1.101" - }, - { - "name": "weatherstation_02", - "ip": "192.168.1.102", - "username": "", - "password": "" } ] }, "controller": { - "precision": 2, //math.round to x digits - "unit": "metric", // or "imperial" - "publishingInterval": 10, //seconds + "precision": 2, + "unit": "metric", + "publishingInterval": 60, "homeassistantdiscovery": true } } +``` +| Section | Key | Default | Description | +|---------|-----|---------|-------------| +| `mqtt` | `host` | — | MQTT broker address (required) | +| `mqtt` | `port` | `1883` | MQTT broker port | +| `mqtt` | `basetopic` | `ecowitt` | Root MQTT topic prefix | +| `mqtt` | `reconnect` | `true` | Auto-reconnect on disconnect | +| `mqtt` | `reconnectAttempts` | `2` | Reconnect retry count | +| `ecowitt` | `pollingInterval` | `30` | Subdevice polling interval (seconds) | +| `ecowitt` | `autodiscovery` | `false` | Auto-discover gateways from incoming data | +| `ecowitt` | `calculateValues` | `true` | Generate calculated sensor values | +| `ecowitt` | `gateways` | `[]` | Manual gateway definitions (name, ip, credentials) | +| `controller` | `precision` | `2` | Decimal places for floating-point values | +| `controller` | `unit` | `metric` | `metric` or `imperial` | +| `controller` | `publishingInterval` | `60` | MQTT publish interval (seconds) | +| `controller` | `homeassistantdiscovery` | `true` | Publish HA MQTT discovery messages | + +### Run with Docker + +```bash +docker run -d --name ecowitt-controller \ + -v /path/to/appsettings.json:/config/appsettings.json:ro \ + -p 8080:8080 \ + mplogas/ecowitt-controller:latest ``` -### Run as Docker -To run Ecowitt Controller in a Docker container, mount your config file and forward port 8080: -`docker run -d --name ecowitt-controller -v path/to/appsettings.json:/config/appsettings.json:ro -p 8080:8080 mplogas/ecowitt-controller:latest` -### Run on Bare-Metal -To run Ecowitt Controller on bare-metal, clone the repository and modify the `appsettings.json`. Then, change into the `src` directory and run: +### Run from Source + +```bash +cd src +dotnet run --project Ecowitt.Controller/Ecowitt.Controller.csproj -c Release +``` -`dotnet run -C Release Ecowitt.Controller.csproj` ### Configure Your Weather Station -To configure your Ecowitt weather station to send data to the Ecowitt Controller, follow these steps: - -1. **Access the WebUI or WS View Plus App:** - - For the GW2000, you can use the built-in WebUI available at the device's IP address . - - Alternatively, use the WS View Plus app to configure the station. - -2. **Set Up Custom Server:** - - Navigate to the weather services configuration page. - - Enable the 'customized' data posting option. - - Choose the Ecowitt protocol. - - Enter the IP address or hostname of your Ecowitt-Controller instance. - - Specify the path as `/data/report`. - - Set the port number (default is 8080). - - Define the posting interval (e.g., 30 seconds). - -3. **Save and Apply Settings:** - - Save the configuration and ensure the weather station starts sending data to the specified Ecowitt-Controller instance. + +1. Open your gateway's WebUI or the WS View Plus app +2. Go to weather services and enable the **Customized** upload +3. Set protocol to **Ecowitt**, enter the controller's IP, path `/data/report`, port `8080` +4. Set the posting interval (e.g. 30 seconds) + ### Home Assistant -Ensure your Home Assistant has permission set up to listen for MQTT messages from the Ecowitt Controller topic. The Home Assistant Discovery should automatically pick up new devices and sensors if `homeassistantdiscovery` is enabled in your `appsettings.json`. -## Implementation - -This project is developed using C# and .NET 8, utilizing ASP.NET Core Web API to create an endpoint for the Ecowitt custom weather station. The main components of the implementation include: -- **ASP.NET Core Web API**: Acts as the endpoint to receive data from the weather station. -- **[MQTTnet](https://github.com/dotnet/MQTTnet)**: Facilitates MQTT background services for communication with the weather station. -- **Polling Background Service**: Periodically retrieves updates from subdevices. -- **[SlimMessageBus](https://github.com/zarusz/SlimMessageBus)**: An in-memory message bus that integrates all components for efficient communication.` + +With `homeassistantdiscovery` enabled (default), devices and sensors appear automatically in HA via MQTT discovery. Make sure your HA instance is connected to the same MQTT broker. + +## Documentation + +- [HTTP API](docs/api.md) — Inbound weather data endpoint and outbound gateway polling +- [MQTT Topics](docs/mqtt.md) — Topic structure, payloads, and Home Assistant discovery + +## Tech Stack + +- **ASP.NET Core Web API** — HTTP endpoint for Ecowitt weather data +- **[MQTTnet](https://github.com/dotnet/MQTTnet)** — MQTT client +- **[SlimMessageBus](https://github.com/zarusz/SlimMessageBus)** — In-memory message bus connecting the services +- **[Serilog](https://serilog.net/)** — Structured logging +- **[Polly](https://github.com/App-vNext/Polly)** — HTTP retry policies + ## Contributing -We welcome contributions from the community! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved. + +Contributions welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details. -## Contact -For any questions or feedback, please open an issue on GitHub. \ No newline at end of file + +MIT — see [LICENSE](LICENSE). \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index e69de29..a20a3f7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -0,0 +1,165 @@ +# HTTP API + +## Inbound — Weather Station Data + +The controller exposes an HTTP endpoint that Ecowitt gateways post weather data to. + +### `GET /data/report` + +Health check / connectivity test. + +**Response:** `200 OK` +```json +{ "status": "ok" } +``` + +### `POST /data/report` + +Receives weather station data from Ecowitt gateways. + +**Content-Type:** `application/x-www-form-urlencoded` + +The gateway sends all sensor readings as form fields. The controller captures the sender's IP address automatically and serializes the form key/value pairs into a JSON payload for internal processing. + +**Known form fields (non-exhaustive):** +| Field | Description | +|-------|-------------| +| `PASSKEY` | Gateway passkey identifier | +| `stationtype` | Station type string (e.g. `GW2000A_V3.1.3`) | +| `runtime` | Gateway uptime in seconds | +| `dateutc` | Gateway timestamp | +| `freq` | Radio frequency | +| `model` | Gateway model (e.g. `GW2000A`) | +| `tempinf` | Indoor temperature (°F) | +| `tempf` | Outdoor temperature (°F) | +| `humidity` | Outdoor humidity (%) | +| `humidityin` | Indoor humidity (%) | +| `baromrelin` | Relative barometric pressure (inHg) | +| `baromabsin` | Absolute barometric pressure (inHg) | +| `winddir` | Wind direction (°) | +| `windspeedmph` | Wind speed (mph) | +| `windgustmph` | Wind gust (mph) | +| `maxdailygust` | Max daily gust (mph) | +| `solarradiation` | Solar radiation (W/m²) | +| `uv` | UV index | +| `rrain_piezo` | Rain rate piezo (in/hr) | +| `drain_piezo` | Daily rain piezo (in) | +| `wrain_piezo` | Weekly rain piezo (in) | +| `mrain_piezo` | Monthly rain piezo (in) | +| `yrain_piezo` | Yearly rain piezo (in) | +| `soilmoisture1`–`8` | Soil moisture channels (%) | +| `soilad1`–`8` | Soil admittance channels (mS) | +| `lightning_num` | Lightning strike count | +| `lightning` | Lightning distance | +| `lightning_time` | Last lightning strike time | +| `tempf1`–`8` | Extra temperature channels (°F) | +| `humidity1`–`8` | Extra humidity channels (%) | +| `tf_co2` | CO2 sensor temperature (°F) | +| `humi_co2` | CO2 sensor humidity (%) | +| `pm25_co2` | CO2 sensor PM2.5 (µg/m³) | +| `co2` | CO2 concentration (ppm) | +| `wh65batt`, `wh80batt`, `wh90batt`, `wh57batt`, `co2_batt`, etc. | Battery states | +| `ws90cap_volt` | WS90 capacitor voltage | + +All imperial values are automatically converted to metric when `controller.unit` is set to `"metric"` (default). + +**Response:** `200 OK`, `400 Bad Request`, or `500 Internal Server Error` + +## Outbound — Gateway Subdevice Polling + +The controller polls Ecowitt gateways via their local HTTP API to retrieve subdevice data. This runs as a background service on the configured `ecowitt.pollingInterval`. + +### `GET http:///get_iot_device_list` + +Retrieves the list of connected IoT subdevices (AC1100, WFC01, WFC02). + +**Response structure:** +```json +{ + "command": [ + { + "id": 12345, + "model": 1, + "ver": 15, + "rfnet_state": 1, + "battery": 4, + "signal": 3 + } + ] +} +``` + +| Field | Description | +|-------|-------------| +| `id` | Unique subdevice identifier | +| `model` | Subdevice model: `1` = WFC01, `2` = AC1100, `3` = WFC02 | +| `ver` | Firmware version | +| `rfnet_state` | RF network state (`1` = available) | +| `battery` | Battery level | +| `signal` | Signal strength | + +### `POST http:///parse_quick_cmd_iot` + +Reads detailed subdevice data or sends commands. + +**Read device request:** +```json +{ + "command": [ + { "cmd": "read_device", "id": 12345, "model": 1 } + ] +} +``` + +**Response structure (varies by model):** +```json +{ + "command": [ + { + "devicename": "WFC01", + "nickname": "Garden Valve", + "water_status": "0", + "water_running": "0", + "water_total": "1234", + "flow_velocity": "0", + "water_temp": "65.3", + "wfc01batt": "4", + "gw_rssi": "-45", + ... + } + ] +} +``` + +Sensor properties are mapped through `SensorBuilder` (see `Model/Mapping/SensorBuilder.cs`) and vary per subdevice model. + +**Quick run command (not yet active):** +```json +{ + "command": [ + { + "cmd": "quick_run", + "id": 12345, + "model": 1, + "val": 20, + "val_type": 1, + "on_type": 0, + "off_type": 0, + "always_on": 1, + "on_time": 0, + "off_time": 0 + } + ] +} +``` + +**Quick stop command (not yet active):** +```json +{ + "command": [ + { "cmd": "quick_stop", "id": 12345, "model": 1 } + ] +} +``` + +> Note: Subdevice command sending is currently commented out in the Dispatcher. The infrastructure is in place but not wired up. \ No newline at end of file diff --git a/docs/mqtt.md b/docs/mqtt.md index 1436e66..560ad2c 100644 --- a/docs/mqtt.md +++ b/docs/mqtt.md @@ -1,13 +1,173 @@ -# Topic definition +# MQTT Topics -## Listen +All topics are prefixed with the configured base topic (default: `ecowitt`). Names are sanitized to lowercase with spaces replaced by hyphens. -- Topic: `homeassistant/status` +## Published Topics -## Publish +### Heartbeat +- **`/heartbeat`** — Published every 30 seconds while MQTT is connected. + ```json + { "service": "2024-01-15T12:00:00Z" } + ``` -- Topic: `ec/` -- Topic: `ec//status` -- Topic: `ec/heartbeat` +### Gateway +- **`/`** — Gateway info, published on first discovery and full updates. + ```json + { + "ip": "192.168.1.101", + "name": "weatherstation-01", + "model": "GW2000A", + "passkey": "...", + "stationType": "GW2000A_V3.1.3", + "runtime": 123456, + "state": "online", + "freq": "868M" + } + ``` +- **`//availability`** — Gateway availability. Payload is plain text `online` or `offline` (offline if no data received for 5 minutes). +### Gateway Sensors +- **`//sensors/`** — Sensor data for measurement sensors. + ```json + { "name": "tempinf", "alias": "Indoor Temperature", "value": 22.5, "unit": "°C" } + ``` + +- **`//diag/`** — Sensor data for diagnostic sensors (batteries, RSSI, etc.). Same payload format as above. + +### Subdevices +- **`//subdevices/`** — Subdevice info. + ```json + { + "id": 12345, + "model": 1, + "devicename": "WFC01", + "nickname": "Garden Valve", + "state": "online", + "ver": 15 + } + ``` + +- **`//subdevices//availability`** — Subdevice availability (`online`/`offline`). + +### Subdevice Sensors +- **`//subdevices//sensors/`** — Measurement sensors. +- **`//subdevices//diag/`** — Diagnostic sensors. + +Same payload format as gateway sensors. + +## Subscribed Topics + +### Home Assistant Status +- **`homeassistant/status`** — Listens for `online`/`offline`. On `online`, all gateways and subdevices are re-emitted so HA picks them up after restart. + +### Subdevice Commands (direct MQTT) +- **`/+/subdevices/+/cmd`** — Accepts JSON commands for subdevice control. + ```json + { "cmd": 0, "id": 12345, "duration": 20, "unit": 1, "alwaysOn": false } + ``` + `cmd`: `0` = Start, `1` = Stop. `unit`: `0` = Seconds, `1` = Minutes, `2` = Hours, `3` = Liters. + +### Subdevice Commands (Home Assistant) +- **`/+/subdevices/+/cmd/homeassistant`** — Simplified command topic for HA switches. Payload is plain text `ON` or `OFF`. + +## Home Assistant Discovery + +When `controller.homeassistantdiscovery` is enabled, discovery configs are published as retained messages under the `homeassistant/` prefix. + +### Discovery Topic Pattern +- **`homeassistant/sensor//config`** — Gateway & subdevice availability entities +- **`homeassistant/sensor/_/config`** — Sensor entities +- **`homeassistant/binary_sensor/_/config`** — Binary sensor entities (rain state, running state, etc.) +- **`homeassistant/switch//config`** — Switch entities for controllable subdevices + +### Discovery Payload Structure + +**Gateway device:** +```json +{ + "device": { + "identifiers": ["ec_weatherstation-01"], + "name": "weatherstation-01", + "model": "GW2000A", + "manufacturer": "Ecowitt", + "hw_version": "GW2000A", + "sw_version": "GW2000A_V3.1.3" + }, + "origin": { + "name": "Ecowitt Controller", + "sw": "v2.0.0", + "url": "https://github.com/mplogas/ecowitt-controller" + }, + "name": "Availability", + "unique_id": "ec_weatherstation-01_availability", + "object_id": "ec_weatherstation-01_availability", + "availability_topic": "/weatherstation-01/availability", + "state_topic": "/weatherstation-01/availability", + "retain": false, + "qos": 1 +} +``` + +**Subdevice device** (linked to gateway via `via_device`): +```json +{ + "device": { + "identifiers": ["ec_garden-valve"], + "name": "Garden Valve", + "model": "1", + "manufacturer": "Ecowitt", + "hw_version": "1", + "sw_version": "15", + "via_device": "ec_weatherstation-01" + }, + ... +} +``` + +**Sensor entity:** +```json +{ + "device": { "..." }, + "origin": { "..." }, + "name": "Indoor Temperature", + "unique_id": "ec_weatherstation-01_tempinf_temperature", + "object_id": "ec_weatherstation-01_tempinf_temperature", + "device_class": "temperature", + "state_topic": "/weatherstation-01/sensors/indoor-temperature", + "value_template": "{{ value_json.value }}", + "unit_of_measurement": "°C", + "retain": false, + "qos": 1 +} +``` + +**Binary sensor entity:** +```json +{ + "value_template": "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}", + ... +} +``` + +**Switch entity (subdevice toggle):** +```json +{ + "name": "switch", + "state_topic": "//subdevices//diag/running", + "command_topic": "//subdevices//cmd/homeassistant", + "value_template": "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}", + ... +} +``` + +### Identifier Format +- Device identifiers: `ec_` (sanitized, lowercase, hyphens) +- Entity unique IDs: `ec__` (e.g. `ec_weatherstation-01_availability`) +- Sensor entity IDs: `ec___` (e.g. `ec_weatherstation-01_tempinf_temperature`) + +### Device Class Mapping +Sensor types are mapped to Home Assistant device classes (see `DiscoveryBuilder.BuildDeviceCategory`). Examples: `temperature`, `humidity`, `pressure`, `battery`, `wind_speed`, `precipitation`, `voltage`, `current`, `power`, `signal_strength`, etc. + +### Entity Categories +- **Diagnostic** sensors (batteries, RSSI, heap, runtime, etc.) are published with `"entity_category": "diagnostic"` so they appear under device diagnostics in HA rather than as primary entities. diff --git a/old/Discovery/DiscoveryPublishService.cs b/old/Discovery/DiscoveryPublishService.cs deleted file mode 100644 index 57cb4d6..0000000 --- a/old/Discovery/DiscoveryPublishService.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System.Text.Encodings.Web; -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Mqtt; -using Microsoft.Extensions.Options; -using System.Text.Json; -using System.Text.Json.Serialization; -using Ecowitt.Controller.Model.Discovery; -using Device = Ecowitt.Controller.Model.Device; -using Ecowitt.Controller.Service.Orchestrator; - -namespace Ecowitt.Controller.Discovery; - -public class DiscoveryPublishService : BackgroundService -{ - private readonly ILogger _logger; - private readonly MqttOptions _mqttOptions; - private readonly IDeviceStore _store; - private readonly IMqttClient _mqttClient; - private readonly ControllerOptions _controllerOptions; - - private readonly Origin _origin; - - public DiscoveryPublishService(IOptions mqttOptions, IOptions controllerOption, - ILogger logger, IMqttClient mqttClient, - IDeviceStore deviceStore) - { - _logger = logger; - _mqttOptions = mqttOptions.Value; - _controllerOptions = controllerOption.Value; - _store = deviceStore; - _mqttClient = mqttClient; - _origin = DiscoveryBuilder.BuildOrigin(); - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (!_controllerOptions.HomeAssistantDiscovery) - { - _logger.LogInformation("Home Assistant discovery is disabled"); - return; - } - - _logger.LogInformation("Starting DiscoveryPublishService"); - - using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(_controllerOptions.PublishingInterval)); - try - { - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - foreach (var gw in _store.GetGatewaysShort().Select(gwKvp => _store.GetGateway(gwKvp.Key)).OfType()) - { - if (gw.DiscoveryUpdate) - { - gw.DiscoveryUpdate = false; - await PublishGatewayDiscovery(gw); - } - - foreach (var sensor in gw.Sensors.Where(sensor => sensor.DiscoveryUpdate)) - { - sensor.DiscoveryUpdate = false; - await PublishSensorDiscovery(gw, sensor); - } - - foreach (var subdevice in gw.Subdevices) - { - if (subdevice.DiscoveryUpdate) - { - subdevice.DiscoveryUpdate = false; - await PublishSubdeviceDiscovery(gw, subdevice); - await PublishSubdeviceSwitchDiscovery(gw, subdevice); - } - - foreach (var sensor in subdevice.Sensors.Where(s => s.DiscoveryUpdate)) - { - sensor.DiscoveryUpdate = false; - await PublishSensorDiscovery(gw, subdevice, sensor); - } - } - } - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("Stopping MqttService"); - } - } - - - - - private async Task PublishGatewayDiscovery(Device gw) - { - var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType??"unknown"); - var id = DiscoveryBuilder.BuildIdentifier(gw.Name, "availability"); - //var statetopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewayTopic(gw.Name)}"; - var availabilityTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewayTopic(gw.Name)}/availability"; - - var config = DiscoveryBuilder.BuildGatewayConfig(device, _origin, "Availability", id, availabilityTopic, availabilityTopic); - - await PublishMessage(Helper.Sanitize($"sensor/{gw.Name}"), config); - - } - private async Task PublishSubdeviceDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice) - { - var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); - var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "availability"); - //var statetopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}"; - var availabilityTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/availability"; - - var config = DiscoveryBuilder.BuildGatewayConfig(device, _origin, "Availability", id, availabilityTopic, availabilityTopic); - - await PublishMessage(Helper.Sanitize($"sensor/{subdevice.Nickname}"), config); - } - - private async Task PublishSubdeviceSwitchDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice) - { - var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); - var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "switch"); - var statetopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/diag/running"; - var valueTemplate = "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}"; - var cmdTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic(gw.Name, subdevice.Id.ToString())}"; - - var config = - DiscoveryBuilder.BuildSwitchConfig(device, _origin, "switch", id, statetopic, cmdTopic, valueTemplate: valueTemplate); - - await PublishMessage(Helper.Sanitize($"switch/{subdevice.Nickname}"), config); - } - - private async Task PublishSensorDiscovery(Device gw, ISensor sensor) - { - var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType ?? "unknown"); - - var statetopic = sensor.SensorCategory == SensorCategory.Diagnostic ? $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewayDiagnosticTopic(gw.Name, sensor.Alias)}" : $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewaySensorTopic(gw.Name, sensor.Alias)}"; - var availabilityTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewayTopic(gw.Name)}/availability"; - await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); - } - - private async Task PublishSensorDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice, ISensor sensor) - { - var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); - var statetopic = sensor.SensorCategory == SensorCategory.Diagnostic ? $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceDiagnosticTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}" : $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceSensorTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}"; - var availabilityTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/availability"; - await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); - } - - private async Task PublishSensorDiscovery(Model.Discovery.Device device, ISensor sensor, string statetopic, string availabilityTopic) - { - var id = DiscoveryBuilder.BuildIdentifier($"{device.Name}_{sensor.Name}", sensor.SensorType.ToString()); - var category = DiscoveryBuilder.BuildDeviceCategory(sensor.SensorType); - - var valueTemplate = sensor.SensorClass == SensorClass.BinarySensor - ? "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}" - : "{{ value_json.value }}"; - //var valueTemplate = "{{ value_json.value }}"; - - var config = sensor.SensorCategory == SensorCategory.Diagnostic - ? DiscoveryBuilder.BuildSensorConfig(device, _origin, sensor.Alias, id, category, statetopic, valueTemplate: valueTemplate, unitOfMeasurement: sensor.UnitOfMeasurement, sensorCategory: sensor.SensorCategory.ToString().ToLower(), isBinarySensor: sensor.SensorClass == SensorClass.BinarySensor) - : DiscoveryBuilder.BuildSensorConfig(device, _origin, sensor.Alias, id, category, statetopic, valueTemplate: valueTemplate, unitOfMeasurement: sensor.UnitOfMeasurement, isBinarySensor: sensor.SensorClass == SensorClass.BinarySensor); - - var sensorClassTopic = BuildSensorClassTopic(sensor.SensorClass); - - await PublishMessage(Helper.Sanitize($"{sensorClassTopic}/{device.Name}_{sensor.Name}"), config); - } - - private async Task PublishMessage(string topic, Config config) - { - topic = $"homeassistant/{topic}/config"; - - if(config.DeviceClass != null && config.DeviceClass.Equals("none", StringComparison.InvariantCultureIgnoreCase)) config.DeviceClass = null; - - var payload = JsonSerializer.Serialize(config, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping}); - - _logger.LogDebug($"Topic: {topic}"); - _logger.LogDebug($"Payload: {payload}"); - - if (!await _mqttClient.Publish(topic, payload)) - _logger.LogWarning($"Failed to publish message to topic {topic}. Is the client connected?"); - } - - private string BuildSensorClassTopic(SensorClass sc) - { - return sc switch - { - SensorClass.BinarySensor => "binary_sensor", - _ => "sensor" - }; - } -} diff --git a/old/Mqtt/Helper.cs b/old/Mqtt/Helper.cs deleted file mode 100644 index 0e9a2d8..0000000 --- a/old/Mqtt/Helper.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Text; - -namespace Ecowitt.Controller.Mqtt; - -public static class Helper -{ - public static string BuildMqttGatewayTopic(string gwName) - { - return Sanitize(gwName); - } - - public static string BuildMqttGatewaySensorTopic(string gwName, string sensorName) - { - return Sanitize($"{gwName}/sensors/{sensorName}"); - } - - public static string BuildMqttGatewayDiagnosticTopic(string gwName, string sensorName) - { - return Sanitize($"{gwName}/diag/{sensorName}"); - } - - public static string BuildMqttSubdeviceTopic(string gwName, string subdeviceName) - { - return Sanitize($"{gwName}/subdevices/{subdeviceName}"); - } - - public static string BuildMqttSubdeviceSensorTopic(string gwName, string subdeviceName, string sensorName) - { - return Sanitize($"{gwName}/subdevices/{subdeviceName}/sensors/{sensorName}"); - } - - - public static string BuildMqttSubdeviceDiagnosticTopic(string gwName, string subdeviceName, string sensorName) - { - return Sanitize($"{gwName}/subdevices/{subdeviceName}/diag/{sensorName}"); - } - - public static string BuildMqttSubdeviceCommandTopic(string gwName, string subdeviceName) - { - return Sanitize($"{gwName}/subdevices/{subdeviceName}/cmd"); - } - - public static string BuildMqttSubdeviceCommandTopic() - { - return Sanitize($"+/subdevices/+/cmd"); - } - - public static string BuildMqttSubdeviceHACommandTopic(string gwName, string subdeviceName) - { - return Sanitize($"{gwName}/subdevices/{subdeviceName}/cmd/homeassistant"); - } - - public static string BuildMqttSubdeviceHACommandTopic() - { - return Sanitize($"+/subdevices/+/cmd/homeassistant"); - } - - public static string Sanitize(string input) - { - return input.Replace(' ', '-').ToLowerInvariant(); - } -} \ No newline at end of file diff --git a/old/Mqtt/MqttClient.cs b/old/Mqtt/MqttClient.cs deleted file mode 100644 index 21d0327..0000000 --- a/old/Mqtt/MqttClient.cs +++ /dev/null @@ -1,153 +0,0 @@ -using Ecowitt.Controller.Configuration; -using Microsoft.Extensions.Options; -using MQTTnet; -using MQTTnet.Client; - -namespace Ecowitt.Controller.Mqtt; - -public interface IMqttClient : IDisposable -{ - Task Connect(); - Task Disconnect(); - Task Subscribe(string topic); - Task Publish(string topic, string message); - - event EventHandler? OnClientConnected; - event EventHandler? OnClientDisconnected; - event EventHandler? OnMessageReceived; -} - -public class MqttMessageReceivedEventArgs : EventArgs -{ - public MqttMessageReceivedEventArgs(string payload, string topic, string clientId) - { - Payload = payload; - Topic = topic; - ClientId = clientId; - } - - public string Payload { get; } - public string Topic { get; } - public string ClientId { get; } -} - -public class MqttClient : IMqttClient -{ - private readonly MQTTnet.Client.IMqttClient _client; - private readonly ILogger _logger; - public event EventHandler? OnClientConnected; - public event EventHandler? OnClientDisconnected; - public event EventHandler? OnMessageReceived; - - private readonly MqttOptions _options; - - - public MqttClient(ILogger logger, MqttFactory factory, IOptions options) - { - _logger = logger; - _options = options.Value; - _client = factory.CreateMqttClient(); - _client.ConnectedAsync += ClientOnConnectedAsync; - _client.DisconnectedAsync += ClientOnDisconnectedAsync; - _client.ApplicationMessageReceivedAsync += ClientOnApplicationMessageReceivedAsync; - } - - public async Task Connect() - { - if (!_client.IsConnected) - { - var optionsBuilder = new MqttClientOptionsBuilder() - .WithTcpServer(_options.Host, _options.Port) - .WithClientId(_options.ClientId) - .WithCleanSession(); - // allowing empty passwords - if (!string.IsNullOrWhiteSpace(_options.User)/* && !string.IsNullOrWhiteSpace(_options.Password)*/) - optionsBuilder.WithCredentials(_options.User, _options.Password); - - if (!_client.IsConnected) await _client.ConnectAsync(optionsBuilder.Build()); - } - else _logger.LogWarning("Can't connect. Client already connected."); - } - - public async Task Disconnect() - { - if (_client.IsConnected) await _client.DisconnectAsync(); - else _logger.LogWarning("Can't disconnect. Client not connected."); - } - - public async Task Subscribe(string topic) - { - var topicFilter = new MqttTopicFilterBuilder().WithTopic(topic).Build(); - - if (_client.IsConnected) await _client.SubscribeAsync(topicFilter); - else _logger.LogWarning($"Can't subscribe to topic {topic}. Client not connected."); - } - - public async Task Publish(string topic, string message) - { - var mqttPayload = new MqttApplicationMessageBuilder() - .WithTopic(topic) - .WithPayload(message) - .Build(); - - if (_client.IsConnected) - { - var result = await _client.PublishAsync(mqttPayload); - if (result.IsSuccess) - { - _logger.LogDebug($"Message {message} published to topic {topic}"); - return true; - } - else _logger.LogWarning($"Failed to publish message {message} to topic {topic}. Reason: {result.ReasonCode}"); - } - else _logger.LogWarning($"Can't publish message {message} to topic {topic}. Client not connected."); - return false; - } - - private Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) - { - var payload = arg.ApplicationMessage.ConvertPayloadToString(); - _logger.LogDebug($"Message received for topic {arg.ApplicationMessage.Topic}: {payload}"); - - OnMessageReceived?.Invoke(this, - new MqttMessageReceivedEventArgs(payload, arg.ApplicationMessage.Topic, arg.ClientId)); - - return Task.CompletedTask; - } - - private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg) - { - _logger.LogInformation($"MQTT client disconnected. Reason: {arg.Reason}"); - OnClientDisconnected?.Invoke(this, EventArgs.Empty); - - if (_options.Reconnect) - { - for (var i = 0; i < _options.ReconnectAttempts; i++) - { - try - { - await Connect(); - break; - } - catch (Exception e) - { - _logger.LogError(e, $"Reconnect attempt {i + 1} failed."); - } - } - } - else _logger.LogWarning("Reconnect is disabled, no reconnect attempt"); - } - - private Task ClientOnConnectedAsync(MqttClientConnectedEventArgs arg) - { - _logger.LogInformation("MQTT client connected."); - OnClientConnected?.Invoke(this, EventArgs.Empty); - return Task.CompletedTask; - } - - public void Dispose() - { - if (_client.IsConnected) _client.DisconnectAsync().GetAwaiter().GetResult(); - _client.Dispose(); - } -} \ No newline at end of file diff --git a/old/Mqtt/MqttService.cs b/old/Mqtt/MqttService.cs deleted file mode 100644 index 649a9a1..0000000 --- a/old/Mqtt/MqttService.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Microsoft.Extensions.Options; -using SlimMessageBus; -using System.Text.Json; -using Ecowitt.Controller.Model.Api; - -namespace Ecowitt.Controller.Mqtt; - -public class MqttService : BackgroundService -{ - private const string CmdTopic = "cmd"; - private const string HeartbeatTopic = "heartbeat"; - private readonly ILogger _logger; - private readonly IMessageBus _messageBus; - private readonly IMqttClient _mqttClient; - private readonly MqttOptions _mqttConfig; - private readonly ControllerOptions _controllerConfig; - - public MqttService(IOptions mqttConfig, IOptions controllerConfig, ILogger logger, IMqttClient mqttClient, - IMessageBus messageBus) - { - _logger = logger; - _mqttConfig = mqttConfig.Value; - _controllerConfig = controllerConfig.Value; - _messageBus = messageBus; - - _mqttClient = mqttClient; - _mqttClient.OnMessageReceived += OnMessageReceived; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Starting MqttService"); - - await Connect(); - - using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); - try - { - while(await timer.WaitForNextTickAsync(stoppingToken)) - { - // because i can't figure out how to escape the special chars for the heartbeat payload :( - await _mqttClient.Publish($"{_mqttConfig.BaseTopic}/{HeartbeatTopic}", JsonSerializer.Serialize(new { service = DateTime.UtcNow })); - _logger.LogInformation("Sent heartbeat"); - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("Stopping MqttService"); - } - } - - public override void Dispose() - { - _mqttClient.Dispose(); - } - - //public async Task StopAsync(CancellationToken cancellationToken) - //{ - // await _mqttClient.Disconnect(); - // _logger.LogInformation("Disconnected"); - - // await base.StopAsync(cancellationToken); - //} - - private async void OnMessageReceived(object? sender, MqttMessageReceivedEventArgs e) - { - _logger.LogDebug("Received message on topic {Topic} with payload {Payload}", e.Topic, e.Payload); - if (e.Topic.EndsWith("homeassistant")) - { - if (int.TryParse(e.Topic.Split('/')[3], out var result)) - { - var cmd = e.Payload.Equals("ON", StringComparison.InvariantCultureIgnoreCase) ? Command.Start : Command.Stop; //I know, everything that's not "ON" is "OFF" - await _messageBus.Publish(new SubdeviceApiCommand() { Cmd = cmd, Id = result }); - } - else - { - _logger.LogWarning("Invalid subdevice id in topic {Topic}", e.Topic); - } - } - else - { - var cmd = JsonSerializer.Deserialize(e.Payload); - await _messageBus.Publish(cmd); - } - - } - - private async Task Connect() - { - await _mqttClient.Connect(); - _logger.LogInformation("Connected"); - - await _mqttClient.Subscribe($"{_mqttConfig.BaseTopic}/{Helper.BuildMqttSubdeviceCommandTopic()}"); - if (_controllerConfig.HomeAssistantDiscovery) - { - await _mqttClient.Subscribe($"{_mqttConfig.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); - } - _logger.LogInformation("subscribed to all topics"); - } -} \ No newline at end of file diff --git a/old/Store/DataPublishService.cs b/old/Store/DataPublishService.cs deleted file mode 100644 index d1533e8..0000000 --- a/old/Store/DataPublishService.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System.Text.Encodings.Web; -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Mqtt; -using Microsoft.Extensions.Options; -using System.Text.Json; -using System.Text.Json.Serialization; -using Ecowitt.Controller.Service.Orchestrator; - -namespace Ecowitt.Controller.Store; - -public class DataPublishService : BackgroundService -{ - private readonly ILogger _logger; - private readonly MqttOptions _mqttOptions; - private readonly IDeviceStore _store; - private readonly IMqttClient _mqttClient; - private readonly ControllerOptions _controllerOptions; - - public DataPublishService(IOptions mqttOptions, IOptions controllerOption, ILogger logger, IMqttClient mqttClient, - IDeviceStore deviceStore) - { - _logger = logger; - _mqttOptions = mqttOptions.Value; - _controllerOptions = controllerOption.Value; - _store = deviceStore; - _mqttClient = mqttClient; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Starting MqttPublishService"); - - using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(_controllerOptions.PublishingInterval)); - try - { - while(await timer.WaitForNextTickAsync(stoppingToken)) - { - foreach (var gwKvp in _store.GetGatewaysShort()) - { - var gw = _store.GetGateway(gwKvp.Key); - if(gw == null) continue; - - // to sent messages according to ideas outlines in mqtt.md the following approach could work - // 1. send gateway state & hw_info to base// as json payload - // 2. send gateway sensors to base//sensors/ as json payload - // 3. send subdevice state & hw_info to base// as json payload - // 4. send subdevice sensors to base///sensors/ as json payload - - // note: HA Discovery seems to break for mqtt topics with spaces in them - - var payload = BuildGatewayPayload(gw); - - await PublishMessage(Helper.BuildMqttGatewayTopic(gw.Name), payload); - await PublishAvailabilityMessage(Helper.BuildMqttGatewayTopic(gw.Name), gw.TimestampUtc); - - foreach (var sensor in gw.Sensors) - { - payload = BuildSensorPayload(sensor); - var topic = sensor.SensorCategory == SensorCategory.Diagnostic ? Helper.BuildMqttGatewayDiagnosticTopic(gw.Name, sensor.Alias) : Helper.BuildMqttGatewaySensorTopic(gw.Name, sensor.Alias); - await PublishMessage(topic, payload); - - } - - if (gw.Subdevices.Count == 0) continue; - foreach (var subdevice in gw.Subdevices) - { - payload = BuildSubdevicePayload(subdevice); - await PublishMessage(Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString()), payload); - await PublishAvailabilityMessage(Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString()), subdevice.TimestampUtc); - - foreach (var sensor in subdevice.Sensors) - { - payload = BuildSensorPayload(sensor); - var topic = sensor.SensorCategory == SensorCategory.Diagnostic ? Helper.BuildMqttSubdeviceDiagnosticTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias) : Helper.BuildMqttSubdeviceSensorTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias); - await PublishMessage(topic, payload); - } - } - } - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("Stopping MqttService"); - } - } - - private dynamic BuildSubdevicePayload(Model.Subdevice subdevice) - { - return new - { - id = subdevice.Id, - model = subdevice.Model, - devicename = subdevice.Devicename, - nickname = subdevice.Nickname, - state = subdevice.Availability ? "online" : "offline", - ver = subdevice.Version - }; - } - - private dynamic BuildSensorPayload(ISensor s) - { - _logger.LogDebug($"Sensor {s.Name} datatype: {s.DataType}"); - return new - { - name = s.Name, - alias = s.Alias, - value = s.DataType == typeof(double) ? Math.Round(Convert.ToDouble(s.Value), _controllerOptions.Precision) : s.Value, - unit = !string.IsNullOrWhiteSpace(s.UnitOfMeasurement) ? s.UnitOfMeasurement : null - //type = s.SensorType != SensorType.None ? s.SensorType.ToString() : null - }; - } - - private dynamic BuildGatewayPayload(Device gw) - { - if (string.IsNullOrWhiteSpace(gw.Model)) - { - return new - { - ip = gw.IpAddress, - name = gw.Name - }; - } - - return new - { - ip = gw.IpAddress, - name = gw.Name, - model = gw.Model, - passkey = gw.PASSKEY, - stationType = gw.StationType, - runtime = gw.Runtime, - state = (DateTime.UtcNow - gw.TimestampUtc).TotalSeconds < _controllerOptions.PublishingInterval * 3 ? "online" : "offline", - freq = gw.Freq - }; - } - - private async Task PublishMessage(string topic, dynamic payload) - { - if (!await _mqttClient.Publish($"{_mqttOptions.BaseTopic}/{topic}", - JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping}))) - _logger.LogWarning($"Failed to publish message to topic {_mqttOptions.BaseTopic}/{topic}. Is the client connected?"); - } - - private async Task PublishAvailabilityMessage(string topic, DateTime timestamp) - { - var available = DateTime.UtcNow.Subtract(timestamp) < TimeSpan.FromSeconds(300) ? "online" : "offline"; - - if (!await _mqttClient.Publish($"{_mqttOptions.BaseTopic}/{topic}/availability", available)) - _logger.LogWarning($"Failed to publish message to topic {_mqttOptions.BaseTopic}/{topic}. Is the client connected?"); - } -} \ No newline at end of file diff --git a/old/Subdevice/SubdeviceService.cs b/old/Subdevice/SubdeviceService.cs deleted file mode 100644 index e37409a..0000000 --- a/old/Subdevice/SubdeviceService.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System.Text.Json; -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Message; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Model.Api; -using Ecowitt.Controller.Service.Orchestrator; -using Microsoft.Extensions.Options; -using SlimMessageBus; - -namespace Ecowitt.Controller.Subdevice; - -public class SubdeviceService : BackgroundService -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger _logger; - private readonly IMessageBus _messageBus; - private readonly EcowittOptions _options; - private readonly IDeviceStore _store; - - public SubdeviceService(ILogger logger, IMessageBus messageBus, - IHttpClientFactory httpClientFactory, IDeviceStore store, IOptions options) - { - _logger = logger; - _messageBus = messageBus; - _httpClientFactory = httpClientFactory; - _store = store; - _options = options.Value; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Starting SubdeviceService"); - - using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.PollingInterval)); - try - { - while(await timer.WaitForNextTickAsync(stoppingToken)) - { - await SubDevicePolling(stoppingToken, _options.AutoDiscovery); - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("Stopping SubdeviceService"); - } - } - - private async Task SubDevicePolling(CancellationToken cancellationToken, bool autoDiscovery = true) - { - var subdevices = new SubdeviceApiAggregate(); - if (autoDiscovery) - { - // TODO: remove reference to _store and replace it with req/resp through smb - // TODO: GWxx are Ecowitt models, what about Froggit etc? - foreach (var gwKvp in _store.GetGatewaysShort().Where(kvp => kvp.Value.StartsWith("GW12") || kvp.Value.StartsWith("GW20"))) - { - subdevices.Subdevices.AddRange(await GetSubdeviceData(gwKvp.Key, cancellationToken)); - } - } - else - { - foreach (var gateway in _options.Gateways) - { - subdevices.Subdevices.AddRange(await GetSubdeviceData(gateway.Ip, cancellationToken)); - } - } - - if(subdevices.Subdevices.Count > 0) await _messageBus.Publish(subdevices, cancellationToken: cancellationToken); - } - - private async Task> GetSubdeviceData(string ipAddress, CancellationToken cancellationToken) - { - _logger.LogInformation($"Polling subdevices from {ipAddress}"); - var subdevices = new List(); - try - { - subdevices.AddRange(await GetSubdevicesOverview(ipAddress, cancellationToken)); - foreach (var subdevice in subdevices) - { - var payload = await GetSubDeviceApiPayload(ipAddress, subdevice.Id, subdevice.Model, cancellationToken); - subdevice.Payload = payload; - await Task.Delay(350, cancellationToken); // delay to not overload the gateway - } - } - catch (Exception e) - { - _logger.LogError(e, $"Exception while trying to get subdevices from {ipAddress}"); - } - - return subdevices; - - } - - private async Task > GetSubdevicesOverview(string ipAddress, CancellationToken cancellationToken) - { - var subdevices = new List(); - using var client = CreateHttpClient(ipAddress); - - try - { - var response = await client.GetAsync("get_iot_device_list", cancellationToken); - if (response.IsSuccessStatusCode) - { - var content = await response.Content.ReadAsStringAsync(); - var jsonDocument = JsonDocument.Parse(content); - var elements = jsonDocument.RootElement.GetProperty("command"); - foreach (var element in elements.EnumerateArray()) - { - - var subdevice = new SubdeviceApiData - { - Id = element.GetProperty("id").GetInt32(), - Model = element.GetProperty("model").GetInt32(), - Version = element.GetProperty("ver").GetInt32(), - RfnetState = element.GetProperty("rfnet_state").GetInt32(), - Battery = element.GetProperty("battery").GetInt32(), - Signal = element.GetProperty("signal").GetInt32(), - GwIp = ipAddress, - TimestampUtc = DateTime.UtcNow - }; - _logger.LogInformation($"Subdevice: {subdevice.Id} ({subdevice.Model})"); - subdevices.Add(subdevice); - } - } - else - { - _logger.LogWarning($"Failed to get subdevices from {ipAddress}"); - } - } - catch (Exception e) - { - _logger.LogError(e, $"Exception while trying to get subdevices from {ipAddress}"); - } - - - return subdevices; - } - - private async Task GetSubDeviceApiPayload(string ipAddress, int subdeviceId, int model, CancellationToken cancellationToken) - { - using var client = CreateHttpClient(ipAddress); - try - { - var payload = new { command = new[] { new { cmd = "read_device", id = subdeviceId, model } } }; - var sContent = new StringContent(JsonSerializer.Serialize(payload)); - var response = await client.PostAsync("parse_quick_cmd_iot", sContent, cancellationToken); - if (response.IsSuccessStatusCode) - { - return await response.Content.ReadAsStringAsync(cancellationToken); - } - else - { - _logger.LogWarning($"Could not get payload from {ipAddress} for subdevice {subdeviceId}"); - } - } - catch (Exception e) - { - _logger.LogError(e, $"Exception while trying to get payload from {ipAddress} for subdevice {subdeviceId}"); - } - - return string.Empty; - } - - private HttpClient CreateHttpClient(string ipAddress) - { - var client = _httpClientFactory.CreateClient("ecowitt-client"); - client.BaseAddress = new Uri($"http://{ipAddress}"); - - var username = _options.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Username; - var password = _options.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Password; - if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) - { - //TODO: authentication header, need to test - //client.DefaultRequestHeaders.Add(); - } - return client; - } -} \ No newline at end of file diff --git a/src/EcoWitt.Controller.Tests/ApiDataExtensionTest.cs b/src/EcoWitt.Controller.Tests/ApiDataExtensionTest.cs new file mode 100644 index 0000000..33b8d00 --- /dev/null +++ b/src/EcoWitt.Controller.Tests/ApiDataExtensionTest.cs @@ -0,0 +1,215 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Mapping; + +namespace EcoWitt.Controller.Tests; + +public class ApiDataExtensionTest +{ + private const string GatewayPayload = "[{\"name\":\"tempinf\",\"value\":\"72.5\"},{\"name\":\"humidityin\",\"value\":\"45\"},{\"name\":\"baromrelin\",\"value\":\"29.92\"},{\"name\":\"windspeedmph\",\"value\":\"5.5\"},{\"name\":\"winddir\",\"value\":\"180\"},{\"name\":\"uv\",\"value\":\"3\"}]"; + + private const string WFC01Payload = "{\"command\":[{\"model\":1,\"id\":13398,\"nickname\":\"WFC01-00003456\",\"devicename\":\"MJULtW6rvT1I8dEKz3o2\",\"version\":113,\"water_status\":0,\"warning\":0,\"always_on\":0,\"val_type\":1,\"val\":15,\"run_time\":24,\"wfc01batt\":5,\"rssi\":4,\"gw_rssi\":-52,\"timeutc\":1721337849,\"publish_time\":1719251738,\"water_action\":4,\"water_running\":0,\"plan_status\":0,\"water_total\":\"617.557\",\"happen_water\":\"614.258\",\"flow_velocity\":\"0.00\",\"water_temp\":\"18.8\"}]}"; + + private const string AC1100Payload = "{\"command\":[{\"model\":2,\"id\":10695,\"nickname\":\"AC1100-000029C7\",\"devicename\":\"xTNGzWMorVwEKqvltP30\",\"version\":103,\"ac_status\":1,\"warning\":0,\"always_on\":1,\"val_type\":1,\"val\":3,\"run_time\":0,\"rssi\":3,\"gw_rssi\":-64,\"timeutc\":1721419571,\"publish_time\":1721419571,\"ac_action\":3,\"ac_running\":1,\"plan_status\":1,\"elect_total\":14821,\"happen_elect\":0,\"realtime_power\":0,\"ac_voltage\":232,\"ac_current\":0}]}"; + + [Test] + public void MapGateway_Metric_ConvertsUnits() + { + var data = new GatewayApiData + { + PASSKEY = "ABC123", + Model = "GW2000A", + StationType = "GW2000A_V3.1.3", + Runtime = 1000, + Freq = "868M", + IpAddress = "192.168.1.100", + Payload = GatewayPayload + }; + + var device = data.Map(isMetric: true, calculateValues: false); + + Assert.That(device.IpAddress, Is.EqualTo("192.168.1.100")); + Assert.That(device.Model, Is.EqualTo("GW2000A")); + Assert.That(device.PASSKEY, Is.EqualTo("ABC123")); + Assert.That(device.Sensors.Count, Is.GreaterThan(0)); + + var temp = device.Sensors.FirstOrDefault(s => s.Name == "tempinf"); + Assert.That(temp, Is.Not.Null); + Assert.That(temp!.UnitOfMeasurement, Is.EqualTo("°C")); + // 72.5°F = 22.5°C + Assert.That((double)temp.Value!, Is.EqualTo(22.5).Within(0.1)); + } + + [Test] + public void MapGateway_Imperial_NoConversion() + { + var data = new GatewayApiData + { + Model = "GW2000A", + Payload = GatewayPayload + }; + + var device = data.Map(isMetric: false, calculateValues: false); + + var temp = device.Sensors.FirstOrDefault(s => s.Name == "tempinf"); + Assert.That(temp, Is.Not.Null); + Assert.That(temp!.UnitOfMeasurement, Is.EqualTo("F")); + Assert.That((double)temp.Value!, Is.EqualTo(72.5).Within(0.01)); + } + + [Test] + public void MapGateway_WithCalculatedValues() + { + var data = new GatewayApiData + { + Model = "GW2000A", + Payload = GatewayPayload + }; + + var device = data.Map(isMetric: true, calculateValues: true); + + // should have calculated dewpoint, wind compass etc. + var compass = device.Sensors.FirstOrDefault(s => s.Name == "winddir-comp"); + Assert.That(compass, Is.Not.Null); + Assert.That(compass!.Value, Is.EqualTo("S")); + } + + [Test] + public void MapGateway_EmptyPayload_NoSensors() + { + var data = new GatewayApiData + { + Model = "GW2000A", + Payload = "" + }; + + var device = data.Map(); + Assert.That(device.Sensors, Is.Empty); + } + + [Test] + public void MapGateway_OkPayload_NoSensors() + { + var data = new GatewayApiData + { + Model = "GW2000A", + Payload = "200 OK" + }; + + var device = data.Map(); + Assert.That(device.Sensors, Is.Empty); + } + + [Test] + public void MapSubdevice_WFC01() + { + var data = new SubdeviceApiData + { + Id = 13398, + Model = 1, + Version = 113, + RfnetState = 1, + Battery = 5, + Signal = 4, + GwIp = "192.168.1.100", + Payload = WFC01Payload + }; + + var subdevice = data.Map(isMetric: true); + + Assert.That(subdevice.Id, Is.EqualTo(13398)); + Assert.That(subdevice.Model, Is.EqualTo(SubdeviceModel.WFC01)); + Assert.That(subdevice.Availability, Is.True); + Assert.That(subdevice.Nickname, Is.EqualTo("WFC01-00003456")); + Assert.That(subdevice.Devicename, Is.EqualTo("MJULtW6rvT1I8dEKz3o2")); + Assert.That(subdevice.Sensors.Count, Is.GreaterThan(0)); + + var waterTotal = subdevice.Sensors.FirstOrDefault(s => s.Name == "water_total"); + Assert.That(waterTotal, Is.Not.Null); + Assert.That(waterTotal!.SensorType, Is.EqualTo(SensorType.Water)); + } + + [Test] + public void MapSubdevice_AC1100() + { + var data = new SubdeviceApiData + { + Id = 10695, + Model = 2, + Version = 103, + RfnetState = 1, + Battery = 9, + Signal = 4, + GwIp = "192.168.1.100", + Payload = AC1100Payload + }; + + var subdevice = data.Map(isMetric: true); + + Assert.That(subdevice.Id, Is.EqualTo(10695)); + Assert.That(subdevice.Model, Is.EqualTo(SubdeviceModel.AC1100)); + Assert.That(subdevice.Nickname, Is.EqualTo("AC1100-000029C7")); + Assert.That(subdevice.Sensors.Count, Is.GreaterThan(0)); + + var power = subdevice.Sensors.FirstOrDefault(s => s.Name == "realtime_power"); + Assert.That(power, Is.Not.Null); + Assert.That(power!.SensorType, Is.EqualTo(SensorType.Power)); + + var voltage = subdevice.Sensors.FirstOrDefault(s => s.Name == "ac_voltage"); + Assert.That(voltage, Is.Not.Null); + } + + [Test] + public void MapSubdevice_Unavailable() + { + var data = new SubdeviceApiData + { + Id = 99, + Model = 1, + RfnetState = 0, // offline + GwIp = "192.168.1.100", + Payload = "" + }; + + var subdevice = data.Map(); + Assert.That(subdevice.Availability, Is.False); + } + + [Test] + public void MapSubdevice_EmptyPayload() + { + var data = new SubdeviceApiData + { + Id = 99, + Model = 1, + RfnetState = 1, + GwIp = "192.168.1.100", + Payload = "" + }; + + var subdevice = data.Map(); + Assert.That(subdevice.Sensors, Is.Empty); + } + + [Test] + public void MapSubdevice_WFC01_WithCalculatedValues() + { + var data = new SubdeviceApiData + { + Id = 13398, + Model = 1, + Version = 113, + RfnetState = 1, + GwIp = "192.168.1.100", + Payload = WFC01Payload + }; + + var subdevice = data.Map(isMetric: true, calculateValues: true); + + // happen_water should be recalculated as delta + var happenWater = subdevice.Sensors.FirstOrDefault(s => s.Name == "happen_water"); + Assert.That(happenWater, Is.Not.Null); + // water_total (617.557) - happen_water (614.258) = 3.299 + Assert.That((double)happenWater!.Value!, Is.EqualTo(3.299).Within(0.01)); + } +} diff --git a/src/EcoWitt.Controller.Tests/DeNoiserTest.cs b/src/EcoWitt.Controller.Tests/DeNoiserTest.cs new file mode 100644 index 0000000..57bcfc2 --- /dev/null +++ b/src/EcoWitt.Controller.Tests/DeNoiserTest.cs @@ -0,0 +1,134 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Service.Orchestrator; + +namespace EcoWitt.Controller.Tests; + +public class DeNoiserTest +{ + [Test] + public void Temperature_WithinTolerance_NoChange() + { + var sensor = new Sensor("tempf", 20.0, SensorDataType.Double, "°C", SensorType.Temperature); + // tolerance is 0.1 for temperature + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 20.05), Is.False); + } + + [Test] + public void Temperature_BeyondTolerance_Changed() + { + var sensor = new Sensor("tempf", 20.0, SensorDataType.Double, "°C", SensorType.Temperature); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 20.2), Is.True); + } + + [Test] + public void Humidity_WithinTolerance_NoChange() + { + var sensor = new Sensor("humidity", 50.0, SensorDataType.Double, "%", SensorType.Humidity); + // tolerance is 1.0 for humidity + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 50.5), Is.False); + } + + [Test] + public void Humidity_BeyondTolerance_Changed() + { + var sensor = new Sensor("humidity", 50.0, SensorDataType.Double, "%", SensorType.Humidity); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 51.5), Is.True); + } + + [Test] + public void Pressure_WithinTolerance_NoChange() + { + var sensor = new Sensor("baromrelin", 1013.0, SensorDataType.Double, "hPa", SensorType.Pressure); + // tolerance is 0.5 for pressure + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 1013.3), Is.False); + } + + [Test] + public void Battery_WithinTolerance_NoChange() + { + var sensor = new Sensor("batt1", 80.0, SensorDataType.Double, "%", SensorType.Battery); + // tolerance is 1.0 for battery + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 80.5), Is.False); + } + + [Test] + public void WindSpeed_BeyondTolerance_Changed() + { + var sensor = new Sensor("windspeedmph", 10.0, SensorDataType.Double, "km/h", SensorType.WindSpeed); + // tolerance is 0.2 for wind speed + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 10.3), Is.True); + } + + [Test] + public void Integer_WithinTolerance_NoChange() + { + var sensor = new Sensor("uv", 5, SensorDataType.Integer, sensorType: SensorType.None); + // default integer tolerance is 1.0 + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 5), Is.False); + } + + [Test] + public void Integer_BeyondTolerance_Changed() + { + var sensor = new Sensor("uv", 5, SensorDataType.Integer, sensorType: SensorType.None); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 7), Is.True); + } + + [Test] + public void Boolean_Changed() + { + var sensor = new Sensor("ac_running", true, SensorDataType.Boolean, sensorType: SensorType.None, sensorClass: SensorClass.BinarySensor); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, false), Is.True); + } + + [Test] + public void Boolean_Same_NoChange() + { + var sensor = new Sensor("ac_running", true, SensorDataType.Boolean, sensorType: SensorType.None, sensorClass: SensorClass.BinarySensor); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, true), Is.False); + } + + [Test] + public void String_Changed() + { + var sensor = new Sensor("winddir-comp", "N", SensorDataType.String); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, "NE"), Is.True); + } + + [Test] + public void String_Same_NoChange() + { + var sensor = new Sensor("winddir-comp", "N", SensorDataType.String); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, "N"), Is.False); + } + + [Test] + public void BothNull_NoChange() + { + var sensor = new Sensor("test", null, SensorDataType.Double); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, null), Is.False); + } + + [Test] + public void OldNull_NewValue_Changed() + { + var sensor = new Sensor("test", null, SensorDataType.Double); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 5.0), Is.True); + } + + [Test] + public void OldValue_NewNull_Changed() + { + var sensor = new Sensor("test", 5.0, SensorDataType.Double); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, null), Is.True); + } + + [Test] + public void DefaultDoubleTolerance_UsedForUnmappedType() + { + // SensorType.None has no specific tolerance, falls back to 0.01 + var sensor = new Sensor("custom", 1.0, SensorDataType.Double, sensorType: SensorType.None); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 1.005), Is.False); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 1.02), Is.True); + } +} diff --git a/src/EcoWitt.Controller.Tests/DiscoveryBuilderTest.cs b/src/EcoWitt.Controller.Tests/DiscoveryBuilderTest.cs new file mode 100644 index 0000000..53079b9 --- /dev/null +++ b/src/EcoWitt.Controller.Tests/DiscoveryBuilderTest.cs @@ -0,0 +1,152 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Discovery; + +namespace EcoWitt.Controller.Tests; + +public class DiscoveryBuilderTest +{ + [Test] + public void BuildIdentifier_DefaultType() + { + var id = DiscoveryBuilder.BuildIdentifier("My Gateway"); + Assert.That(id, Is.EqualTo("ec_my-gateway_config")); + } + + [Test] + public void BuildIdentifier_WithType() + { + var id = DiscoveryBuilder.BuildIdentifier("weatherstation 01", "availability"); + Assert.That(id, Is.EqualTo("ec_weatherstation-01_availability")); + } + + [Test] + public void BuildIdentifier_AlreadyClean() + { + var id = DiscoveryBuilder.BuildIdentifier("gw1", "temperature"); + Assert.That(id, Is.EqualTo("ec_gw1_temperature")); + } + + [Test] + public void BuildDevice_MinimalNoModel() + { + var device = DiscoveryBuilder.BuildDevice("testgw"); + Assert.That(device.Name, Is.EqualTo("testgw")); + Assert.That(device.Identifiers, Contains.Item("ec_testgw_config")); + Assert.That(device.Model, Is.Null); + Assert.That(device.ViaDevice, Is.Null); + } + + [Test] + public void BuildDevice_Full() + { + var device = DiscoveryBuilder.BuildDevice("MyGW", "GW2000A", "Ecowitt", "GW2000A", "V3.1.3"); + Assert.That(device.Name, Is.EqualTo("MyGW")); + Assert.That(device.Model, Is.EqualTo("GW2000A")); + Assert.That(device.Manufacturer, Is.EqualTo("Ecowitt")); + Assert.That(device.HwVersion, Is.EqualTo("GW2000A")); + Assert.That(device.SwVersion, Is.EqualTo("V3.1.3")); + } + + [Test] + public void BuildDevice_WithViaDevice() + { + var device = DiscoveryBuilder.BuildDevice("subdev", "WFC01", "Ecowitt", "WFC01", "113", "ec_gw1_config"); + Assert.That(device.ViaDevice, Is.EqualTo("ec_gw1_config")); + } + + [Test] + public void BuildOrigin_Default() + { + var origin = DiscoveryBuilder.BuildOrigin(); + Assert.That(origin.Name, Is.EqualTo("Ecowitt Controller")); + Assert.That(origin.Sw, Is.EqualTo("v2.0.0")); + Assert.That(origin.Url, Does.Contain("github.com")); + } + + [Test] + public void BuildGatewayConfig() + { + var device = DiscoveryBuilder.BuildDevice("gw1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildGatewayConfig(device, origin, "Availability", "ec_gw1_availability", "ecowitt/gw1/availability", "ecowitt/gw1/availability"); + + Assert.That(config.Name, Is.EqualTo("Availability")); + Assert.That(config.UniqueId, Is.EqualTo("ec_gw1_availability")); + Assert.That(config.ObjectId, Is.EqualTo("ec_gw1_availability")); + Assert.That(config.StateTopic, Is.EqualTo("ecowitt/gw1/availability")); + Assert.That(config.Qos, Is.EqualTo(1)); + Assert.That(config.Retain, Is.False); + } + + [Test] + public void BuildSensorConfig() + { + var device = DiscoveryBuilder.BuildDevice("gw1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildSensorConfig(device, origin, "Indoor Temperature", "ec_gw1_tempinf_temperature", "temperature", "ecowitt/gw1/sensors/indoor-temperature", unitOfMeasurement: "°C"); + + Assert.That(config.Name, Is.EqualTo("Indoor Temperature")); + Assert.That(config.DeviceClass, Is.EqualTo("temperature")); + Assert.That(config.UnitOfMeasurement, Is.EqualTo("°C")); + Assert.That(config.ValueTemplate, Is.EqualTo("{{ value_json.value }}")); + } + + [Test] + public void BuildSensorConfig_BinarySensor() + { + var device = DiscoveryBuilder.BuildDevice("gw1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildSensorConfig(device, origin, "Rain State", "ec_gw1_rain", "none", "ecowitt/gw1/sensors/rain", isBinarySensor: true); + + Assert.That(config, Is.Not.Null); + } + + [Test] + public void BuildSensorConfig_DiagnosticCategory() + { + var device = DiscoveryBuilder.BuildDevice("gw1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildSensorConfig(device, origin, "RSSI", "ec_gw1_rssi", "signal_strength", "ecowitt/gw1/diag/rssi", sensorCategory: "diagnostic"); + + Assert.That(config.SensorCategory, Is.EqualTo("diagnostic")); + } + + [Test] + public void BuildSwitchConfig() + { + var device = DiscoveryBuilder.BuildDevice("valve1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildSwitchConfig(device, origin, "switch", "ec_valve1_switch", "ecowitt/gw1/subdevices/123/diag/running", "ecowitt/gw1/subdevices/123/cmd/homeassistant"); + + Assert.That(config.CommandTopic, Is.EqualTo("ecowitt/gw1/subdevices/123/cmd/homeassistant")); + Assert.That(config.StateTopic, Does.Contain("running")); + } + + // Device class mapping + [TestCase(SensorType.Temperature, "temperature")] + [TestCase(SensorType.Humidity, "humidity")] + [TestCase(SensorType.Pressure, "pressure")] + [TestCase(SensorType.Battery, "battery")] + [TestCase(SensorType.WindSpeed, "wind_speed")] + [TestCase(SensorType.Precipitation, "precipitation")] + [TestCase(SensorType.PrecipitationIntensity, "precipitation_intensity")] + [TestCase(SensorType.Voltage, "voltage")] + [TestCase(SensorType.Current, "current")] + [TestCase(SensorType.Power, "power")] + [TestCase(SensorType.Energy, "energy")] + [TestCase(SensorType.SignalStrength, "signal_strength")] + [TestCase(SensorType.CarbonDioxide, "carbon_dioxide")] + [TestCase(SensorType.Pm25, "pm25")] + [TestCase(SensorType.Pm10, "pm10")] + [TestCase(SensorType.Pm1, "pm1")] + [TestCase(SensorType.Distance, "distance")] + [TestCase(SensorType.Irradiance, "irradiance")] + [TestCase(SensorType.Moisture, "moisture")] + [TestCase(SensorType.Water, "water")] + [TestCase(SensorType.VolumeFlowRate, "volume_flow_rate")] + [TestCase(SensorType.None, "none")] + public void BuildDeviceCategory_MapsCorrectly(SensorType type, string expected) + { + Assert.That(DiscoveryBuilder.BuildDeviceCategory(type), Is.EqualTo(expected)); + } +} diff --git a/src/EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj b/src/EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj index 3d8fd0b..1aa1d37 100644 --- a/src/EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj +++ b/src/EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable diff --git a/src/EcoWitt.Controller.Tests/MqttPathBuilderTest.cs b/src/EcoWitt.Controller.Tests/MqttPathBuilderTest.cs new file mode 100644 index 0000000..12ffc5d --- /dev/null +++ b/src/EcoWitt.Controller.Tests/MqttPathBuilderTest.cs @@ -0,0 +1,93 @@ +using Ecowitt.Controller.Service.Mqtt; + +namespace EcoWitt.Controller.Tests; + +public class MqttPathBuilderTest +{ + [Test] + public void BuildMqttGatewayTopic() + { + Assert.That(MqttPathBuilder.BuildMqttGatewayTopic("MyGateway"), Is.EqualTo("mygateway")); + } + + [Test] + public void BuildMqttGatewaySensorTopic() + { + Assert.That(MqttPathBuilder.BuildMqttGatewaySensorTopic("gw1", "Indoor Temperature"), + Is.EqualTo("gw1/sensors/indoor-temperature")); + } + + [Test] + public void BuildMqttGatewayDiagnosticTopic() + { + Assert.That(MqttPathBuilder.BuildMqttGatewayDiagnosticTopic("gw1", "WH90 Battery"), + Is.EqualTo("gw1/diag/wh90-battery")); + } + + [Test] + public void BuildMqttSubdeviceTopic() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceTopic("gw1", "12345"), + Is.EqualTo("gw1/subdevices/12345")); + } + + [Test] + public void BuildMqttSubdeviceSensorTopic() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceSensorTopic("gw1", "12345", "Water Temperature"), + Is.EqualTo("gw1/subdevices/12345/sensors/water-temperature")); + } + + [Test] + public void BuildMqttSubdeviceDiagnosticTopic() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceDiagnosticTopic("gw1", "12345", "RSSI"), + Is.EqualTo("gw1/subdevices/12345/diag/rssi")); + } + + [Test] + public void BuildMqttSubdeviceCommandTopic_Specific() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceCommandTopic("gw1", "12345"), + Is.EqualTo("gw1/subdevices/12345/cmd")); + } + + [Test] + public void BuildMqttSubdeviceCommandTopic_Wildcard() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceCommandTopic(), + Is.EqualTo("+/subdevices/+/cmd")); + } + + [Test] + public void BuildMqttSubdeviceHACommandTopic_Specific() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceHACommandTopic("gw1", "12345"), + Is.EqualTo("gw1/subdevices/12345/cmd/homeassistant")); + } + + [Test] + public void BuildMqttSubdeviceHACommandTopic_Wildcard() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceHACommandTopic(), + Is.EqualTo("+/subdevices/+/cmd/homeassistant")); + } + + [Test] + public void Sanitize_SpacesToHyphens() + { + Assert.That(MqttPathBuilder.Sanitize("My Gateway Name"), Is.EqualTo("my-gateway-name")); + } + + [Test] + public void Sanitize_ToLowercase() + { + Assert.That(MqttPathBuilder.Sanitize("GW2000A"), Is.EqualTo("gw2000a")); + } + + [Test] + public void Sanitize_AlreadyClean() + { + Assert.That(MqttPathBuilder.Sanitize("gw1/sensors/tempf"), Is.EqualTo("gw1/sensors/tempf")); + } +} diff --git a/src/EcoWitt.Controller.Tests/MqttPayloadBuilderTest.cs b/src/EcoWitt.Controller.Tests/MqttPayloadBuilderTest.cs new file mode 100644 index 0000000..20542cc --- /dev/null +++ b/src/EcoWitt.Controller.Tests/MqttPayloadBuilderTest.cs @@ -0,0 +1,158 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Service.Mqtt; + +namespace EcoWitt.Controller.Tests; + +public class MqttPayloadBuilderTest +{ + [Test] + public void BuildGatewayPayload_FullModel() + { + var gw = new Device + { + IpAddress = "192.168.1.100", + Name = "weatherstation-01", + Model = "GW2000A", + PASSKEY = "ABC123", + StationType = "GW2000A_V3.1.3", + Runtime = 12345, + Freq = "868M" + }; + + dynamic payload = MqttPayloadBuilder.BuildGatewayPayload(gw); + + Assert.That((string)payload.ip, Is.EqualTo("192.168.1.100")); + Assert.That((string)payload.name, Is.EqualTo("weatherstation-01")); + Assert.That((string)payload.model, Is.EqualTo("GW2000A")); + Assert.That((string)payload.state, Is.EqualTo("online")); + } + + [Test] + public void BuildGatewayPayload_NoModel_MinimalPayload() + { + var gw = new Device + { + IpAddress = "192.168.1.100", + Name = "weatherstation-01", + Model = null + }; + + dynamic payload = MqttPayloadBuilder.BuildGatewayPayload(gw); + + Assert.That((string)payload.ip, Is.EqualTo("192.168.1.100")); + Assert.That((string)payload.name, Is.EqualTo("weatherstation-01")); + // should not have model property (anonymous type without it) + var type = payload.GetType(); + Assert.That(type.GetProperty("model"), Is.Null); + } + + [Test] + public void BuildGatewayPayload_EmptyModel_MinimalPayload() + { + var gw = new Device + { + IpAddress = "192.168.1.100", + Name = "gw1", + Model = "" + }; + + dynamic payload = MqttPayloadBuilder.BuildGatewayPayload(gw); + var type = payload.GetType(); + Assert.That(type.GetProperty("model"), Is.Null); + } + + [Test] + public void BuildSubdevicePayload_Online() + { + var sd = new Subdevice + { + Id = 12345, + Model = SubdeviceModel.WFC01, + Devicename = "WFC01", + Nickname = "Garden Valve", + Availability = true, + Version = 113 + }; + + dynamic payload = MqttPayloadBuilder.BuildSubdevicePayload(sd); + + Assert.That((int)payload.id, Is.EqualTo(12345)); + Assert.That((SubdeviceModel)payload.model, Is.EqualTo(SubdeviceModel.WFC01)); + Assert.That((string)payload.state, Is.EqualTo("online")); + Assert.That((string)payload.nickname, Is.EqualTo("Garden Valve")); + } + + [Test] + public void BuildSubdevicePayload_Offline() + { + var sd = new Subdevice + { + Id = 99, + Model = SubdeviceModel.AC1100, + Availability = false, + Version = 103 + }; + + dynamic payload = MqttPayloadBuilder.BuildSubdevicePayload(sd); + Assert.That((string)payload.state, Is.EqualTo("offline")); + } + + [Test] + public void BuildSensorPayload_DoubleRounding() + { + var sensor = new Sensor("tempf", "Outdoor Temperature", 22.456789, SensorDataType.Double, "°C", SensorType.Temperature); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((double)payload.value, Is.EqualTo(22.46).Within(0.001)); + + dynamic payload1 = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 1); + Assert.That((double)payload1.value, Is.EqualTo(22.5).Within(0.01)); + } + + [Test] + public void BuildSensorPayload_IntegerNotRounded() + { + var sensor = new Sensor("uv", 5, SensorDataType.Integer); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((int)payload.value, Is.EqualTo(5)); + } + + [Test] + public void BuildSensorPayload_NullUnitOmitted() + { + var sensor = new Sensor("uv", 5, SensorDataType.Integer, unitOfMeasurement: ""); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((object?)payload.unit, Is.Null); + } + + [Test] + public void BuildSensorPayload_UnitIncluded() + { + var sensor = new Sensor("tempf", 20.0, SensorDataType.Double, "°C", SensorType.Temperature); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((string)payload.unit, Is.EqualTo("°C")); + } + + [Test] + public void BuildSensorPayload_IncludesNameAndAlias() + { + var sensor = new Sensor("tempinf", "Indoor Temperature", 20.0, SensorDataType.Double, "°C", SensorType.Temperature); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((string)payload.name, Is.EqualTo("tempinf")); + Assert.That((string)payload.alias, Is.EqualTo("Indoor Temperature")); + } + + [Test] + public void BuildSensorPayload_DefaultPrecision() + { + var sensor = new Sensor("tempf", 22.456789, SensorDataType.Double, "°C", SensorType.Temperature); + + // null precision should default to 2 + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: null); + Assert.That((double)payload.value, Is.EqualTo(22.46).Within(0.001)); + } +} diff --git a/src/EcoWitt.Controller.Tests/SensorBuilderAddonTest.cs b/src/EcoWitt.Controller.Tests/SensorBuilderAddonTest.cs new file mode 100644 index 0000000..45c8f6c --- /dev/null +++ b/src/EcoWitt.Controller.Tests/SensorBuilderAddonTest.cs @@ -0,0 +1,162 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Mapping; + +namespace EcoWitt.Controller.Tests; + +public class SensorBuilderAddonTest +{ + [Test] + public void CalculateGatewayAddons_DewpointMetric() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("tempinf", "Indoor Temperature", 20.0, SensorDataType.Double, "°C", SensorType.Temperature)); + device.Sensors.Add(new Sensor("humidityin", "Indoor Humidity", 50.0, SensorDataType.Double, "%", SensorType.Humidity)); + device.Sensors.Add(new Sensor("tempf", "Outdoor Temperature", 25.0, SensorDataType.Double, "°C", SensorType.Temperature)); + device.Sensors.Add(new Sensor("humidity", "Outdoor Humidity", 60.0, SensorDataType.Double, "%", SensorType.Humidity)); + device.Sensors.Add(new Sensor("windspeedmph", "Wind Speed", 15.0, SensorDataType.Double, "km/h", SensorType.WindSpeed)); + device.Sensors.Add(new Sensor("winddir", "Wind Direction", 180, SensorDataType.Integer, "°")); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var indoorDewpoint = device.Sensors.FirstOrDefault(s => s.Name == "dewpointin"); + Assert.That(indoorDewpoint, Is.Not.Null); + Assert.That(indoorDewpoint!.SensorType, Is.EqualTo(SensorType.Temperature)); + // dewpoint = temp - (100 - humidity) / 5 = 20 - (100-50)/5 = 20 - 10 = 10 + Assert.That((double)indoorDewpoint.Value!, Is.EqualTo(10.0).Within(0.5)); + + var outdoorDewpoint = device.Sensors.FirstOrDefault(s => s.Name == "dewpoint"); + Assert.That(outdoorDewpoint, Is.Not.Null); + // 25 - (100-60)/5 = 25 - 8 = 17 + Assert.That((double)outdoorDewpoint!.Value!, Is.EqualTo(17.0).Within(0.5)); + } + + [Test] + public void CalculateGatewayAddons_HeatIndex() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("tempf", "Outdoor Temperature", 30.0, SensorDataType.Double, "°C", SensorType.Temperature)); + device.Sensors.Add(new Sensor("humidity", "Outdoor Humidity", 70.0, SensorDataType.Double, "%", SensorType.Humidity)); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var heatIndex = device.Sensors.FirstOrDefault(s => s.Name == "heatindex"); + Assert.That(heatIndex, Is.Not.Null); + Assert.That(heatIndex!.SensorType, Is.EqualTo(SensorType.Temperature)); + } + + [Test] + public void CalculateGatewayAddons_WindChill() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("tempf", "Outdoor Temperature", 5.0, SensorDataType.Double, "°C", SensorType.Temperature)); + device.Sensors.Add(new Sensor("humidity", "Outdoor Humidity", 50.0, SensorDataType.Double, "%", SensorType.Humidity)); + device.Sensors.Add(new Sensor("windspeedmph", "Wind Speed", 20.0, SensorDataType.Double, "km/h", SensorType.WindSpeed)); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var windChill = device.Sensors.FirstOrDefault(s => s.Name == "windchill"); + Assert.That(windChill, Is.Not.Null); + Assert.That(windChill!.SensorType, Is.EqualTo(SensorType.Temperature)); + } + + [Test] + public void CalculateGatewayAddons_WindDirectionCompass() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("winddir", "Wind Direction", 180, SensorDataType.Integer, "°")); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var compass = device.Sensors.FirstOrDefault(s => s.Name == "winddir-comp"); + Assert.That(compass, Is.Not.Null); + Assert.That(compass!.Value, Is.EqualTo("S")); + } + + [TestCase(0, "N")] + [TestCase(10, "N")] + [TestCase(11, "NNE")] + [TestCase(45, "NE")] + [TestCase(90, "E")] + [TestCase(135, "SE")] + [TestCase(180, "S")] + [TestCase(225, "SW")] + [TestCase(270, "W")] + [TestCase(315, "NW")] + [TestCase(349, "N")] + [TestCase(359, "N")] + public void CalculateGatewayAddons_WindCompassBoundaries(int degrees, string expected) + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("winddir", "Wind Direction", degrees, SensorDataType.Integer, "°")); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var compass = device.Sensors.FirstOrDefault(s => s.Name == "winddir-comp"); + Assert.That(compass, Is.Not.Null); + Assert.That(compass!.Value, Is.EqualTo(expected)); + } + + [Test] + public void CalculateGatewayAddons_PM25AQI() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("pm25_24h_co2", "CO2 PM2.5 24h Average", 5.0, SensorDataType.Double, "µg/m³", SensorType.Pm25, SensorState.Total)); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var aqi = device.Sensors.FirstOrDefault(s => s.Name == "pm25_24h_co2-aqi"); + Assert.That(aqi, Is.Not.Null); + Assert.That(aqi!.Value, Is.EqualTo("Good")); + } + + [Test] + public void CalculateGatewayAddons_MissingSensors_NoError() + { + var device = new Device { IpAddress = "1.2.3.4" }; + // no sensors at all + Assert.DoesNotThrow(() => SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true)); + // only indoor temp, no humidity — shouldn't add dewpoint + device.Sensors.Add(new Sensor("tempinf", "Indoor Temperature", 20.0, SensorDataType.Double, "°C", SensorType.Temperature)); + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + Assert.That(device.Sensors.FirstOrDefault(s => s.Name == "dewpointin"), Is.Null); + } + + [Test] + public void CalculateWFC01Addons_WaterDelta() + { + var subdevice = new Subdevice + { + Id = 1, + Model = SubdeviceModel.WFC01, + GwIp = "1.2.3.4" + }; + subdevice.Sensors.Add(new Sensor("water_total", "Total Water", 1000.0, SensorDataType.Double, "L", SensorType.Water, SensorState.TotalIncreasing)); + subdevice.Sensors.Add(new Sensor("happen_water", "Last Planned Consumption", 800.0, SensorDataType.Double, "L", SensorType.Water)); + + SensorBuilder.CalculateWFC01Addons(ref subdevice); + + var happen = subdevice.Sensors.First(s => s.Name == "happen_water"); + // delta: 1000 - 800 = 200... wait, it replaces happen_water with (total - happen) + // so: 1000 - 800 = 200 + Assert.That((double)happen.Value!, Is.EqualTo(200.0).Within(0.01)); + } + + [Test] + public void CalculateWFC01Addons_NonWFC01_Ignored() + { + var subdevice = new Subdevice + { + Id = 1, + Model = SubdeviceModel.AC1100, + GwIp = "1.2.3.4" + }; + subdevice.Sensors.Add(new Sensor("water_total", "Total Water", 1000.0, SensorDataType.Double, "L", SensorType.Water)); + subdevice.Sensors.Add(new Sensor("happen_water", "Last Planned Consumption", 800.0, SensorDataType.Double, "L", SensorType.Water)); + + SensorBuilder.CalculateWFC01Addons(ref subdevice); + + // should not modify since model is AC1100 + var happen = subdevice.Sensors.First(s => s.Name == "happen_water"); + Assert.That((double)happen.Value!, Is.EqualTo(800.0).Within(0.01)); + } +} diff --git a/src/EcoWitt.Controller.Tests/SensorBuilderTest.cs b/src/EcoWitt.Controller.Tests/SensorBuilderTest.cs new file mode 100644 index 0000000..ebdf79b --- /dev/null +++ b/src/EcoWitt.Controller.Tests/SensorBuilderTest.cs @@ -0,0 +1,359 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Mapping; + +namespace EcoWitt.Controller.Tests; + +public class SensorBuilderTest +{ + [Test] + public void BuildSensor_UnknownProperty_ReturnsNull() + { + var result = SensorBuilder.BuildSensor("unknown_property_xyz", "42"); + Assert.That(result, Is.Null); + } + + [Test] + public void BuildSensor_TemperatureIndoor_MetricConversion() + { + var sensor = SensorBuilder.BuildSensor("tempinf", "68.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Name, Is.EqualTo("tempinf")); + Assert.That(sensor.Alias, Is.EqualTo("Indoor Temperature")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Temperature)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("°C")); + Assert.That(sensor.DataType, Is.EqualTo(SensorDataType.Double)); + // 68°F = 20°C + Assert.That((double)sensor.Value!, Is.EqualTo(20.0).Within(0.01)); + } + + [Test] + public void BuildSensor_TemperatureIndoor_ImperialNoConversion() + { + var sensor = SensorBuilder.BuildSensor("tempinf", "68.0", isMetric: false); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.UnitOfMeasurement, Is.EqualTo("F")); + Assert.That((double)sensor.Value!, Is.EqualTo(68.0).Within(0.01)); + } + + [Test] + public void BuildSensor_TemperatureOutdoor_MetricConversion() + { + var sensor = SensorBuilder.BuildSensor("tempf", "32.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Outdoor Temperature")); + // 32°F = 0°C + Assert.That((double)sensor.Value!, Is.EqualTo(0.0).Within(0.01)); + } + + [Test] + public void BuildSensor_NumberedTemperature() + { + var sensor = SensorBuilder.BuildSensor("tempf3", "50.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Temperature 3")); + // 50°F = 10°C + Assert.That((double)sensor.Value!, Is.EqualTo(10.0).Within(0.01)); + } + + [Test] + public void BuildSensor_Humidity() + { + var sensor = SensorBuilder.BuildSensor("humidity", "65.5"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Outdoor Humidity")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Humidity)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("%")); + Assert.That((double)sensor.Value!, Is.EqualTo(65.5).Within(0.01)); + } + + [Test] + public void BuildSensor_NumberedHumidity() + { + var sensor = SensorBuilder.BuildSensor("humidity5", "45.0"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Humidity 5")); + } + + [Test] + public void BuildSensor_PressureRelative_MetricConversion() + { + var sensor = SensorBuilder.BuildSensor("baromrelin", "29.92", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Relative Pressure")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Pressure)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("hPa")); + // 29.92 inHg * 33.86388 = ~1013.25 hPa + Assert.That((double)sensor.Value!, Is.EqualTo(1013.25).Within(0.5)); + } + + [Test] + public void BuildSensor_WindSpeed_MetricConversion() + { + var sensor = SensorBuilder.BuildSensor("windspeedmph", "10.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.WindSpeed)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("km/h")); + // 10 mph * 1.60934 = 16.0934 km/h + Assert.That((double)sensor.Value!, Is.EqualTo(16.09).Within(0.01)); + } + + [Test] + public void BuildSensor_WindGust() + { + var sensor = SensorBuilder.BuildSensor("windgustmph", "25.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Wind Gust")); + } + + [Test] + public void BuildSensor_WindDirection() + { + var sensor = SensorBuilder.BuildSensor("winddir", "180"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.UnitOfMeasurement, Is.EqualTo("°")); + Assert.That(sensor.Value, Is.EqualTo(180)); + } + + [Test] + public void BuildSensor_SolarRadiation() + { + var sensor = SensorBuilder.BuildSensor("solarradiation", "850.5"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Solar Radiation")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Irradiance)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("W/m²")); + } + + [Test] + public void BuildSensor_UvIndex() + { + var sensor = SensorBuilder.BuildSensor("uv", "5"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Value, Is.EqualTo(5)); + } + + [Test] + public void BuildSensor_RainRate_Metric() + { + var sensor = SensorBuilder.BuildSensor("rainratein", "0.5", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.PrecipitationIntensity)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("mm/h")); + // 0.5 in * 25.4 = 12.7 mm + Assert.That((double)sensor.Value!, Is.EqualTo(12.7).Within(0.01)); + } + + [Test] + public void BuildSensor_DailyRain_Metric() + { + var sensor = SensorBuilder.BuildSensor("dailyrainin", "1.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.Precipitation)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("mm")); + Assert.That((double)sensor.Value!, Is.EqualTo(25.4).Within(0.01)); + } + + [Test] + public void BuildSensor_PiezoRainVariants() + { + Assert.That(SensorBuilder.BuildSensor("rrain_piezo", "0.1", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("erain_piezo", "0.2", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("hrain_piezo", "0.3", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("drain_piezo", "0.4", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wrain_piezo", "0.5", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("mrain_piezo", "0.6", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("yrain_piezo", "0.7", true), Is.Not.Null); + } + + [Test] + public void BuildSensor_RainState_Binary() + { + var sensor = SensorBuilder.BuildSensor("srain_piezo", "1"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorClass, Is.EqualTo(SensorClass.BinarySensor)); + Assert.That(sensor.Value, Is.EqualTo(true)); + } + + [Test] + public void BuildSensor_SoilMoisture() + { + var sensor = SensorBuilder.BuildSensor("soilmoisture4", "55.0"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Soil Moisture 4")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Moisture)); + } + + [Test] + public void BuildSensor_SoilAdmittance() + { + var sensor = SensorBuilder.BuildSensor("soilad2", "123"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Soil Admittance 2")); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("mS")); + } + + [Test] + public void BuildSensor_PM25Channel() + { + var sensor = SensorBuilder.BuildSensor("pm25_ch1", "12.5", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.Pm25)); + } + + [Test] + public void BuildSensor_CO2() + { + var sensor = SensorBuilder.BuildSensor("co2", "450"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.CarbonDioxide)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("ppm")); + } + + [Test] + public void BuildSensor_Lightning() + { + var num = SensorBuilder.BuildSensor("lightning_num", "5"); + Assert.That(num, Is.Not.Null); + Assert.That(num!.Alias, Is.EqualTo("Lightning Strikes")); + + var dist = SensorBuilder.BuildSensor("lightning", "10.5"); + Assert.That(dist, Is.Not.Null); + Assert.That(dist!.SensorType, Is.EqualTo(SensorType.Distance)); + + var time = SensorBuilder.BuildSensor("lightning_time", "1700000000"); + Assert.That(time, Is.Not.Null); + Assert.That(time!.DataType, Is.EqualTo(SensorDataType.DateTime)); + } + + [Test] + public void BuildSensor_Battery_WithMultiplier() + { + var sensor = SensorBuilder.BuildSensor("wh57batt", "3"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.Battery)); + Assert.That(sensor.SensorCategory, Is.EqualTo(SensorCategory.Diagnostic)); + // 3 * 20 = 60% + Assert.That(sensor.Value, Is.EqualTo(60)); + } + + [Test] + public void BuildSensor_Battery_WithoutMultiplier() + { + var sensor = SensorBuilder.BuildSensor("batt1", "4"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor.Value, Is.EqualTo(4)); + } + + [Test] + public void BuildSensor_Battery_ClampsTo100() + { + var sensor = SensorBuilder.BuildSensor("wh57batt", "6"); + Assert.That(sensor, Is.Not.Null); + // 6 * 20 = 120 → clamped to 100 + Assert.That(sensor!.Value, Is.EqualTo(100)); + } + + [Test] + public void BuildSensor_Voltage() + { + var sensor = SensorBuilder.BuildSensor("ws90cap_volt", "3.2"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.Voltage)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("V")); + } + + [Test] + public void BuildSensor_ACSubdeviceSensors() + { + Assert.That(SensorBuilder.BuildSensor("ac_status", "1")!.SensorClass, Is.EqualTo(SensorClass.BinarySensor)); + Assert.That(SensorBuilder.BuildSensor("ac_running", "0")!.Value, Is.EqualTo(false)); + Assert.That(SensorBuilder.BuildSensor("realtime_power", "150")!.SensorType, Is.EqualTo(SensorType.Power)); + Assert.That(SensorBuilder.BuildSensor("ac_voltage", "230")!.SensorType, Is.EqualTo(SensorType.Voltage)); + Assert.That(SensorBuilder.BuildSensor("ac_current", "2")!.SensorType, Is.EqualTo(SensorType.Current)); + Assert.That(SensorBuilder.BuildSensor("elect_total", "5000")!.SensorType, Is.EqualTo(SensorType.Energy)); + } + + [Test] + public void BuildSensor_WFCSubdeviceSensors() + { + Assert.That(SensorBuilder.BuildSensor("water_status", "1")!.SensorClass, Is.EqualTo(SensorClass.BinarySensor)); + Assert.That(SensorBuilder.BuildSensor("water_running", "0")!.Value, Is.EqualTo(false)); + + var total = SensorBuilder.BuildSensor("water_total", "123.4", isMetric: true); + Assert.That(total, Is.Not.Null); + Assert.That(total!.SensorType, Is.EqualTo(SensorType.Water)); + + var flow = SensorBuilder.BuildSensor("flow_velocity", "2.5", isMetric: true); + Assert.That(flow, Is.Not.Null); + Assert.That(flow!.SensorType, Is.EqualTo(SensorType.VolumeFlowRate)); + + var temp = SensorBuilder.BuildSensor("water_temp", "65.3", isMetric: true); + Assert.That(temp, Is.Not.Null); + Assert.That(temp!.SensorType, Is.EqualTo(SensorType.Temperature)); + } + + [Test] + public void BuildSensor_WFC02Sensors() + { + Assert.That(SensorBuilder.BuildSensor("wfc02_total", "500.0", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wfc02_flow_velocity", "1.5", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wfc02_status", "1"), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wfc02rssi", "3"), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wfc02batt", "80"), Is.Not.Null); + } + + [Test] + public void BuildSensor_LeafWetness() + { + var sensor = SensorBuilder.BuildSensor("leafwetness_ch3", "42"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Leaf Wetness 3")); + } + + [Test] + public void BuildSensor_LeakChannel() + { + var sensor = SensorBuilder.BuildSensor("leak_ch2", "1"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Leak Channel 2")); + } + + // Edge cases + [TestCase("na")] + [TestCase("--")] + [TestCase("")] + [TestCase("n/a")] + [TestCase("nan")] + [TestCase("null")] + [TestCase("none")] + public void BuildSensor_InvalidTokens_ReturnsNull(string value) + { + var sensor = SensorBuilder.BuildSensor("tempinf", value); + Assert.That(sensor, Is.Null); + } + + [Test] + public void BuildSensor_CommaAsDecimal_ParsesCorrectly() + { + var sensor = SensorBuilder.BuildSensor("tempinf", "68,0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That((double)sensor!.Value!, Is.EqualTo(20.0).Within(0.01)); + } + + [Test] + public void BuildSensor_DiagnosticCategory() + { + var sensor = SensorBuilder.BuildSensor("gw_rssi", "-64"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorCategory, Is.EqualTo(SensorCategory.Diagnostic)); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.SignalStrength)); + } + + [Test] + public void BuildSensor_HeapDiagnostic() + { + var sensor = SensorBuilder.BuildSensor("heap", "32000"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorCategory, Is.EqualTo(SensorCategory.Diagnostic)); + } +} diff --git a/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs b/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs index 1c10b55..13fd8b4 100644 --- a/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs +++ b/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs @@ -78,7 +78,7 @@ public void TestDeviceAcCreation() Assert.That(subDevice.Devicename, Is.EqualTo("xTNGzWMorVwEKqvltP30")); Assert.That(subDevice.Nickname, Is.EqualTo("AC1100-000029C7")); Assert.That(subDevice.Availability, Is.EqualTo(true)); - Assert.That(subDevice.Sensors, Has.Count.EqualTo(18)); + Assert.That(subDevice.Sensors, Has.Count.EqualTo(15)); } [Test] @@ -116,6 +116,6 @@ public void TestDeviceWfcCreation() Assert.That(subDevice.Devicename, Is.EqualTo("MJULtW6rvT1I8dEKz3o2")); Assert.That(subDevice.Nickname, Is.EqualTo("WFC01-00003456")); Assert.That(subDevice.Availability, Is.EqualTo(true)); - Assert.That(subDevice.Sensors, Has.Count.EqualTo(18)); + Assert.That(subDevice.Sensors, Has.Count.EqualTo(15)); } } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Ecowitt.Controller.csproj b/src/Ecowitt.Controller/Ecowitt.Controller.csproj index 91e6eb2..7161cb7 100644 --- a/src/Ecowitt.Controller/Ecowitt.Controller.csproj +++ b/src/Ecowitt.Controller/Ecowitt.Controller.csproj @@ -1,22 +1,22 @@ - net8.0 + net10.0 enable enable a86b1f49-380d-4c8c-8812-1f07670a6652 Linux + + + + - - - - diff --git a/src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs b/src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs index 5a5c986..bafea4b 100644 --- a/src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs +++ b/src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs @@ -11,7 +11,6 @@ public class EcowittOptions public class GatewayOptions { - public string Passkey { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Username { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; diff --git a/src/Ecowitt.Controller/Model/Device.cs b/src/Ecowitt.Controller/Model/Device.cs index a5ab51a..9972b16 100644 --- a/src/Ecowitt.Controller/Model/Device.cs +++ b/src/Ecowitt.Controller/Model/Device.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; - namespace Ecowitt.Controller.Model; public class Device @@ -23,6 +21,4 @@ public class Device // sensors public List Sensors { get; set; } = new List(); - - public ConcurrentBag ConcurrentSensors { get; set; } = new ConcurrentBag(); } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs b/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs index 53a3424..c67a27f 100644 --- a/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs +++ b/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs @@ -31,5 +31,4 @@ public HttpHost(string host, string user, string password) public string Password { get; set; } = string.Empty; public string Protocol { get; set; } = "http"; // or "https" public string BaseUrl => $"{Protocol}://{Host}"; - public string Passkey { get; set; } = string.Empty; } diff --git a/src/Ecowitt.Controller/Service/Mqtt/HealthCheck.cs b/src/Ecowitt.Controller/Service/Mqtt/HealthCheck.cs deleted file mode 100644 index 2cea0bc..0000000 --- a/src/Ecowitt.Controller/Service/Mqtt/HealthCheck.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Ecowitt.Controller.Service.Http; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace Ecowitt.Controller.Service.Mqtt; - -public class HealthCheck : IHealthCheck -{ - private readonly HttpPublishingService _service; - - public HealthCheck(HttpPublishingService service) - { - _service = service; - } - - - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = new CancellationToken()) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/ha_discovery_payload.md b/src/Ecowitt.Controller/ha_discovery_payload.md deleted file mode 100644 index 8658223..0000000 --- a/src/Ecowitt.Controller/ha_discovery_payload.md +++ /dev/null @@ -1,184 +0,0 @@ -### setting up a gateway -topic `homeassistant/sensor/gw2000/config` - -payload: -```json -{ - "device": { - "hw_version": "gw2000_v1.2.3", - "identifiers": [ - "gw2000_testgateway" - ], - "manufacturer": "Ecowitt", - "model": "GW2000", - "name": "Testgateway", - "sw_version": "1.2.3" - }, - "name": "Availability", - "availability_topic": "ecowitt/gw2000/availability", - "object_id": "ecowitt_gw2000_availability_state", - "state_topic": "ecowitt/gw2000/state", - "unique_id": "ecowitt_gw2000_availability_state", - "origin": { - "name": "Ecowitt Controller", - "sw": "0.0.1", - "url": "https://github.com/mplogas/ecowitt-controller" - } -} -``` - -``` json -{ - "Device": { - "Identifiers": [ - "ec_GW2000A_192-168-103-162" - ], - "Manufacturer": "Ecowitt", - "Model": "GW2000A", - "Name": "192-168-103-162", - "Hw_Version": "GW2000A_V3.1.3", - "Sw_Version": "5844040" - }, - "Origin": { - "Name": "Ecowitt Controller", - "Sw": "0.1", - "Url": "https://github.com/mplogas/ecowitt-controller" - }, - "Name": "Availability", - "Retain": false, - "Qos": 1, - "Availability_Topic": "ecowitt-dev/192-168-103-162/availability", - "State_Topic": "ecowitt-dev/192-168-103-162", - "Unique_Id": "ecowitt-controller_192-168-103-162_availability_state", - "Object_Id": "ecowitt-controller_192-168-103-162_availability_state" -} -``` - - -### setting up a sensor for the gateway -topic `homeassistant/sensor/gw2000/uv/config` - -payload: -```json -{ - "device": { - "hw_version": "gw2000_v1.2.3", - "identifiers": [ - "gw2000_testgateway" - ], - "manufacturer": "Ecowitt", - "model": "GW2000", - "name": "Testgateway", - "sw_version": "1.2.3" - }, - "name": "uv", - "retain": false, - "availability_topic": "ecowitt/gw2000/availability", - "object_id": "ecowitt_gw2000_uv_state", - "state_topic": "ecowitt/gw2000/state", - "value_template": "{{ value_json.uv }}", - "unique_id": "ecowitt_gateway_1234567890_uv_state", - "icon": "mdi:weather-sunny", - "qos": 1, - "state_class": "measurement", - "unit_of_measurement": "UV index", - "origin": { - "name": "Ecowitt Controller", - "sw": "0.0.1", - "url": "https://github.com/mplogas/ecowitt-controller" - } -} -``` -### subdevice -topic `homeassistant/sensor/wfc01/config` - -payload: -```json -{ - "device": { - "hw_version": "wfc01_v1.0.0", - "identifiers": [ - "wfc01_012345" - ], - "manufacturer": "Ecowitt", - "model": "WFC01", - "name": "WittFlow 001", - "sw_version": "1.0.1", - "via_device": "gw2000_testgateway" - }, - "name": "availability", - "retain": false, - "availability": [ - { - "topic": "ecowitt/gw2000/availability", - "payload_available": "online", - "payload_not_available": "offline", - "value_template": "{{ value_json.state }}" - }, - { - "topic": "ecowitt/wfc01/availability", - "payload_available": "online", - "payload_not_available": "offline", - "value_template": "{{ value_json.state }}" - } - ], - "availability_mode": "all", - "state_topic": "ecowitt/wfc01/state", - "object_id": "ecowitt_wfc01_availability_state", - "unique_id": "ecowitt_wfc01_availability_state", - "qos": 1, - "origin": { - "name": "Ecowitt Controller", - "sw": "0.0.1", - "url": "https://github.com/mplogas/ecowitt-controller" - } -} -``` - -### subdevice toggle -topic `homeassistant/switch/wfc01/config` - -payload: -```json -{ - "device": { - "hw_version": "wfc01_v1.0.0", - "identifiers": [ - "wfc01_012345" - ], - "manufacturer": "Ecowitt", - "model": "WFC01", - "name": "WittFlow 001", - "sw_version": "1.0.1", - "via_device": "gw2000_testgateway" - }, - "name": "toggle", - "retain": false, - "availability": [ - { - "topic": "ecowitt/gw2000/availability", - "payload_available": "online", - "payload_not_available": "offline", - "value_template": "{{ value_json.state }}" - }, - { - "topic": "ecowitt/wfc01/availability", - "payload_available": "online", - "payload_not_available": "offline", - "value_template": "{{ value_json.state }}" - } - ], - "availability_mode": "all", - "command_topic": "ecowitt/wfc01/cmd", - "state_topic": "ecowitt/wfc01/state", - "object_id": "ecowitt_wfc01_toggle_state", - "unique_id": "ecowitt_wfc01_toggle_state", - "qos": 1, - "icon": "mdi:water", - "origin": { - "name": "Ecowitt Controller", - "sw": "0.0.1", - "url": "https://github.com/mplogas/ecowitt-controller" - } -} -``` \ No newline at end of file diff --git a/src/Ecowitt.Controller/mqtt.md b/src/Ecowitt.Controller/mqtt.md deleted file mode 100644 index 46126b5..0000000 --- a/src/Ecowitt.Controller/mqtt.md +++ /dev/null @@ -1,17 +0,0 @@ -# MQTT - -## MQTT topic considerations - -#### Requirements -- easily accessible for HA Discovery -- Gateways, Subdevices and Sensors should be easily accessible through topic filter, eg. `ecowitt/+/subdevices/#` or `ecowitt/+/sensors/#` or `ecowitt/+/subdevices/+/sensors/#` -- sensor topics should be filtered with `/sensors/+/temperature` or `/sensors/+/humidity` -- sensor payload should be simple json, eg. `{"value": 23.4, "unit": "°C"}` -- state payload should be `{"state": "online"}` or `{"state": "offline"}` and part of the payload to gateways or subdevices - -#### Topic Templates -- `ecowitt/` - Gateway hw_info & state -- `ecowitt//subdevices/` - Subdevice hw_info & state -- `ecowitt//sensors//` - Gateway Sensor data -- `ecowitt//subdevices//sensors//` - Subdevice Sensor data - From 6f991c7c833e95bc5dcfc3b657632eeea83ef5bb Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Mon, 2 Mar 2026 22:47:33 +0100 Subject: [PATCH 37/44] Phase 2: fix wiring gaps, add stale discovery removal Fix switch discovery: publish HA switch config for WFC01/AC1100/WFC02. Fix HA reconnect: re-emit discovery on HomeAssistant online status. Fix gateway HTTP auth: add Basic auth header when credentials configured. Wire ecowitt.retries config to Polly retry policy. Remove unused PublishingInterval from config, model, and docs. Add DiscoveryRemovalEvent to clear retained HA discovery for stale sensors. --- README.md | 2 -- .../Model/Configuration/ControllerOptions.cs | 1 - .../Model/Message/Config/MqttConfig.cs | 1 - .../Model/Message/Event/DiscoveryRemovalEvent.cs | 7 +++++++ src/Ecowitt.Controller/Program.cs | 5 ++++- .../Service/Http/HttpPublishingService.cs | 4 ++-- .../Service/Mqtt/MqttService.Consumer.cs | 11 +++++++++++ src/Ecowitt.Controller/Service/Mqtt/MqttService.cs | 5 ++++- .../Service/Orchestrator/Dispatcher.ConsumerHttp.cs | 12 ++++++++++-- .../Service/Orchestrator/Dispatcher.ConsumerMqtt.cs | 2 +- .../Service/Orchestrator/Dispatcher.cs | 7 ++++++- src/Ecowitt.Controller/appsettings.json | 1 - 12 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 src/Ecowitt.Controller/Model/Message/Event/DiscoveryRemovalEvent.cs diff --git a/README.md b/README.md index 5ff0f13..4330e46 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,6 @@ Full configuration with all options and their defaults: "controller": { "precision": 2, "unit": "metric", - "publishingInterval": 60, "homeassistantdiscovery": true } } @@ -89,7 +88,6 @@ Full configuration with all options and their defaults: | `ecowitt` | `gateways` | `[]` | Manual gateway definitions (name, ip, credentials) | | `controller` | `precision` | `2` | Decimal places for floating-point values | | `controller` | `unit` | `metric` | `metric` or `imperial` | -| `controller` | `publishingInterval` | `60` | MQTT publish interval (seconds) | | `controller` | `homeassistantdiscovery` | `true` | Publish HA MQTT discovery messages | ### Run with Docker diff --git a/src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs b/src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs index 4a6bb39..5a5f036 100644 --- a/src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs +++ b/src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs @@ -4,7 +4,6 @@ public class ControllerOptions { public int Precision { get; set; } = 2; public Units Units { get; set; } = Units.Metric; - public int PublishingInterval { get; set; } = 60; public bool HomeAssistantDiscovery { get; set; } = true; } diff --git a/src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs b/src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs index 10e0b0f..71ae573 100644 --- a/src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs +++ b/src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs @@ -15,7 +15,6 @@ public class MqttConfig public int ReconnectAttempts { get; set; } = 2; public int Precision { get; set; } = 2; public Units Units { get; set; } = Units.Metric; - public int PublishingInterval { get; set; } = 60; // 0 or lower immediately publish all data public bool HomeAssistantDiscovery { get; set; } = true; public string CmdTopic { get; } = "cmd"; diff --git a/src/Ecowitt.Controller/Model/Message/Event/DiscoveryRemovalEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/DiscoveryRemovalEvent.cs new file mode 100644 index 0000000..f2330c9 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Event/DiscoveryRemovalEvent.cs @@ -0,0 +1,7 @@ +namespace Ecowitt.Controller.Model.Message.Event; + +public class DiscoveryRemovalEvent +{ + public string DeviceName { get; set; } = string.Empty; + public List Sensors { get; set; } = new(); +} diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index 3b1abd7..cbe6ed5 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -47,7 +47,8 @@ public static async Task Main(string[] args) builder.Services.Configure(configuration.GetSection("mqtt")); builder.Services.Configure(configuration.GetSection("controller")); - builder.Services.AddHttpClient("ecowitt-client").AddPolicyHandler(GetRetryPolicy(2)); + var ecowittRetries = configuration.GetSection("ecowitt").GetValue("retries"); + builder.Services.AddHttpClient("ecowitt-client").AddPolicyHandler(GetRetryPolicy(ecowittRetries > 0 ? ecowittRetries : 2)); builder.Services.AddSerilog((services, lc) => lc .ReadFrom.Services(services) @@ -74,12 +75,14 @@ public static async Task Main(string[] args) // statemachine -> mqttservice smb.Produce(x => x.DefaultTopic("config-mqtt")); smb.Produce(x => x.DefaultTopic("home-assistant-discovery")); + smb.Produce(x => x.DefaultTopic("discovery-removal")); smb.Produce(x => x.DefaultTopic("device-data")); smb.Produce(x => x.DefaultTopic("device-data-full")); smb.Produce(x => x.DefaultTopic("subdevice-data")); smb.Produce(x => x.DefaultTopic("subdevice-data-full")); smb.Consume(x => x.Topic("config-mqtt").WithConsumer()); smb.Consume(x => x.Topic("home-assistant-discovery").WithConsumer()); + smb.Consume(x => x.Topic("discovery-removal").WithConsumer()); smb.Consume(x => x.Topic("device-data").WithConsumer()); smb.Consume(x => x.Topic("device-data-full").WithConsumer()); smb.Consume(x => x.Topic("subdevice-data").WithConsumer()); diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs index a6e4b21..67fb172 100644 --- a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs @@ -120,8 +120,8 @@ private HttpClient CreateHttpClient(HttpHost host) if (!string.IsNullOrWhiteSpace(host.User) && !string.IsNullOrWhiteSpace(host.Password)) { - //TODO: authentication header, need to test - //client.DefaultRequestHeaders.Add(); + var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{host.User}:{host.Password}")); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); } return client; } diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs index 8ee60eb..2f28318 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs @@ -59,6 +59,17 @@ public async Task OnHandle(HomeAssistantDiscoveryEvent message) await EmitHomeAssistantDiscovery(message.Device); } + public async Task OnHandle(DiscoveryRemovalEvent message) + { + foreach (var sensor in message.Sensors) + { + var sensorClassTopic = BuildSensorClassTopic(sensor.SensorClass); + var topic = $"homeassistant/{MqttPathBuilder.Sanitize($"{sensorClassTopic}/{message.DeviceName}_{sensor.Name}")}/config"; + await RemoveDiscoveryMessage(topic); + _logger.LogInformation("Removed discovery for {DeviceName}/{SensorName}", message.DeviceName, sensor.Name); + } + } + public async Task OnHandle(DeviceData message) { if (_client == null || !_client.IsConnected) diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index 0d24ca6..639ee00 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -1,6 +1,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Model.Discovery; using Ecowitt.Controller.Model.Message.Config; @@ -12,7 +13,7 @@ namespace Ecowitt.Controller.Service.Mqtt; -public partial class MqttService : BackgroundService, IHostedLifecycleService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer +public partial class MqttService : BackgroundService, IHostedLifecycleService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer { private readonly ILogger _logger; private readonly MqttFactory _factory; @@ -94,6 +95,8 @@ private async Task EmitHomeAssistantDiscovery(Model.Device gateway) foreach (var subdevice in gateway.Subdevices) { await PublishSubdeviceDiscovery(gateway, subdevice); + if (subdevice.Model is SubdeviceModel.WFC01 or SubdeviceModel.AC1100 or SubdeviceModel.WFC02) + await PublishSubdeviceSwitchDiscovery(gateway, subdevice); foreach (var sensor in subdevice.Sensors) { await PublishSensorDiscovery(gateway, subdevice, sensor); diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs index 6422bd7..b351721 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs @@ -104,7 +104,11 @@ public async Task OnHandle(GatewayApiData message) else _logger.LogInformation("no changes for gateway {StoredGatewayIpAddress} ({StoredGatewayModel})", storedGateway.IpAddress, storedGateway.Model); var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); - if(sensorsToRemove.Count > 0) emitDiscovery = true; + if (sensorsToRemove.Count > 0) + { + emitDiscovery = true; + await EmitDiscoveryRemoval(storedGateway.Name, sensorsToRemove); + } foreach (var sensor in sensorsToRemove) { storedGateway.Sensors.Remove(sensor); @@ -206,7 +210,11 @@ public async Task OnHandle(SubdeviceApiAggregate message) else _logger.LogInformation("no changes for subdevice {DataId} ({DataModel})", data.Id, data.Model); var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); - if (sensorsToRemove.Count > 0) flushData = true; + if (sensorsToRemove.Count > 0) + { + flushData = true; + await EmitDiscoveryRemoval(storedSubDevice.Nickname, sensorsToRemove); + } foreach (var sensor in sensorsToRemove) { storedSubDevice.Sensors.Remove(sensor); diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs index be97f65..dd034cb 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs @@ -65,7 +65,7 @@ public async Task OnHandle(HomeAssistantStatusEvent message) _logger.LogWarning("HomeAssistant is online"); foreach (var gateway in _deviceStore.GetGatewaysShort().Select(kvp => _deviceStore.GetGateway(kvp.Key)).OfType()) { - //await EmitHomeAssistantDiscovery(gateway); + await EmitHomeAssistantDiscovery(gateway); await EmitGatewayFull(gateway); foreach (var subdevice in gateway.Subdevices) { diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs index 0639820..d8f38b5 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs @@ -49,6 +49,12 @@ private async Task EmitHomeAssistantDiscovery(Device device) await _messageBus.Publish(discoveryEvent); } + private async Task EmitDiscoveryRemoval(string deviceName, List sensors) + { + if (sensors.Count == 0) return; + await _messageBus.Publish(new DiscoveryRemovalEvent { DeviceName = deviceName, Sensors = sensors }); + } + private async Task EmitHttpConfig() { var httpConfig = new HttpConfig @@ -84,7 +90,6 @@ private async Task EmitMqttConfig() ReconnectAttempts = _mqttOptions.ReconnectAttempts, HomeAssistantDiscovery = _controllerOptions.HomeAssistantDiscovery, Precision = _controllerOptions.Precision, - PublishingInterval = _controllerOptions.PublishingInterval, Units = _controllerOptions.Units }; if (!string.IsNullOrWhiteSpace(_mqttOptions.User)) diff --git a/src/Ecowitt.Controller/appsettings.json b/src/Ecowitt.Controller/appsettings.json index bf15d23..cbab75c 100644 --- a/src/Ecowitt.Controller/appsettings.json +++ b/src/Ecowitt.Controller/appsettings.json @@ -31,7 +31,6 @@ "controller": { "precision": 2, "unit": "metric", - "publishingInterval": 5, "homeassistantdiscovery": true } } From fb69e973a43e4394aabad4c326ab4f7360ae37cc Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Mon, 2 Mar 2026 23:56:55 +0100 Subject: [PATCH 38/44] Phase 3: implement subdevice commands, fix WFC02 battery mapping Register SubdeviceApiCommand on the message bus so MQTT commands reach the Dispatcher. Add SendSubdeviceCommand to HttpPublishingService with the correct gateway payload format (position, always_on, content-type application/json). Wire the Dispatcher handler to resolve the gateway and subdevice, default bare HA ON to always-on, and delegate to HttpPublishingService. Remove old commented-out SendCommand from Dispatcher. Fix WFC02 battery reporting 5% instead of 100% by using BuildBatterySensor with the x20 multiplier (5 bars = 100%). --- .../Model/Mapping/SensorBuilder.cs | 2 +- src/Ecowitt.Controller/Program.cs | 4 ++ .../Service/Http/HttpPublishingService.cs | 62 +++++++++++++++++++ .../Orchestrator/Dispatcher.ConsumerHttp.cs | 32 +++++----- .../Service/Orchestrator/Dispatcher.cs | 43 ++----------- 5 files changed, 85 insertions(+), 58 deletions(-) diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs index af2cc19..3e3e179 100644 --- a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs @@ -288,7 +288,7 @@ public partial class SensorBuilder case "wfc02rssi": return BuildIntSensor(propertyName, "WFC02 RSSI", propertyValue, "/5", SensorType.SignalStrength, true); case "wfc02batt": - return BuildIntSensor(propertyName, "WFC02 Battery", propertyValue, "%", SensorType.Battery, true); + return BuildBatterySensor(propertyName, "WFC02 Battery", propertyValue, true); case "capacity": return BuildIntSensor(propertyName, "Capacity", propertyValue, "byte", isDiag: true); case "transition": diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index cbe6ed5..6935568 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -110,6 +110,10 @@ public static async Task Main(string[] args) smb.Consume(x => x.Topic("subdevice-api-data").WithConsumer()); smb.Consume(x => x.Topic("http-service-event").WithConsumer()); + // mqttservice -> statemachine (subdevice commands) + smb.Produce(x => x.DefaultTopic("subdevice-api-command")); + smb.Consume(x => x.Topic("subdevice-api-command").WithConsumer()); + smb.AddServicesFromAssembly(Assembly.GetExecutingAssembly()); }); diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs index 67fb172..4cfb72a 100644 --- a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs @@ -1,3 +1,4 @@ +using Ecowitt.Controller.Model; using Ecowitt.Controller.Model.Api; using Ecowitt.Controller.Model.Message.Config; using SlimMessageBus; @@ -113,6 +114,67 @@ private async Task GetSubDeviceApiPayload(HttpHost host, int subdeviceId return string.Empty; } + public async Task SendSubdeviceCommand(string gatewayIp, SubdeviceApiCommand command, SubdeviceModel model) + { + var host = _config.Hosts.FirstOrDefault(h => h.Host == gatewayIp); + if (host == null) + { + _logger.LogWarning("No host config found for gateway {GatewayIp}", gatewayIp); + return false; + } + + using var client = CreateHttpClient(host); + try + { + object payload; + switch (command.Cmd) + { + case Command.Start: + var val = command.Duration ?? 0; + var valType = (int)(command.Unit ?? DurationUnit.Minutes); + var alwaysOn = command.AlwaysOn == true || !command.Duration.HasValue ? 1 : 0; + payload = new + { + command = new[] + { + new + { + always_on = alwaysOn, val_type = valType, val, + position = 100, cmd = "quick_run", + id = command.Id, model = (int)model + } + } + }; + break; + case Command.Stop: + payload = new { command = new[] { new { cmd = "quick_stop", id = command.Id, model = (int)model } } }; + break; + default: + _logger.LogWarning("Unsupported command {Cmd} for subdevice {Id}", command.Cmd, command.Id); + return false; + } + + var json = JsonSerializer.Serialize(payload); + _logger.LogDebug("Sending command payload: {Json}", json); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + var response = await client.PostAsync("parse_quick_cmd_iot", content); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Sent {Cmd} command to subdevice {Id} on {GatewayIp}", command.Cmd, command.Id, gatewayIp); + return true; + } + + _logger.LogWarning("Failed to send command to subdevice {Id} on {GatewayIp}: {StatusCode}", command.Id, gatewayIp, response.StatusCode); + return false; + } + catch (Exception e) + { + _logger.LogError(e, "Exception sending command to subdevice {Id} on {GatewayIp}", command.Id, gatewayIp); + return false; + } + } + private HttpClient CreateHttpClient(HttpHost host) { var client = _httpClientFactory.CreateClient("ecowitt-client"); diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs index b351721..407e801 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs @@ -21,31 +21,27 @@ public async Task OnHandle(SubdeviceApiCommand message) _logger.LogWarning("Gateway not found for subdevice {MessageId}", message.Id); return; } - // hey compiler, this can't be null! - var subdevice = gw.Subdevices.FirstOrDefault(sd => sd.Id == message.Id); - - if (message.Cmd == Command.Start) - { - //var val = message.Duration ?? 20; - //var valType = message.Unit ?? DurationUnit.Minutes; - // // the magic "maganiFator" :D - //if(valType == DurationUnit.Liters) val *= 10; - //var alwaysOn = message.AlwaysOn.HasValue ? 1 : 0; - //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model, val: val, valType: (int)valType, alwaysOn: alwaysOn); - - //default always-on message for now - //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model); - } - else if (message.Cmd == Command.Stop) + var subdevice = gw.Subdevices.FirstOrDefault(sd => sd.Id == message.Id); + if (subdevice == null) { - //await SendCommand(gw.IpAddress, "quick_stop", message.Id, (int)subdevice!.Model); + _logger.LogWarning("Subdevice {MessageId} not found in gateway {GwIp}", message.Id, gw.IpAddress); + return; } - else + + if (message.Cmd is not (Command.Start or Command.Stop)) { _logger.LogWarning("Ignoring unsupported command {MessageCmd} for subdevice {MessageId}", message.Cmd, message.Id); return; } + + // HA sends bare ON/OFF without duration — default to always-on + if (message.Cmd == Command.Start && !message.Duration.HasValue) + { + message.AlwaysOn = true; + } + + await _httpPublishingService.SendSubdeviceCommand(gw.IpAddress, message, subdevice.Model); } public async Task OnHandle(GatewayApiData message) diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs index d8f38b5..54e3e5a 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs @@ -5,6 +5,7 @@ using Ecowitt.Controller.Model.Message.Config; using Ecowitt.Controller.Model.Message.Data; using Ecowitt.Controller.Model.Message.Event; +using Ecowitt.Controller.Service.Http; using Microsoft.Extensions.Options; using SlimMessageBus; @@ -18,10 +19,11 @@ public partial class Dispatcher : BackgroundService, IConsumer private readonly ControllerOptions _controllerOptions; private readonly MqttOptions _mqttOptions; private readonly IMessageBus _messageBus; + private readonly HttpPublishingService _httpPublishingService; private MqttServiceEventType _lastMqttServiceState = MqttServiceEventType.Unknown; private HttpServiceEventType _lastHttpServiceState = HttpServiceEventType.Unknown; - public Dispatcher(ILogger logger, IDeviceStore deviceStore, IMessageBus messageBus, IOptions mqttOptions, IOptions ecowittOptions, IOptions controllerOptions) + public Dispatcher(ILogger logger, IDeviceStore deviceStore, IMessageBus messageBus, IOptions mqttOptions, IOptions ecowittOptions, IOptions controllerOptions, HttpPublishingService httpPublishingService) { _logger = logger; _deviceStore = deviceStore; @@ -29,6 +31,7 @@ public Dispatcher(ILogger logger, IDeviceStore deviceStore, IMessage _ecowittOptions = ecowittOptions.Value; _controllerOptions = controllerOptions.Value; _messageBus = messageBus; + _httpPublishingService = httpPublishingService; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -163,42 +166,4 @@ await _messageBus.Publish(new SubdeviceData() }); } - // public async Task SendCommand(string ipAddress, string cmd, int id, int model, int val = 0, int valType = 0, int onType = 0, int offType = 0, int alwaysOn = 1, int onTime = 0, int offTime = 0) - // { - // var client = _httpClientFactory.CreateClient("ecowitt-client"); - // client.BaseAddress = new Uri($"http://{ipAddress}"); - // - // var username = _ecowittOptions.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Username; - // var password = _ecowittOptions.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Password; - // if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) - // { - // //TODO: authentication header, need to test - // //client.DefaultRequestHeaders.Add(); - // } - // - // // [{"on_type":0,"off_type":0,"always_on":0,"on_time":0,"off_time":0,"val_type":1,"val":20,"cmd":"quick_run","id":12345,"model":1}]} - // dynamic payload; - // switch (cmd) - // { - // case "quick_run": - // payload = new { command = new[] { new { cmd, id, model, val, val_type = valType, on_type = onType, off_type = offType, always_on = alwaysOn, on_time = onTime, off_time = offTime } } }; - // break; - // case "quick_stop": - // payload = new { command = new[] { new { cmd, id, model } } }; - // break; - // default: - // _logger.LogWarning($"Unsupported command type {cmd}. Not sending command to {ipAddress} for subdevice {id}"); - // return false; - // } - // - // var sContent = new StringContent(JsonSerializer.Serialize(payload)); - // var response = await client.PostAsync("parse_quick_cmd_iot", sContent); - // - // if (response.IsSuccessStatusCode) return true; - // else { - // _logger.LogWarning($"Could not send command to {ipAddress} for subdevice {id}"); - // return false; - // } - // } - } \ No newline at end of file From e193e437f7dbb02b8851c22a9254ba8eb56227db Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Mon, 2 Mar 2026 23:59:52 +0100 Subject: [PATCH 39/44] config changes --- src/Ecowitt.Controller/appsettings.json | 2 +- src/docker-compose.yml | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Ecowitt.Controller/appsettings.json b/src/Ecowitt.Controller/appsettings.json index cbab75c..bd9925d 100644 --- a/src/Ecowitt.Controller/appsettings.json +++ b/src/Ecowitt.Controller/appsettings.json @@ -9,7 +9,7 @@ }, "AllowedHosts": "*", "mqtt": { - "host": "", + "host": "localhost", "user": "", "password": "", "port": 1883, diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 011cde4..faa72a0 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -10,7 +10,7 @@ services: - 9001:9001 volumes: - type: bind - source: H:\docker\mosquitto + source: ~/docker/config/mosquitto target: /mosquitto/ homeassistant-dev: container_name: homeassistant-dev @@ -22,13 +22,9 @@ services: - 8123:8123 volumes: - type: bind - source: H:\docker\homeassistant + source: ~/docker/config/homeassistant target: /config - type: bind source: /etc/localtime target: /etc/localtime read_only: true - # - type: bind - # source: /run/dbus - # target: /run/dbus - # read_only: true From 87999dd96da6c1dd4e8c8d7b358dbb3b4381f505 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 3 Mar 2026 00:10:22 +0100 Subject: [PATCH 40/44] Update Dockerfile to .NET 10.0 base images The project was upgraded to net10.0 but the Dockerfile still referenced dotnet/sdk:8.0 and dotnet/aspnet:8.0, causing the Docker build CI action to fail. --- src/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Dockerfile b/src/Dockerfile index 2bb2810..1deb7c9 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -1,10 +1,10 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app -EXPOSE 8080 +EXPOSE 8080 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ADD Ecowitt.Controller/ /src/Ecowitt.Controller WORKDIR "/src/Ecowitt.Controller" RUN dotnet restore "./Ecowitt.Controller.csproj" From a0df2eeffb98b20a302718c806e35c12b825bb76 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 3 Mar 2026 20:51:10 +0100 Subject: [PATCH 41/44] Address PR review findings - Fix null guard in MqttService Subscribe/UnsubscribeHomeAssistant that would NRE when _client is null (condition was inverted) - Replace throw in MQTT callback JSON deserialization with log+return to prevent crashing the MQTT client callback path - Include port in HttpHost.BaseUrl when non-default - Add explicit WithConsumer for GatewayApiData on bus - Remove .idea/ files from source control (already in .gitignore) - Reset appsettings.json to generic defaults (no local dev values) - Fix "Updateing" typo in DeviceStore log message --- src/.idea/.idea.Ecowitt.Controller/.idea/.gitignore | 13 ------------- src/.idea/.idea.Ecowitt.Controller/.idea/.name | 1 - .../.idea.Ecowitt.Controller/.idea/encodings.xml | 4 ---- .../.idea.Ecowitt.Controller/.idea/indexLayout.xml | 8 -------- src/.idea/.idea.Ecowitt.Controller/.idea/vcs.xml | 6 ------ .../Model/Message/Config/HttpConfig.cs | 4 +++- src/Ecowitt.Controller/Program.cs | 2 +- .../Service/Mqtt/MqttService.Events.cs | 8 ++++++-- src/Ecowitt.Controller/Service/Mqtt/MqttService.cs | 4 ++-- .../Service/Orchestrator/DeviceStore.cs | 2 +- src/Ecowitt.Controller/appsettings.json | 11 +++++------ 11 files changed, 18 insertions(+), 45 deletions(-) delete mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/.gitignore delete mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/.name delete mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/encodings.xml delete mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/indexLayout.xml delete mode 100644 src/.idea/.idea.Ecowitt.Controller/.idea/vcs.xml diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/.gitignore b/src/.idea/.idea.Ecowitt.Controller/.idea/.gitignore deleted file mode 100644 index 4110581..0000000 --- a/src/.idea/.idea.Ecowitt.Controller/.idea/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Rider ignored files -/modules.xml -/contentModel.xml -/projectSettingsUpdater.xml -/.idea.Ecowitt.Controller.iml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/.name b/src/.idea/.idea.Ecowitt.Controller/.idea/.name deleted file mode 100644 index 85771ed..0000000 --- a/src/.idea/.idea.Ecowitt.Controller/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -Ecowitt.Controller \ No newline at end of file diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/encodings.xml b/src/.idea/.idea.Ecowitt.Controller/.idea/encodings.xml deleted file mode 100644 index df87cf9..0000000 --- a/src/.idea/.idea.Ecowitt.Controller/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/indexLayout.xml b/src/.idea/.idea.Ecowitt.Controller/.idea/indexLayout.xml deleted file mode 100644 index 7b08163..0000000 --- a/src/.idea/.idea.Ecowitt.Controller/.idea/indexLayout.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/.idea/.idea.Ecowitt.Controller/.idea/vcs.xml b/src/.idea/.idea.Ecowitt.Controller/.idea/vcs.xml deleted file mode 100644 index 6c0b863..0000000 --- a/src/.idea/.idea.Ecowitt.Controller/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs b/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs index c67a27f..1baa7f4 100644 --- a/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs +++ b/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs @@ -30,5 +30,7 @@ public HttpHost(string host, string user, string password) public string User { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public string Protocol { get; set; } = "http"; // or "https" - public string BaseUrl => $"{Protocol}://{Host}"; + public string BaseUrl => (Protocol == "http" && Port == 80) || (Protocol == "https" && Port == 443) + ? $"{Protocol}://{Host}" + : $"{Protocol}://{Host}:{Port}"; } diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index 6935568..1df0bd2 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -98,7 +98,7 @@ public static async Task Main(string[] args) // controller -> statemachine smb.Produce(x => x.DefaultTopic("gw-api-data")); - smb.Consume(x => x.Topic("gw-api-data")); + smb.Consume(x => x.Topic("gw-api-data").WithConsumer()); // statemachine -> HttpPublishingService smb.Produce(x => x.DefaultTopic("config-http")); diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs index a8466fb..326fcdd 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs @@ -61,12 +61,16 @@ private async Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessag try { var cmd = JsonSerializer.Deserialize(payload); + if (cmd == null) + { + _logger.LogWarning("Failed to deserialize command from topic {Topic}", topic); + return; + } await _messageBus.Publish(cmd); } catch (Exception e) { - Console.WriteLine(e); - throw; + _logger.LogError(e, "Failed to deserialize command from topic {Topic}: {Payload}", topic, payload); } } } diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs index 639ee00..ad8878a 100644 --- a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -60,7 +60,7 @@ await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", private async Task SubscribeHomeAssistant() { - if(_client != null && (!_client.IsConnected || _isConnecting)) + if (_client == null || !_client.IsConnected || _isConnecting) { _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot subscribe to Home Assistant state."); return; @@ -73,7 +73,7 @@ private async Task SubscribeHomeAssistant() private async Task UnsubscribeHomeAssistant() { - if(_client != null && (!_client.IsConnected || _isConnecting)) + if (_client == null || !_client.IsConnected || _isConnecting) { _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot unsubscribe from Home Assistant state."); return; diff --git a/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs index 3aa7633..a1bf081 100644 --- a/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs @@ -41,7 +41,7 @@ public bool UpsertGateway(Device data) { if (_gateways.TryGetValue(data.IpAddress, out var gateway)) { - _logger.LogInformation("Updateing Gateway {DataIpAddress} ({DataModel})", data.IpAddress, data.Model); + _logger.LogInformation("Updating gateway {DataIpAddress} ({DataModel})", data.IpAddress, data.Model); return _gateways.TryUpdate(data.IpAddress, data, gateway); } else diff --git a/src/Ecowitt.Controller/appsettings.json b/src/Ecowitt.Controller/appsettings.json index bd9925d..ada5a09 100644 --- a/src/Ecowitt.Controller/appsettings.json +++ b/src/Ecowitt.Controller/appsettings.json @@ -3,28 +3,27 @@ "MinimumLevel": { "Default": "Information", "Override": { - //"Ecowitt.Controller.Service.Orchestrator": "Debug" } } }, "AllowedHosts": "*", "mqtt": { - "host": "localhost", + "host": "", "user": "", "password": "", "port": 1883, - "basetopic": "ec-dev", + "basetopic": "ecowitt", "clientId": "ecowitt-controller", "reconnect": true, "reconnectAttemps": 5 }, "ecowitt": { - "pollingInterval": 3, + "pollingInterval": 30, "autodiscovery": false, "gateways": [ { - "name": "gw3000", - "ip": "192.168.56.138" + "name": "", + "ip": "" } ] }, From d20bee4b757c141ed8415a4996521b9349bac1ff Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 3 Mar 2026 20:57:50 +0100 Subject: [PATCH 42/44] removed empty, legacy folder --- src/Ecowitt.Controller.sln | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Ecowitt.Controller.sln b/src/Ecowitt.Controller.sln index f59151b..b2626fb 100644 --- a/src/Ecowitt.Controller.sln +++ b/src/Ecowitt.Controller.sln @@ -28,13 +28,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{02EA681E-C ..\docs\mqtt.md = ..\docs\mqtt.md EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "old_classes", "old_classes", "{F45EBE6B-D610-44B2-B7ED-B96A65472093}" - ProjectSection(SolutionItems) = preProject - ..\old\Store\DataPublishService.cs = ..\old\Store\DataPublishService.cs - ..\old\Discovery\DiscoveryPublishService.cs = ..\old\Discovery\DiscoveryPublishService.cs - ..\old\Subdevice\SubdeviceService.cs = ..\old\Subdevice\SubdeviceService.cs - EndProjectSection -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU From adf4d58e3af0fe4cc1455fc3f51342785df89991 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 3 Mar 2026 21:02:16 +0100 Subject: [PATCH 43/44] Add semver tagging to Docker build workflow Trigger on v* tags to produce versioned container images (e.g. v2.0.0 generates 2.0.0, 2.0, 2 tags). Skip push on PR builds. Keep latest tag for main branch pushes. --- .github/workflows/main.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b2621f4..97835c6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,6 +3,7 @@ name: Docker Image Build and Push to DockerHub on: push: branches: [ "main" ] + tags: [ "v*" ] pull_request: branches: [ "main" ] workflow_dispatch: @@ -22,19 +23,18 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - # list of Docker images to use as base name for tags images: | ${{ vars.DOCKERHUB_USERNAME }}/ecowitt-controller - # Docker tags to generate tags: | - type=schedule,enable=true,pattern={{date 'YYYYMMDD-hhmmss' tz='Europe/Berlin'}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} type=raw,enable={{is_default_branch}},value=latest - type=sha,enable=true,format=short + type=sha,enable=true,format=short - name: Build and push uses: docker/build-push-action@v5 with: context: "{{defaultContext}}:src" - #dockerfile: ./Dockerfile - push: true + push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.meta.outputs.labels }} tags: ${{ steps.meta.outputs.tags }} From f846439b723664b1f2dd99bd3c4a05cd80c51b52 Mon Sep 17 00:00:00 2001 From: Marc Plogas Date: Tue, 3 Mar 2026 21:07:58 +0100 Subject: [PATCH 44/44] Update README to reflect .NET 10 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4330e46..d8ed1c9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Ecowitt Controller -A .NET 8 bridge that connects Ecowitt weather stations and IoT subdevices to MQTT, with native Home Assistant auto-discovery. +A .NET 10 bridge that connects Ecowitt weather stations and IoT subdevices to MQTT, with native Home Assistant auto-discovery. ## Features