From 908bb419e65db95d5962d89fe847f3649017ff3e Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 18 Apr 2025 08:05:16 +0200 Subject: [PATCH 01/14] Removing blocking logic from NetDaemonRuntime. --- .../Internal/NetDaemonRuntime.cs | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index abc061f5..98d84b91 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -26,8 +26,6 @@ internal class NetDaemonRuntime(IHomeAssistantRunner homeAssistantRunner, internal IReadOnlyCollection ApplicationInstances => _applicationModelContext?.Applications ?? []; - private readonly TaskCompletionSource _startedAndConnected = new(); - private Task _runnerTask = Task.CompletedTask; public async Task StartAsync(CancellationToken stoppingToken) @@ -46,6 +44,7 @@ public async Task StartAsync(CancellationToken stoppingToken) { _runnerCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + // Assign the runner so we can dispose it later. Note that this task contains the connection loop and will not end. We don't want to await it. _runnerTask = homeAssistantRunner.RunAsync( _haSettings.Host, _haSettings.Port, @@ -54,14 +53,6 @@ public async Task StartAsync(CancellationToken stoppingToken) _haSettings.WebsocketPath, TimeSpan.FromSeconds(TimeoutInSeconds), _runnerCancellationSource.Token); - - // make sure we cancel the task if the stoppingToken is cancelled - stoppingToken.Register(() => - { - _startedAndConnected.TrySetCanceled(); - }); - // Make sure we only return after the connection is made and initialization is ready - await _startedAndConnected.Task; } catch (OperationCanceledException) { @@ -89,22 +80,10 @@ private async Task OnHomeAssistantClientConnected( await cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); await LoadNewAppContextAsync(haConnection, cancelToken); - - _startedAndConnected.TrySetResult(); } catch (Exception ex) { - if (!_startedAndConnected.Task.IsCompleted) - { - // This means this was the first time we connected and StartAsync is still awaiting _startedAndConnected - // By setting the exception on the task it will propagate up. - _startedAndConnected.SetException(ex); - } - else - { - // There is none waiting for this event handler to finish so we need to Log the exception here - logger.LogCritical(ex, "Error re-initializing after reconnect to Home Assistant"); - } + logger.LogCritical(ex, "Error re-initializing after reconnect to Home Assistant"); } } @@ -151,12 +130,6 @@ private async Task OnHomeAssistantClientDisconnected(DisconnectReason reason) logger.LogError(e, "Error disposing applications"); } IsConnected = false; - - if (reason == DisconnectReason.Unauthorized) - { - // We will exit the runtime if the token is unauthorized to avoid hammering the server - _startedAndConnected.SetResult(); - } } private async Task DisposeApplicationsAsync() From 4c588b50cac426be32d3fef4da214cf69d298bd5 Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 18 Apr 2025 08:09:52 +0200 Subject: [PATCH 02/14] Changing StartAsync to Start as it doesn't do any async work itself anymore. --- .../Internal/NetDaemonRuntimeTests.cs | 12 ++++++------ src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs | 7 +++++-- .../NetDaemon.Runtime/Internal/NetDaemonRuntime.cs | 2 +- .../NetDaemon.Runtime/Internal/RuntimeService.cs | 2 +- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs index 2c4dcd98..289fa7ea 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs @@ -23,7 +23,7 @@ public sealed class NetDaemonRuntimeTests : IDisposable public async Task TestStartAsyncAsync() { await using var runtime = SetupNetDaemonRuntime(); - var startingTask = runtime.StartAsync(CancellationToken.None); + var startingTask = runtime.Start(CancellationToken.None); _connectSubject.HasObservers.Should().BeTrue(); _disconnectSubject.HasObservers.Should().BeTrue(); @@ -35,7 +35,7 @@ public async Task TestStartAsyncAsync() public async Task TestOnConnect() { await using var runtime = SetupNetDaemonRuntime(); - var startingTask = runtime.StartAsync(CancellationToken.None); + var startingTask = runtime.Start(CancellationToken.None); startingTask.IsCompleted.Should().BeFalse(); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); @@ -50,7 +50,7 @@ public async Task TestOnConnect() public async Task TestOnDisconnect() { await using var runtime = SetupNetDaemonRuntime(); - var startingTask = runtime.StartAsync(CancellationToken.None); + var startingTask = runtime.Start(CancellationToken.None); // First make sure we add an connection _connectSubject.OnNext(_homeAssistantConnectionMock.Object); @@ -70,7 +70,7 @@ public async Task TestOnDisconnect() public async Task TestReconnect() { await using var runtime = SetupNetDaemonRuntime(); - var startingTask = runtime.StartAsync(CancellationToken.None); + var startingTask = runtime.Start(CancellationToken.None); // First make sure we add an connection _connectSubject.OnNext(_homeAssistantConnectionMock.Object); @@ -101,7 +101,7 @@ public async Task TestOnConnectError() await using var runtime = SetupNetDaemonRuntime(); - var startAsync = runtime.StartAsync(CancellationToken.None); + var startAsync = runtime.Start(CancellationToken.None); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); Func startingTask = ()=> startAsync; await startingTask.Should().ThrowAsync(); @@ -113,7 +113,7 @@ public async Task TestOnReConnectError() { await using var runtime = SetupNetDaemonRuntime(); - _ = runtime.StartAsync(CancellationToken.None); + _ = runtime.Start(CancellationToken.None); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); _disconnectSubject.OnNext(DisconnectReason.Client); diff --git a/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs b/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs index 9e050cea..0cc47225 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs @@ -2,5 +2,8 @@ namespace NetDaemon.Runtime; internal interface IRuntime : IAsyncDisposable { - Task StartAsync(CancellationToken stoppingToken); -} \ No newline at end of file + /// + /// Starts the runtime and passes to the runtime task/thread. + /// + void Start(CancellationToken stoppingToken); +} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index 98d84b91..7a24ef1d 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -28,7 +28,7 @@ internal class NetDaemonRuntime(IHomeAssistantRunner homeAssistantRunner, private Task _runnerTask = Task.CompletedTask; - public async Task StartAsync(CancellationToken stoppingToken) + public void Start(CancellationToken stoppingToken) { logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version); diff --git a/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs b/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs index 0e1bf0ee..3c711563 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs @@ -6,7 +6,7 @@ public override async Task StartAsync(CancellationToken cancellationToken) { try { - await runtime.StartAsync(cancellationToken); + runtime.Start(cancellationToken); await base.StartAsync(cancellationToken); } catch (OperationCanceledException) { } From 19a5f14ee54025b50a15b9ebf417989434e122d8 Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 18 Apr 2025 08:19:10 +0200 Subject: [PATCH 03/14] Updated NetDaemonRuntimeTests and updated TestOnConnectError to reflect the new behavior. --- .../Internal/NetDaemonRuntimeTests.cs | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs index 289fa7ea..797a6120 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs @@ -23,23 +23,19 @@ public sealed class NetDaemonRuntimeTests : IDisposable public async Task TestStartAsyncAsync() { await using var runtime = SetupNetDaemonRuntime(); - var startingTask = runtime.Start(CancellationToken.None); + runtime.Start(CancellationToken.None); _connectSubject.HasObservers.Should().BeTrue(); _disconnectSubject.HasObservers.Should().BeTrue(); - - startingTask.IsCompleted.Should().BeFalse(); } [Fact] public async Task TestOnConnect() { await using var runtime = SetupNetDaemonRuntime(); - var startingTask = runtime.Start(CancellationToken.None); + runtime.Start(CancellationToken.None); - startingTask.IsCompleted.Should().BeFalse(); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); - await startingTask.ConfigureAwait(false); _appModelMock.Verify(n => n.LoadNewApplicationContext(It.IsAny())); @@ -50,12 +46,11 @@ public async Task TestOnConnect() public async Task TestOnDisconnect() { await using var runtime = SetupNetDaemonRuntime(); - var startingTask = runtime.Start(CancellationToken.None); + runtime.Start(CancellationToken.None); // First make sure we add an connection _connectSubject.OnNext(_homeAssistantConnectionMock.Object); - await startingTask.ConfigureAwait(false); runtime.IsConnected.Should().BeTrue(); // Then fake a disconnect @@ -70,12 +65,11 @@ public async Task TestOnDisconnect() public async Task TestReconnect() { await using var runtime = SetupNetDaemonRuntime(); - var startingTask = runtime.Start(CancellationToken.None); + runtime.Start(CancellationToken.None); // First make sure we add an connection _connectSubject.OnNext(_homeAssistantConnectionMock.Object); - await startingTask.ConfigureAwait(false); runtime.IsConnected.Should().BeTrue(); // Then fake a disconnect @@ -101,10 +95,15 @@ public async Task TestOnConnectError() await using var runtime = SetupNetDaemonRuntime(); - var startAsync = runtime.Start(CancellationToken.None); + runtime.Start(CancellationToken.None); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); - Func startingTask = ()=> startAsync; - await startingTask.Should().ThrowAsync(); + + _loggerMock.Verify( + x => x.Log(LogLevel.Critical, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.Is>((v, t) => true)!), times: Times.Once); } @@ -113,7 +112,7 @@ public async Task TestOnReConnectError() { await using var runtime = SetupNetDaemonRuntime(); - _ = runtime.Start(CancellationToken.None); + runtime.Start(CancellationToken.None); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); _disconnectSubject.OnNext(DisconnectReason.Client); @@ -121,7 +120,6 @@ public async Task TestOnReConnectError() _cacheManagerMock.Setup(m => m.InitializeAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("Something wrong while initializing")); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); - _loggerMock.Verify( x => x.Log(LogLevel.Critical, It.IsAny(), From c85ee7c1a43bce4cfcf9b6c37144bac2d9b921f9 Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 18 Apr 2025 09:30:36 +0200 Subject: [PATCH 04/14] Adding method that blocks until connection to home assistant is established. --- .../HomeAssistantRunnerExtension.cs | 20 +++++++++++++++++++ .../Common/IHomeAssistantRunner.cs | 13 ++++++------ .../Internal/HomeAssistantRunner.cs | 4 ++-- .../Internal/NetDaemonRuntime.cs | 2 +- .../Helpers/NetDaemonIntegrationBase.cs | 16 +++++++++++++++ 5 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 src/Client/NetDaemon.HassClient/Common/Extensions/HomeAssistantRunnerExtension.cs diff --git a/src/Client/NetDaemon.HassClient/Common/Extensions/HomeAssistantRunnerExtension.cs b/src/Client/NetDaemon.HassClient/Common/Extensions/HomeAssistantRunnerExtension.cs new file mode 100644 index 00000000..951da4e1 --- /dev/null +++ b/src/Client/NetDaemon.HassClient/Common/Extensions/HomeAssistantRunnerExtension.cs @@ -0,0 +1,20 @@ +namespace NetDaemon.Client.Extensions; + +public static class HomeAssistantRunnerExtension +{ + /// + /// Observable that emits when a (new) connection is established. If the runner is connected upon subscribing, it will immediately emit the current connection. + /// + public static IObservable OnConnectWithCurrent(this IHomeAssistantRunner homeAssistantRunner) + { + // Generate a one-time observable for CurrentConnection if it’s non-null. Using Defer ensures the observable is created when subscribed to, not beforehand, avoiding potential race conditions. + var currentConnectionObservable = Observable.Defer(() => + homeAssistantRunner.CurrentConnection != null + ? Observable.Return(homeAssistantRunner.CurrentConnection) + : Observable.Empty()); + + // Combine CurrentConnection and OnConnect, taking the first valid connection + return currentConnectionObservable + .Merge(homeAssistantRunner.OnConnect); + } +} diff --git a/src/Client/NetDaemon.HassClient/Common/IHomeAssistantRunner.cs b/src/Client/NetDaemon.HassClient/Common/IHomeAssistantRunner.cs index 6aa54a68..958acd91 100644 --- a/src/Client/NetDaemon.HassClient/Common/IHomeAssistantRunner.cs +++ b/src/Client/NetDaemon.HassClient/Common/IHomeAssistantRunner.cs @@ -3,23 +3,22 @@ namespace NetDaemon.Client; public interface IHomeAssistantRunner : IAsyncDisposable { /// - /// Event when new connection is established + /// Observable that emits when a (new) connection is established. /// IObservable OnConnect { get; } /// - /// Event when connection is lost + /// Observable that emits when the current connection is lost. /// IObservable OnDisconnect { get; } /// - /// The current connection to Home Assistant + /// The current connection to Home Assistant. Null if disconnected. /// - /// IHomeAssistantConnection? CurrentConnection { get; } /// - /// Maintains a connection to the Home Assistant server + /// Maintains a connection to the Home Assistant server /// /// Host of Home Assistant instance /// Port of Home Assistant instance @@ -30,7 +29,7 @@ public interface IHomeAssistantRunner : IAsyncDisposable Task RunAsync(string host, int port, bool ssl, string token, TimeSpan timeout, CancellationToken cancelToken); /// - /// Maintains a connection to the Home Assistant server + /// Maintains a connection to the Home Assistant server /// /// Host of Home Assistant instance /// Port of Home Assistant instance @@ -40,4 +39,4 @@ public interface IHomeAssistantRunner : IAsyncDisposable /// Wait time between connects /// Cancel token Task RunAsync(string host, int port, bool ssl, string token, string websocketPath, TimeSpan timeout, CancellationToken cancelToken); -} \ No newline at end of file +} diff --git a/src/Client/NetDaemon.HassClient/Internal/HomeAssistantRunner.cs b/src/Client/NetDaemon.HassClient/Internal/HomeAssistantRunner.cs index 1478987d..8c0a6c22 100644 --- a/src/Client/NetDaemon.HassClient/Internal/HomeAssistantRunner.cs +++ b/src/Client/NetDaemon.HassClient/Internal/HomeAssistantRunner.cs @@ -12,7 +12,7 @@ internal class HomeAssistantRunner(IHomeAssistantClient client, private readonly Subject _onDisconnectSubject = new(); - private readonly TimeSpan MaxTimeoutInSeconds = TimeSpan.FromSeconds(80); + private readonly TimeSpan _maxTimeoutInSeconds = TimeSpan.FromSeconds(80); private Task? _runTask; @@ -62,7 +62,7 @@ await Task.WhenAny( private async Task InternalRunAsync(string host, int port, bool ssl, string token, string websocketPath, TimeSpan timeout, CancellationToken cancelToken) { - var progressiveTimeout = new ProgressiveTimeout(timeout, MaxTimeoutInSeconds, 2.0); + var progressiveTimeout = new ProgressiveTimeout(timeout, _maxTimeoutInSeconds, 2.0); var combinedToken = CancellationTokenSource.CreateLinkedTokenSource(_internalTokenSource.Token, cancelToken); while (!combinedToken.IsCancellationRequested) { diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index 7a24ef1d..1e76b771 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -71,7 +71,7 @@ private async Task OnHomeAssistantClientConnected( if (_applicationModelContext is not null) { // Something wrong with unloading and disposing apps on restart of HA, we need to prevent apps loading multiple times - logger.LogWarning("Applications were not successfully disposed during restart, skippin loading apps again"); + logger.LogWarning("Applications were not successfully disposed during restart, skipping loading apps again"); return; } diff --git a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs index f5273242..c7be9341 100644 --- a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs +++ b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs @@ -1,9 +1,13 @@ using System.Globalization; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Reflection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using NetDaemon.AppModel; +using NetDaemon.Client; +using NetDaemon.Client.Extensions; using NetDaemon.Runtime; using Xunit; @@ -45,9 +49,21 @@ private IHost StartNetDaemon() ).Build(); netDaemon.Start(); + WaitForConnectionToEstablish(netDaemon).GetAwaiter().GetResult(); + return netDaemon; } + private static async Task WaitForConnectionToEstablish(IHost host) + { + var homeAssistantRunner = host.Services.GetRequiredService(); + + await homeAssistantRunner.OnConnectWithCurrent() + .FirstAsync() + .Timeout(TimeSpan.FromSeconds(10)) // Throw TimeoutException if it takes too long + .ToTask(); + } + /// /// Runs the specified function without a synchronization context and restores the synchronization context afterwards. /// From d576dc045a67443b69108947b61c26ee72f1131a Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 18 Apr 2025 16:18:36 +0200 Subject: [PATCH 05/14] Adding logic to wait for cache to be initialized. --- src/HassModel/NetDaemon.HassModel/ICacheManager.cs | 7 ++++++- .../NetDaemon.HassModel/Internal/CacheManager.cs | 6 ++++++ .../Helpers/NetDaemonIntegrationBase.cs | 11 +++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/HassModel/NetDaemon.HassModel/ICacheManager.cs b/src/HassModel/NetDaemon.HassModel/ICacheManager.cs index 429af68c..a193fd19 100644 --- a/src/HassModel/NetDaemon.HassModel/ICacheManager.cs +++ b/src/HassModel/NetDaemon.HassModel/ICacheManager.cs @@ -11,4 +11,9 @@ public interface ICacheManager /// /// Task InitializeAsync(CancellationToken cancellationToken); -} \ No newline at end of file + + /// + /// Method that can be awaited to ensure that the caches are initialized before any other code is executed. + /// + Task EnsureInitializedAsync(); +} diff --git a/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs b/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs index d8dcb91e..adbb755e 100644 --- a/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs +++ b/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs @@ -3,9 +3,15 @@ internal class CacheManager(EntityStateCache entityStateCache, RegistryCache registryCache) : ICacheManager { + private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async Task InitializeAsync(CancellationToken cancellationToken) { await entityStateCache.InitializeAsync(cancellationToken).ConfigureAwait(false); await registryCache.InitializeAsync(cancellationToken).ConfigureAwait(false); + + _initializationTcs.SetResult(null); } + + public Task EnsureInitializedAsync() => _initializationTcs.Task; } diff --git a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs index c7be9341..aa30c9ea 100644 --- a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs +++ b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs @@ -8,6 +8,8 @@ using NetDaemon.AppModel; using NetDaemon.Client; using NetDaemon.Client.Extensions; +using NetDaemon.HassModel; +using NetDaemon.HassModel.Internal; using NetDaemon.Runtime; using Xunit; @@ -49,7 +51,9 @@ private IHost StartNetDaemon() ).Build(); netDaemon.Start(); + WaitForConnectionToEstablish(netDaemon).GetAwaiter().GetResult(); + WaitForCacheToBeInitialized(netDaemon).GetAwaiter().GetResult(); return netDaemon; } @@ -64,6 +68,13 @@ await homeAssistantRunner.OnConnectWithCurrent() .ToTask(); } + private static async Task WaitForCacheToBeInitialized(IHost host) + { + var cacheManager = host.Services.GetRequiredService(); + + await cacheManager.EnsureInitializedAsync().ConfigureAwait(false); + } + /// /// Runs the specified function without a synchronization context and restores the synchronization context afterwards. /// From bad76123b215ca220e30839db6a1b7ee5dc21ada Mon Sep 17 00:00:00 2001 From: Jasper Date: Fri, 18 Apr 2025 16:22:54 +0200 Subject: [PATCH 06/14] Changing test name. --- .../NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs index 797a6120..422f9ce0 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs @@ -20,7 +20,7 @@ public sealed class NetDaemonRuntimeTests : IDisposable [Fact] - public async Task TestStartAsyncAsync() + public async Task TestStartSubscribesToHomeAssistantRunnerEvents() { await using var runtime = SetupNetDaemonRuntime(); runtime.Start(CancellationToken.None); From b5e471634b03e0e769c75765489e7e0c649c03f9 Mon Sep 17 00:00:00 2001 From: Jasper Date: Sat, 19 Apr 2025 14:44:11 +0200 Subject: [PATCH 07/14] Exposing INetDaemonRuntimeInitializedCheck and implementing its use for testing. --- .../NetDaemon.HassModel/ICacheManager.cs | 5 ----- .../Internal/CacheManager.cs | 6 ------ .../Extensions/HostBuilderExtensions.cs | 6 ++++-- .../INetDaemonRuntimeInitializedCheck.cs | 12 +++++++++++ .../Internal/NetDaemonRuntime.cs | 8 +++++++- .../Helpers/NetDaemonIntegrationBase.cs | 20 ++++--------------- 6 files changed, 27 insertions(+), 30 deletions(-) create mode 100644 src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntimeInitializedCheck.cs diff --git a/src/HassModel/NetDaemon.HassModel/ICacheManager.cs b/src/HassModel/NetDaemon.HassModel/ICacheManager.cs index a193fd19..f1cf70f5 100644 --- a/src/HassModel/NetDaemon.HassModel/ICacheManager.cs +++ b/src/HassModel/NetDaemon.HassModel/ICacheManager.cs @@ -11,9 +11,4 @@ public interface ICacheManager /// /// Task InitializeAsync(CancellationToken cancellationToken); - - /// - /// Method that can be awaited to ensure that the caches are initialized before any other code is executed. - /// - Task EnsureInitializedAsync(); } diff --git a/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs b/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs index adbb755e..d8dcb91e 100644 --- a/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs +++ b/src/HassModel/NetDaemon.HassModel/Internal/CacheManager.cs @@ -3,15 +3,9 @@ internal class CacheManager(EntityStateCache entityStateCache, RegistryCache registryCache) : ICacheManager { - private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - public async Task InitializeAsync(CancellationToken cancellationToken) { await entityStateCache.InitializeAsync(cancellationToken).ConfigureAwait(false); await registryCache.InitializeAsync(cancellationToken).ConfigureAwait(false); - - _initializationTcs.SetResult(null); } - - public Task EnsureInitializedAsync() => _initializationTcs.Task; } diff --git a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs index 219b1178..7a362056 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs @@ -72,7 +72,9 @@ public static IHostBuilder UseNetDaemonRuntime(this IHostBuilder hostBuilder) services.AddHostedService(); services.AddHomeAssistantClient(); services.Configure(context.Configuration.GetSection("HomeAssistant")); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); }); } -} \ No newline at end of file +} diff --git a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntimeInitializedCheck.cs b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntimeInitializedCheck.cs new file mode 100644 index 00000000..29da9511 --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntimeInitializedCheck.cs @@ -0,0 +1,12 @@ +namespace NetDaemon.Runtime; + +/// +/// Interface that can be used to check if NetDaemon runtime is initialized. +/// +public interface INetDaemonRuntimeInitializedCheck +{ + /// + /// Method that can be awaited to ensure that the runtime is fully started and initialized (initial connection is created and cache is initialized). + /// + Task EnsureInitializedAsync(); +} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index 1e76b771..07eefe2c 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -9,11 +9,12 @@ internal class NetDaemonRuntime(IHomeAssistantRunner homeAssistantRunner, IServiceProvider serviceProvider, ILogger logger, ICacheManager cacheManager) - : IRuntime + : IRuntime, INetDaemonRuntimeInitializedCheck { private const string Version = "local build"; private const int TimeoutInSeconds = 5; + private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly HomeAssistantSettings _haSettings = settings.Value; private IAppModelContext? _applicationModelContext; @@ -80,6 +81,9 @@ private async Task OnHomeAssistantClientConnected( await cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); await LoadNewAppContextAsync(haConnection, cancelToken); + + // Signal anyone waiting that the runtime is now initialized + _initializationTcs.TrySetResult(null); } catch (Exception ex) { @@ -132,6 +136,8 @@ private async Task OnHomeAssistantClientDisconnected(DisconnectReason reason) IsConnected = false; } + public Task EnsureInitializedAsync() => _initializationTcs.Task; + private async Task DisposeApplicationsAsync() { if (_applicationModelContext is not null) diff --git a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs index aa30c9ea..dfbc5c11 100644 --- a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs +++ b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs @@ -52,27 +52,15 @@ private IHost StartNetDaemon() netDaemon.Start(); - WaitForConnectionToEstablish(netDaemon).GetAwaiter().GetResult(); - WaitForCacheToBeInitialized(netDaemon).GetAwaiter().GetResult(); + WaitForRuntimeToBeInitialized(netDaemon).GetAwaiter().GetResult(); return netDaemon; } - private static async Task WaitForConnectionToEstablish(IHost host) + private static async Task WaitForRuntimeToBeInitialized(IHost host) { - var homeAssistantRunner = host.Services.GetRequiredService(); - - await homeAssistantRunner.OnConnectWithCurrent() - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(10)) // Throw TimeoutException if it takes too long - .ToTask(); - } - - private static async Task WaitForCacheToBeInitialized(IHost host) - { - var cacheManager = host.Services.GetRequiredService(); - - await cacheManager.EnsureInitializedAsync().ConfigureAwait(false); + var netDaemonRuntimeInitializedCheck = host.Services.GetRequiredService(); + await netDaemonRuntimeInitializedCheck.EnsureInitializedAsync(); } /// From 9fdc72b86a0db49fe6688e3ae52a56c6dbd9ebd3 Mon Sep 17 00:00:00 2001 From: Jasper Date: Sat, 19 Apr 2025 14:49:22 +0200 Subject: [PATCH 08/14] Cleanup of unused extension method. --- .../HomeAssistantRunnerExtension.cs | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 src/Client/NetDaemon.HassClient/Common/Extensions/HomeAssistantRunnerExtension.cs diff --git a/src/Client/NetDaemon.HassClient/Common/Extensions/HomeAssistantRunnerExtension.cs b/src/Client/NetDaemon.HassClient/Common/Extensions/HomeAssistantRunnerExtension.cs deleted file mode 100644 index 951da4e1..00000000 --- a/src/Client/NetDaemon.HassClient/Common/Extensions/HomeAssistantRunnerExtension.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace NetDaemon.Client.Extensions; - -public static class HomeAssistantRunnerExtension -{ - /// - /// Observable that emits when a (new) connection is established. If the runner is connected upon subscribing, it will immediately emit the current connection. - /// - public static IObservable OnConnectWithCurrent(this IHomeAssistantRunner homeAssistantRunner) - { - // Generate a one-time observable for CurrentConnection if it’s non-null. Using Defer ensures the observable is created when subscribed to, not beforehand, avoiding potential race conditions. - var currentConnectionObservable = Observable.Defer(() => - homeAssistantRunner.CurrentConnection != null - ? Observable.Return(homeAssistantRunner.CurrentConnection) - : Observable.Empty()); - - // Combine CurrentConnection and OnConnect, taking the first valid connection - return currentConnectionObservable - .Merge(homeAssistantRunner.OnConnect); - } -} From 8d95e98255616d044d685c53f57471d6618b1898 Mon Sep 17 00:00:00 2001 From: Jasper Date: Sat, 19 Apr 2025 14:53:38 +0200 Subject: [PATCH 09/14] TaskCompletionSource -> TaskCompletionSource --- src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index 07eefe2c..1aea74a4 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -14,7 +14,7 @@ internal class NetDaemonRuntime(IHomeAssistantRunner homeAssistantRunner, private const string Version = "local build"; private const int TimeoutInSeconds = 5; - private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly HomeAssistantSettings _haSettings = settings.Value; private IAppModelContext? _applicationModelContext; @@ -83,7 +83,7 @@ private async Task OnHomeAssistantClientConnected( await LoadNewAppContextAsync(haConnection, cancelToken); // Signal anyone waiting that the runtime is now initialized - _initializationTcs.TrySetResult(null); + _initializationTcs.TrySetResult(); } catch (Exception ex) { From cd9f3878e2c3f3e2fcdf58a68ba9330a47d26165 Mon Sep 17 00:00:00 2001 From: Jasper Date: Mon, 21 Apr 2025 15:51:17 +0200 Subject: [PATCH 10/14] Implementing EnsureInitializedAsync and renaming IRuntime to INetDaemonRuntime. --- .../Integration/TestRuntime.cs | 6 +-- .../Internal/NetDaemonRuntimeTests.cs | 14 +++---- .../Extensions/HostBuilderExtensions.cs | 4 +- .../Common/INetDaemonRuntime.cs | 15 ++++++++ .../INetDaemonRuntimeInitializedCheck.cs | 12 ------ .../NetDaemon.Runtime/Common/IRuntime.cs | 9 ----- .../Internal/NetDaemonRuntime.cs | 38 ++++++++++++++++--- .../Internal/RuntimeService.cs | 7 ++-- .../Helpers/NetDaemonIntegrationBase.cs | 4 +- 9 files changed, 65 insertions(+), 44 deletions(-) create mode 100644 src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs delete mode 100644 src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntimeInitializedCheck.cs delete mode 100644 src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs b/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs index 47db2f7c..3d9b56b5 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs @@ -26,7 +26,7 @@ public async Task TestApplicationIsLoaded() var runnerTask = host.RunAsync(timedCancellationSource.Token); haRunner.MockConnect(); - var service = (NetDaemonRuntime) host.Services.GetService()!; + var service = (NetDaemonRuntime) host.Services.GetService()!; var instances = service.ApplicationInstances; instances.Where(n => n.Id == "LocalApps.LocalApp").Should().NotBeEmpty(); @@ -50,7 +50,7 @@ public async Task TestApplicationReactToNewEvents() var runnerTask = host.StartAsync(timedCancellationSource.Token); haRunner.MockConnect(); - _ = (NetDaemonRuntime) host.Services.GetService()!; + _ = (NetDaemonRuntime) host.Services.GetService()!; await runnerTask.ConfigureAwait(false); haRunner.ClientMock.ConnectionMock.AddStateChangeEvent( @@ -89,7 +89,7 @@ public async Task TestApplicationReactToNewEventsAndThrowException() var runnerTask = host.StartAsync(timedCancellationSource.Token); haRunner.MockConnect(); - _ = (NetDaemonRuntime) host.Services.GetService()!; + _ = (NetDaemonRuntime) host.Services.GetService()!; haRunner.ClientMock.ConnectionMock.AddStateChangeEvent( new HassState diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs index 422f9ce0..476ed4d8 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs @@ -20,10 +20,10 @@ public sealed class NetDaemonRuntimeTests : IDisposable [Fact] - public async Task TestStartSubscribesToHomeAssistantRunnerEvents() + public async Task TestEnsureInitializedAsyncSubscribesToHomeAssistantRunnerEvents() { await using var runtime = SetupNetDaemonRuntime(); - runtime.Start(CancellationToken.None); + _ = runtime.EnsureInitializedAsync(); _connectSubject.HasObservers.Should().BeTrue(); _disconnectSubject.HasObservers.Should().BeTrue(); @@ -33,7 +33,7 @@ public async Task TestStartSubscribesToHomeAssistantRunnerEvents() public async Task TestOnConnect() { await using var runtime = SetupNetDaemonRuntime(); - runtime.Start(CancellationToken.None); + _ = runtime.EnsureInitializedAsync(); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); @@ -46,7 +46,7 @@ public async Task TestOnConnect() public async Task TestOnDisconnect() { await using var runtime = SetupNetDaemonRuntime(); - runtime.Start(CancellationToken.None); + _ = runtime.EnsureInitializedAsync(); // First make sure we add an connection _connectSubject.OnNext(_homeAssistantConnectionMock.Object); @@ -65,7 +65,7 @@ public async Task TestOnDisconnect() public async Task TestReconnect() { await using var runtime = SetupNetDaemonRuntime(); - runtime.Start(CancellationToken.None); + _ = runtime.EnsureInitializedAsync(); // First make sure we add an connection _connectSubject.OnNext(_homeAssistantConnectionMock.Object); @@ -95,7 +95,7 @@ public async Task TestOnConnectError() await using var runtime = SetupNetDaemonRuntime(); - runtime.Start(CancellationToken.None); + _ = runtime.EnsureInitializedAsync(); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); _loggerMock.Verify( @@ -112,7 +112,7 @@ public async Task TestOnReConnectError() { await using var runtime = SetupNetDaemonRuntime(); - runtime.Start(CancellationToken.None); + _ = runtime.EnsureInitializedAsync(); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); _disconnectSubject.OnNext(DisconnectReason.Client); diff --git a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs index 7a362056..e3c65efa 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs @@ -72,9 +72,7 @@ public static IHostBuilder UseNetDaemonRuntime(this IHostBuilder hostBuilder) services.AddHostedService(); services.AddHomeAssistantClient(); services.Configure(context.Configuration.GetSection("HomeAssistant")); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); }); } } diff --git a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs new file mode 100644 index 00000000..2ab88134 --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs @@ -0,0 +1,15 @@ +namespace NetDaemon.Runtime; + +/// +/// The NetDaemon runtime interface. +/// +public interface INetDaemonRuntime : IAsyncDisposable +{ + /// + /// Starts the runtime and passes to the runtime task/thread. + /// Will return a task that completes when the NetDaemon runtime is initialized. + /// + /// Calling this method multiple times will only start the runtime once, but can be useful for other processes if they want to await the NetDaemon runtime to be initialized. + /// + Task EnsureInitializedAsync(CancellationToken stoppingToken = default); +} diff --git a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntimeInitializedCheck.cs b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntimeInitializedCheck.cs deleted file mode 100644 index 29da9511..00000000 --- a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntimeInitializedCheck.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace NetDaemon.Runtime; - -/// -/// Interface that can be used to check if NetDaemon runtime is initialized. -/// -public interface INetDaemonRuntimeInitializedCheck -{ - /// - /// Method that can be awaited to ensure that the runtime is fully started and initialized (initial connection is created and cache is initialized). - /// - Task EnsureInitializedAsync(); -} diff --git a/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs b/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs deleted file mode 100644 index 0cc47225..00000000 --- a/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NetDaemon.Runtime; - -internal interface IRuntime : IAsyncDisposable -{ - /// - /// Starts the runtime and passes to the runtime task/thread. - /// - void Start(CancellationToken stoppingToken); -} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index 1aea74a4..c64f9ccb 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -9,14 +9,17 @@ internal class NetDaemonRuntime(IHomeAssistantRunner homeAssistantRunner, IServiceProvider serviceProvider, ILogger logger, ICacheManager cacheManager) - : IRuntime, INetDaemonRuntimeInitializedCheck + : INetDaemonRuntime { private const string Version = "local build"; private const int TimeoutInSeconds = 5; + private readonly Lock _initializationLock = new(); private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly HomeAssistantSettings _haSettings = settings.Value; + private Lazy? _initializationTask; private IAppModelContext? _applicationModelContext; private CancellationToken? _stoppingToken; private CancellationTokenSource? _runnerCancellationSource; @@ -29,7 +32,22 @@ internal class NetDaemonRuntime(IHomeAssistantRunner homeAssistantRunner, private Task _runnerTask = Task.CompletedTask; - public void Start(CancellationToken stoppingToken) + public Task EnsureInitializedAsync(CancellationToken stoppingToken = default) + { + if (_initializationTask is null) + { + lock (_initializationLock) + { + if (_initializationTask is null) + { + _initializationTask = new Lazy(() => StartAndInitializeAsync(stoppingToken)); + } + } + } + return _initializationTask.Value; + } + + private Task StartAndInitializeAsync(CancellationToken stoppingToken) { logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version); @@ -54,11 +72,17 @@ public void Start(CancellationToken stoppingToken) _haSettings.WebsocketPath, TimeSpan.FromSeconds(TimeoutInSeconds), _runnerCancellationSource.Token); + + // make sure we cancel the task if the stoppingToken is cancelled + stoppingToken.Register(() => _initializationTcs.TrySetCanceled()); + + return _initializationTcs.Task; } catch (OperationCanceledException) { // Ignore and just stop } + return Task.CompletedTask; } private async Task OnHomeAssistantClientConnected( @@ -87,7 +111,13 @@ private async Task OnHomeAssistantClientConnected( } catch (Exception ex) { - logger.LogCritical(ex, "Error re-initializing after reconnect to Home Assistant"); + if (!_initializationTcs.Task.IsCompleted) + { + // This means this was the first time we connected and StartAsync is still awaiting _startedAndConnected + // By setting the exception on the task it will propagate up. + _initializationTcs.SetException(ex); + } + logger.LogCritical(ex, "Error (re-)initializing after connect to Home Assistant"); } } @@ -136,8 +166,6 @@ private async Task OnHomeAssistantClientDisconnected(DisconnectReason reason) IsConnected = false; } - public Task EnsureInitializedAsync() => _initializationTcs.Task; - private async Task DisposeApplicationsAsync() { if (_applicationModelContext is not null) diff --git a/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs b/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs index 3c711563..11ff672b 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs @@ -1,12 +1,13 @@ namespace NetDaemon.Runtime.Internal; -internal class RuntimeService(IRuntime runtime, ILogger logger) : BackgroundService +internal class RuntimeService(INetDaemonRuntime netDaemonRuntime, ILogger logger) : BackgroundService { public override async Task StartAsync(CancellationToken cancellationToken) { try { - runtime.Start(cancellationToken); + // Note: we purposely do not await this method as we don't want to block the startup of the host. EnsureInitializedAsync can be called again to get access to the same Task if another process wants to await it. + _ = netDaemonRuntime.EnsureInitializedAsync(cancellationToken); await base.StartAsync(cancellationToken); } catch (OperationCanceledException) { } @@ -17,7 +18,7 @@ public override async Task StartAsync(CancellationToken cancellationToken) public override async Task StopAsync(CancellationToken cancellationToken) { logger.LogInformation("NetDaemon RuntimeService is stopping"); - await runtime.DisposeAsync(); + await netDaemonRuntime.DisposeAsync(); await base.StopAsync(cancellationToken); } } diff --git a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs index dfbc5c11..93df90ae 100644 --- a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs +++ b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs @@ -59,8 +59,8 @@ private IHost StartNetDaemon() private static async Task WaitForRuntimeToBeInitialized(IHost host) { - var netDaemonRuntimeInitializedCheck = host.Services.GetRequiredService(); - await netDaemonRuntimeInitializedCheck.EnsureInitializedAsync(); + var netDaemonRuntime = host.Services.GetRequiredService(); + await netDaemonRuntime.EnsureInitializedAsync(); } /// From cf48fc395e33368daca9bfd35a260936319ed3d1 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 24 Apr 2025 09:36:54 +0200 Subject: [PATCH 11/14] Added behavior for unauthorized disconnect. --- .../Common/AutoReconnectOptions.cs | 14 ++++++++++++++ .../NetDaemon.Runtime/Common/INetDaemonRuntime.cs | 5 +++++ .../NetDaemon.Runtime/Internal/NetDaemonRuntime.cs | 9 +++++++++ 3 files changed, 28 insertions(+) create mode 100644 src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs diff --git a/src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs b/src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs new file mode 100644 index 00000000..466f555e --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs @@ -0,0 +1,14 @@ +namespace NetDaemon.Runtime; + +public enum AutoReconnectOptions +{ + /// + /// Will stop automatically reconnecting if the Home Assistant server returns an Unauthorized response. This prevents the user from being locked out of the Home Assistant server. This is the default behavior. + /// + StopReconnectOnUnAuthorized, + + /// + /// Will always attempt to reconnect to the Home Assistant server. + /// + AlwaysAttemptReconnect +} diff --git a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs index 2ab88134..9eac1c54 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs @@ -12,4 +12,9 @@ public interface INetDaemonRuntime : IAsyncDisposable /// Calling this method multiple times will only start the runtime once, but can be useful for other processes if they want to await the NetDaemon runtime to be initialized. /// Task EnsureInitializedAsync(CancellationToken stoppingToken = default); + + /// + /// Determines auto reconnect behavior of the runtime. + /// + AutoReconnectOptions AutoReconnectOptions { get; set; } } diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index c64f9ccb..684bc85b 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -47,6 +47,8 @@ public Task EnsureInitializedAsync(CancellationToken stoppingToken = default) return _initializationTask.Value; } + public AutoReconnectOptions AutoReconnectOptions { get; set; } = AutoReconnectOptions.StopReconnectOnUnAuthorized; + private Task StartAndInitializeAsync(CancellationToken stoppingToken) { logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version); @@ -163,6 +165,13 @@ private async Task OnHomeAssistantClientDisconnected(DisconnectReason reason) { logger.LogError(e, "Error disposing applications"); } + + if (AutoReconnectOptions == AutoReconnectOptions.StopReconnectOnUnAuthorized && reason == DisconnectReason.Unauthorized) + { + logger.LogInformation("Home Assistant runtime will dispose itself to stop automatic retrying to prevent user from being locked out."); + await DisposeAsync(); + } + IsConnected = false; } From 3ab4e9d217dc3d724efbdb1501d9533b12a092b9 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 24 Apr 2025 09:50:19 +0200 Subject: [PATCH 12/14] Adding xml comment for enum --- src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs b/src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs index 466f555e..abce3f5f 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/AutoReconnectOptions.cs @@ -1,5 +1,8 @@ namespace NetDaemon.Runtime; +/// +/// Enum to determine the auto reconnect behavior of the runtime. +/// public enum AutoReconnectOptions { /// From d4dd69e8f48c3c9f95020b089b995e0b72ff6e39 Mon Sep 17 00:00:00 2001 From: Jasper Date: Wed, 30 Apr 2025 17:12:46 +0200 Subject: [PATCH 13/14] Implementing separate IRuntime (internal) and INetDaemonRuntime (public). --- .../Integration/TestRuntime.cs | 6 ++--- .../Internal/NetDaemonRuntimeTests.cs | 14 +++++----- .../Extensions/HostBuilderExtensions.cs | 4 ++- .../Common/INetDaemonRuntime.cs | 12 ++------- .../NetDaemon.Runtime/Common/IRuntime.cs | 9 +++++++ .../Internal/NetDaemonRuntime.cs | 26 +++---------------- .../Internal/RuntimeService.cs | 7 +++-- .../Helpers/NetDaemonIntegrationBase.cs | 8 +----- 8 files changed, 32 insertions(+), 54 deletions(-) create mode 100644 src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs b/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs index 3d9b56b5..47db2f7c 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Integration/TestRuntime.cs @@ -26,7 +26,7 @@ public async Task TestApplicationIsLoaded() var runnerTask = host.RunAsync(timedCancellationSource.Token); haRunner.MockConnect(); - var service = (NetDaemonRuntime) host.Services.GetService()!; + var service = (NetDaemonRuntime) host.Services.GetService()!; var instances = service.ApplicationInstances; instances.Where(n => n.Id == "LocalApps.LocalApp").Should().NotBeEmpty(); @@ -50,7 +50,7 @@ public async Task TestApplicationReactToNewEvents() var runnerTask = host.StartAsync(timedCancellationSource.Token); haRunner.MockConnect(); - _ = (NetDaemonRuntime) host.Services.GetService()!; + _ = (NetDaemonRuntime) host.Services.GetService()!; await runnerTask.ConfigureAwait(false); haRunner.ClientMock.ConnectionMock.AddStateChangeEvent( @@ -89,7 +89,7 @@ public async Task TestApplicationReactToNewEventsAndThrowException() var runnerTask = host.StartAsync(timedCancellationSource.Token); haRunner.MockConnect(); - _ = (NetDaemonRuntime) host.Services.GetService()!; + _ = (NetDaemonRuntime) host.Services.GetService()!; haRunner.ClientMock.ConnectionMock.AddStateChangeEvent( new HassState diff --git a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs index 476ed4d8..422f9ce0 100644 --- a/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs +++ b/src/Runtime/NetDaemon.Runtime.Tests/Internal/NetDaemonRuntimeTests.cs @@ -20,10 +20,10 @@ public sealed class NetDaemonRuntimeTests : IDisposable [Fact] - public async Task TestEnsureInitializedAsyncSubscribesToHomeAssistantRunnerEvents() + public async Task TestStartSubscribesToHomeAssistantRunnerEvents() { await using var runtime = SetupNetDaemonRuntime(); - _ = runtime.EnsureInitializedAsync(); + runtime.Start(CancellationToken.None); _connectSubject.HasObservers.Should().BeTrue(); _disconnectSubject.HasObservers.Should().BeTrue(); @@ -33,7 +33,7 @@ public async Task TestEnsureInitializedAsyncSubscribesToHomeAssistantRunnerEvent public async Task TestOnConnect() { await using var runtime = SetupNetDaemonRuntime(); - _ = runtime.EnsureInitializedAsync(); + runtime.Start(CancellationToken.None); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); @@ -46,7 +46,7 @@ public async Task TestOnConnect() public async Task TestOnDisconnect() { await using var runtime = SetupNetDaemonRuntime(); - _ = runtime.EnsureInitializedAsync(); + runtime.Start(CancellationToken.None); // First make sure we add an connection _connectSubject.OnNext(_homeAssistantConnectionMock.Object); @@ -65,7 +65,7 @@ public async Task TestOnDisconnect() public async Task TestReconnect() { await using var runtime = SetupNetDaemonRuntime(); - _ = runtime.EnsureInitializedAsync(); + runtime.Start(CancellationToken.None); // First make sure we add an connection _connectSubject.OnNext(_homeAssistantConnectionMock.Object); @@ -95,7 +95,7 @@ public async Task TestOnConnectError() await using var runtime = SetupNetDaemonRuntime(); - _ = runtime.EnsureInitializedAsync(); + runtime.Start(CancellationToken.None); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); _loggerMock.Verify( @@ -112,7 +112,7 @@ public async Task TestOnReConnectError() { await using var runtime = SetupNetDaemonRuntime(); - _ = runtime.EnsureInitializedAsync(); + runtime.Start(CancellationToken.None); _connectSubject.OnNext(_homeAssistantConnectionMock.Object); _disconnectSubject.OnNext(DisconnectReason.Client); diff --git a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs index e3c65efa..6ac6dd5b 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs @@ -72,7 +72,9 @@ public static IHostBuilder UseNetDaemonRuntime(this IHostBuilder hostBuilder) services.AddHostedService(); services.AddHomeAssistantClient(); services.Configure(context.Configuration.GetSection("HomeAssistant")); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); }); } } diff --git a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs index 9eac1c54..d18d8e96 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs @@ -6,15 +6,7 @@ namespace NetDaemon.Runtime; public interface INetDaemonRuntime : IAsyncDisposable { /// - /// Starts the runtime and passes to the runtime task/thread. - /// Will return a task that completes when the NetDaemon runtime is initialized. - /// - /// Calling this method multiple times will only start the runtime once, but can be useful for other processes if they want to await the NetDaemon runtime to be initialized. + /// Method that can be awaited to ensure that the runtime is fully started and initialized (initial connection is created and cache is initialized). /// - Task EnsureInitializedAsync(CancellationToken stoppingToken = default); - - /// - /// Determines auto reconnect behavior of the runtime. - /// - AutoReconnectOptions AutoReconnectOptions { get; set; } + Task WaitForInitializationAsync(); } diff --git a/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs b/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs new file mode 100644 index 00000000..0cc47225 --- /dev/null +++ b/src/Runtime/NetDaemon.Runtime/Common/IRuntime.cs @@ -0,0 +1,9 @@ +namespace NetDaemon.Runtime; + +internal interface IRuntime : IAsyncDisposable +{ + /// + /// Starts the runtime and passes to the runtime task/thread. + /// + void Start(CancellationToken stoppingToken); +} diff --git a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs index 684bc85b..e09b38c8 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/NetDaemonRuntime.cs @@ -9,17 +9,15 @@ internal class NetDaemonRuntime(IHomeAssistantRunner homeAssistantRunner, IServiceProvider serviceProvider, ILogger logger, ICacheManager cacheManager) - : INetDaemonRuntime + : IRuntime, INetDaemonRuntime { private const string Version = "local build"; private const int TimeoutInSeconds = 5; - private readonly Lock _initializationLock = new(); private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly HomeAssistantSettings _haSettings = settings.Value; - private Lazy? _initializationTask; private IAppModelContext? _applicationModelContext; private CancellationToken? _stoppingToken; private CancellationTokenSource? _runnerCancellationSource; @@ -32,24 +30,9 @@ internal class NetDaemonRuntime(IHomeAssistantRunner homeAssistantRunner, private Task _runnerTask = Task.CompletedTask; - public Task EnsureInitializedAsync(CancellationToken stoppingToken = default) - { - if (_initializationTask is null) - { - lock (_initializationLock) - { - if (_initializationTask is null) - { - _initializationTask = new Lazy(() => StartAndInitializeAsync(stoppingToken)); - } - } - } - return _initializationTask.Value; - } - public AutoReconnectOptions AutoReconnectOptions { get; set; } = AutoReconnectOptions.StopReconnectOnUnAuthorized; - private Task StartAndInitializeAsync(CancellationToken stoppingToken) + public void Start(CancellationToken stoppingToken) { logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version); @@ -77,16 +60,15 @@ private Task StartAndInitializeAsync(CancellationToken stoppingToken) // make sure we cancel the task if the stoppingToken is cancelled stoppingToken.Register(() => _initializationTcs.TrySetCanceled()); - - return _initializationTcs.Task; } catch (OperationCanceledException) { // Ignore and just stop } - return Task.CompletedTask; } + public Task WaitForInitializationAsync() => _initializationTcs.Task; + private async Task OnHomeAssistantClientConnected( IHomeAssistantConnection haConnection, CancellationToken cancelToken) diff --git a/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs b/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs index 11ff672b..3c711563 100644 --- a/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs +++ b/src/Runtime/NetDaemon.Runtime/Internal/RuntimeService.cs @@ -1,13 +1,12 @@ namespace NetDaemon.Runtime.Internal; -internal class RuntimeService(INetDaemonRuntime netDaemonRuntime, ILogger logger) : BackgroundService +internal class RuntimeService(IRuntime runtime, ILogger logger) : BackgroundService { public override async Task StartAsync(CancellationToken cancellationToken) { try { - // Note: we purposely do not await this method as we don't want to block the startup of the host. EnsureInitializedAsync can be called again to get access to the same Task if another process wants to await it. - _ = netDaemonRuntime.EnsureInitializedAsync(cancellationToken); + runtime.Start(cancellationToken); await base.StartAsync(cancellationToken); } catch (OperationCanceledException) { } @@ -18,7 +17,7 @@ public override async Task StartAsync(CancellationToken cancellationToken) public override async Task StopAsync(CancellationToken cancellationToken) { logger.LogInformation("NetDaemon RuntimeService is stopping"); - await netDaemonRuntime.DisposeAsync(); + await runtime.DisposeAsync(); await base.StopAsync(cancellationToken); } } diff --git a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs index 93df90ae..fc341b20 100644 --- a/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs +++ b/tests/Integration/NetDaemon.Tests.Integration/Helpers/NetDaemonIntegrationBase.cs @@ -1,15 +1,9 @@ using System.Globalization; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; using System.Reflection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using NetDaemon.AppModel; -using NetDaemon.Client; -using NetDaemon.Client.Extensions; -using NetDaemon.HassModel; -using NetDaemon.HassModel.Internal; using NetDaemon.Runtime; using Xunit; @@ -60,7 +54,7 @@ private IHost StartNetDaemon() private static async Task WaitForRuntimeToBeInitialized(IHost host) { var netDaemonRuntime = host.Services.GetRequiredService(); - await netDaemonRuntime.EnsureInitializedAsync(); + await netDaemonRuntime.WaitForInitializationAsync(); } /// From a1870e3273c6e352db909335cb0038d7d3a91e1f Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 1 May 2025 09:02:52 +0200 Subject: [PATCH 14/14] Added AutoReconnectOptions to INetDaemonRuntime. --- src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs index d18d8e96..c5fc2023 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs @@ -5,6 +5,12 @@ namespace NetDaemon.Runtime; /// public interface INetDaemonRuntime : IAsyncDisposable { + /// + /// Enum to determine the auto reconnect behavior of the runtime. + /// Default is StopReconnectOnUnAuthorized. + /// + AutoReconnectOptions AutoReconnectOptions { get; set; } + /// /// Method that can be awaited to ensure that the runtime is fully started and initialized (initial connection is created and cache is initialized). ///