diff --git a/NetDaemon.sln b/NetDaemon.sln index 814574fa8..643352da2 100644 --- a/NetDaemon.sln +++ b/NetDaemon.sln @@ -67,6 +67,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetDaemon.AppModel.SourceDeployedApps", "src\AppModel\NetDaemon.AppModel.SourceDeployedApps\NetDaemon.AppModel.SourceDeployedApps.csproj", "{AE48B790-C3D7-440E-945E-EE5C82AE2A86}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleClient", "src\debug\ConsoleClient\ConsoleClient.csproj", "{E966FBC9-D7AF-44F1-BE0F-C344B01A5323}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -341,6 +343,18 @@ Global {AE48B790-C3D7-440E-945E-EE5C82AE2A86}.Release|x64.Build.0 = Release|Any CPU {AE48B790-C3D7-440E-945E-EE5C82AE2A86}.Release|x86.ActiveCfg = Release|Any CPU {AE48B790-C3D7-440E-945E-EE5C82AE2A86}.Release|x86.Build.0 = Release|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Debug|x64.ActiveCfg = Debug|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Debug|x64.Build.0 = Debug|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Debug|x86.ActiveCfg = Debug|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Debug|x86.Build.0 = Debug|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Release|Any CPU.Build.0 = Release|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Release|x64.ActiveCfg = Release|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Release|x64.Build.0 = Release|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Release|x86.ActiveCfg = Release|Any CPU + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -367,6 +381,7 @@ Global {AEBC7828-7C19-4A86-B6E2-58B5171347B1} = {E15D4280-7FFC-4F8B-9B8C-CF9AF2BF838C} {CF7273C1-A4FE-4598-9C99-D746CE279A32} = {E15D4280-7FFC-4F8B-9B8C-CF9AF2BF838C} {AE48B790-C3D7-440E-945E-EE5C82AE2A86} = {A62F78F8-2EF5-49C8-B437-E5FC6321E866} + {E966FBC9-D7AF-44F1-BE0F-C344B01A5323} = {E15D4280-7FFC-4F8B-9B8C-CF9AF2BF838C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7C5FBB7F-654C-4CAC-964F-6D71AF3D62F8} diff --git a/src/Client/NetDaemon.HassClient/Common/Extensions/ServiceCollectionExtension.cs b/src/Client/NetDaemon.HassClient/Common/Extensions/ServiceCollectionExtension.cs index e23123723..2a832b0f4 100644 --- a/src/Client/NetDaemon.HassClient/Common/Extensions/ServiceCollectionExtension.cs +++ b/src/Client/NetDaemon.HassClient/Common/Extensions/ServiceCollectionExtension.cs @@ -18,6 +18,12 @@ public static IServiceCollection AddHomeAssistantClient(this IServiceCollection .AddSingleton() .AddSingleton(s => s.GetRequiredService()) .AddTransient(s => s.GetRequiredService().CurrentConnection!) + + + + .AddSingleton(s => s.GetRequiredService()) + .AddTransient(s => s.GetRequiredService().CurrentConnection!) + .AddWebSocketFactory() .AddPipelineFactory() .AddConnectionFactory() diff --git a/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs b/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs index 8aa43161b..8e0c41ce3 100644 --- a/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs +++ b/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs @@ -35,7 +35,6 @@ public static async Task ConnectClientAsync(string hos var loggerFactory = LoggerFactory.Create(b => b.AddConsole()); var loggerConnect = loggerFactory.CreateLogger(); var loggerClient = loggerFactory.CreateLogger(); - var loggerResultMessageHandler = loggerFactory.CreateLogger(); var settings = new HomeAssistantSettings { Host = host, @@ -52,4 +51,20 @@ public static async Task ConnectClientAsync(string hos return await client.ConnectAsync(host, port, ssl, token, websocketPath, cancelToken).ConfigureAwait(false); } + + /// + /// Connect to Home Assistant + /// + public static async Task ConnectClientAsync(string websocketUrl, string token, CancellationToken cancelToken) + { + return await ConnectClientAsync(new Uri(websocketUrl), token, cancelToken); + } + + /// + /// Connect to Home Assistant + /// + public static async Task ConnectClientAsync(Uri websocketUrl, string token, CancellationToken cancelToken) + { + return await ConnectClientAsync(websocketUrl.Host, websocketUrl.Port, websocketUrl.Scheme is "https" or "wss", token, websocketUrl.PathAndQuery, cancelToken); + } } diff --git a/src/Client/NetDaemon.HassClient/Common/IHomeAssistantConnectionProvider.cs b/src/Client/NetDaemon.HassClient/Common/IHomeAssistantConnectionProvider.cs new file mode 100644 index 000000000..b3645287a --- /dev/null +++ b/src/Client/NetDaemon.HassClient/Common/IHomeAssistantConnectionProvider.cs @@ -0,0 +1,12 @@ +namespace NetDaemon.Client; + +/// +/// Interface to retrieve the current connection to HomeAsstant +/// +public interface IHomeAssistantConnectionProvider +{ + /// + /// The current connection to Home Assistant. Null if disconnected. + /// + IHomeAssistantConnection? CurrentConnection { get; } +} diff --git a/src/Client/NetDaemon.HassClient/Common/IHomeAssistantRunner.cs b/src/Client/NetDaemon.HassClient/Common/IHomeAssistantRunner.cs index 958acd919..5aa79e499 100644 --- a/src/Client/NetDaemon.HassClient/Common/IHomeAssistantRunner.cs +++ b/src/Client/NetDaemon.HassClient/Common/IHomeAssistantRunner.cs @@ -1,6 +1,6 @@ namespace NetDaemon.Client; -public interface IHomeAssistantRunner : IAsyncDisposable +public interface IHomeAssistantRunner : IHomeAssistantConnectionProvider, IAsyncDisposable { /// /// Observable that emits when a (new) connection is established. @@ -27,7 +27,7 @@ public interface IHomeAssistantRunner : IAsyncDisposable /// Wait time between connects /// Cancel token Task RunAsync(string host, int port, bool ssl, string token, TimeSpan timeout, CancellationToken cancelToken); - + /// /// Maintains a connection to the Home Assistant server /// diff --git a/src/Client/NetDaemon.HassClient/Internal/HomeAssistantClient.cs b/src/Client/NetDaemon.HassClient/Internal/HomeAssistantClient.cs index 435532e90..154f91ff0 100644 --- a/src/Client/NetDaemon.HassClient/Internal/HomeAssistantClient.cs +++ b/src/Client/NetDaemon.HassClient/Internal/HomeAssistantClient.cs @@ -77,7 +77,14 @@ await transportPipeline.SendMessageAsync( private static Uri GetHomeAssistantWebSocketUri(string host, int port, bool ssl, string websocketPath) { - return new Uri($"{(ssl ? "wss" : "ws")}://{host}:{port}/{websocketPath}"); + return new UriBuilder + { + Host = host, + Port = port, + Scheme = ssl ? "wss" : "ws", + Path = websocketPath, + Query = string.Empty, + }.Uri; } private static async Task CheckIfRunning(IHomeAssistantConnection connection, CancellationToken cancelToken) diff --git a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/EntityFactoryGeneratorTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/EntityFactoryGeneratorTest.cs index bc547aa14..be898505e 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/EntityFactoryGeneratorTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/EntityFactoryGeneratorTest.cs @@ -107,10 +107,10 @@ private static void AddMockServices(ServiceCollection serviceCollection) var hassConnectionMock = new Mock(); serviceCollection.AddSingleton(hassConnectionMock.Object); - var haRunnerMock = new Mock(); - haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object); + var connectionProviderMock = new Mock(); + connectionProviderMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object); - serviceCollection.AddSingleton(_ => haRunnerMock.Object); + serviceCollection.AddSingleton(_ => connectionProviderMock.Object); var apiManagerMock = new Mock(); serviceCollection.AddSingleton(_ => apiManagerMock.Object); diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs index 1a3f4a5c0..a5b29ffb0 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs @@ -363,10 +363,10 @@ private async Task CreateServiceProvider() ); serviceCollection.AddSingleton>(_hassEventSubjectMock); - var haRunnerMock = new Mock(); + var connectioProviderMock = new Mock(); - haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object); - serviceCollection.AddSingleton(_ => haRunnerMock.Object); + connectioProviderMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object); + serviceCollection.AddSingleton(_ => connectioProviderMock.Object); var apiManagerMock = new Mock(); @@ -379,7 +379,7 @@ private async Task CreateServiceProvider() var provider = serviceCollection.BuildServiceProvider(); - await provider.GetRequiredService().InitializeAsync(CancellationToken.None); + await provider.GetRequiredService().InitializeAsync(_hassConnectionMock.Object, CancellationToken.None); return provider; } diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCachTests.cs b/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCacheTests.cs similarity index 76% rename from src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCachTests.cs rename to src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCacheTests.cs index caf8d7c7c..7c0558333 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCachTests.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCacheTests.cs @@ -9,28 +9,25 @@ namespace NetDaemon.HassModel.Tests.Internal; -public class EntityAreaCachTests +public class EntityAreaCacheTests { [Fact] public async Task EntityIdWithArea_Returns_HassArea() { // Arrange using var testSubject = new Subject(); - var _hassConnectionMock = new Mock(); - var haRunnerMock = new Mock(); - - haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object); + var hassConnectionMock = new Mock(); - _hassConnectionMock.Setup(n => + hassConnectionMock.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync>( It.IsAny(), It.IsAny() )).ReturnsAsync(Array.Empty()); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( It.IsAny(), It.IsAny() @@ -40,7 +37,7 @@ public async Task EntityIdWithArea_Returns_HassArea() new() {Id = "AreaId", Name = "Area Name"} }); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( It.IsAny(), It.IsAny() @@ -53,14 +50,14 @@ public async Task EntityIdWithArea_Returns_HassArea() var serviceColletion = new ServiceCollection(); _ = serviceColletion.AddTransient(_ => new Mock>().Object); - using var cache = new RegistryCache(haRunnerMock.Object, NullLogger.Instance); + using var cache = new RegistryCache(NullLogger.Instance); // Act - await cache.InitializeAsync(CancellationToken.None); + await cache.InitializeAsync(hassConnectionMock.Object, CancellationToken.None); // Assert var area = cache.GetAreaById(cache.GetHassEntityById("sensor.sensor1")!.AreaId); Assert.NotNull(area); - Assert.Equal("Area Name", area!.Name); + Assert.Equal("Area Name", area.Name); } [Fact] @@ -68,16 +65,13 @@ public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea() { // Arrange using var testSubject = new Subject(); - var _hassConnectionMock = new Mock(); - var haRunnerMock = new Mock(); + var hassConnectionMock = new Mock(); - haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object); - - _hassConnectionMock.Setup(n => + hassConnectionMock.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync>( It.IsAny(), It.IsAny() )).ReturnsAsync( @@ -86,7 +80,7 @@ public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea() new() {Id = "DeviceId", AreaId = "AreaId"} }); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( It.IsAny(), It.IsAny() @@ -96,7 +90,7 @@ public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea() new() {Id = "AreaId", Name = "Area Name"} }); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( It.IsAny(), It.IsAny() @@ -109,15 +103,15 @@ public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea() var serviceColletion = new ServiceCollection(); _ = serviceColletion.AddTransient(_ => new Mock>().Object); - using var cache = new RegistryCache(haRunnerMock.Object, Mock.Of>()); + using var cache = new RegistryCache(Mock.Of>()); // Act - await cache.InitializeAsync(CancellationToken.None); + await cache.InitializeAsync(hassConnectionMock.Object, CancellationToken.None); // Assert var area = cache.GetAreaById(cache.GetHassEntityById("sensor.sensor1")!.AreaId); Assert.NotNull(area); - Assert.Equal("Area Name", area!.Name); + Assert.Equal("Area Name", area.Name); } [Fact] @@ -125,16 +119,16 @@ public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea() { // Arrange using var testSubject = new Subject(); - var _hassConnectionMock = new Mock(); + var hassConnectionMock = new Mock(); var haRunnerMock = new Mock(); - haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object); - _hassConnectionMock.Setup(n => + hassConnectionMock.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync>( It.IsAny(), It.IsAny() )).ReturnsAsync( @@ -143,7 +137,7 @@ public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea() new() {Id = "DeviceId", AreaId = "AreaId"} }); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( It.IsAny(), It.IsAny() @@ -154,7 +148,7 @@ public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea() new() {Id = "AreaId2", Name = "Area2 Name"} }); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( It.IsAny(), It.IsAny() @@ -166,15 +160,15 @@ public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea() var serviceColletion = new ServiceCollection(); _ = serviceColletion.AddTransient(_ => new Mock>().Object); - using var cache = new RegistryCache(haRunnerMock.Object, Mock.Of>()); + using var cache = new RegistryCache(Mock.Of>()); // Act - await cache.InitializeAsync(CancellationToken.None); + await cache.InitializeAsync(hassConnectionMock.Object, CancellationToken.None); // Assert var area = cache.GetAreaById(cache.GetHassEntityById("sensor.sensor1")!.AreaId); Assert.NotNull(area); - Assert.Equal("Area2 Name", area!.Name); + Assert.Equal("Area2 Name", area.Name); } [Fact] @@ -182,21 +176,21 @@ public async Task EntityArea_Updates() { // Arrange using var testSubject = new Subject(); - var _hassConnectionMock = new Mock(); + var hassConnectionMock = new Mock(); var haRunnerMock = new Mock(); - haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object); + haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync>( It.IsAny(), It.IsAny() )).ReturnsAsync(Array.Empty()); - _hassConnectionMock.Setup(n => + hassConnectionMock.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( It.IsAny(), It.IsAny() @@ -207,7 +201,7 @@ public async Task EntityArea_Updates() new() {Id = "AreaId2", Name = "Area2 Name"} }); - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( It.IsAny(), It.IsAny() @@ -217,17 +211,13 @@ public async Task EntityArea_Updates() new() {EntityId = "sensor.sensor1", AreaId = "AreaId"} }); - var serviceColletion = new ServiceCollection(); - _ = serviceColletion.AddTransient>(_ => testSubject); - var sp = serviceColletion.BuildServiceProvider(); - - using var cache = new RegistryCache(haRunnerMock.Object, Mock.Of>()); + using var cache = new RegistryCache(Mock.Of>()); // Act 1: Init - await cache.InitializeAsync(CancellationToken.None); + await cache.InitializeAsync(hassConnectionMock.Object, CancellationToken.None); // Act/Rearrage - _hassConnectionMock.Setup( + hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( It.IsAny(), It.IsAny() @@ -243,6 +233,6 @@ public async Task EntityArea_Updates() // Assert var area = cache.GetAreaById(cache.GetHassEntityById("sensor.sensor1")!.AreaId); Assert.NotNull(area); - Assert.Equal("Area2 Name", area!.Name); + Assert.Equal("Area2 Name", area.Name); } } diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityStateCacheTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityStateCacheTest.cs index 2b0fb4f28..7222b3394 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityStateCacheTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityStateCacheTest.cs @@ -18,9 +18,6 @@ public async Task StateChangeEventIsFirstStoredInCacheThanForwarded() // Arrange using var testSubject = new Subject(); var hassConnectionMock = new Mock(); - var haRunnerMock = new Mock(); - - haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object); hassConnectionMock .Setup(m => m.SendCommandAndReturnResponseAsync> @@ -31,13 +28,13 @@ public async Task StateChangeEventIsFirstStoredInCacheThanForwarded() .Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - using var cache = new EntityStateCache(haRunnerMock.Object); + using var cache = new EntityStateCache(); var eventObserverMock = new Mock>(); cache.AllEvents.Subscribe(eventObserverMock.Object); // ACT 1: after initialization of the cache it should show the values retrieved from Hass - await cache.InitializeAsync(CancellationToken.None); + await cache.InitializeAsync(hassConnectionMock.Object, CancellationToken.None); cache.GetState(entityId)!.State.Should().Be("InitialState", "The initial value should be available"); @@ -85,9 +82,6 @@ public async Task AllEntityIds_returnsInitialPlusChangedEntities() // Arrange using var testSubject = new Subject(); var hassConnectionMock = new Mock(); - var haRunnerMock = new Mock(); - - haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object); hassConnectionMock .Setup(m => m.SendCommandAndReturnResponseAsync> @@ -103,13 +97,13 @@ public async Task AllEntityIds_returnsInitialPlusChangedEntities() n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - using var cache = new EntityStateCache(haRunnerMock.Object); + using var cache = new EntityStateCache(); var stateChangeObserverMock = new Mock>(); cache.AllEvents.Subscribe(stateChangeObserverMock.Object); // ACT 1: after initialization of the cache it should show the values retieved from Hass - await cache.InitializeAsync(CancellationToken.None); + await cache.InitializeAsync(hassConnectionMock.Object, CancellationToken.None); // initial value for sensor.sensor1 shoul be visible right away cache.GetState("sensor.sensor1")!.AttributesJson.GetValueOrDefault().GetProperty("brightness").GetInt32().Should().Be(100); diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Registry/RegistryNavigationTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/Registry/RegistryNavigationTest.cs index d400a5958..f40544af5 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/Registry/RegistryNavigationTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/Registry/RegistryNavigationTest.cs @@ -19,11 +19,8 @@ public RegistryNavigationTest() private async Task InitializeCacheAndBuildRegistry() { - var runnerMock = new Mock(); - runnerMock.SetupGet(m => m.CurrentConnection).Returns(_connectionMock.Object); - - var cache = new RegistryCache(runnerMock.Object, new NullLogger()); - await cache.InitializeAsync(CancellationToken.None); + var cache = new RegistryCache(new NullLogger()); + await cache.InitializeAsync(_connectionMock.Object, CancellationToken.None); var haContextMock = new Mock { CallBase = true }; return new HaRegistry(haContextMock.Object, cache); diff --git a/src/HassModel/NetDaemon.HassModel/HaContextFactory.cs b/src/HassModel/NetDaemon.HassModel/HaContextFactory.cs new file mode 100644 index 000000000..5bb05f84d --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel/HaContextFactory.cs @@ -0,0 +1,40 @@ +namespace NetDaemon.HassModel; + +/// +/// Factory class to create an instance of for use in console applications or scripts. +/// +public static class HaContextFactory +{ + /// + /// Creates a new instance of using the provided WebSocket URL and token. + /// + /// Do not use this in NetDaemon apps. Apps should use UseNetDaemonRuntime and resolve IHaContext via dependency injection + /// The Websocket Url to HomeAssistant, eg: ws://localhost:8123/api/websocket + /// The long lived accestoken for Home Assitant + /// An instance of IHaContext + public static async Task CreateAsync(string homeAssistantWebsocketUrl, string token) + { + var connection = await HomeAssistantClientConnector.ConnectClientAsync(homeAssistantWebsocketUrl, token, CancellationToken.None); + + var collection = new ServiceCollection(); + collection.AddLogging(builder => builder.AddConsole()); + collection.AddSingleton(connection); + collection.AddSingleton(new ConnectionProvider(connection)); + + collection.AddScopedHaContext(); + var serviceProvider = collection.BuildServiceProvider().CreateScope().ServiceProvider; + + var cacheManager = serviceProvider.GetRequiredService(); + await cacheManager.InitializeAsync(connection, CancellationToken.None); + + return serviceProvider.GetRequiredService(); + } + + /// + /// ConnectionProvider to provide the current connection without reconnect logic + /// + class ConnectionProvider(IHomeAssistantConnection connection) : IHomeAssistantConnectionProvider + { + public IHomeAssistantConnection CurrentConnection => connection; + } +} diff --git a/src/HassModel/NetDaemon.HassModel/ICacheManager.cs b/src/HassModel/NetDaemon.HassModel/ICacheManager.cs index f1cf70f5b..2cf86ac83 100644 --- a/src/HassModel/NetDaemon.HassModel/ICacheManager.cs +++ b/src/HassModel/NetDaemon.HassModel/ICacheManager.cs @@ -6,9 +6,7 @@ public interface ICacheManager { /// - /// (re) Initializes the Hass Model internal caches from Home Assistant. Should be called + /// (re) Initializes the HassModel internal caches from Home Assistant. Should be called after (re)connecting /// - /// - /// - Task InitializeAsync(CancellationToken cancellationToken); + Task InitializeAsync(IHomeAssistantConnection homeAssistantConnection, CancellationToken cancellationToken); } diff --git a/src/HassModel/NetDaemon.HassModel/Internal/AppScopedHaContextProvider.cs b/src/HassModel/NetDaemon.HassModel/Internal/AppScopedHaContextProvider.cs index 374d79591..38de1c2ac 100644 --- a/src/HassModel/NetDaemon.HassModel/Internal/AppScopedHaContextProvider.cs +++ b/src/HassModel/NetDaemon.HassModel/Internal/AppScopedHaContextProvider.cs @@ -11,10 +11,9 @@ internal class AppScopedHaContextProvider : IHaContext, IAsyncDisposable { private volatile bool _isDisposed; private volatile bool _isDisposing; - private readonly IHomeAssistantApiManager _apiManager; private readonly EntityStateCache _entityStateCache; - private readonly IHomeAssistantRunner _hassRunner; + private readonly IHomeAssistantConnectionProvider _connectionProvider; private readonly QueuedObservable _queuedObservable; private readonly IBackgroundTaskTracker _backgroundTaskTracker; private readonly IEntityFactory _entityFactory; @@ -23,16 +22,14 @@ internal class AppScopedHaContextProvider : IHaContext, IAsyncDisposable public AppScopedHaContextProvider( EntityStateCache entityStateCache, - IHomeAssistantRunner hassRunner, - IHomeAssistantApiManager apiManager, + IHomeAssistantConnectionProvider connectionProvider, IBackgroundTaskTracker backgroundTaskTracker, IServiceProvider serviceProvider, ILogger logger, IEntityFactory entityFactory) { _entityStateCache = entityStateCache; - _hassRunner = hassRunner; - _apiManager = apiManager; + _connectionProvider = connectionProvider; // Create QueuedObservable for this app // This makes sure we will unsubscribe when this ContextProvider is Disposed @@ -45,6 +42,9 @@ public AppScopedHaContextProvider( Registry = ActivatorUtilities.CreateInstance(serviceProvider, this); } + IHomeAssistantConnection CurrentConnection => _connectionProvider.CurrentConnection ?? throw new InvalidOperationException("No connection to Home Assistant"); + + // By making the HaRegistry instance internal it can also be registered as scoped in the DI container and injected into applications internal HaRegistry Registry { get; } @@ -72,17 +72,15 @@ public IReadOnlyList GetAllEntities() public void CallService(string domain, string service, ServiceTarget? target = null, object? data = null) { ObjectDisposedException.ThrowIf(_isDisposed, this); - _ = _hassRunner.CurrentConnection ?? throw new InvalidOperationException("No connection to Home Assistant"); - _backgroundTaskTracker.TrackBackgroundTask(_hassRunner.CurrentConnection.CallServiceAsync(domain, service, data, target.Map(), _tokenSource.Token), "Error in sending event"); + _backgroundTaskTracker.TrackBackgroundTask(CurrentConnection.CallServiceAsync(domain, service, data, target.Map(), _tokenSource.Token), "Error in sending event"); } public async Task CallServiceWithResponseAsync(string domain, string service, ServiceTarget? target = null, object? data = null) { ObjectDisposedException.ThrowIf(_isDisposed, this); - _ = _hassRunner.CurrentConnection ?? throw new InvalidOperationException("No connection to Home Assistant"); - var result = await _hassRunner.CurrentConnection + var result = await CurrentConnection .CallServiceWithResponseAsync(domain, service, data, target?.Map(), _tokenSource.Token) .ConfigureAwait(false); return result?.Response; @@ -101,7 +99,7 @@ public IObservable StateAllChanges() public void SendEvent(string eventType, object? data = null) { ObjectDisposedException.ThrowIf(_isDisposed, this); - _backgroundTaskTracker.TrackBackgroundTask(_apiManager.SendEventAsync(eventType, _tokenSource.Token, data), "Error in sending event"); + _backgroundTaskTracker.TrackBackgroundTask(CurrentConnection.SendEventAsync(eventType, _tokenSource.Token, data), "Error in sending event"); } public async ValueTask DisposeAsync() diff --git a/src/HassModel/NetDaemon.HassModel/Internal/BackgroundTaskTracker.cs b/src/HassModel/NetDaemon.HassModel/Internal/BackgroundTaskTracker.cs index 1581279c8..9ee3276ce 100644 --- a/src/HassModel/NetDaemon.HassModel/Internal/BackgroundTaskTracker.cs +++ b/src/HassModel/NetDaemon.HassModel/Internal/BackgroundTaskTracker.cs @@ -4,14 +4,13 @@ namespace NetDaemon.HassModel.Internal; internal class BackgroundTaskTracker(ILogger logger) : IBackgroundTaskTracker { - private readonly ILogger _logger = logger; private volatile bool _isDisposed; internal readonly ConcurrentDictionary BackgroundTasks = new(); public void TrackBackgroundTask(Task? task, string? description = null) { - ArgumentNullException.ThrowIfNull(task, nameof(task)); + if (task == null) return; BackgroundTasks.TryAdd(task, null); @@ -24,11 +23,11 @@ async Task Wrap() } catch (OperationCanceledException) { - _logger.LogTrace("Task was canceled processing Home Assistant event: {Description}", description ?? ""); + logger.LogTrace("Task was canceled processing Home Assistant event: {Description}", description ?? ""); } catch (Exception e) { - _logger.LogError(e, "Exception processing Home Assistant event: {Description}", description ?? ""); + logger.LogError(e, "Exception processing Home Assistant event: {Description}", description ?? ""); } finally { diff --git a/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs b/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs index d8dcb91e6..644b8bc71 100644 --- a/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs +++ b/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs @@ -3,9 +3,9 @@ internal class CacheManager(EntityStateCache entityStateCache, RegistryCache registryCache) : ICacheManager { - public async Task InitializeAsync(CancellationToken cancellationToken) + public async Task InitializeAsync(IHomeAssistantConnection homeAssistantConnection, CancellationToken cancellationToken) { - await entityStateCache.InitializeAsync(cancellationToken).ConfigureAwait(false); - await registryCache.InitializeAsync(cancellationToken).ConfigureAwait(false); + await entityStateCache.InitializeAsync(homeAssistantConnection, cancellationToken).ConfigureAwait(false); + await registryCache.InitializeAsync(homeAssistantConnection, cancellationToken).ConfigureAwait(false); } } diff --git a/src/HassModel/NetDaemon.HassModel/Internal/EntityStateCache.cs b/src/HassModel/NetDaemon.HassModel/Internal/EntityStateCache.cs index 134ce50f2..2d8b523cc 100644 --- a/src/HassModel/NetDaemon.HassModel/Internal/EntityStateCache.cs +++ b/src/HassModel/NetDaemon.HassModel/Internal/EntityStateCache.cs @@ -3,7 +3,7 @@ namespace NetDaemon.HassModel.Internal; -internal class EntityStateCache(IHomeAssistantRunner hassRunner) : IDisposable +internal class EntityStateCache : IDisposable { private IDisposable? _eventSubscription; private readonly Subject _eventSubject = new(); @@ -15,14 +15,13 @@ internal class EntityStateCache(IHomeAssistantRunner hassRunner) : IDisposable public IObservable AllEvents => _eventSubject; - public async Task InitializeAsync(CancellationToken cancellationToken) + public async Task InitializeAsync(IHomeAssistantConnection homeAssistantConnection, CancellationToken cancellationToken) { - _ = hassRunner.CurrentConnection ?? throw new InvalidOperationException(); - var events = await hassRunner.CurrentConnection!.SubscribeToHomeAssistantEventsAsync(null, cancellationToken).ConfigureAwait(false); + var events = await homeAssistantConnection.SubscribeToHomeAssistantEventsAsync(null, cancellationToken).ConfigureAwait(false); _eventSubscription = events.Subscribe(HandleEvent); - var hassStates = await hassRunner.CurrentConnection.GetStatesAsync(cancellationToken).ConfigureAwait(false); + var hassStates = await homeAssistantConnection.GetStatesAsync(cancellationToken).ConfigureAwait(false); foreach (var hassClientState in hassStates ?? []) { diff --git a/src/HassModel/NetDaemon.HassModel/Internal/RegistryCache.cs b/src/HassModel/NetDaemon.HassModel/Internal/RegistryCache.cs index f43894ab8..393d3ae64 100644 --- a/src/HassModel/NetDaemon.HassModel/Internal/RegistryCache.cs +++ b/src/HassModel/NetDaemon.HassModel/Internal/RegistryCache.cs @@ -3,7 +3,7 @@ namespace NetDaemon.HassModel.Internal; -internal class RegistryCache(IHomeAssistantRunner hassRunner, ILogger logger) : IDisposable +internal class RegistryCache(ILogger logger) : IDisposable { private CancellationToken _cancellationToken; private readonly List _toDispose = []; @@ -23,11 +23,13 @@ internal class RegistryCache(IHomeAssistantRunner hassRunner, ILogger _entitiesByLabel = Array.Empty().ToLookup(e =>default(string)!); // We use this connection here during startup, and after we have received .._registry_updates events. In both cases we expect the connection to be available - private IHomeAssistantConnection CurrentConnection => hassRunner.CurrentConnection ?? throw new InvalidOperationException("Home assistantConnection is not available "); + private IHomeAssistantConnection CurrentConnection => _currentConnection ?? throw new InvalidOperationException("Home assistantConnection is not available "); + private IHomeAssistantConnection? _currentConnection; - public async Task InitializeAsync(CancellationToken cancellationToken) + public async Task InitializeAsync(IHomeAssistantConnection homeAssistantConnection, CancellationToken cancellationToken) { _cancellationToken = cancellationToken; + _currentConnection = homeAssistantConnection; await SubscribeRegistryUpdates().ConfigureAwait(false); diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Helpers/HomeAssistantRunnerMock.cs b/src/Runtime/NetDaemon.Runtime.Tests/Helpers/HomeAssistantRunnerMock.cs index 6fbf3681d..689e03d7f 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Helpers/HomeAssistantRunnerMock.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Helpers/HomeAssistantRunnerMock.cs @@ -13,7 +13,8 @@ public HomeAssistantRunnerMock() ConnectMock = new(); DisconnectMock = new(); ClientMock = new(); - SetupGet(n => n.CurrentConnection).Returns(ClientMock.ConnectionMock.Object); + SetupGet(n => n.CurrentConnection).Returns(() => ClientMock.ConnectionMock.Object); + As().SetupGet(n => n.CurrentConnection).Returns(() => ClientMock.ConnectionMock.Object); SetupGet(n => n.OnConnect).Returns(ConnectMock); SetupGet(n => n.OnDisconnect).Returns(DisconnectMock); diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs b/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs index fc1a30d35..0d93b7719 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs @@ -16,6 +16,12 @@ public async Task TestApplicationIsLoaded() var timedCancellationSource = new CancellationTokenSource(5000); var haRunner = new HomeAssistantRunnerMock(); + var con1 = haRunner.Object.CurrentConnection; + var run = haRunner.Object; + var conprovider = run as IHomeAssistantConnectionProvider; + var con2 = conprovider.CurrentConnection; + + var hostBuilder = GetDefaultHostBuilder(); var host = hostBuilder.ConfigureServices((_, services) => services @@ -44,6 +50,7 @@ public async Task TestApplicationReactToNewEvents() var host = hostBuilder.ConfigureServices((_, services) => services .AddSingleton(haRunner.Object) + .AddSingleton(haRunner.Object) .AddNetDaemonApp() .AddTransient>(_ => haRunner.ClientMock.ConnectionMock.HomeAssistantEventMock) ).Build(); diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs index 422f9ce07..0b851a5dd 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs @@ -91,7 +91,7 @@ public async Task TestReconnect() [Fact] public async Task TestOnConnectError() { - _cacheManagerMock.Setup(m => m.InitializeAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("Something wrong while initializing")); + _cacheManagerMock.Setup(m => m.InitializeAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Something wrong while initializing")); await using var runtime = SetupNetDaemonRuntime(); @@ -117,7 +117,7 @@ public async Task TestOnReConnectError() _disconnectSubject.OnNext(DisconnectReason.Client); // now it should err on the second connection - _cacheManagerMock.Setup(m => m.InitializeAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("Something wrong while initializing")); + _cacheManagerMock.Setup(m => m.InitializeAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Something wrong while initializing")); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); _loggerMock.Verify( diff --git a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs index 6ac6dd5b4..9a53e2869 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs @@ -19,7 +19,7 @@ public static class HostBuilderExtensions /// - Register appsettings.json to the host configuration /// - Register all the yaml settings from the path set in the current configuration to the configuration provider /// - Call 'ConfigureNetDaemonServices' in the service collection - /// + /// /// You can call these methods separately if you want to do something else in between, or if you're calling any of these methods already. /// Change `UseNetDaemonAppSettings` to `.RegisterAppSettingsJsonToHost().RegisterYamlSettings()` and call `ConfigureNetDaemonServices(context.Configuration)` in ConfigureServices. /// diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index 3ae23f47f..de6e61b19 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -84,7 +84,7 @@ private async Task OnHomeAssistantClientConnected( IsConnected = true; - await cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); + await cacheManager.InitializeAsync(haConnection, cancelToken).ConfigureAwait(false); await LoadNewAppContextAsync(haConnection, cancelToken); diff --git a/src/debug/ConsoleClient/ConsoleClient.csproj b/src/debug/ConsoleClient/ConsoleClient.csproj new file mode 100644 index 000000000..ca6f315e8 --- /dev/null +++ b/src/debug/ConsoleClient/ConsoleClient.csproj @@ -0,0 +1,17 @@ + + + Exe + enable + enable + + + + + + + + + PreserveNewest + + + diff --git a/src/debug/ConsoleClient/Program.cs b/src/debug/ConsoleClient/Program.cs new file mode 100644 index 000000000..5baa206da --- /dev/null +++ b/src/debug/ConsoleClient/Program.cs @@ -0,0 +1,6 @@ +//#:package NetDaemon.HassModel@25.18.1 + +var ha = await NetDaemon.HassModel.HaContextFactory.CreateAsync("ws://lebigmac:8123/api/websocket", "Your Token here"); + +ha.Entity("input_boolean.dummy_switch").CallService("toggle"); +Console.WriteLine("done"); diff --git a/src/debug/ConsoleClient/appsettings.json b/src/debug/ConsoleClient/appsettings.json new file mode 100644 index 000000000..e4dc9d5cb --- /dev/null +++ b/src/debug/ConsoleClient/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Warning" + }, + "ConsoleThemeType": "Ansi" + } +} diff --git a/tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs b/tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs new file mode 100644 index 000000000..84cc79804 --- /dev/null +++ b/tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs @@ -0,0 +1,38 @@ +using System.Reactive.Linq; +using FluentAssertions; +using NetDaemon.HassModel; +using NetDaemon.Tests.Integration.Helpers; +using Xunit; + +namespace NetDaemon.Tests.Integration; + +[Collection("HomeAssistant collection")] +public sealed class HaContextFactoryTest(HomeAssistantLifetime homeAssistantLifetime) : IAsyncDisposable +{ + [Fact] + public async Task CreateAsync() + { + var testValue = Guid.CreateVersion7().ToString(); + + // Arrange + var haContext = await HaContextFactory.CreateAsync($"ws://localhost:{homeAssistantLifetime.Port}/api/websocket", + homeAssistantLifetime.AccessToken!); + + var inputText = haContext.Entity("input_text.test_result"); + var nextStateChange = inputText.StateChanges().FirstAsync(); + + // Act + inputText.CallService("set_value", new { value = testValue }); + + // Assert + var stateChange = await nextStateChange; + + stateChange.New!.State.Should().Be(testValue, "We should have received the state change after calling the service"); + inputText.State.Should().Be(testValue, "The state should be updated in the cache after the state_change event is received"); + } + + public async ValueTask DisposeAsync() + { + await homeAssistantLifetime.DisposeAsync(); + } +} diff --git a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs index fc341b208..cf04718c9 100644 --- a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs +++ b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs @@ -35,7 +35,8 @@ private IHost StartNetDaemon() config.AddInMemoryCollection(new Dictionary { { "HomeAssistant:Port", _homeAssistantLifetime.Port.ToString(CultureInfo.InvariantCulture) }, - { "HomeAssistant:Token", _homeAssistantLifetime.AccessToken } + { "HomeAssistant:Token", _homeAssistantLifetime.AccessToken }, + { "HomeAssistant:Host", "localhost" } }); }) .ConfigureServices((_, services) =>