add ui for sensors and improve many things

pull/9/head
sleevezipper 4 years ago
parent 1c0fe97c1b
commit db89b725de

@ -8,6 +8,7 @@
<Application.Styles>
<StyleInclude Source="avares://Avalonia.Themes.Default/DefaultTheme.xaml"/>
<StyleInclude Source="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/>
<StyleInclude Source="avares://Avalonia.Themes.Default/Accents/BaseDark.xaml"/>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml"/>
</Application.Styles>
</Application>

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
@ -12,6 +12,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="0.9.12" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.9.12" />
<PackageReference Include="Avalonia.Desktop" Version="0.9.12" />
<PackageReference Include="Avalonia.ReactiveUI" Version="0.9.12" />
<PackageReference Include="Avalonia.Win32" Version="0.9.12" />

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
namespace UserInterface.Util
{
public class BrowserUtil
{
public static void OpenBrowser(string url)
{
try
{
Process.Start(url);
}
catch
{
// hack because of this: https://github.com/dotnet/corefx/issues/10361
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
url = url.Replace("&", "^&");
Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { CreateNoWindow = true });
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Process.Start("xdg-open", url);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("open", url);
}
else
{
throw;
}
}
}
}
}

@ -0,0 +1,28 @@
using hass_workstation_service.Communication.InterProcesCommunication.Models;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Text;
namespace UserInterface.ViewModels
{
public class AddSensorViewModel : ViewModelBase
{
private AvailableSensors selectedType;
private string description;
public string Description { get => description; set => this.RaiseAndSetIfChanged(ref description, value); }
private string moreInfoLink;
public string MoreInfoLink
{
get { return moreInfoLink; }
set { this.RaiseAndSetIfChanged(ref moreInfoLink, value); }
}
public AvailableSensors SelectedType { get => selectedType; set => this.RaiseAndSetIfChanged(ref selectedType, value); }
public string Name { get; set; }
}
}

@ -0,0 +1,24 @@
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Text;
namespace UserInterface.ViewModels
{
public class SensorSettingsViewModel : ViewModelBase
{
private ICollection<SensorViewModel> configuredSensors;
public ICollection<SensorViewModel> ConfiguredSensors { get => configuredSensors; set => this.RaiseAndSetIfChanged(ref configuredSensors, value); }
}
public class SensorViewModel : ViewModelBase
{
private string _value;
public Guid Id { get; set; }
public string Type { get; set; }
public string Name { get; set; }
public string Value { get => _value; set => this.RaiseAndSetIfChanged(ref _value, value); }
}
}

@ -0,0 +1,19 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="UserInterface.Views.AddSensorDialog"
SizeToContent="WidthAndHeight"
Title="Add sensor">
<StackPanel Margin="40" MinWidth="200">
<ContentControl Margin="0 20 0 10">Sensor type</ContentControl>
<ComboBox x:Name="ComboBox" SelectionChanged="ComboBoxClosed" SelectedItem="{Binding SelectedType}" MinHeight="27"></ComboBox>
<TextBlock Margin="0 10 0 10" MaxWidth="300" TextWrapping="Wrap" TextAlignment="Left" Text="{Binding Description}"></TextBlock>
<Button IsVisible="{Binding MoreInfoLink, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" Click="OpenInfo" Margin="0 10 0 10">Click for more information.</Button>
<ContentControl Margin="0 20 0 10">Name</ContentControl>
<TextBox Text="{Binding Name}" HorizontalAlignment="Left" Width="150"/>
<Button Width="75" HorizontalAlignment="Right" Margin="0 40 0 10" Click="Save">Save</Button>
</StackPanel>
</Window>

@ -0,0 +1,86 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using hass_workstation_service.Communication.InterProcesCommunication.Models;
using hass_workstation_service.Communication.NamedPipe;
using JKang.IpcServiceFramework.Client;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Dynamic;
using System.Linq;
using System.Text.Json;
using UserInterface.Util;
using UserInterface.ViewModels;
namespace UserInterface.Views
{
public class AddSensorDialog : Window
{
private readonly IIpcClient<ServiceContractInterfaces> client;
public ComboBox comboBox { get; set; }
public AddSensorDialog()
{
this.InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
this.comboBox = this.FindControl<ComboBox>("ComboBox");
this.comboBox.Items = Enum.GetValues(typeof(AvailableSensors)).Cast<AvailableSensors>();
// register IPC clients
ServiceProvider serviceProvider = new ServiceCollection()
.AddNamedPipeIpcClient<ServiceContractInterfaces>("addsensor", pipeName: "pipeinternal")
.BuildServiceProvider();
// resolve IPC client factory
IIpcClientFactory<ServiceContractInterfaces> clientFactory = serviceProvider
.GetRequiredService<IIpcClientFactory<ServiceContractInterfaces>>();
// create client
this.client = clientFactory.CreateClient("addsensor");
DataContext = new AddSensorViewModel();
}
public async void Save(object sender, RoutedEventArgs args)
{
var item = ((AddSensorViewModel)this.DataContext);
dynamic model = new { Name = item.Name };
string json = JsonSerializer.Serialize(model);
await this.client.InvokeAsync(x => x.AddSensor(item.SelectedType, json));
Close();
}
public void ComboBoxClosed(object sender, SelectionChangedEventArgs args)
{
var item = ((AddSensorViewModel)this.DataContext);
switch (item.SelectedType)
{
case AvailableSensors.UserNotificationStateSensor:
item.Description = "This sensor watches the UserNotificationState. This is normally used in applications to determine if it is appropriate to send a notification but we can use it to expose this state. \n ";
item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service#usernotificationstate";
break;
case AvailableSensors.DummySensor:
item.Description = "This sensor spits out a random number every second. Useful for testing, maybe you'll find some other use for it.";
item.MoreInfoLink = "https://github.com/sleevezipper/hass-workstation-service#dummy";
break;
default:
item.Description = null;
item.MoreInfoLink = null;
break;
}
}
public void OpenInfo(object sender, RoutedEventArgs args)
{
var item = ((AddSensorViewModel)this.DataContext);
BrowserUtil.OpenBrowser(item.MoreInfoLink);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}

@ -8,17 +8,15 @@
x:Class="UserInterface.Views.MainWindow"
Icon="/Assets/hass-workstation-logo.ico"
SizeToContent="WidthAndHeight"
Title="Settings"
Background="LightGray">
Title="Settings">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid ColumnDefinitions="Auto,1*,Auto" RowDefinitions="Auto,Auto,Auto" Margin="30">
<views:BrokerSettings Grid.Column="0" Grid.Row="0" Margin="10 0" Background="White"/>
<views:SensorSettings Grid.Column="1" Grid.Row="0" Margin="10 0" Background="White"/>
<views:BackgroundServiceSettings Grid.Column="2" Grid.Row="0" Margin="10 0" Background="White"/>
<views:BrokerSettings Grid.Column="0" Grid.Row="0" Margin="10 0" Background="#2D2D30"/>
<views:SensorSettings Grid.Column="1" Grid.Row="0" Margin="10 0" Background="#2D2D30"/>
<views:BackgroundServiceSettings Grid.Column="2" Grid.Row="0" Margin="10 0" Background="#2D2D30"/>
<!--<views:BrokerSettings Grid.Column="1" Grid.Row="0"/>
<views:BrokerSettings Grid.Column="2" Grid.Row="0"/>-->

@ -4,17 +4,25 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450"
MaxWidth="500"
x:Class="UserInterface.Views.SensorSettings">
x:Class="UserInterface.Views.SensorSettings" >
<StackPanel Margin="30" HorizontalAlignment="Left" >
<ContentControl FontSize="18" FontWeight="Bold">Mqtt broker</ContentControl>
<TextBlock IsVisible="{Binding IsConnected}" Foreground="Green" Text="{Binding Message}"></TextBlock >
<TextBlock IsVisible="{Binding !IsConnected}" Foreground="Red" Text="{Binding Message}"></TextBlock >
<ContentControl FontSize="18" Margin="0 30 0 10">IP or hostname</ContentControl>
<TextBox Text="{Binding Host}" HorizontalAlignment="Left" Width="100" Watermark="192.168.1.200"/>
<ContentControl FontSize="18" Margin="0 30 0 10">Username</ContentControl>
<TextBox Text="{Binding Username}" Width="200"/>
<ContentControl FontSize="18" Margin="0 30 0 10">Password</ContentControl>
<TextBox Text="{Binding Password}" Width="200" PasswordChar="•"/>
<Button Width="75" HorizontalAlignment="Right" Margin="0 40 0 10" Click="Configure">Save</Button>
<DataGrid x:Name="Grid" IsVisible="{Binding ConfiguredSensors.Count}" AutoGenerateColumns="False" IsReadOnly="True" SelectionMode="Single" Items="{Binding ConfiguredSensors}">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding Name}"
Width="1*" />
<DataGridTextColumn Header="Type"
Binding="{Binding Type}"
Width="1*" />
<DataGridTextColumn Header="Value"
Binding="{Binding Value}"
Width="1*" />
</DataGrid.Columns>
</DataGrid>
<TextBlock IsVisible="{Binding !ConfiguredSensors.Count}">Add some sensors by clicking the "Add" button. </TextBlock>
<Button Width="75" HorizontalAlignment="Right" Margin="0 40 0 10" Click="AddSensor">Add</Button>
<Button Width="75" HorizontalAlignment="Right" Margin="0 40 0 10" Click="Delete">Delete</Button>
</StackPanel>
</UserControl>

@ -10,12 +10,17 @@ using System.Reactive.Linq;
using UserInterface.ViewModels;
using System.Security;
using hass_workstation_service.Communication.InterProcesCommunication.Models;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls.ApplicationLifetimes;
namespace UserInterface.Views
{
public class SensorSettings : UserControl
{
private readonly IIpcClient<ServiceContractInterfaces> client;
private DataGrid _dataGrid { get; set; }
private bool sensorsNeedToRefresh { get; set; }
public SensorSettings()
{
@ -33,23 +38,61 @@ namespace UserInterface.Views
this.client = clientFactory.CreateClient("sensors");
DataContext = new BrokerSettingsViewModel();
DataContext = new SensorSettingsViewModel();
GetConfiguredSensors();
this._dataGrid = this.FindControl<DataGrid>("Grid");
}
public void Ping(object sender, RoutedEventArgs args) {
var result = this.client.InvokeAsync(x => x.Ping("ping")).Result;
public async void GetConfiguredSensors()
{
sensorsNeedToRefresh = false;
List<ConfiguredSensorModel> status = await this.client.InvokeAsync(x => x.GetConfiguredSensors());
((SensorSettingsViewModel)this.DataContext).ConfiguredSensors = status.Select(s => new SensorViewModel() { Name = s.Name, Type = s.Type, Value = s.Value, Id = s.Id }).ToList();
while (!sensorsNeedToRefresh)
{
await Task.Delay(2000);
List<ConfiguredSensorModel> statusUpdated = await this.client.InvokeAsync(x => x.GetConfiguredSensors());
var configuredSensors = ((SensorSettingsViewModel)this.DataContext).ConfiguredSensors;
statusUpdated.ForEach(s =>
{
var configuredSensor = configuredSensors.FirstOrDefault(cs => cs.Id == s.Id);
if (configuredSensor != null)
{
configuredSensor.Value = s.Value;
configuredSensors.FirstOrDefault(cs => cs.Id == s.Id).Value = s.Value;
}
});
}
public void Configure(object sender, RoutedEventArgs args)
}
public void Delete(object sender, RoutedEventArgs args)
{
var model = (BrokerSettingsViewModel)this.DataContext;
var result = this.client.InvokeAsync(x => x.WriteMqttBrokerSettingsAsync(new MqttSettings() { Host = model.Host, Username = model.Username, Password = model.Password }));
var item = ((SensorViewModel)this._dataGrid.SelectedItem);
this.client.InvokeAsync(x => x.RemoveSensorById(item.Id));
// TODO: improve this. it is not working well.
sensorsNeedToRefresh = true;
GetConfiguredSensors();
}
public async void AddSensor(object sender, RoutedEventArgs args)
{
AddSensorDialog dialog = new AddSensorDialog();
if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
await dialog.ShowDialog(desktop.MainWindow);
GetConfiguredSensors();
}
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}

@ -1,9 +1,15 @@
using hass_workstation_service.Communication.InterProcesCommunication.Models;
using hass_workstation_service.Communication.NamedPipe;
using hass_workstation_service.Communication.Util;
using hass_workstation_service.Data;
using hass_workstation_service.Domain.Sensors;
using Serilog;
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace hass_workstation_service.Communication.InterProcesCommunication
@ -57,5 +63,42 @@ namespace hass_workstation_service.Communication.InterProcesCommunication
{
return this._configurationService.IsAutoStartEnabled();
}
public List<ConfiguredSensorModel> GetConfiguredSensors()
{
return this._configurationService.ConfiguredSensors.Select(s => new ConfiguredSensorModel() { Name = s.Name, Type = s.GetType().Name, Value = s.PreviousPublishedState, Id = s.Id }).ToList();
}
public void RemoveSensorById(Guid id)
{
this._configurationService.DeleteConfiguredSensor(id);
}
public void AddSensor(AvailableSensors sensorType, string json)
{
var serializerOptions = new JsonSerializerOptions
{
Converters = { new DynamicJsonConverter() }
};
dynamic model = JsonSerializer.Deserialize<dynamic>(json, serializerOptions);
AbstractSensor sensorToCreate = null;
switch (sensorType)
{
case AvailableSensors.UserNotificationStateSensor:
sensorToCreate = new UserNotificationStateSensor(this._publisher, model.Name);
break;
case AvailableSensors.DummySensor:
sensorToCreate = new DummySensor(this._publisher, model.Name);
break;
default:
Log.Logger.Error("Unknown sensortype");
break;
}
if (sensorToCreate != null)
{
this._configurationService.AddConfiguredSensor(sensorToCreate);
}
}
}
}

@ -14,5 +14,8 @@ namespace hass_workstation_service.Communication.NamedPipe
MqqtClientStatus GetMqqtClientStatus();
void EnableAutostart(bool enable);
bool IsAutoStartEnabled();
List<ConfiguredSensorModel> GetConfiguredSensors();
void RemoveSensorById(Guid id);
void AddSensor(AvailableSensors sensorType, string json);
}
}

@ -1,4 +1,5 @@
using System;
using hass_workstation_service.Domain.Sensors;
using System;
using System.Collections.Generic;
using System.Text;
@ -16,4 +17,18 @@ namespace hass_workstation_service.Communication.InterProcesCommunication.Models
public bool IsConnected { get; set; }
public string Message { get; set; }
}
public class ConfiguredSensorModel
{
public Guid Id { get; set; }
public string Type { get; set; }
public string Name { get; set; }
public string Value { get; set; }
}
public enum AvailableSensors
{
UserNotificationStateSensor,
DummySensor
}
}

@ -0,0 +1,127 @@

using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace hass_workstation_service.Communication.Util
{
/// <summary>
/// Temp Dynamic Converter
/// by:tchivs@live.cn
/// </summary>
public class DynamicJsonConverter : JsonConverter<dynamic>
{
public override dynamic Read(ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.True)
{
return true;
}
if (reader.TokenType == JsonTokenType.False)
{
return false;
}
if (reader.TokenType == JsonTokenType.Number)
{
if (reader.TryGetInt64(out long l))
{
return l;
}
return reader.GetDouble();
}
if (reader.TokenType == JsonTokenType.String)
{
if (reader.TryGetDateTime(out DateTime datetime))
{
return datetime;
}
return reader.GetString();
}
if (reader.TokenType == JsonTokenType.StartObject)
{
using JsonDocument documentV = JsonDocument.ParseValue(ref reader);
return ReadObject(documentV.RootElement);
}
// Use JsonElement as fallback.
// Newtonsoft uses JArray or JObject.
JsonDocument document = JsonDocument.ParseValue(ref reader);
return document.RootElement.Clone();
}
private object ReadObject(JsonElement jsonElement)
{
IDictionary<string, object> expandoObject = new ExpandoObject();
foreach (var obj in jsonElement.EnumerateObject())
{
var k = obj.Name;
var value = ReadValue(obj.Value);
expandoObject[k] = value;
}
return expandoObject;
}
private object? ReadValue(JsonElement jsonElement)
{
object? result = null;
switch (jsonElement.ValueKind)
{
case JsonValueKind.Object:
result = ReadObject(jsonElement);
break;
case JsonValueKind.Array:
result = ReadList(jsonElement);
break;
case JsonValueKind.String:
//TODO: Missing Datetime&Bytes Convert
result = jsonElement.GetString();
break;
case JsonValueKind.Number:
//TODO: more num type
result = 0;
if (jsonElement.TryGetInt64(out long l))
{
result = l;
}
break;
case JsonValueKind.True:
result = true;
break;
case JsonValueKind.False:
result = false;
break;
case JsonValueKind.Undefined:
case JsonValueKind.Null:
result = null;
break;
default:
throw new ArgumentOutOfRangeException();
}
return result;
}
private object? ReadList(JsonElement jsonElement)
{
IList<object?> list = new List<object?>();
foreach (var item in jsonElement.EnumerateArray())
{
list.Add(ReadValue(item));
}
return list.Count == 0 ? null : list;
}
public override void Write(Utf8JsonWriter writer,
object value,
JsonSerializerOptions options)
{
// writer.WriteStringValue(value.ToString());
}
}
}

@ -123,6 +123,13 @@ namespace hass_workstation_service.Data
WriteSettingsAsync();
}
public void DeleteConfiguredSensor(Guid id)
{
var sensorToRemove = this.ConfiguredSensors.FirstOrDefault(s => s.Id == id);
this.ConfiguredSensors.Remove(sensorToRemove);
WriteSettingsAsync();
}
public void AddConfiguredSensors(List<AbstractSensor> sensors)
{
sensors.ForEach((sensor) => this.ConfiguredSensors.Add(sensor));

@ -23,5 +23,6 @@ namespace hass_workstation_service.Data
Task<MqttSettings> GetMqttBrokerSettings();
void EnableAutoStart(bool enable);
bool IsAutoStartEnabled();
void DeleteConfiguredSensor(Guid id);
}
}

@ -1,18 +0,0 @@
using System;
using System.Collections.Generic;
using hass_workstation_service.Domain.Sensors;
namespace hass_workstation_service.Domain
{
public abstract class Device
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public string Manufacturer { get; private set; }
public string Model { get; private set; }
public string Version { get; private set; }
public ICollection<AbstractSensor> Sensors { get; set; }
}
}

@ -5,9 +5,7 @@ using MQTTnet;
namespace hass_workstation_service.Domain.Sensors
{
/// <summary>
/// This
/// </summary>
public abstract class AbstractSensor
{
public Guid Id { get; protected set; }

@ -88,11 +88,7 @@ namespace hass_workstation_service.Domain.Sensors
QuietTime = 6,
/// <summary>
/// Introduced in Windows 7. The current user is in "quiet time", which is the first hour after
/// a new user logs into his or her account for the first time. During this time, most notifications
/// should not be sent or shown. This lets a user become accustomed to a new computer system
/// without those distractions.
/// Quiet time also occurs for each user after an operating system upgrade or clean installation.
/// A Windows Store app is running.
/// </summary>
RunningWindowsStoreApp = 7
};

@ -34,7 +34,9 @@ namespace hass_workstation_service
// We do it this way because there is currently no way to pass an argument to a dotnet core app when using clickonce
if (Process.GetProcessesByName("hass-workstation-service").Count() > 1) //bg service running
{
#if !DEBUG
StartUI();
#endif
}
else
{

@ -4,12 +4,12 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ApplicationRevision>15</ApplicationRevision>
<ApplicationRevision>20</ApplicationRevision>
<ApplicationVersion>1.0.0.*</ApplicationVersion>
<BootstrapperEnabled>True</BootstrapperEnabled>
<Configuration>Release</Configuration>
<CreateDesktopShortcut>True</CreateDesktopShortcut>
<ErrorReportUrl>https://github.com/sleevezipper/hass-workstation-service/issues</ErrorReportUrl>
<ErrorReportUrl>https://github.com/sleevezipper/hass-workstation-service</ErrorReportUrl>
<GenerateManifests>True</GenerateManifests>
<Install>true</Install>
<InstallFrom>Web</InstallFrom>
@ -32,7 +32,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<SelfContained>False</SelfContained>
<SignatureAlgorithm>sha256RSA</SignatureAlgorithm>
<SignManifests>True</SignManifests>
<SupportUrl>https://github.com/sleevezipper/hass-workstation-service/issues</SupportUrl>
<SupportUrl>https://github.com/sleevezipper/hass-workstation-service</SupportUrl>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TrustUrlParameters>True</TrustUrlParameters>
<UpdateEnabled>True</UpdateEnabled>

@ -31,18 +31,13 @@ namespace hass_workstation_service
{
_configurationService.ReadSensorSettings(_mqttPublisher);
while (!_mqttPublisher.IsConnected)
{
_logger.LogInformation($"Connecting to MQTT broker...");
await Task.Delay(2000);
}
_logger.LogInformation("Connected. Sending auto discovery messages.");
// if there are no configured sensors we add a dummy sensor
if (_configurationService.ConfiguredSensors.Count == 0)
{
_configurationService.AddConfiguredSensors(new List<AbstractSensor>() { new DummySensor(_mqttPublisher), new UserNotificationStateSensor(_mqttPublisher) });
}
foreach (AbstractSensor sensor in _configurationService.ConfiguredSensors)
{
await sensor.PublishAutoDiscoveryConfigAsync();

Loading…
Cancel
Save