From edd84780a8d8db8c6f9e7a428f3016fc0dc2da65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Thu, 3 Feb 2022 22:16:09 +0100 Subject: [PATCH 1/6] Remove unused input booleans on NetDaemon runtime start --- .../Internal/AppStateManagerTests.cs | 97 +++++++----- .../Internal/EntityMapperHelperTests.cs | 23 +++ .../Extensions/ServiceBuilderExtensions.cs | 5 +- .../Internal/AppStateManager.cs | 138 ++++-------------- .../Internal/AppStateRepository.cs | 70 +++++++++ .../Internal/EntityMapperHelper.cs | 52 +++++++ .../HomeAssistantConnectionExtensions.cs | 22 ++- .../Internal/IAppStateRepository.cs | 8 + .../IHandleHomeAssistantAppStateUpdates.cs | 4 +- .../Model/CreateInputBooleanHelperCommand.cs | 20 ++- .../Internal/NetDaemonRuntime.cs | 7 +- 11 files changed, 288 insertions(+), 158 deletions(-) create mode 100644 src/Runtime/NetDaemon.Runtime.Tests/Internal/EntityMapperHelperTests.cs create mode 100644 src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs create mode 100644 src/Runtime/NetDaemon.Runtime/Internal/EntityMapperHelper.cs create mode 100644 src/Runtime/NetDaemon.Runtime/Internal/IAppStateRepository.cs diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateManagerTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateManagerTests.cs index 0c08c5883..d7dc82ee8 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateManagerTests.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateManagerTests.cs @@ -16,8 +16,11 @@ public async Task TestGetStateAsyncReturnsCorrectStateEnabled() { // ARRANGE var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() - .AddTransient(_ => haConnectionMock.Object) + .AddSingleton(haRunnerMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); @@ -42,8 +45,11 @@ public async Task TestGetStateAsyncReturnsCorrectStateDisabled() { // ARRANGE var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() - .AddTransient(_ => haConnectionMock.Object) + .AddSingleton(haRunnerMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); @@ -68,8 +74,11 @@ public async Task TestSaveStateAsyncReturnsCorrectStateDisabled() { // ARRANGE var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() - .AddTransient(_ => haConnectionMock.Object) + .AddSingleton(haRunnerMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); @@ -98,8 +107,11 @@ public async Task TestGetStateAsyncNotExistReturnsCorrectStateEnabled() { // ARRANGE var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() - .AddTransient(_ => haConnectionMock.Object) + .AddSingleton(haRunnerMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); @@ -126,8 +138,11 @@ public async Task TestSetStateAsyncEnabled() { // ARRANGE var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() - .AddTransient(_ => haConnectionMock.Object) + .AddSingleton(haRunnerMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); @@ -156,8 +171,11 @@ public async Task TestSetStateAsyncRunning() { // ARRANGE var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() - .AddTransient(_ => haConnectionMock.Object) + .AddSingleton(haRunnerMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); @@ -183,8 +201,11 @@ public async Task TestSetStateAsyncError() { // ARRANGE var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() - .AddTransient(_ => haConnectionMock.Object) + .AddSingleton(haRunnerMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); @@ -207,15 +228,19 @@ public async Task TestSetStateAsyncError() } [Fact] - public void TestInitialize() + public async Task TestInitialize() { // ARRANGE var haContextMock = new Mock(); var appModelContextMock = new Mock(); + appModelContextMock.SetupGet(n => n.Applications).Returns(new List()); var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() + .AddSingleton(haRunnerMock.Object) .AddScoped(_ => haContextMock.Object) - .AddTransient(_ => haConnectionMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); @@ -226,22 +251,27 @@ public void TestInitialize() haConnectionMock.SetupGet(n => n.OnHomeAssistantEvent).Returns(hassEvent); // ACT - homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); + await homeAssistantStateUpdater.InitializeAsync(haConnectionMock.Object, appModelContextMock.Object) + .ConfigureAwait(false); // ASSERT hassEvent.HasObservers.Should().BeTrue(); } [Fact] - public void TestAppDisabledShouldCallSetStateAsyncEnabled() + public async Task TestAppDisabledShouldCallSetStateAsyncEnabled() { // ARRANGE var haContextMock = new Mock(); var appModelContextMock = new Mock(); + appModelContextMock.SetupGet(n => n.Applications).Returns(new List()); var appMock = new Mock(); var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() + .AddSingleton(haRunnerMock.Object) .AddScoped(_ => haContextMock.Object) - .AddTransient(_ => haConnectionMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); @@ -258,7 +288,9 @@ public void TestAppDisabledShouldCallSetStateAsyncEnabled() }); // ACT - homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); + await homeAssistantStateUpdater.InitializeAsync(haConnectionMock.Object, appModelContextMock.Object) + .ConfigureAwait(false); + hassEvent.OnNext(new HassEvent { EventType = "state_changed", @@ -282,16 +314,20 @@ public void TestAppDisabledShouldCallSetStateAsyncEnabled() } [Fact] - public void TestAppNoChangeShouldNotCallSetStateAsync() + public async Task TestAppNoChangeShouldNotCallSetStateAsync() { // ARRANGE var haContextMock = new Mock(); var appModelContextMock = new Mock(); + appModelContextMock.SetupGet(n => n.Applications).Returns(new List()); var appMock = new Mock(); var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() + .AddSingleton(haRunnerMock.Object) .AddScoped(_ => haContextMock.Object) - .AddTransient(_ => haConnectionMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); @@ -308,7 +344,9 @@ public void TestAppNoChangeShouldNotCallSetStateAsync() }); // ACT - homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); + await homeAssistantStateUpdater.InitializeAsync(haConnectionMock.Object, appModelContextMock.Object) + .ConfigureAwait(false); + hassEvent.OnNext(new HassEvent { EventType = "state_changed", @@ -333,16 +371,20 @@ public void TestAppNoChangeShouldNotCallSetStateAsync() } [Fact] - public void TestAppOneStateIsNullShouldNotCallSetStateAsync() + public async Task TestAppOneStateIsNullShouldNotCallSetStateAsync() { // ARRANGE var haContextMock = new Mock(); var appModelContextMock = new Mock(); + appModelContextMock.SetupGet(n => n.Applications).Returns(new List()); var appMock = new Mock(); var haConnectionMock = new Mock(); + var haRunnerMock = new Mock(); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(haConnectionMock.Object); + var provider = new ServiceCollection() + .AddSingleton(haRunnerMock.Object) .AddScoped(_ => haContextMock.Object) - .AddTransient(_ => haConnectionMock.Object) .AddNetDaemonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); @@ -359,7 +401,7 @@ public void TestAppOneStateIsNullShouldNotCallSetStateAsync() }); // ACT - homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); + await homeAssistantStateUpdater.InitializeAsync(haConnectionMock.Object, appModelContextMock.Object); hassEvent.OnNext(new HassEvent { EventType = "state_changed", @@ -377,21 +419,4 @@ public void TestAppOneStateIsNullShouldNotCallSetStateAsync() // ASSERT appMock.Verify(n => n.SetStateAsync(ApplicationState.Disabled), Times.Never); } - - [Theory] - [InlineData("lowercase", "lowercase")] - [InlineData("lower.namespace.lowercase", "lower_namespace_lowercase")] - [InlineData("Namespace.Class", "namespace_class")] - [InlineData("Namespace.ClassNameWithUpperAndLower", "namespace_class_name_with_upper_and_lower")] - [InlineData("ALLUPPERCASE", "alluppercase")] - [InlineData("DIClass", "diclass")] - [InlineData("DiClass", "di_class")] - [InlineData("Di_Class", "di_class")] - [InlineData("di_class", "di_class")] - [InlineData("di__class", "di_class")] - public void TestToSafeHomeAssistantEntityIdFromApplicationIdShouldGiveCorrectName(string fromId, string toId) - { - var expected = $"input_boolean.netdaemon_{toId}"; - AppStateManager.ToSafeHomeAssistantEntityIdFromApplicationId(fromId).Should().Be(expected); - } } diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/EntityMapperHelperTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/EntityMapperHelperTests.cs new file mode 100644 index 000000000..b3387680e --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/EntityMapperHelperTests.cs @@ -0,0 +1,23 @@ +using NetDaemon.Runtime.Internal; + +namespace NetDaemon.Runtime.Tests.Internal; + +public class EntityMapperHelperTests +{ + [Theory] + [InlineData("lowercase", "lowercase")] + [InlineData("lower.namespace.lowercase", "lower_namespace_lowercase")] + [InlineData("Namespace.Class", "namespace_class")] + [InlineData("Namespace.ClassNameWithUpperAndLower", "namespace_class_name_with_upper_and_lower")] + [InlineData("ALLUPPERCASE", "alluppercase")] + [InlineData("DIClass", "diclass")] + [InlineData("DiClass", "di_class")] + [InlineData("Di_Class", "di_class")] + [InlineData("di_class", "di_class")] + [InlineData("di__class", "di_class")] + public void TestToSafeHomeAssistantEntityIdFromApplicationIdShouldGiveCorrectName(string fromId, string toId) + { + var expected = $"input_boolean.netdaemon_{toId}"; + EntityMapperHelper.ToSafeHomeAssistantEntityIdFromApplicationId(fromId).Should().Be(expected); + } +} diff --git a/src/Runtime/NetDaemon.Runtime/Common/Extensions/ServiceBuilderExtensions.cs b/src/Runtime/NetDaemon.Runtime/Common/Extensions/ServiceBuilderExtensions.cs index dc9a4c734..4f066cfd2 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/Extensions/ServiceBuilderExtensions.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/Extensions/ServiceBuilderExtensions.cs @@ -10,7 +10,8 @@ public static IServiceCollection AddNetDaemonStateManager(this IServiceCollectio services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); services.AddSingleton(s => s.GetRequiredService()); - + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); return services; } -} \ No newline at end of file +} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs index ffcc0cee0..2dc821f76 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs @@ -1,67 +1,61 @@ using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Net; using System.Reactive.Linq; -using System.Text; using NetDaemon.AppModel; -using NetDaemon.Client.HomeAssistant.Model; -using NetDaemon.Client.Internal.Exceptions; namespace NetDaemon.Runtime.Internal; internal class AppStateManager : IAppStateManager, IHandleHomeAssistantAppStateUpdates, IDisposable { + private readonly IAppStateRepository _appStateRepository; private readonly CancellationTokenSource _cancelTokenSource = new(); - private readonly IServiceProvider _provider; private readonly ConcurrentDictionary _stateCache = new(); public AppStateManager( - IServiceProvider provider + IAppStateRepository appStateRepository ) { - _provider = provider; + _appStateRepository = appStateRepository; } public async Task GetStateAsync(string applicationId) { - var entityId = ToSafeHomeAssistantEntityIdFromApplicationId(applicationId); - if (_stateCache.TryGetValue(entityId, out var applicationState)) return applicationState; + if (_stateCache.TryGetValue(applicationId, out var applicationState)) return applicationState; - return (await GetOrCreateStateForApp(entityId).ConfigureAwait(false))?.State == "on" + return await _appStateRepository.GetOrCreateAsync(applicationId, _cancelTokenSource.Token) + .ConfigureAwait(false) ? ApplicationState.Enabled : ApplicationState.Disabled; } public async Task SaveStateAsync(string applicationId, ApplicationState state) { - var haConnection = _provider.GetRequiredService() ?? - throw new InvalidOperationException(); - var entityId = ToSafeHomeAssistantEntityIdFromApplicationId(applicationId); + _stateCache[applicationId] = state; - _stateCache[entityId] = state; - - var currentState = (await GetOrCreateStateForApp(entityId).ConfigureAwait(false))?.State - ?? throw new InvalidOperationException(); + var isEnabled = await _appStateRepository.GetOrCreateAsync(applicationId, _cancelTokenSource.Token) + .ConfigureAwait(false); switch (state) { - case ApplicationState.Enabled when currentState == "off": - await haConnection.CallServiceAsync("input_boolean", "turn_on", - new HassTarget {EntityIds = new[] {entityId}}, - cancelToken: _cancelTokenSource.Token); - break; - case ApplicationState.Disabled when currentState == "on": - await haConnection.CallServiceAsync("input_boolean", "turn_off", - new HassTarget {EntityIds = new[] {entityId}}, - cancelToken: _cancelTokenSource.Token); + case ApplicationState.Enabled when !isEnabled: + case ApplicationState.Disabled when isEnabled: + await _appStateRepository.UpdateAsync(applicationId, isEnabled, _cancelTokenSource.Token) + .ConfigureAwait(false); break; } } - public void Initialize(IHomeAssistantConnection haConnection, IAppModelContext appContext) + public void Dispose() + { + _cancelTokenSource.Dispose(); + } + + public async Task InitializeAsync(IHomeAssistantConnection haConnection, IAppModelContext appContext) { - ClearExistingCacheOnNewConnection(); + _stateCache.Clear(); + if (appContext.Applications.Count > 0) + await _appStateRepository.RemoveNotUsedStatesAsync(appContext.Applications.Select(a => a.Id).ToList()!, + _cancelTokenSource.Token); + haConnection.OnHomeAssistantEvent .Where(n => n.EventType == "state_changed") .Select(async s => @@ -77,7 +71,8 @@ public void Initialize(IHomeAssistantConnection haConnection, IAppModelContext a foreach (var app in appContext.Applications) { var entityId = - ToSafeHomeAssistantEntityIdFromApplicationId(app.Id ?? throw new InvalidOperationException()); + EntityMapperHelper.ToSafeHomeAssistantEntityIdFromApplicationId(app.Id ?? + throw new InvalidOperationException()); if (entityId != changedEvent.NewState.EntityId) continue; var appState = changedEvent.NewState?.State == "on" @@ -91,85 +86,4 @@ await app.SetStateAsync( } }).Subscribe(); } - - - /// - /// Converts any unicode string to a safe Home Assistant name - /// - /// The unicode string to convert - [SuppressMessage("Microsoft.Globalization", "CA1308")] - [SuppressMessage("", "CA1062")] - public static string ToSafeHomeAssistantEntityIdFromApplicationId(string applicationId) - { - var normalizedString = applicationId.Normalize(NormalizationForm.FormD); - StringBuilder stringBuilder = new(applicationId.Length); - - char lastChar = '\0'; - - foreach (var c in normalizedString) - { - switch (CharUnicodeInfo.GetUnicodeCategory(c)) - { - case UnicodeCategory.LowercaseLetter: - stringBuilder.Append(c); - break; - case UnicodeCategory.UppercaseLetter: - if (CharUnicodeInfo.GetUnicodeCategory(lastChar) == UnicodeCategory.LowercaseLetter) - { - if (lastChar != '_') - stringBuilder.Append('_'); - } - stringBuilder.Append(char.ToLowerInvariant(c)); - break; - case UnicodeCategory.DecimalDigitNumber: - stringBuilder.Append(c); - break; - case UnicodeCategory.SpaceSeparator: - case UnicodeCategory.ConnectorPunctuation: - case UnicodeCategory.DashPunctuation: - case UnicodeCategory.OtherPunctuation: - if (lastChar != '_') - stringBuilder.Append('_'); - break; - } - lastChar = c; - } - - return $"input_boolean.netdaemon_{stringBuilder.ToString().ToLowerInvariant()}"; - } - - private void ClearExistingCacheOnNewConnection() - { - _stateCache.Clear(); - } - - private async Task GetOrCreateStateForApp(string entityId) - { - var haConnection = _provider.GetRequiredService(); - - try - { - var state = await haConnection.GetEntityStateAsync(entityId, _cancelTokenSource.Token) - .ConfigureAwait(false); - return state; - } - catch (HomeAssistantApiCallException e) - { - // Missing entity will throw a http status not found - if (e.Code != HttpStatusCode.NotFound) throw; - // The app state input_boolean does not exist, lets create a helper - var name = entityId[14..]; // remove the "input_boolean." part - await haConnection.CreateInputBooleanHelperAsync(name, _cancelTokenSource.Token); - _stateCache[entityId] = ApplicationState.Enabled; - await haConnection.CallServiceAsync("input_boolean", "turn_on", - new HassTarget {EntityIds = new[] {entityId}}, - cancelToken: _cancelTokenSource.Token).ConfigureAwait(false); - return new HassState {State = "on"}; - } - } - - public void Dispose() - { - _cancelTokenSource.Dispose(); - } } diff --git a/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs b/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs new file mode 100644 index 000000000..fc1e5dc03 --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs @@ -0,0 +1,70 @@ +using System.Net; +using NetDaemon.Client.HomeAssistant.Model; +using NetDaemon.Client.Internal.Exceptions; + +namespace NetDaemon.Runtime.Internal; + +internal class AppStateRepository : IAppStateRepository +{ + private readonly IHomeAssistantRunner _runner; + + public AppStateRepository(IHomeAssistantRunner runner) + { + _runner = runner; + } + + public async Task GetOrCreateAsync(string applicationId, CancellationToken token) + { + var haConnection = _runner.CurrentConnection ?? + throw new InvalidOperationException(); + + var entityId = EntityMapperHelper.ToSafeHomeAssistantEntityIdFromApplicationId(applicationId); + + try + { + var state = await haConnection.GetEntityStateAsync(entityId, token) + .ConfigureAwait(false); + return state?.State == "on"; + } + catch (HomeAssistantApiCallException e) + { + // Missing entity will throw a http status not found + if (e.Code != HttpStatusCode.NotFound) throw; + // The app state input_boolean does not exist, lets create a helper + var name = entityId[14..]; // remove the "input_boolean." part + await haConnection.CreateInputBooleanHelperAsync(name, token); + await UpdateAsync(applicationId, true, token); + return true; + } + } + + public async Task UpdateAsync(string applicationId, bool enabled, CancellationToken token) + { + var haConnection = _runner.CurrentConnection ?? + throw new InvalidOperationException(); + + var entityId = EntityMapperHelper.ToSafeHomeAssistantEntityIdFromApplicationId(applicationId); + + await haConnection.CallServiceAsync("input_boolean", enabled ? "turn_on" : "turn_off", + new HassTarget {EntityIds = new[] {entityId}}, + cancelToken: token).ConfigureAwait(false); + } + + public async Task RemoveNotUsedStatesAsync(IReadOnlyCollection applicationIds, CancellationToken token) + { + if (applicationIds.Count == 0) + return; + + var haConnection = _runner.CurrentConnection ?? + throw new InvalidOperationException(); + var helpers = await haConnection.ListInputBooleanHelpersAsync(token).ConfigureAwait(false); + + var entityIds = applicationIds.Select(EntityMapperHelper.ToSafeHomeAssistantEntityIdFromApplicationId); + + var notUsedHelperIds = helpers.Where(n => + !entityIds.Contains($"input_boolean.{n.Name}") && n.Id.StartsWith("netdaemon_")); + + foreach (var helper in notUsedHelperIds) + await haConnection.DeleteInputBooleanHelperAsync(helper.Id, token).ConfigureAwait(false); + } +} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/EntityMapperHelper.cs b/src/Runtime/NetDaemon.Runtime/Internal/EntityMapperHelper.cs new file mode 100644 index 000000000..e615b8d05 --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Internal/EntityMapperHelper.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; + +namespace NetDaemon.Runtime.Internal; + +public static class EntityMapperHelper +{ + /// + /// Converts any unicode string to a safe Home Assistant name for the helper + /// + /// The unicode string to convert + [SuppressMessage("Microsoft.Globalization", "CA1308")] + [SuppressMessage("", "CA1062")] + public static string ToSafeHomeAssistantEntityIdFromApplicationId(string applicationId) + { + var normalizedString = applicationId.Normalize(NormalizationForm.FormD); + StringBuilder stringBuilder = new(applicationId.Length); + + var lastChar = '\0'; + + foreach (var c in normalizedString) + { + switch (CharUnicodeInfo.GetUnicodeCategory(c)) + { + case UnicodeCategory.LowercaseLetter: + stringBuilder.Append(c); + break; + case UnicodeCategory.UppercaseLetter: + if (CharUnicodeInfo.GetUnicodeCategory(lastChar) == UnicodeCategory.LowercaseLetter) + if (lastChar != '_') + stringBuilder.Append('_'); + stringBuilder.Append(char.ToLowerInvariant(c)); + break; + case UnicodeCategory.DecimalDigitNumber: + stringBuilder.Append(c); + break; + case UnicodeCategory.SpaceSeparator: + case UnicodeCategory.ConnectorPunctuation: + case UnicodeCategory.DashPunctuation: + case UnicodeCategory.OtherPunctuation: + if (lastChar != '_') + stringBuilder.Append('_'); + break; + } + + lastChar = c; + } + + return $"input_boolean.netdaemon_{stringBuilder.ToString().ToLowerInvariant()}"; + } +} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs b/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs index 98f20b978..2c775f87a 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs @@ -12,6 +12,24 @@ internal static class HomeAssistantConnectionExtensions new CreateInputBooleanHelperCommand { Name = name - }, cancelToken); + }, cancelToken).ConfigureAwait(false); } -} \ No newline at end of file + + public static async Task DeleteInputBooleanHelperAsync( + this IHomeAssistantConnection connection, + string inputBooleanId, CancellationToken cancelToken) + { + await connection.SendCommandAndReturnResponseAsync( + new DeleteInputBooleanHelperCommand + { + InputBooleanId = inputBooleanId + }, cancelToken).ConfigureAwait(false); + } + + public static async Task> ListInputBooleanHelpersAsync( + this IHomeAssistantConnection connection, CancellationToken cancelToken) + { + return await connection.SendCommandAndReturnResponseAsync>( + new ListInputBooleanHelperCommand(), cancelToken) ?? Array.Empty(); + } +} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/IAppStateRepository.cs b/src/Runtime/NetDaemon.Runtime/Internal/IAppStateRepository.cs new file mode 100644 index 000000000..c65be363e --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Internal/IAppStateRepository.cs @@ -0,0 +1,8 @@ +namespace NetDaemon.Runtime.Internal; + +internal interface IAppStateRepository +{ + Task GetOrCreateAsync(string applicationId, CancellationToken token); + Task UpdateAsync(string applicationId, bool enabled, CancellationToken token); + Task RemoveNotUsedStatesAsync(IReadOnlyCollection applicationIds, CancellationToken token); +} \ No newline at end of file diff --git a/src/Runtime/NetDaemon.Runtime/Internal/IHandleHomeAssistantAppStateUpdates.cs b/src/Runtime/NetDaemon.Runtime/Internal/IHandleHomeAssistantAppStateUpdates.cs index 006ea366c..415bf3ff0 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/IHandleHomeAssistantAppStateUpdates.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/IHandleHomeAssistantAppStateUpdates.cs @@ -4,5 +4,5 @@ namespace NetDaemon.Runtime.Internal; internal interface IHandleHomeAssistantAppStateUpdates { - void Initialize(IHomeAssistantConnection haConnection, IAppModelContext appContext); -} \ No newline at end of file + Task InitializeAsync(IHomeAssistantConnection haConnection, IAppModelContext appContext); +} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/Model/CreateInputBooleanHelperCommand.cs b/src/Runtime/NetDaemon.Runtime/Internal/Model/CreateInputBooleanHelperCommand.cs index d614646b7..3d8a60990 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/Model/CreateInputBooleanHelperCommand.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/Model/CreateInputBooleanHelperCommand.cs @@ -11,4 +11,22 @@ public CreateInputBooleanHelperCommand() } [JsonPropertyName("name")] public string Name { get; init; } = string.Empty; -} \ No newline at end of file +} + +internal record DeleteInputBooleanHelperCommand : CommandMessage +{ + public DeleteInputBooleanHelperCommand() + { + Type = "input_boolean/delete"; + } + + [JsonPropertyName("input_boolean_id")] public string InputBooleanId { get; init; } = string.Empty; +} + +internal record ListInputBooleanHelperCommand : CommandMessage +{ + public ListInputBooleanHelperCommand() + { + Type = "input_boolean/list"; + } +} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index 6498de8c0..482f6fa0e 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -97,12 +97,13 @@ CancellationToken cancelToken await _cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); + _applicationModelContext = await _appModel.InitializeAsync(CancellationToken.None).ConfigureAwait(false); // Handle state change for apps if registered - var appStateHandler = _serviceProvider.GetService(); - appStateHandler?.Initialize(haConnection, _applicationModelContext); + var appStateHandler = _serviceProvider.GetRequiredService(); + await appStateHandler.InitializeAsync(haConnection, _applicationModelContext); } catch (Exception e) { @@ -144,4 +145,4 @@ private async Task DisposeApplicationsAsync() _applicationModelContext = null; } } -} \ No newline at end of file +} From 8b03a8e61dacc15e3ab98e81903c85add81318cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Thu, 3 Feb 2022 22:16:30 +0100 Subject: [PATCH 2/6] And the tests --- .../Internal/AppStateRepositoryTests.cs | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateRepositoryTests.cs diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateRepositoryTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateRepositoryTests.cs new file mode 100644 index 000000000..6b5a84c72 --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateRepositoryTests.cs @@ -0,0 +1,122 @@ +using System.Net; +using NetDaemon.Client.Internal.Exceptions; +using NetDaemon.Runtime.Internal; +using NetDaemon.Runtime.Internal.Model; + +namespace NetDaemon.Runtime.Tests.Internal; + +public class AppStateRepositoryTests +{ + private readonly Mock _connectionMock = new(); + private readonly AppStateRepository _repository; + private readonly Mock _runnerMock = new(); + + public AppStateRepositoryTests() + { + _runnerMock.SetupGet(x => x.CurrentConnection).Returns(_connectionMock.Object); + _repository = new AppStateRepository(_runnerMock.Object); + } + + [Fact] + public async Task GetOrCreateAsyncShouldSendCreateInputBooleanCommandIfEntityNotExists() + { + _connectionMock + .Setup(n => n.GetApiCallAsync("states/input_boolean.netdaemon_some_app_id", + It.IsAny())) + .ThrowsAsync(new HomeAssistantApiCallException("", HttpStatusCode.NotFound)); + + var result = await _repository.GetOrCreateAsync("some_app_id", CancellationToken.None); + + _connectionMock.Verify( + n => n.SendCommandAndReturnResponseAsync( + It.IsAny(), It.IsAny()), Times.Once); + + result.Should().BeTrue(); + } + + [Fact] + public async Task GetOrCreateAsyncShouldNotSendCreateInputBooleanCommandIfEntityExists() + { + _connectionMock + .Setup(n => n.GetApiCallAsync("states/input_boolean.netdaemon_some_app_id", + It.IsAny())) + .ReturnsAsync(new HassState + { + EntityId = "input_boolean.netdaemon_some_app_id", + State = "on" + }); + + _ = await _repository.GetOrCreateAsync("some_app_id", CancellationToken.None); + _connectionMock.Verify( + n => n.SendCommandAndReturnResponseAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("on", true)] + [InlineData("off", false)] + public async Task GetOrCreateAsyncShouldReturnCorrectEnabledStateForAppDependingOnStateOfInputBooleanHelper( + string entityState, bool isEnabled) + { + _connectionMock + .Setup(n => n.GetApiCallAsync("states/input_boolean.netdaemon_some_app_id", + It.IsAny())) + .ReturnsAsync(new HassState + { + EntityId = "input_boolean.netdaemon_some_app_id", + State = entityState + }); + + + var result = await _repository.GetOrCreateAsync("some_app_id", CancellationToken.None); + + result.Should().Be(isEnabled); + } + + [Fact] + public async Task RemoveNotUsedStatesAsyncShouldRemoveEntitiesThatDoesNotCorrespondToAnAppId() + { + var resultList = new[] + { + new InputBooleanHelper {Id = "netdaemon_some_app_id", Name = "netdaemon_some_app_id"}, + new InputBooleanHelper {Id = "netdaemon_some_app_id2", Name = "netdaemon_some_app_id2"} + }; + _connectionMock.Setup(n => + n.SendCommandAndReturnResponseAsync>(It.IsAny(), + It.IsAny())).ReturnsAsync(resultList.ToList()); + + var applicationIds = new[] {"some_app_id"}; + await _repository.RemoveNotUsedStatesAsync(applicationIds, CancellationToken.None); + + // var command = new DeleteInputBooleanHelperCommand() {InputBooleanId = "some_app_id2", Type = "input_boolean/list""}; + _connectionMock.Verify( + n => n.SendCommandAndReturnResponseAsync( + It.Is(n => n.InputBooleanId == "netdaemon_some_app_id2"), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task RemoveNotUsedStatesAsyncShouldNotRemoveNonNetDaemonInputBooleans() + { + var resultList = new[] + { + new InputBooleanHelper {Id = "netdaemon_some_app_id", Name = "netdaemon_some_app_id"}, + new InputBooleanHelper {Id = "netdaemon_some_app_id2", Name = "netdaemon_some_app_id2"}, + new InputBooleanHelper {Id = "non_netdaemon_input_boolean", Name = "non_netdaemon_input_boolean"} + }; + _connectionMock.Setup(n => + n.SendCommandAndReturnResponseAsync>(It.IsAny(), + It.IsAny())).ReturnsAsync(resultList.ToList()); + + var applicationIds = new[] {"some_app_id"}; + await _repository.RemoveNotUsedStatesAsync(applicationIds, CancellationToken.None); + + // var command = new DeleteInputBooleanHelperCommand() {InputBooleanId = "some_app_id2", Type = "input_boolean/list""}; + _connectionMock.Verify( + n => n.SendCommandAndReturnResponseAsync( + It.Is(n => n.InputBooleanId == "non_netdaemon_input_boolean"), + It.IsAny()), Times.Never); + } +} From 091b69f5c07d62b24b032a3bddf9794c1c6fa465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Thu, 3 Feb 2022 22:40:14 +0100 Subject: [PATCH 3/6] Review fix --- .../Internal/AppStateRepositoryTests.cs | 28 ++++++++ .../Internal/AppStateManager.cs | 64 +++++++++---------- .../Internal/AppStateRepository.cs | 3 - 3 files changed, 60 insertions(+), 35 deletions(-) diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateRepositoryTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateRepositoryTests.cs index 6b5a84c72..4cfb4a552 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateRepositoryTests.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateRepositoryTests.cs @@ -96,6 +96,34 @@ public async Task RemoveNotUsedStatesAsyncShouldRemoveEntitiesThatDoesNotCorresp It.IsAny()), Times.Once); } + [Fact] + public async Task RemoveNotUsedStatesAsyncShouldRemoveAllHelpersIfNoAppsPresent() + { + var resultList = new[] + { + new InputBooleanHelper {Id = "netdaemon_some_app_id", Name = "netdaemon_some_app_id"}, + new InputBooleanHelper {Id = "netdaemon_some_app_id2", Name = "netdaemon_some_app_id2"} + }; + _connectionMock.Setup(n => + n.SendCommandAndReturnResponseAsync>(It.IsAny(), + It.IsAny())).ReturnsAsync(resultList.ToList()); + + var applicationIds = new List(); + await _repository.RemoveNotUsedStatesAsync(applicationIds, CancellationToken.None); + + // var command = new DeleteInputBooleanHelperCommand() {InputBooleanId = "some_app_id2", Type = "input_boolean/list""}; + _connectionMock.Verify( + n => n.SendCommandAndReturnResponseAsync( + It.Is(n => n.InputBooleanId == "netdaemon_some_app_id"), + It.IsAny()), Times.Once); + + _connectionMock.Verify( + n => n.SendCommandAndReturnResponseAsync( + It.Is(n => n.InputBooleanId == "netdaemon_some_app_id2"), + It.IsAny()), Times.Once); + } + [Fact] public async Task RemoveNotUsedStatesAsyncShouldNotRemoveNonNetDaemonInputBooleans() { diff --git a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs index 2dc821f76..fe862e7d6 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs @@ -17,38 +17,6 @@ IAppStateRepository appStateRepository _appStateRepository = appStateRepository; } - public async Task GetStateAsync(string applicationId) - { - if (_stateCache.TryGetValue(applicationId, out var applicationState)) return applicationState; - - return await _appStateRepository.GetOrCreateAsync(applicationId, _cancelTokenSource.Token) - .ConfigureAwait(false) - ? ApplicationState.Enabled - : ApplicationState.Disabled; - } - - public async Task SaveStateAsync(string applicationId, ApplicationState state) - { - _stateCache[applicationId] = state; - - var isEnabled = await _appStateRepository.GetOrCreateAsync(applicationId, _cancelTokenSource.Token) - .ConfigureAwait(false); - - switch (state) - { - case ApplicationState.Enabled when !isEnabled: - case ApplicationState.Disabled when isEnabled: - await _appStateRepository.UpdateAsync(applicationId, isEnabled, _cancelTokenSource.Token) - .ConfigureAwait(false); - break; - } - } - - public void Dispose() - { - _cancelTokenSource.Dispose(); - } - public async Task InitializeAsync(IHomeAssistantConnection haConnection, IAppModelContext appContext) { _stateCache.Clear(); @@ -86,4 +54,36 @@ await app.SetStateAsync( } }).Subscribe(); } + + public async Task GetStateAsync(string applicationId) + { + if (_stateCache.TryGetValue(applicationId, out var applicationState)) return applicationState; + + return await _appStateRepository.GetOrCreateAsync(applicationId, _cancelTokenSource.Token) + .ConfigureAwait(false) + ? ApplicationState.Enabled + : ApplicationState.Disabled; + } + + public async Task SaveStateAsync(string applicationId, ApplicationState state) + { + _stateCache[applicationId] = state; + + var isEnabled = await _appStateRepository.GetOrCreateAsync(applicationId, _cancelTokenSource.Token) + .ConfigureAwait(false); + + switch (state) + { + case ApplicationState.Enabled when !isEnabled: + case ApplicationState.Disabled when isEnabled: + await _appStateRepository.UpdateAsync(applicationId, isEnabled, _cancelTokenSource.Token) + .ConfigureAwait(false); + break; + } + } + + public void Dispose() + { + _cancelTokenSource.Dispose(); + } } diff --git a/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs b/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs index fc1e5dc03..fce3b60e2 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs @@ -52,9 +52,6 @@ await haConnection.CallServiceAsync("input_boolean", enabled ? "turn_on" : "turn public async Task RemoveNotUsedStatesAsync(IReadOnlyCollection applicationIds, CancellationToken token) { - if (applicationIds.Count == 0) - return; - var haConnection = _runner.CurrentConnection ?? throw new InvalidOperationException(); var helpers = await haConnection.ListInputBooleanHelpersAsync(token).ConfigureAwait(false); From bdfe1a7277081e5d41448ab1dfe4ff6274fe7dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Thu, 3 Feb 2022 22:59:42 +0100 Subject: [PATCH 4/6] Review new line --- src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index 482f6fa0e..a9fabf80c 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -97,7 +97,6 @@ CancellationToken cancelToken await _cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); - _applicationModelContext = await _appModel.InitializeAsync(CancellationToken.None).ConfigureAwait(false); From bd1c074b792c0acf7abdfba6f3d6807f7dca7e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Thu, 3 Feb 2022 23:07:36 +0100 Subject: [PATCH 5/6] switch -> if --- .../NetDaemon.Runtime/Internal/AppStateManager.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs index fe862e7d6..5da4b35c3 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs @@ -54,7 +54,7 @@ await app.SetStateAsync( } }).Subscribe(); } - + public async Task GetStateAsync(string applicationId) { if (_stateCache.TryGetValue(applicationId, out var applicationState)) return applicationState; @@ -72,13 +72,14 @@ public async Task SaveStateAsync(string applicationId, ApplicationState state) var isEnabled = await _appStateRepository.GetOrCreateAsync(applicationId, _cancelTokenSource.Token) .ConfigureAwait(false); - switch (state) + // Only update state if it is different from current + if ( + (state == ApplicationState.Enabled && !isEnabled) || + (state == ApplicationState.Disabled && isEnabled) + ) { - case ApplicationState.Enabled when !isEnabled: - case ApplicationState.Disabled when isEnabled: - await _appStateRepository.UpdateAsync(applicationId, isEnabled, _cancelTokenSource.Token) - .ConfigureAwait(false); - break; + await _appStateRepository.UpdateAsync(applicationId, isEnabled, _cancelTokenSource.Token) + .ConfigureAwait(false); } } From 5577c9dc2dd19887dbbb24dbbd4f561f247acb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Fri, 4 Feb 2022 09:07:45 +0100 Subject: [PATCH 6/6] A bit more efficient lookup --- src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs b/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs index fce3b60e2..10752023b 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/AppStateRepository.cs @@ -56,7 +56,8 @@ public async Task RemoveNotUsedStatesAsync(IReadOnlyCollection applicati throw new InvalidOperationException(); var helpers = await haConnection.ListInputBooleanHelpersAsync(token).ConfigureAwait(false); - var entityIds = applicationIds.Select(EntityMapperHelper.ToSafeHomeAssistantEntityIdFromApplicationId); + var entityIds = applicationIds.Select(EntityMapperHelper.ToSafeHomeAssistantEntityIdFromApplicationId) + .ToHashSet(); var notUsedHelperIds = helpers.Where(n => !entityIds.Contains($"input_boolean.{n.Name}") && n.Id.StartsWith("netdaemon_"));