From c779a824969a9410a7d8e92e809132366efe7634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Mon, 17 Jan 2022 18:55:53 +0100 Subject: [PATCH 1/4] Use helpers for app-state --- .../HomeAssistantApiCallException.cs | 13 + .../Common/IHomeAssistantConnection.cs | 2 +- .../Commands/CreateHelperCommandBase.cs | 11 + .../Internal/HomeAssistantApiManager.cs | 6 +- .../Internal/AppStateManagerTests.cs | 369 +++++++++--------- .../Internal/AppStateManager.cs | 118 +++--- .../HomeAssistantConnectionExtensions.cs | 25 ++ .../Model/CreateInputBooleanHelperCommand.cs | 14 + .../Internal/Model/HassHelper.cs | 10 + .../Model/ListInputBooleanHelperCommand.cs | 11 + 10 files changed, 336 insertions(+), 243 deletions(-) create mode 100644 src/Client/NetDaemon.HassClient/Common/Exceptions/HomeAssistantApiCallException.cs create mode 100644 src/Client/NetDaemon.HassClient/Internal/HomeAssistant/Commands/CreateHelperCommandBase.cs create mode 100644 src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs create mode 100644 src/Runtime/NetDaemon.Runtime/Internal/Model/CreateInputBooleanHelperCommand.cs create mode 100644 src/Runtime/NetDaemon.Runtime/Internal/Model/HassHelper.cs create mode 100644 src/Runtime/NetDaemon.Runtime/Internal/Model/ListInputBooleanHelperCommand.cs diff --git a/src/Client/NetDaemon.HassClient/Common/Exceptions/HomeAssistantApiCallException.cs b/src/Client/NetDaemon.HassClient/Common/Exceptions/HomeAssistantApiCallException.cs new file mode 100644 index 000000000..d4c59d1db --- /dev/null +++ b/src/Client/NetDaemon.HassClient/Common/Exceptions/HomeAssistantApiCallException.cs @@ -0,0 +1,13 @@ +using System.Net; + +namespace NetDaemon.Client.Internal.Exceptions; + +public class HomeAssistantApiCallException : ApplicationException +{ + public HttpStatusCode Code { get; private set; } + public HomeAssistantApiCallException(string? message, HttpStatusCode code) : base(message) + { + Code = code; + } + +} \ No newline at end of file diff --git a/src/Client/NetDaemon.HassClient/Common/IHomeAssistantConnection.cs b/src/Client/NetDaemon.HassClient/Common/IHomeAssistantConnection.cs index 820257026..92f3c25d2 100644 --- a/src/Client/NetDaemon.HassClient/Common/IHomeAssistantConnection.cs +++ b/src/Client/NetDaemon.HassClient/Common/IHomeAssistantConnection.cs @@ -19,7 +19,7 @@ public interface IHomeAssistantConnection : IHomeAssistantApiManager, IAsyncDisp Task SendCommandAsync(T command, CancellationToken cancelToken) where T : CommandMessage; /// - /// Sends a command message to Home Assistant without handling the result + /// Sends a command message to Home Assistant and return the result /// /// Command message to send /// token to cancel operation diff --git a/src/Client/NetDaemon.HassClient/Internal/HomeAssistant/Commands/CreateHelperCommandBase.cs b/src/Client/NetDaemon.HassClient/Internal/HomeAssistant/Commands/CreateHelperCommandBase.cs new file mode 100644 index 000000000..e043cd0f0 --- /dev/null +++ b/src/Client/NetDaemon.HassClient/Internal/HomeAssistant/Commands/CreateHelperCommandBase.cs @@ -0,0 +1,11 @@ +namespace NetDaemon.Client.Internal.HomeAssistant.Commands; + +internal record CreateHelperCommandBase : CommandMessage +{ + public CreateHelperCommandBase(string helperType) + { + Type = $"{helperType}/create"; + } + [JsonPropertyName("name")] public string Name { get; init; } = string.Empty; + [JsonPropertyName("icon")] public string Icon { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/Client/NetDaemon.HassClient/Internal/HomeAssistantApiManager.cs b/src/Client/NetDaemon.HassClient/Internal/HomeAssistantApiManager.cs index c6eff61a2..b84935796 100644 --- a/src/Client/NetDaemon.HassClient/Internal/HomeAssistantApiManager.cs +++ b/src/Client/NetDaemon.HassClient/Internal/HomeAssistantApiManager.cs @@ -1,3 +1,5 @@ +using NetDaemon.Client.Internal.Exceptions; + namespace NetDaemon.Client.Internal; internal class HomeAssistantApiManager : IHomeAssistantApiManager @@ -39,8 +41,8 @@ HttpClient httpClient .ConfigureAwait(false); } - throw new ApplicationException( - $"Call to API unsuccessful, code {result.StatusCode}: reason: {result.ReasonPhrase}"); + throw new HomeAssistantApiCallException( + $"Call to API unsuccessful, code {result.StatusCode}: reason: {result.ReasonPhrase}", result.StatusCode); } public async Task PostApiCallAsync(string apiPath, CancellationToken cancelToken, object? data = null) diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateManagerTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateManagerTests.cs index c24e3c464..91d3ae8f7 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateManagerTests.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateManagerTests.cs @@ -1,10 +1,11 @@ +using System.Net; using System.Reactive.Subjects; using Microsoft.Extensions.DependencyInjection; using NetDaemon.AppModel; +using NetDaemon.Client.Internal.Exceptions; using NetDaemon.HassModel.Common; -using NetDaemon.HassModel.Entities; using NetDaemon.Runtime.Internal; -using NetDaemon.Runtime.Tests.Helpers; +using NetDaemon.Runtime.Internal.Model; namespace NetDaemon.Runtime.Tests.Internal; @@ -14,9 +15,9 @@ public class AppStateManagerTests public async Task TestGetStateAsyncReturnsCorrectStateEnabled() { // ARRANGE - var haContextMock = new Mock(); + var haConnectionMock = new Mock(); var provider = new ServiceCollection() - .AddScoped(_ => haContextMock.Object) + .AddTransient(_ => haConnectionMock.Object) .AddNetDameonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); @@ -25,13 +26,13 @@ public async Task TestGetStateAsyncReturnsCorrectStateEnabled() // ACT // ASSERT - haContextMock.Setup(n => n.GetState(It.IsAny())).Returns( - new EntityState - { - EntityId = "switch.helloapp", - State = "on" - } - ); + haConnectionMock.Setup(n => n.GetApiCallAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + new HassState + { + EntityId = "input_boolean.helloapp", + State = "on" + }); (await appStateManager.GetStateAsync("hellpapp")) .Should().Be(ApplicationState.Enabled); } @@ -40,9 +41,9 @@ public async Task TestGetStateAsyncReturnsCorrectStateEnabled() public async Task TestGetStateAsyncReturnsCorrectStateDisabled() { // ARRANGE - var haContextMock = new Mock(); + var haConnectionMock = new Mock(); var provider = new ServiceCollection() - .AddScoped(_ => haContextMock.Object) + .AddTransient(_ => haConnectionMock.Object) .AddNetDameonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); @@ -51,165 +52,160 @@ public async Task TestGetStateAsyncReturnsCorrectStateDisabled() // ACT // ASSERT - haContextMock.Setup(n => n.GetState(It.IsAny())).Returns( - new EntityState - { - EntityId = "switch.helloapp", - State = "off" - } - ); + haConnectionMock.Setup(n => n.GetApiCallAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + new HassState + { + EntityId = "input_boolean.helloapp", + State = "off" + }); (await appStateManager.GetStateAsync("hellpapp")) .Should().Be(ApplicationState.Disabled); } [Fact] - public async Task TestGetStateAsyncNotExistReturnsCorrectStateEnabled() + public async Task TestSaveStateAsyncReturnsCorrectStateDisabled() { // ARRANGE - var haContextMock = new Mock(); + var haConnectionMock = new Mock(); var provider = new ServiceCollection() - .AddScoped(_ => haContextMock.Object) + .AddTransient(_ => haConnectionMock.Object) .AddNetDameonStateManager() .BuildServiceProvider(); - using var scopedProvider = provider.CreateScope(); - - var appStateManager = scopedProvider.ServiceProvider.GetRequiredService(); + var appStateManager = provider.GetRequiredService(); + haConnectionMock.Setup(n => n.GetApiCallAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + new HassState + { + EntityId = "input_boolean.helloapp", + State = "on" + }); // ACT - // ASSERT - haContextMock.Setup(n => n.GetState(It.IsAny())).Returns( - (EntityState?) null - ); - (await appStateManager.GetStateAsync("hellpapp")) - .Should().Be(ApplicationState.Enabled); + await appStateManager.SaveStateAsync("helloapp", ApplicationState.Disabled); - haContextMock.Verify(n => n.CallService("netdaemon", "entity_create", null, It.IsAny()), Times.Once); + // ASSERT + haConnectionMock.Verify(n => + n.GetApiCallAsync("states/input_boolean.netdaemon_helloapp", It.IsAny())); + // It exists so it should turn it on + haConnectionMock.Verify(n => + n.SendCommandAndReturnResponseAsync(It.IsAny(), + It.IsAny())); } [Fact] - public async Task TestSetStateAsyncEnabled() + public async Task TestGetStateAsyncNotExistReturnsCorrectStateEnabled() { // ARRANGE - var haContextMock = new Mock(); + var haConnectionMock = new Mock(); var provider = new ServiceCollection() - .AddScoped(_ => haContextMock.Object) + .AddTransient(_ => haConnectionMock.Object) .AddNetDameonStateManager() .BuildServiceProvider(); - using var scopedProvider = provider.CreateScope(); - - var appStateManager = scopedProvider.ServiceProvider.GetRequiredService(); - haContextMock.Setup(n => n.GetState(It.IsAny())).Returns( - new EntityState - { - EntityId = "switch.helloapp" - }); + var appStateManager = provider.GetRequiredService(); + haConnectionMock.Setup(n => n.GetApiCallAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync( + new HomeAssistantApiCallException("ohh no", HttpStatusCode.NotFound)); // ACT - await appStateManager.SaveStateAsync("helloapp", ApplicationState.Enabled); + var state = await appStateManager.GetStateAsync("helloapp"); // ASSERT - - haContextMock.Verify(n => n.CallService("netdaemon", "entity_update", null, It.IsAny()), Times.Once); - var invocation = haContextMock.Invocations.First(n => n.Method.Name == "CallService").Arguments[3]; - invocation.GetType().GetProperty("entity_id")!.GetValue(invocation, null)!.Should() - .Be("switch.netdaemon_helloapp"); - invocation.GetType().GetProperty("state")!.GetValue(invocation, null)!.Should().Be("on"); - var attributes = invocation.GetType().GetProperty("attributes")!.GetValue(invocation, null)!; - attributes.GetType().GetProperty("app_state")!.GetValue(attributes, null).Should().Be("enabled"); + haConnectionMock.Verify(n => + n.GetApiCallAsync("states/input_boolean.netdaemon_helloapp", It.IsAny())); + // It exists so it should turn it on + haConnectionMock.Verify(n => + n.SendCommandAndReturnResponseAsync( + It.IsAny(), It.IsAny())); + haConnectionMock.Verify(n => + n.SendCommandAndReturnResponseAsync(It.IsAny(), + It.IsAny())); } [Fact] - public async Task TestSetStateAsyncRunning() + public async Task TestSetStateAsyncEnabled() { // ARRANGE - var haContextMock = new Mock(); + var haConnectionMock = new Mock(); var provider = new ServiceCollection() - .AddScoped(_ => haContextMock.Object) + .AddTransient(_ => haConnectionMock.Object) .AddNetDameonStateManager() .BuildServiceProvider(); - using var scopedProvider = provider.CreateScope(); - - var appStateManager = scopedProvider.ServiceProvider.GetRequiredService(); - haContextMock.Setup(n => n.GetState(It.IsAny())).Returns( - new EntityState - { - EntityId = "switch.helloapp" - }); + var appStateManager = provider.GetRequiredService(); + haConnectionMock.Setup(n => n.GetApiCallAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + new HassState + { + EntityId = "input_boolean.helloapp", + State = "off" + }); // ACT - await appStateManager.SaveStateAsync("helloapp", ApplicationState.Running); - // ASSERT + await appStateManager.SaveStateAsync("helloapp", ApplicationState.Enabled); - haContextMock.Verify(n => n.CallService("netdaemon", "entity_update", null, It.IsAny()), Times.Once); - var invocation = haContextMock.Invocations.First(n => n.Method.Name == "CallService").Arguments[3]; - invocation.GetType().GetProperty("entity_id")!.GetValue(invocation, null)!.Should() - .Be("switch.netdaemon_helloapp"); - invocation.GetType().GetProperty("state")!.GetValue(invocation, null)!.Should().Be("on"); - var attributes = invocation.GetType().GetProperty("attributes")!.GetValue(invocation, null)!; - attributes.GetType().GetProperty("app_state")!.GetValue(attributes, null).Should().Be("running"); + // ASSERT + haConnectionMock.Verify(n => + n.GetApiCallAsync("states/input_boolean.netdaemon_helloapp", It.IsAny())); + // It exists so it should turn it on + haConnectionMock.Verify(n => + n.SendCommandAndReturnResponseAsync(It.IsAny(), + It.IsAny())); } [Fact] - public async Task TestSetStateAsyncError() + public async Task TestSetStateAsyncRunning() { // ARRANGE - var haContextMock = new Mock(); + var haConnectionMock = new Mock(); var provider = new ServiceCollection() - .AddScoped(_ => haContextMock.Object) + .AddTransient(_ => haConnectionMock.Object) .AddNetDameonStateManager() .BuildServiceProvider(); - using var scopedProvider = provider.CreateScope(); - - var appStateManager = scopedProvider.ServiceProvider.GetRequiredService(); - haContextMock.Setup(n => n.GetState(It.IsAny())).Returns( - new EntityState - { - EntityId = "switch.helloapp" - }); + var appStateManager = provider.GetRequiredService(); + haConnectionMock.Setup(n => n.GetApiCallAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + new HassState + { + EntityId = "input_boolean.helloapp", + State = "on" + }); // ACT - await appStateManager.SaveStateAsync("helloapp", ApplicationState.Error); - // ASSERT + await appStateManager.SaveStateAsync("helloapp", ApplicationState.Running); - haContextMock.Verify(n => n.CallService("netdaemon", "entity_update", null, It.IsAny()), Times.Once); - var invocation = haContextMock.Invocations.First(n => n.Method.Name == "CallService").Arguments[3]; - invocation.GetType().GetProperty("entity_id")!.GetValue(invocation, null)!.Should() - .Be("switch.netdaemon_helloapp"); - invocation.GetType().GetProperty("state")!.GetValue(invocation, null)!.Should().Be("on"); - var attributes = invocation.GetType().GetProperty("attributes")!.GetValue(invocation, null); - attributes?.GetType().GetProperty("app_state")!.GetValue(attributes, null).Should().Be("error"); + // ASSERT + // This should just render in a get api check + haConnectionMock.Verify(n => + n.GetApiCallAsync("states/input_boolean.netdaemon_helloapp", It.IsAny())); } [Fact] - public async Task TestSetStateAsyncDisabled() + public async Task TestSetStateAsyncError() { // ARRANGE - var haContextMock = new Mock(); + var haConnectionMock = new Mock(); var provider = new ServiceCollection() - .AddScoped(_ => haContextMock.Object) + .AddTransient(_ => haConnectionMock.Object) .AddNetDameonStateManager() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); var appStateManager = scopedProvider.ServiceProvider.GetRequiredService(); - haContextMock.Setup(n => n.GetState(It.IsAny())).Returns( - new EntityState - { - EntityId = "switch.helloapp" - }); + haConnectionMock.Setup(n => n.GetApiCallAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + new HassState + { + EntityId = "input_boolean.helloapp", + State = "on" + }); // ACT - await appStateManager.SaveStateAsync("helloapp", ApplicationState.Disabled); + await appStateManager.SaveStateAsync("helloapp", ApplicationState.Error); + // This should just render in a get api check // ASSERT + haConnectionMock.Verify(n => + n.GetApiCallAsync("states/input_boolean.netdaemon_helloapp", It.IsAny())); + } - haContextMock.Verify(n => n.CallService("netdaemon", "entity_update", null, It.IsAny()), Times.Once); - var invocation = haContextMock.Invocations.First(n => n.Method.Name == "CallService").Arguments[3]; - invocation.GetType().GetProperty("entity_id")!.GetValue(invocation, null)!.Should() - .Be("switch.netdaemon_helloapp"); - invocation.GetType().GetProperty("state")!.GetValue(invocation, null)!.Should().Be("off"); - var attributes = invocation.GetType().GetProperty("attributes")!.GetValue(invocation, null)!; - attributes.GetType().GetProperty("app_state")!.GetValue(attributes, null).Should().Be("disabled"); - } - [Fact] public void TestInitialize() { @@ -224,65 +220,67 @@ public void TestInitialize() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); - var homeAssistantStateUpdater = scopedProvider.ServiceProvider.GetRequiredService(); + var homeAssistantStateUpdater = + scopedProvider.ServiceProvider.GetRequiredService(); Subject hassEvent = new(); haConnectionMock.SetupGet(n => n.OnHomeAssistantEvent).Returns(hassEvent); - + // ACT homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); // ASSERT hassEvent.HasObservers.Should().BeTrue(); } - - [Fact] - public void TestAppEnabledShouldCallSetStateAsyncDisabled() - { - // ARRANGE - var haContextMock = new Mock(); - var appModelContextMock = new Mock(); - var appMock = new Mock(); - var haConnectionMock = new Mock(); - var provider = new ServiceCollection() - .AddScoped(_ => haContextMock.Object) - .AddTransient(_ => haConnectionMock.Object) - .AddNetDameonStateManager() - .BuildServiceProvider(); - using var scopedProvider = provider.CreateScope(); - var homeAssistantStateUpdater = scopedProvider.ServiceProvider.GetRequiredService(); - Subject hassEvent = new(); - haConnectionMock.SetupGet(n => n.OnHomeAssistantEvent).Returns(hassEvent); - appMock.SetupGet(n => n.Id).Returns("app"); - appModelContextMock.SetupGet(n => n.Applications).Returns( - new List - { - appMock.Object - }); - - // ACT - homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); - hassEvent.OnNext(new HassEvent() - { - EventType = "state_changed", - DataElement = new HassStateChangedEventData - { - EntityId= "switch.netdaemon_app", - NewState = new HassState - { - EntityId = "switch.netdaemon_app", - State = "on" - }, - OldState = new HassState - { - EntityId = "switch.netdaemon_app", - State = "off" - } - }.ToJsonElement() - }); - // ASSERT - appMock.Verify(n => n.SetStateAsync(ApplicationState.Enabled), Times.Once); - } - + // [Fact] + // public void TestAppEnabledShouldCallSetStateAsyncDisabled() + // { + // // ARRANGE + // var haContextMock = new Mock(); + // var appModelContextMock = new Mock(); + // var appMock = new Mock(); + // var haConnectionMock = new Mock(); + // var provider = new ServiceCollection() + // .AddScoped(_ => haContextMock.Object) + // .AddTransient(_ => haConnectionMock.Object) + // .AddNetDameonStateManager() + // .BuildServiceProvider(); + // using var scopedProvider = provider.CreateScope(); + // + // var homeAssistantStateUpdater = + // scopedProvider.ServiceProvider.GetRequiredService(); + // Subject hassEvent = new(); + // haConnectionMock.SetupGet(n => n.OnHomeAssistantEvent).Returns(hassEvent); + // appMock.SetupGet(n => n.Id).Returns("app"); + // appModelContextMock.SetupGet(n => n.Applications).Returns( + // new List + // { + // appMock.Object + // }); + // + // // ACT + // homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); + // hassEvent.OnNext(new HassEvent + // { + // EventType = "state_changed", + // DataElement = new HassStateChangedEventData + // { + // EntityId = "switch.netdaemon_app", + // NewState = new HassState + // { + // EntityId = "switch.netdaemon_app", + // State = "on" + // }, + // OldState = new HassState + // { + // EntityId = "switch.netdaemon_app", + // State = "off" + // } + // }.ToJsonElement() + // }); + // // ASSERT + // appMock.Verify(n => n.SetStateAsync(ApplicationState.Enabled), Times.Once); + // } + [Fact] public void TestAppDisabledShouldCallSetStateAsyncEnabled() { @@ -298,7 +296,8 @@ public void TestAppDisabledShouldCallSetStateAsyncEnabled() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); - var homeAssistantStateUpdater = scopedProvider.ServiceProvider.GetRequiredService(); + var homeAssistantStateUpdater = + scopedProvider.ServiceProvider.GetRequiredService(); Subject hassEvent = new(); haConnectionMock.SetupGet(n => n.OnHomeAssistantEvent).Returns(hassEvent); appMock.SetupGet(n => n.Id).Returns("app"); @@ -307,31 +306,31 @@ public void TestAppDisabledShouldCallSetStateAsyncEnabled() { appMock.Object }); - + // ACT homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); - hassEvent.OnNext(new HassEvent() + hassEvent.OnNext(new HassEvent { EventType = "state_changed", DataElement = new HassStateChangedEventData { - EntityId= "switch.netdaemon_app", + EntityId = "input_boolean.netdaemon_app", NewState = new HassState { - EntityId = "switch.netdaemon_app", + EntityId = "input_boolean.netdaemon_app", State = "off" }, OldState = new HassState { - EntityId = "switch.netdaemon_app", + EntityId = "input_boolean.netdaemon_app", State = "on" } }.ToJsonElement() }); // ASSERT appMock.Verify(n => n.SetStateAsync(ApplicationState.Disabled), Times.Once); - } - + } + [Fact] public void TestAppNoChangeShouldNotCallSetStateAsync() { @@ -347,7 +346,8 @@ public void TestAppNoChangeShouldNotCallSetStateAsync() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); - var homeAssistantStateUpdater = scopedProvider.ServiceProvider.GetRequiredService(); + var homeAssistantStateUpdater = + scopedProvider.ServiceProvider.GetRequiredService(); Subject hassEvent = new(); haConnectionMock.SetupGet(n => n.OnHomeAssistantEvent).Returns(hassEvent); appMock.SetupGet(n => n.Id).Returns("app"); @@ -356,23 +356,23 @@ public void TestAppNoChangeShouldNotCallSetStateAsync() { appMock.Object }); - + // ACT homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); - hassEvent.OnNext(new HassEvent() + hassEvent.OnNext(new HassEvent { EventType = "state_changed", DataElement = new HassStateChangedEventData { - EntityId= "switch.netdaemon_app", + EntityId = "input_boolean.netdaemon_app", NewState = new HassState { - EntityId = "switch.netdaemon_app", + EntityId = "input_boolean.netdaemon_app", State = "on" }, OldState = new HassState { - EntityId = "switch.netdaemon_app", + EntityId = "input_boolean.netdaemon_app", State = "on" } }.ToJsonElement() @@ -380,8 +380,8 @@ public void TestAppNoChangeShouldNotCallSetStateAsync() // ASSERT appMock.Verify(n => n.SetStateAsync(ApplicationState.Disabled), Times.Never); - } - + } + [Fact] public void TestAppOneStateIsNullShouldNotCallSetStateAsync() { @@ -397,7 +397,8 @@ public void TestAppOneStateIsNullShouldNotCallSetStateAsync() .BuildServiceProvider(); using var scopedProvider = provider.CreateScope(); - var homeAssistantStateUpdater = scopedProvider.ServiceProvider.GetRequiredService(); + var homeAssistantStateUpdater = + scopedProvider.ServiceProvider.GetRequiredService(); Subject hassEvent = new(); haConnectionMock.SetupGet(n => n.OnHomeAssistantEvent).Returns(hassEvent); appMock.SetupGet(n => n.Id).Returns("app"); @@ -406,23 +407,23 @@ public void TestAppOneStateIsNullShouldNotCallSetStateAsync() { appMock.Object }); - + // ACT homeAssistantStateUpdater.Initialize(haConnectionMock.Object, appModelContextMock.Object); - hassEvent.OnNext(new HassEvent() + hassEvent.OnNext(new HassEvent { EventType = "state_changed", DataElement = new HassStateChangedEventData { - EntityId= "switch.netdaemon_app", + EntityId = "input_boolean.netdaemon_app", NewState = new HassState { - EntityId = "switch.netdaemon_app", + EntityId = "input_boolean.netdaemon_app", State = "on" } }.ToJsonElement() }); - + // ASSERT appMock.Verify(n => n.SetStateAsync(ApplicationState.Disabled), Times.Never); } diff --git a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs index b0e3ab908..b054e5d78 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs @@ -1,16 +1,18 @@ 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.HassModel.Common; -using NetDaemon.HassModel.Integration; +using NetDaemon.Client.Common.HomeAssistant.Model; +using NetDaemon.Client.Internal.Exceptions; namespace NetDaemon.Runtime.Internal; -internal class AppStateManager : IAppStateManager, IHandleHomeAssistantAppStateUpdates +internal class AppStateManager : IAppStateManager, IHandleHomeAssistantAppStateUpdates, IDisposable { + private readonly CancellationTokenSource _cancelTokenSource = new(); private readonly IServiceProvider _provider; private readonly ConcurrentDictionary _stateCache = new(); @@ -25,66 +27,43 @@ public async Task GetStateAsync(string applicationId) { var entityId = ToSafeHomeAssistantEntityIdFromApplicationId(applicationId); if (_stateCache.TryGetValue(entityId, out var applicationState)) return applicationState; - // Since IHaContext is scoped and StateManager is singleton we get the - // IHaContext everytime we need to check state - var scope = _provider.CreateScope(); - try - { - var haContext = scope.ServiceProvider.GetRequiredService(); - - var appState = haContext.GetState(entityId); - if (appState is null) - { - haContext.SetEntityState(entityId, "on"); - return ApplicationState.Enabled; - } - - var appStateFromHomeAssistant = - appState.State == "on" ? ApplicationState.Enabled : ApplicationState.Disabled; - _stateCache[entityId] = appStateFromHomeAssistant; - return appStateFromHomeAssistant; - } - finally - { - if (scope is IAsyncDisposable serviceScopeAsyncDisposable) - await serviceScopeAsyncDisposable.DisposeAsync().ConfigureAwait(false); - } + return (await GetOrCreateStateForApp(entityId).ConfigureAwait(false))?.State == "on" + ? ApplicationState.Enabled + : ApplicationState.Disabled; } public async Task SaveStateAsync(string applicationId, ApplicationState state) { - // Since IHaContext is scoped and StateManager is singleton we get the - // IHaContext everytime we need to check state - var scope = _provider.CreateScope(); - try - { - var haContext = scope.ServiceProvider.GetRequiredService(); - var entityId = ToSafeHomeAssistantEntityIdFromApplicationId(applicationId); + var haConnection = _provider.GetRequiredService() ?? + throw new InvalidOperationException(); + var entityId = ToSafeHomeAssistantEntityIdFromApplicationId(applicationId); - switch (state) - { - case ApplicationState.Enabled: - haContext.SetEntityState(entityId, "on", new {app_state = "enabled"}); - break; - case ApplicationState.Running: - haContext.SetEntityState(entityId, "on", new {app_state = "running"}); - break; - case ApplicationState.Error: - haContext.SetEntityState(entityId, "on", new {app_state = "error"}); - break; - case ApplicationState.Disabled: - haContext.SetEntityState(entityId, "off", new {app_state = "disabled"}); - break; - } - } - finally + _stateCache[entityId] = state; + + var currentState = (await GetOrCreateStateForApp(entityId).ConfigureAwait(false))?.State + ?? throw new InvalidOperationException(); + + switch (state) { - if (scope is IAsyncDisposable serviceScopeAsyncDisposable) - await serviceScopeAsyncDisposable.DisposeAsync().ConfigureAwait(false); + 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); + break; } } + public void Dispose() + { + _cancelTokenSource.Dispose(); + } + public void Initialize(IHomeAssistantConnection haConnection, IAppModelContext appContext) { haConnection.OnHomeAssistantEvent @@ -109,8 +88,6 @@ public void Initialize(IHomeAssistantConnection haConnection, IAppModelContext a ? ApplicationState.Enabled : ApplicationState.Disabled; - _stateCache[entityId] = appState; - await app.SetStateAsync( appState ); @@ -148,6 +125,35 @@ public static string ToSafeHomeAssistantEntityIdFromApplicationId(string applica break; } - return $"switch.netdaemon_{stringBuilder.ToString().ToLowerInvariant()}"; + return $"input_boolean.netdaemon_{stringBuilder.ToString().ToLowerInvariant()}"; + } + + private async Task GetOrCreateStateForApp(string entityId) + { + var haConnection = _provider.GetRequiredService() ?? + throw new InvalidOperationException(); + 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) + { + // The app state input_boolean does not exist, lets create a helper + var name = entityId[14..]; // remove the "switch." 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"}; + } + + throw; + } } } \ No newline at end of file diff --git a/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs b/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs new file mode 100644 index 000000000..fec2aa62d --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs @@ -0,0 +1,25 @@ +using NetDaemon.Runtime.Internal.Model; + +namespace NetDaemon.Runtime.Internal; + +internal static class HomeAssistantConnectionExtensions +{ + public static async Task?> GetInputBooleanHelpersAsync( + this IHomeAssistantConnection connection, CancellationToken cancelToken) + { + return await connection + .SendCommandAndReturnResponseAsync?>( + new ListInputBooleanHelperCommand(), cancelToken); + } + + public static async Task CreateInputBooleanHelperAsync( + this IHomeAssistantConnection connection, + string name, CancellationToken cancelToken) + { + return await connection.SendCommandAndReturnResponseAsync( + new CreateInputBooleanHelperCommand + { + Name = name + }, cancelToken); + } +} \ No newline at end of file diff --git a/src/Runtime/NetDaemon.Runtime/Internal/Model/CreateInputBooleanHelperCommand.cs b/src/Runtime/NetDaemon.Runtime/Internal/Model/CreateInputBooleanHelperCommand.cs new file mode 100644 index 000000000..ea471a5d5 --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Internal/Model/CreateInputBooleanHelperCommand.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using NetDaemon.Client.Common.HomeAssistant.Model; + +namespace NetDaemon.Runtime.Internal.Model; + +internal record CreateInputBooleanHelperCommand : CommandMessage +{ + public CreateInputBooleanHelperCommand() + { + Type = "input_boolean/create"; + } + + [JsonPropertyName("name")] public string Name { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/Runtime/NetDaemon.Runtime/Internal/Model/HassHelper.cs b/src/Runtime/NetDaemon.Runtime/Internal/Model/HassHelper.cs new file mode 100644 index 000000000..c945645e5 --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Internal/Model/HassHelper.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace NetDaemon.Runtime.Internal.Model; + +internal record InputBooleanHelper +{ + [JsonPropertyName("name")] public string Name { get; init; } = string.Empty; + [JsonPropertyName("icon")] public string? Icon { get; init; } + [JsonPropertyName("id")] public string Id { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/Runtime/NetDaemon.Runtime/Internal/Model/ListInputBooleanHelperCommand.cs b/src/Runtime/NetDaemon.Runtime/Internal/Model/ListInputBooleanHelperCommand.cs new file mode 100644 index 000000000..422ee227d --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Internal/Model/ListInputBooleanHelperCommand.cs @@ -0,0 +1,11 @@ +using NetDaemon.Client.Common.HomeAssistant.Model; + +namespace NetDaemon.Runtime.Internal.Model; + +internal record ListInputBooleanHelperCommand : CommandMessage +{ + public ListInputBooleanHelperCommand() + { + Type = "input_boolean/list"; + } +} \ No newline at end of file From 7b7af83e668f1a93e49d78a43d1550736776660d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Mon, 17 Jan 2022 19:03:12 +0100 Subject: [PATCH 2/4] Fix warning --- .../Common/Exceptions/HomeAssistantApiCallException.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Client/NetDaemon.HassClient/Common/Exceptions/HomeAssistantApiCallException.cs b/src/Client/NetDaemon.HassClient/Common/Exceptions/HomeAssistantApiCallException.cs index d4c59d1db..840c7e66d 100644 --- a/src/Client/NetDaemon.HassClient/Common/Exceptions/HomeAssistantApiCallException.cs +++ b/src/Client/NetDaemon.HassClient/Common/Exceptions/HomeAssistantApiCallException.cs @@ -2,6 +2,7 @@ namespace NetDaemon.Client.Internal.Exceptions; +[SuppressMessage("", "RCS1194")] public class HomeAssistantApiCallException : ApplicationException { public HttpStatusCode Code { get; private set; } From ea99a14024ebe2286f9852a889768789a6166ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Tue, 18 Jan 2022 00:00:18 +0100 Subject: [PATCH 3/4] Review comments --- .../NetDaemon.Runtime/Internal/AppStateManager.cs | 2 +- .../Internal/HomeAssistantConnectionExtensions.cs | 8 -------- .../Internal/Model/ListInputBooleanHelperCommand.cs | 11 ----------- 3 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 src/Runtime/NetDaemon.Runtime/Internal/Model/ListInputBooleanHelperCommand.cs diff --git a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs index b054e5d78..90a56198c 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs @@ -144,7 +144,7 @@ public static string ToSafeHomeAssistantEntityIdFromApplicationId(string applica if (e.Code == HttpStatusCode.NotFound) { // The app state input_boolean does not exist, lets create a helper - var name = entityId[14..]; // remove the "switch." part + 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", diff --git a/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs b/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs index fec2aa62d..98f20b978 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/HomeAssistantConnectionExtensions.cs @@ -4,14 +4,6 @@ namespace NetDaemon.Runtime.Internal; internal static class HomeAssistantConnectionExtensions { - public static async Task?> GetInputBooleanHelpersAsync( - this IHomeAssistantConnection connection, CancellationToken cancelToken) - { - return await connection - .SendCommandAndReturnResponseAsync?>( - new ListInputBooleanHelperCommand(), cancelToken); - } - public static async Task CreateInputBooleanHelperAsync( this IHomeAssistantConnection connection, string name, CancellationToken cancelToken) diff --git a/src/Runtime/NetDaemon.Runtime/Internal/Model/ListInputBooleanHelperCommand.cs b/src/Runtime/NetDaemon.Runtime/Internal/Model/ListInputBooleanHelperCommand.cs deleted file mode 100644 index 422ee227d..000000000 --- a/src/Runtime/NetDaemon.Runtime/Internal/Model/ListInputBooleanHelperCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using NetDaemon.Client.Common.HomeAssistant.Model; - -namespace NetDaemon.Runtime.Internal.Model; - -internal record ListInputBooleanHelperCommand : CommandMessage -{ - public ListInputBooleanHelperCommand() - { - Type = "input_boolean/list"; - } -} \ No newline at end of file From 54dc76f531dff75984aa649b8ed3dfd2758dcb96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Tue, 18 Jan 2022 00:02:31 +0100 Subject: [PATCH 4/4] Fix Dispose at the end --- .../NetDaemon.Runtime/Internal/AppStateManager.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs index 90a56198c..9a6ffe10d 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs @@ -59,11 +59,6 @@ await haConnection.CallServiceAsync("input_boolean", "turn_off", } } - public void Dispose() - { - _cancelTokenSource.Dispose(); - } - public void Initialize(IHomeAssistantConnection haConnection, IAppModelContext appContext) { haConnection.OnHomeAssistantEvent @@ -156,4 +151,9 @@ await haConnection.CallServiceAsync("input_boolean", "turn_on", throw; } } + + public void Dispose() + { + _cancelTokenSource.Dispose(); + } } \ No newline at end of file