From a0e9e2fbbd8443d89d4d1ede3bb019492668fd58 Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Tue, 31 Dec 2024 08:37:54 -0500 Subject: [PATCH 1/3] Add Global Settings / Improve Node Settings --- src/Controllers/StateController.cs | 12 +- src/Models/NodeSettings.cs | 84 ++++++++++--- src/Models/OptimizationResults.cs | 6 +- src/Optimizers/OptimizationRunner.cs | 4 +- src/Services/NodeSettingsStore.cs | 103 ++++++++++++++-- src/ui/src/lib/GlobalSettings.svelte | 155 ++++++++++++++++++++++++ src/ui/src/lib/NodeSettings.svelte | 81 ++++++++++--- src/ui/src/lib/TriStateCheckbox.svelte | 38 ++++++ src/ui/src/lib/types.ts | 30 ++++- src/ui/src/routes/+layout.svelte | 1 + src/ui/src/routes/settings/+page.svelte | 14 +++ 11 files changed, 466 insertions(+), 62 deletions(-) create mode 100644 src/ui/src/lib/GlobalSettings.svelte create mode 100644 src/ui/src/lib/TriStateCheckbox.svelte create mode 100644 src/ui/src/routes/settings/+page.svelte diff --git a/src/Controllers/StateController.cs b/src/Controllers/StateController.cs index 8733fd74..a45cf761 100644 --- a/src/Controllers/StateController.cs +++ b/src/Controllers/StateController.cs @@ -66,9 +66,9 @@ public Calibration GetCalibration() { var rxNs = _nsd.Get(rxId); var rxM = txM.GetOrAdd(rx.Rx?.Name ?? rxId); - if (txNs.TxRefRssi is not null) rxM["tx_ref_rssi"] = txNs.TxRefRssi.Value; - if (rxNs.RxAdjRssi is not null) rxM["rx_adj_rssi"] = rxNs.RxAdjRssi.Value; - if (rxNs.Absorption is not null) rxM["absorption"] = rxNs.Absorption.Value; + if (txNs.Calibration.TxRefRssi is not null) rxM["tx_ref_rssi"] = txNs.Calibration.TxRefRssi.Value; + if (rxNs.Calibration.RxAdjRssi is not null) rxM["rx_adj_rssi"] = rxNs.Calibration.RxAdjRssi.Value; + if (rxNs.Calibration.Absorption is not null) rxM["absorption"] = rxNs.Calibration.Absorption.Value; rxM["expected"] = rx.Expected; rxM["actual"] = rx.Distance; rxM["rssi"] = rx.Rssi; @@ -145,9 +145,9 @@ public async Task ResetCalibration() foreach (var node in _state.Nodes.Values) { var nodeSettings = _nsd.Get(node.Id); - nodeSettings.TxRefRssi = null; - nodeSettings.RxAdjRssi = null; - nodeSettings.Absorption = null; + nodeSettings.Calibration.TxRefRssi = null; + nodeSettings.Calibration.RxAdjRssi = null; + nodeSettings.Calibration.Absorption = null; await _nsd.Set(node.Id, nodeSettings); } diff --git a/src/Models/NodeSettings.cs b/src/Models/NodeSettings.cs index a7dceb02..3b8c16ab 100644 --- a/src/Models/NodeSettings.cs +++ b/src/Models/NodeSettings.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Newtonsoft.Json; @@ -11,28 +11,76 @@ public class NodeSettings(string id) [StringLength(64)] public string? Id { get; set; } = id; - [JsonPropertyName("absorption")] - [JsonProperty("absorption")] - [Range(1, 10)] - public double? Absorption { get; set; } + [JsonPropertyName("updating")] + [JsonProperty("updating")] + public UpdatingSettings Updating { get; set; } = new UpdatingSettings(); - [JsonPropertyName("rx_adj_rssi")] - [JsonProperty("rx_adj_rssi")] - [Range(-127, 128)] - public int? RxAdjRssi { get; set; } + [JsonPropertyName("scanning")] + [JsonProperty("scanning")] + public ScanningSettings Scanning { get; set; } = new ScanningSettings(); - [JsonPropertyName("tx_ref_rssi")] - [JsonProperty("tx_ref_rssi")] - [Range(-127, 128)] - public int? TxRefRssi { get; set; } + [JsonPropertyName("counting")] + [JsonProperty("counting")] + public CountingSettings Counting { get; set; } = new CountingSettings(); - [JsonPropertyName("max_distance")] - [JsonProperty("max_distance")] - [Range(0, 100)] - public double? MaxDistance { get; set; } + [JsonPropertyName("filtering")] + [JsonProperty("filtering")] + public FilteringSettings Filtering { get; set; } = new FilteringSettings(); + + [JsonPropertyName("calibration")] + [JsonProperty("calibration")] + public CalibrationSettings Calibration { get; set; } = new CalibrationSettings(); public NodeSettings Clone() { - return (NodeSettings)MemberwiseClone(); + return new NodeSettings(id) + { + Updating = Updating.Clone(), + Scanning = Scanning.Clone(), + Counting = Counting.Clone(), + Filtering = Filtering.Clone(), + Calibration = Calibration.Clone() + }; } +} + +public class UpdatingSettings +{ + public bool? AutoUpdate { get; set; } + public bool? PreRelease { get; set; } + public UpdatingSettings Clone() => (UpdatingSettings)MemberwiseClone(); +} + +public class ScanningSettings +{ + public int? ForgetAfterMs { get; set; } + public ScanningSettings Clone() => (ScanningSettings)MemberwiseClone(); +} + +public class CountingSettings +{ + public string? IdPrefixes { get; set; } + public double? StartCountingDistance { get; set; } + public double? StopCountingDistance { get; set; } + public int? IncludeDevicesAge { get; set; } + public CountingSettings Clone() => (CountingSettings)MemberwiseClone(); +} + +public class FilteringSettings +{ + public string? IncludeIds { get; set; } + public string? ExcludeIds { get; set; } + public double? MaxDistance { get; set; } + public double? EarlyReportDistance { get; set; } + public int? SkipReportAge { get; set; } + public FilteringSettings Clone() => (FilteringSettings)MemberwiseClone(); +} + +public class CalibrationSettings +{ + public int? RssiAt1m { get; set; } + public int? RxAdjRssi { get; set; } + public double? Absorption { get; set; } + public int? TxRefRssi { get; set; } + public CalibrationSettings Clone() => (CalibrationSettings)MemberwiseClone(); } \ No newline at end of file diff --git a/src/Models/OptimizationResults.cs b/src/Models/OptimizationResults.cs index 8dffa723..0308901c 100644 --- a/src/Models/OptimizationResults.cs +++ b/src/Models/OptimizationResults.cs @@ -20,9 +20,9 @@ public double Evaluate(List oss, NodeSettingsStore nss) var rx = nss.Get(m.Rx.Id); RxNodes.TryGetValue(m.Rx.Id, out var pv); - double rxAdjRssi = pv?.RxAdjRssi ?? rx.RxAdjRssi ?? 0; - double txPower = tx.TxRefRssi ?? -59; - double pathLossExponent = pv?.Absorption ?? rx.Absorption ?? 3; + double rxAdjRssi = pv?.RxAdjRssi ?? rx.Calibration.RxAdjRssi ?? 0; + double txPower = tx.Calibration.TxRefRssi ?? -59; + double pathLossExponent = pv?.Absorption ?? rx.Calibration.Absorption ?? 3; double distance = m.Rx.Location.DistanceTo(m.Tx.Location); double predictedRssi = txPower + rxAdjRssi - 10 * pathLossExponent * Math.Log10(distance); diff --git a/src/Optimizers/OptimizationRunner.cs b/src/Optimizers/OptimizationRunner.cs index 87b25fc7..e20bfc07 100644 --- a/src/Optimizers/OptimizationRunner.cs +++ b/src/Optimizers/OptimizationRunner.cs @@ -74,8 +74,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) Log.Information("Optimizer set {0,-20} to Absorption: {1:0.00} RxAdj: {2:00} Error: {3}", id, result.Absorption, result.RxAdjRssi, result.Error); var a = _nsd.Get(id); if (optimization == null) continue; - if (result.Absorption != null && result.Absorption > optimization.AbsorptionMin && result.Absorption < optimization.AbsorptionMax) a.Absorption = result.Absorption; - if (result.RxAdjRssi != null && result.RxAdjRssi > optimization.RxAdjRssiMin && result.RxAdjRssi < optimization.RxAdjRssiMax) a.RxAdjRssi = result.RxAdjRssi == null ? 0 : (int?)Math.Round(result.RxAdjRssi.Value); + if (result.Absorption != null && result.Absorption > optimization.AbsorptionMin && result.Absorption < optimization.AbsorptionMax) a.Calibration.Absorption = result.Absorption; + if (result.RxAdjRssi != null && result.RxAdjRssi > optimization.RxAdjRssiMin && result.RxAdjRssi < optimization.RxAdjRssiMax) a.Calibration.RxAdjRssi = result.RxAdjRssi == null ? 0 : (int?)Math.Round(result.RxAdjRssi.Value); await _nsd.Set(id, a); } diff --git a/src/Services/NodeSettingsStore.cs b/src/Services/NodeSettingsStore.cs index cebd9337..a0144349 100644 --- a/src/Services/NodeSettingsStore.cs +++ b/src/Services/NodeSettingsStore.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using ESPresense.Models; namespace ESPresense.Services @@ -15,12 +15,46 @@ public NodeSettings Get(string id) public async Task Set(string id, NodeSettings ds) { var old = Get(id); - if (ds.Absorption == null || ds.Absorption != old.Absorption) - await mqtt.EnqueueAsync($"espresense/rooms/{id}/absorption/set", $"{ds.Absorption:0.00}"); - if (ds.RxAdjRssi == null || ds.RxAdjRssi != old.RxAdjRssi) - await mqtt.EnqueueAsync($"espresense/rooms/{id}/rx_adj_rssi/set", $"{ds.RxAdjRssi}"); - if (ds.TxRefRssi == null || ds.TxRefRssi != old.TxRefRssi) - await mqtt.EnqueueAsync($"espresense/rooms/{id}/tx_ref_rssi/set", $"{ds.TxRefRssi}"); + + // Updating settings + if (ds.Updating.AutoUpdate == null || ds.Updating.AutoUpdate != old.Updating.AutoUpdate) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/auto_update/set", $"{ds.Updating.AutoUpdate}"); + if (ds.Updating.PreRelease == null || ds.Updating.PreRelease != old.Updating.PreRelease) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/pre_release/set", $"{ds.Updating.PreRelease}"); + + // Scanning settings + if (ds.Scanning.ForgetAfterMs == null || ds.Scanning.ForgetAfterMs != old.Scanning.ForgetAfterMs) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/forget_after_ms/set", $"{ds.Scanning.ForgetAfterMs}"); + + // Counting settings + if (ds.Counting.IdPrefixes == null || ds.Counting.IdPrefixes != old.Counting.IdPrefixes) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/count_ids/set", $"{ds.Counting.IdPrefixes}"); + if (ds.Counting.StartCountingDistance == null || ds.Counting.StartCountingDistance != old.Counting.StartCountingDistance) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/count_min_dist/set", $"{ds.Counting.StartCountingDistance:0.00}"); + if (ds.Counting.StopCountingDistance == null || ds.Counting.StopCountingDistance != old.Counting.StopCountingDistance) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/count_max_dist/set", $"{ds.Counting.StopCountingDistance:0.00}"); + if (ds.Counting.IncludeDevicesAge == null || ds.Counting.IncludeDevicesAge != old.Counting.IncludeDevicesAge) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/include_devices_age/set", $"{ds.Counting.IncludeDevicesAge}"); + + // Filtering settings + if (ds.Filtering.IncludeIds == null || ds.Filtering.IncludeIds != old.Filtering.IncludeIds) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/include_ids/set", $"{ds.Filtering.IncludeIds}"); + if (ds.Filtering.ExcludeIds == null || ds.Filtering.ExcludeIds != old.Filtering.ExcludeIds) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/exclude_ids/set", $"{ds.Filtering.ExcludeIds}"); + if (ds.Filtering.MaxDistance == null || ds.Filtering.MaxDistance != old.Filtering.MaxDistance) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/max_distance/set", $"{ds.Filtering.MaxDistance:0.00}"); + if (ds.Filtering.EarlyReportDistance == null || ds.Filtering.EarlyReportDistance != old.Filtering.EarlyReportDistance) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/early_report_distance/set", $"{ds.Filtering.EarlyReportDistance:0.00}"); + if (ds.Filtering.SkipReportAge == null || ds.Filtering.SkipReportAge != old.Filtering.SkipReportAge) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/skip_report_age/set", $"{ds.Filtering.SkipReportAge}"); + + // Calibration settings + if (ds.Calibration.Absorption == null || ds.Calibration.Absorption != old.Calibration.Absorption) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/absorption/set", $"{ds.Calibration.Absorption:0.00}"); + if (ds.Calibration.RxAdjRssi == null || ds.Calibration.RxAdjRssi != old.Calibration.RxAdjRssi) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/rx_adj_rssi/set", $"{ds.Calibration.RxAdjRssi}"); + if (ds.Calibration.TxRefRssi == null || ds.Calibration.TxRefRssi != old.Calibration.TxRefRssi) + await mqtt.EnqueueAsync($"espresense/rooms/{id}/tx_ref_rssi/set", $"{ds.Calibration.TxRefRssi}"); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -32,18 +66,61 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var ns = Get(arg.NodeId); switch (arg.Setting) { + // Updating settings + case "auto_update": + ns.Updating.AutoUpdate = bool.Parse(arg.Payload); + break; + case "pre_release": + ns.Updating.PreRelease = bool.Parse(arg.Payload); + break; + + // Scanning settings + case "forget_after_ms": + ns.Scanning.ForgetAfterMs = int.Parse(arg.Payload); + break; + + // Counting settings + case "count_ids": + ns.Counting.IdPrefixes = arg.Payload; + break; + case "count_min_dist": + ns.Counting.StartCountingDistance = double.Parse(arg.Payload); + break; + case "count_max_dist": + ns.Counting.StopCountingDistance = double.Parse(arg.Payload); + break; + case "include_devices_age": + ns.Counting.IncludeDevicesAge = int.Parse(arg.Payload); + break; + + // Filtering settings + case "include_ids": + ns.Filtering.IncludeIds = arg.Payload; + break; + case "exclude_ids": + ns.Filtering.ExcludeIds = arg.Payload; + break; + case "max_distance": + ns.Filtering.MaxDistance = double.Parse(arg.Payload); + break; + case "early_report_distance": + ns.Filtering.EarlyReportDistance = double.Parse(arg.Payload); + break; + case "skip_report_age": + ns.Filtering.SkipReportAge = int.Parse(arg.Payload); + break; + + // Calibration settings case "absorption": - ns.Absorption = double.Parse(arg.Payload); + ns.Calibration.Absorption = double.Parse(arg.Payload); break; case "rx_adj_rssi": - ns.RxAdjRssi = int.Parse(arg.Payload); + ns.Calibration.RxAdjRssi = int.Parse(arg.Payload); break; case "tx_ref_rssi": - ns.TxRefRssi = int.Parse(arg.Payload); - break; - case "max_distance": - ns.MaxDistance = double.Parse(arg.Payload); + ns.Calibration.TxRefRssi = int.Parse(arg.Payload); break; + default: return Task.CompletedTask; } diff --git a/src/ui/src/lib/GlobalSettings.svelte b/src/ui/src/lib/GlobalSettings.svelte new file mode 100644 index 00000000..8e3aecb1 --- /dev/null +++ b/src/ui/src/lib/GlobalSettings.svelte @@ -0,0 +1,155 @@ + + +{#if loading} +
+
+ +

Loading settings...

+
+
+{:else if error} +
+

Error: {error}

+
+{:else if $settings} +
+
+

Updating

+
+
+ + +
+
+ + +
+
+
+ +
+

Scanning

+
+ +
+
+ +
+

Counting

+
+ + + + + + + +
+
+ +
+

Filtering

+
+ + + + + + + + + +
+
+ +
+

Calibration

+
+ + + + + + + +
+
+ +
+ +
+
+{:else} +
+

No settings available.

+
+{/if} \ No newline at end of file diff --git a/src/ui/src/lib/NodeSettings.svelte b/src/ui/src/lib/NodeSettings.svelte index 32d72df6..26c41339 100644 --- a/src/ui/src/lib/NodeSettings.svelte +++ b/src/ui/src/lib/NodeSettings.svelte @@ -46,22 +46,71 @@ Name - - - - + +
+

Updating

+ + +
+ +
+

Counting

+ + + +
+ +
+

Filtering

+ + + +
+ +
+

Calibration

+ + + + +
+ diff --git a/src/ui/src/lib/TriStateCheckbox.svelte b/src/ui/src/lib/TriStateCheckbox.svelte new file mode 100644 index 00000000..b96b7480 --- /dev/null +++ b/src/ui/src/lib/TriStateCheckbox.svelte @@ -0,0 +1,38 @@ + + + + + \ No newline at end of file diff --git a/src/ui/src/lib/types.ts b/src/ui/src/lib/types.ts index 681bab04..bf8d252e 100644 --- a/src/ui/src/lib/types.ts +++ b/src/ui/src/lib/types.ts @@ -76,10 +76,32 @@ export interface Config { export type NodeSetting = { id: string | null; name: string | null; - absorption: number | null; - rx_adj_rssi: number | null; - tx_ref_rssi: number | null; - max_distance: number | null; + updating: { + autoUpdate: boolean | null; + preRelease: boolean | null; + }; + scanning: { + forgetAfterMs: number | null; + }; + counting: { + idPrefixes: string | null; + startCountingDistance: number | null; + stopCountingDistance: number | null; + includeDevicesAge: number | null; + }; + filtering: { + includeIds: string | null; + excludeIds: string | null; + maxDistance: number | null; + earlyReportDistance: number | null; + skipReportAge: number | null; + }; + calibration: { + rssiAt1m: number | null; + rxAdjRssi: number | null; + absorption: number | null; + txRefRssi: number | null; + }; }; export type DeviceSetting = { diff --git a/src/ui/src/routes/+layout.svelte b/src/ui/src/routes/+layout.svelte index dd7143f3..c3cf4499 100644 --- a/src/ui/src/routes/+layout.svelte +++ b/src/ui/src/routes/+layout.svelte @@ -27,6 +27,7 @@ { href: '/nodes', name: 'nodes', icon: nodes, alt: 'Nodes' }, { href: '/calibration', name: 'calibration', icon: calibration, alt: 'Calibration' }, { href: '/3d', name: '3d', icon: cube, alt: '3D View' }, + { href: '/settings', name: 'settings', icon: settings, alt: 'Settings' } ]; diff --git a/src/ui/src/routes/settings/+page.svelte b/src/ui/src/routes/settings/+page.svelte new file mode 100644 index 00000000..a77dcab0 --- /dev/null +++ b/src/ui/src/routes/settings/+page.svelte @@ -0,0 +1,14 @@ + + + + ESPresense Companion: Settings + + +
+

Settings

+

These settings will be applied to every node, including new nodes.

+ + +
From 4213bfd7028a7230882d842c5af1d904a8185b18 Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Tue, 31 Dec 2024 08:55:18 -0500 Subject: [PATCH 2/3] Fix ON/OFF --- src/Services/NodeSettingsStore.cs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/Services/NodeSettingsStore.cs b/src/Services/NodeSettingsStore.cs index a0144349..31c55143 100644 --- a/src/Services/NodeSettingsStore.cs +++ b/src/Services/NodeSettingsStore.cs @@ -1,10 +1,23 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using ESPresense.Models; namespace ESPresense.Services { public class NodeSettingsStore(MqttCoordinator mqtt, ILogger logger) : BackgroundService { + private static bool ParseBool(string value) + { + if (string.IsNullOrEmpty(value)) + return false; + + value = value.Trim().ToUpperInvariant(); + return value switch + { + "TRUE" or "1" or "ON" => true, + "FALSE" or "0" or "OFF" => false, + _ => bool.Parse(value) + }; + } private readonly ConcurrentDictionary _storeById = new(); public NodeSettings Get(string id) @@ -18,9 +31,9 @@ public async Task Set(string id, NodeSettings ds) // Updating settings if (ds.Updating.AutoUpdate == null || ds.Updating.AutoUpdate != old.Updating.AutoUpdate) - await mqtt.EnqueueAsync($"espresense/rooms/{id}/auto_update/set", $"{ds.Updating.AutoUpdate}"); + await mqtt.EnqueueAsync($"espresense/rooms/{id}/auto_update/set", ds.Updating.AutoUpdate == true ? "ON" : "OFF"); if (ds.Updating.PreRelease == null || ds.Updating.PreRelease != old.Updating.PreRelease) - await mqtt.EnqueueAsync($"espresense/rooms/{id}/pre_release/set", $"{ds.Updating.PreRelease}"); + await mqtt.EnqueueAsync($"espresense/rooms/{id}/pre_release/set", ds.Updating.PreRelease == true ? "ON" : "OFF"); // Scanning settings if (ds.Scanning.ForgetAfterMs == null || ds.Scanning.ForgetAfterMs != old.Scanning.ForgetAfterMs) @@ -68,10 +81,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Updating settings case "auto_update": - ns.Updating.AutoUpdate = bool.Parse(arg.Payload); + ns.Updating.AutoUpdate = ParseBool(arg.Payload); break; case "pre_release": - ns.Updating.PreRelease = bool.Parse(arg.Payload); + ns.Updating.PreRelease = ParseBool(arg.Payload); break; // Scanning settings From af9eccdf6182f76a1ff106cf399215fc0afec028 Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Tue, 31 Dec 2024 08:57:58 -0500 Subject: [PATCH 3/3] Fix null discovery message --- src/Services/DeviceTracker.cs | 15 +++++++++++++++ src/Services/MqttCoordinator.cs | 6 +++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Services/DeviceTracker.cs b/src/Services/DeviceTracker.cs index 9f68d8d8..1ec2b071 100644 --- a/src/Services/DeviceTracker.cs +++ b/src/Services/DeviceTracker.cs @@ -16,6 +16,21 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) mqtt.PreviousDeviceDiscovered += (s, arg) => { + if (arg.AutoDiscover == null) + { + // Handle null AutoDiscover (message deletion) + var deleteDeviceId = arg.AutoDiscover?.Message?.StateTopic?.Split('/').Last(); + if (deleteDeviceId != null && state.Devices.TryRemove(deleteDeviceId, out var removedDevice)) + { + Log.Debug("[-] Removed device: {Device} (disc)", removedDevice); + } + else + { + Log.Debug("Device not found for deletion: {DeviceId}", deleteDeviceId); + } + return; + } + if (arg.AutoDiscover.Component != "device_tracker") { Log.Debug("Ignoring, component isn't device_tracker (" + arg.AutoDiscover.Component + ")"); diff --git a/src/Services/MqttCoordinator.cs b/src/Services/MqttCoordinator.cs index 999cdfd5..8b296dbb 100644 --- a/src/Services/MqttCoordinator.cs +++ b/src/Services/MqttCoordinator.cs @@ -317,7 +317,11 @@ private async Task ProcessDiscoveryMessage(string topic, string? payload) _logger.LogTrace($"Received discovery message on topic: {topic}"); if (payload == null) - throw new MqttMessageProcessingException("Discovery message payload was null", topic, null, "Discovery"); + { + // Null payload indicates deletion of retained message + PreviousDeviceDiscovered?.Invoke(this, new PreviousDeviceDiscoveredEventArgs { AutoDiscover = null }); + return; + } if (!AutoDiscovery.TryDeserialize(topic, payload, out var msg)) throw new MqttMessageProcessingException("Failed to deserialize discovery message", topic, payload, "Discovery");