From 33c82c897ee1b74e3239ad461d7bee1bf654315b Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Sun, 11 May 2025 11:31:35 +0200 Subject: [PATCH 1/9] working draft --- NetDaemon.sln | 15 ++ .../NetDaemon.HassModel/HassModelFactory.cs | 12 ++ .../Extensions/HostBuilderExtensions.cs | 22 ++- .../Common/INetDaemonRuntime.cs | 3 + src/debug/ConsoleClient/ConsoleClient.csproj | 14 ++ .../ConsoleClient/HomeAssistantRunnerLight.cs | 137 ++++++++++++++++++ .../ConsoleClient/HomeAssistantRunnerMini.cs | 137 ++++++++++++++++++ src/debug/ConsoleClient/Program.cs | 61 ++++++++ 8 files changed, 393 insertions(+), 8 deletions(-) create mode 100644 src/HassModel/NetDaemon.HassModel/HassModelFactory.cs create mode 100644 src/debug/ConsoleClient/ConsoleClient.csproj create mode 100644 src/debug/ConsoleClient/HomeAssistantRunnerLight.cs create mode 100644 src/debug/ConsoleClient/HomeAssistantRunnerMini.cs create mode 100644 src/debug/ConsoleClient/Program.cs 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/HassModel/NetDaemon.HassModel/HassModelFactory.cs b/src/HassModel/NetDaemon.HassModel/HassModelFactory.cs new file mode 100644 index 000000000..ecdc979d6 --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel/HassModelFactory.cs @@ -0,0 +1,12 @@ +namespace NetDaemon.HassModel; + +public class HassModelFactory +{ + public static IHaContext Create(IHomeAssistantRunner runner) + { + var collection = new ServiceCollection(); + collection.AddScopedHaContext(); + collection.AddSingleton(runner); + return collection.BuildServiceProvider().GetRequiredService(); + } +} diff --git a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs index 6ac6dd5b4..63c9673ee 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. /// @@ -64,17 +64,23 @@ public static IHostBuilder RegisterYamlSettings(this IHostBuilder hostBuilder) /// public static IHostBuilder UseNetDaemonRuntime(this IHostBuilder hostBuilder) { + return hostBuilder - .UseAppScopedHaContext() .ConfigureServices((context, services) => { - services.AddLogging(); - services.AddHostedService(); - services.AddHomeAssistantClient(); services.Configure(context.Configuration.GetSection("HomeAssistant")); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); + AddNetDaemonRuntime(services); }); } + + public static void AddNetDaemonRuntime(this IServiceCollection services) + { + services.AddScopedHaContext(); + services.AddLogging(); + services.AddHostedService(); + services.AddHomeAssistantClient(); + 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 d18d8e967..f8ad42262 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs @@ -8,5 +8,8 @@ public interface INetDaemonRuntime : IAsyncDisposable /// /// Method that can be awaited to ensure that the runtime is fully started and initialized (initial connection is created and cache is initialized). /// + + void Start(CancellationToken stoppingToken); + Task WaitForInitializationAsync(); } diff --git a/src/debug/ConsoleClient/ConsoleClient.csproj b/src/debug/ConsoleClient/ConsoleClient.csproj new file mode 100644 index 000000000..01081cd13 --- /dev/null +++ b/src/debug/ConsoleClient/ConsoleClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + enable + enable + + + + + + + + diff --git a/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs b/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs new file mode 100644 index 000000000..e78ab1bc0 --- /dev/null +++ b/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs @@ -0,0 +1,137 @@ +using System.Reactive.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NetDaemon.Client; +using NetDaemon.Client.Settings; +using NetDaemon.HassModel; + +namespace NetDaemon.Runtime.Internal; + +internal class NetDaemonRuntimeLight(IHomeAssistantRunner homeAssistantRunner, + IOptions settings, + ILogger logger, + ICacheManager cacheManager) +{ + 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 CancellationToken? _stoppingToken; + private CancellationTokenSource? _runnerCancellationSource; + + public bool IsConnected; + + private Task _runnerTask = Task.CompletedTask; + + public void Start(CancellationToken stoppingToken) + { + logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version); + + _stoppingToken = stoppingToken; + + homeAssistantRunner.OnConnect + .Select(async c => await OnHomeAssistantClientConnected(c, stoppingToken).ConfigureAwait(false)) + .Subscribe(); + homeAssistantRunner.OnDisconnect + .Select(async s => await OnHomeAssistantClientDisconnected(s).ConfigureAwait(false)) + .Subscribe(); + try + { + _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, + _haSettings.Ssl, + _haSettings.Token, + _haSettings.WebsocketPath, + TimeSpan.FromSeconds(TimeoutInSeconds), + _runnerCancellationSource.Token); + + // make sure we cancel the task if the stoppingToken is cancelled + stoppingToken.Register(() => _initializationTcs.TrySetCanceled()); + } + catch (OperationCanceledException) + { + // Ignore and just stop + } + } + + public Task WaitForInitializationAsync() => _initializationTcs.Task; + + private async Task OnHomeAssistantClientConnected( + IHomeAssistantConnection haConnection, + CancellationToken cancelToken) + { + try + { + logger.LogInformation("Successfully connected to Home Assistant"); + + IsConnected = true; + + await cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); + + // Signal anyone waiting that the runtime is now initialized + _initializationTcs.TrySetResult(); + } + catch (Exception ex) + { + 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"); + } + } + + private async Task OnHomeAssistantClientDisconnected(DisconnectReason reason) + { + if (_stoppingToken?.IsCancellationRequested == true || reason == DisconnectReason.Client) + { + logger.LogInformation("HassClient disconnected cause of user stopping"); + } + else + { + var reasonString = reason switch + { + DisconnectReason.Remote => "home assistant closed the connection", + DisconnectReason.Error => "unknown error, set loglevel to debug to view details", + DisconnectReason.Unauthorized => "token not authorized", + DisconnectReason.NotReady => "home assistant not ready yet", + _ => "unknown error" + }; + logger.LogInformation("Home Assistant disconnected due to {Reason}", + reasonString ); + } + + if (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; + } + + private volatile bool _isDisposed; + public async ValueTask DisposeAsync() + { + if (_isDisposed) return; + _isDisposed = true; + + if (_runnerCancellationSource is not null) + await _runnerCancellationSource.CancelAsync(); + try + { + await _runnerTask.ConfigureAwait(false); + } + catch (OperationCanceledException) { } + _runnerCancellationSource?.Dispose(); + } +} diff --git a/src/debug/ConsoleClient/HomeAssistantRunnerMini.cs b/src/debug/ConsoleClient/HomeAssistantRunnerMini.cs new file mode 100644 index 000000000..4ed16c322 --- /dev/null +++ b/src/debug/ConsoleClient/HomeAssistantRunnerMini.cs @@ -0,0 +1,137 @@ +using System.Reactive.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NetDaemon.Client; +using NetDaemon.Client.Settings; +using NetDaemon.HassModel; + +namespace NetDaemon.Runtime.Internal; + +internal class NetDaemonRuntimeMini(IHomeAssistantRunner homeAssistantRunner, + IOptions settings, + ILogger logger, + ICacheManager cacheManager) +{ + 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 CancellationToken? _stoppingToken; + private CancellationTokenSource? _runnerCancellationSource; + + public bool IsConnected; + + private Task _runnerTask = Task.CompletedTask; + + public void Start(CancellationToken stoppingToken) + { + logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version); + + _stoppingToken = stoppingToken; + + homeAssistantRunner.OnConnect + .Select(async c => await OnHomeAssistantClientConnected(c, stoppingToken).ConfigureAwait(false)) + .Subscribe(); + homeAssistantRunner.OnDisconnect + .Select(async s => await OnHomeAssistantClientDisconnected(s).ConfigureAwait(false)) + .Subscribe(); + try + { + _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, + _haSettings.Ssl, + _haSettings.Token, + _haSettings.WebsocketPath, + TimeSpan.FromSeconds(TimeoutInSeconds), + _runnerCancellationSource.Token); + + // make sure we cancel the task if the stoppingToken is cancelled + stoppingToken.Register(() => _initializationTcs.TrySetCanceled()); + } + catch (OperationCanceledException) + { + // Ignore and just stop + } + } + + public Task WaitForInitializationAsync() => _initializationTcs.Task; + + private async Task OnHomeAssistantClientConnected( + IHomeAssistantConnection haConnection, + CancellationToken cancelToken) + { + try + { + logger.LogInformation("Successfully connected to Home Assistant"); + + IsConnected = true; + + await cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); + + // Signal anyone waiting that the runtime is now initialized + _initializationTcs.TrySetResult(); + } + catch (Exception ex) + { + 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"); + } + } + + private async Task OnHomeAssistantClientDisconnected(DisconnectReason reason) + { + if (_stoppingToken?.IsCancellationRequested == true || reason == DisconnectReason.Client) + { + logger.LogInformation("HassClient disconnected cause of user stopping"); + } + else + { + var reasonString = reason switch + { + DisconnectReason.Remote => "home assistant closed the connection", + DisconnectReason.Error => "unknown error, set loglevel to debug to view details", + DisconnectReason.Unauthorized => "token not authorized", + DisconnectReason.NotReady => "home assistant not ready yet", + _ => "unknown error" + }; + logger.LogInformation("Home Assistant disconnected due to {Reason}", + reasonString ); + } + + if (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; + } + + private volatile bool _isDisposed; + public async ValueTask DisposeAsync() + { + if (_isDisposed) return; + _isDisposed = true; + + if (_runnerCancellationSource is not null) + await _runnerCancellationSource.CancelAsync(); + try + { + await _runnerTask.ConfigureAwait(false); + } + catch (OperationCanceledException) { } + _runnerCancellationSource?.Dispose(); + } +} diff --git a/src/debug/ConsoleClient/Program.cs b/src/debug/ConsoleClient/Program.cs new file mode 100644 index 000000000..325fc035c --- /dev/null +++ b/src/debug/ConsoleClient/Program.cs @@ -0,0 +1,61 @@ +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NetDaemon.Client; +using NetDaemon.Client.Extensions; +using NetDaemon.Client.Settings; +using NetDaemon.HassModel; +using NetDaemon.Runtime.Internal; + +var collection = new ServiceCollection(); + +collection.Configure(s => + { + s.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MDhlZjQ3NWRlOTU0YWJmYTYwNTRkZDc2YzRkZmJjNiIsImlhdCI6MTc0NjkxMTkxOSwiZXhwIjoyMDYyMjcxOTE5fQ.KSwYw1IER965EUcN2_7XPPgVikeIli-mTH8XreveWvA"; + s.Host = "localhost"; + s.Port = 8123; + s.Ssl = false; + }); + +collection.AddHomeAssistantClient(); +collection.AddScopedHaContext(); +collection.AddTransient(); + +var serviceProvider = collection.BuildServiceProvider().CreateScope().ServiceProvider; + +var runner = serviceProvider.GetRequiredService(); + + +var runtime = serviceProvider.GetRequiredService(); +runtime.Start(CancellationToken.None); +await runtime.WaitForInitializationAsync(); + + +//await StartAsync(runner, CancellationToken.None).ConfigureAwait(false); + +//await serviceProvider.GetRequiredService().InitializeAsync(CancellationToken.None); +var haContext = serviceProvider.GetRequiredService(); + +var state = haContext.GetState("sun.sun")?.State; +Console.WriteLine(state); + +haContext.Entity("input_button.test_button").StateAllChanges().Subscribe(s => Console.WriteLine($"Pressed {s.New?.State}")); + +Console.ReadLine(); + + +async Task StartAsync(IHomeAssistantRunner homeAssistantRunner, CancellationToken stoppingToken) +{ + var connectedTask = homeAssistantRunner.OnConnect.Take(1).ToTask(); + + homeAssistantRunner.RunAsync( + "localhost", + 8123, + false, + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MDhlZjQ3NWRlOTU0YWJmYTYwNTRkZDc2YzRkZmJjNiIsImlhdCI6MTc0NjkxMTkxOSwiZXhwIjoyMDYyMjcxOTE5fQ.KSwYw1IER965EUcN2_7XPPgVikeIli-mTH8XreveWvA", + "api/websocket", + TimeSpan.FromSeconds(30), + stoppingToken); + + await connectedTask.ConfigureAwait(false); +} From 0dbc622740d47592d39aaa2b760fb64b89d80ca0 Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Sun, 11 May 2025 13:00:19 +0200 Subject: [PATCH 2/9] Simplified --- .../Internal/EntityStateCache.cs | 2 +- src/debug/ConsoleClient/HaSettings.cs | 7 +++ .../ConsoleClient/HomeAssistantRunnerLight.cs | 9 ++- src/debug/ConsoleClient/Program.cs | 61 +++++++------------ 4 files changed, 38 insertions(+), 41 deletions(-) create mode 100644 src/debug/ConsoleClient/HaSettings.cs diff --git a/src/HassModel/NetDaemon.HassModel/Internal/EntityStateCache.cs b/src/HassModel/NetDaemon.HassModel/Internal/EntityStateCache.cs index 134ce50f2..591e5e70e 100644 --- a/src/HassModel/NetDaemon.HassModel/Internal/EntityStateCache.cs +++ b/src/HassModel/NetDaemon.HassModel/Internal/EntityStateCache.cs @@ -17,7 +17,7 @@ internal class EntityStateCache(IHomeAssistantRunner hassRunner) : IDisposable public async Task InitializeAsync(CancellationToken cancellationToken) { - _ = hassRunner.CurrentConnection ?? throw new InvalidOperationException(); + _ = hassRunner.CurrentConnection ?? throw new InvalidOperationException("Home Assistant connection is not available when trying to initialize the StateCache."); var events = await hassRunner.CurrentConnection!.SubscribeToHomeAssistantEventsAsync(null, cancellationToken).ConfigureAwait(false); _eventSubscription = events.Subscribe(HandleEvent); diff --git a/src/debug/ConsoleClient/HaSettings.cs b/src/debug/ConsoleClient/HaSettings.cs new file mode 100644 index 000000000..d188c0f40 --- /dev/null +++ b/src/debug/ConsoleClient/HaSettings.cs @@ -0,0 +1,7 @@ +class HaSettings +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 8123; + public bool Ssl { get; set; } = false; + public string Token { get; set; } = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MDhlZjQ3NWRlOTU0YWJmYTYwNTRkZDc2YzRkZmJjNiIsImlhdCI6MTc0NjkxMTkxOSwiZXhwIjoyMDYyMjcxOTE5fQ.KSwYw1IER965EUcN2_7XPPgVikeIli-mTH8XreveWvA"; +} diff --git a/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs b/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs index e78ab1bc0..fee11236d 100644 --- a/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs +++ b/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs @@ -26,6 +26,13 @@ internal class NetDaemonRuntimeLight(IHomeAssistantRunner homeAssistantRunner, private Task _runnerTask = Task.CompletedTask; + public async Task StartAsync(CancellationToken stoppingToken) + { + Start(stoppingToken); + await WaitForInitializationAsync().ConfigureAwait(false); + } + + public void Start(CancellationToken stoppingToken) { logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version); @@ -73,7 +80,7 @@ private async Task OnHomeAssistantClientConnected( IsConnected = true; - await cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); + //await cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); // Signal anyone waiting that the runtime is now initialized _initializationTcs.TrySetResult(); diff --git a/src/debug/ConsoleClient/Program.cs b/src/debug/ConsoleClient/Program.cs index 325fc035c..42267e3eb 100644 --- a/src/debug/ConsoleClient/Program.cs +++ b/src/debug/ConsoleClient/Program.cs @@ -3,59 +3,42 @@ using Microsoft.Extensions.DependencyInjection; using NetDaemon.Client; using NetDaemon.Client.Extensions; -using NetDaemon.Client.Settings; using NetDaemon.HassModel; -using NetDaemon.Runtime.Internal; -var collection = new ServiceCollection(); - -collection.Configure(s => - { - s.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MDhlZjQ3NWRlOTU0YWJmYTYwNTRkZDc2YzRkZmJjNiIsImlhdCI6MTc0NjkxMTkxOSwiZXhwIjoyMDYyMjcxOTE5fQ.KSwYw1IER965EUcN2_7XPPgVikeIli-mTH8XreveWvA"; - s.Host = "localhost"; - s.Port = 8123; - s.Ssl = false; - }); - -collection.AddHomeAssistantClient(); -collection.AddScopedHaContext(); -collection.AddTransient(); - -var serviceProvider = collection.BuildServiceProvider().CreateScope().ServiceProvider; - -var runner = serviceProvider.GetRequiredService(); - - -var runtime = serviceProvider.GetRequiredService(); -runtime.Start(CancellationToken.None); -await runtime.WaitForInitializationAsync(); - - -//await StartAsync(runner, CancellationToken.None).ConfigureAwait(false); - -//await serviceProvider.GetRequiredService().InitializeAsync(CancellationToken.None); -var haContext = serviceProvider.GetRequiredService(); +var haContext = await CreateHaContext(new HaSettings()); var state = haContext.GetState("sun.sun")?.State; Console.WriteLine(state); haContext.Entity("input_button.test_button").StateAllChanges().Subscribe(s => Console.WriteLine($"Pressed {s.New?.State}")); -Console.ReadLine(); +while (!Console.KeyAvailable) +{ + await Task.Delay(1000); +} -async Task StartAsync(IHomeAssistantRunner homeAssistantRunner, CancellationToken stoppingToken) +async Task CreateHaContext(HaSettings haSettings) { - var connectedTask = homeAssistantRunner.OnConnect.Take(1).ToTask(); + var collection = new ServiceCollection(); + collection.AddHomeAssistantClient(); + collection.AddScopedHaContext(); + + var serviceProvider = collection.BuildServiceProvider().CreateScope().ServiceProvider; - homeAssistantRunner.RunAsync( - "localhost", - 8123, - false, - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MDhlZjQ3NWRlOTU0YWJmYTYwNTRkZDc2YzRkZmJjNiIsImlhdCI6MTc0NjkxMTkxOSwiZXhwIjoyMDYyMjcxOTE5fQ.KSwYw1IER965EUcN2_7XPPgVikeIli-mTH8XreveWvA", + var runner = serviceProvider.GetRequiredService(); + + var connectedTask = runner.OnConnect.Take(1).ToTask(); + + var _ = runner.RunAsync(haSettings.Host, haSettings.Port, haSettings.Ssl, haSettings.Token, "api/websocket", TimeSpan.FromSeconds(30), - stoppingToken); + CancellationToken.None); await connectedTask.ConfigureAwait(false); + + var cacheManager = serviceProvider.GetRequiredService(); + await cacheManager.InitializeAsync(CancellationToken.None); + + return serviceProvider.GetRequiredService(); } From 9c5352f0735edc5febb5f1a190577ee3d6014613 Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Thu, 24 Jul 2025 23:50:15 +0200 Subject: [PATCH 3/9] Simplified --- .../Common/HomeAssistantClientConnector.cs | 1 - .../AppScopedHaContextProviderTest.cs | 2 +- .../NetDaemon.HassModel/HassModelFactory.cs | 12 -- .../NetDaemon.HassModel/ICacheManager.cs | 3 +- .../Internal/AppScopedHaContextProvider.cs | 18 +-- .../Internal/CacheManager.cs | 6 +- .../Internal/EntityStateCache.cs | 9 +- .../Internal/RegistryCache.cs | 8 +- .../Internal/NetDaemonRuntime.cs | 2 +- src/debug/ConsoleClient/ConsoleClient.csproj | 15 ++ src/debug/ConsoleClient/HaContextFactory.cs | 65 ++++++++ src/debug/ConsoleClient/HaSettings.cs | 7 - .../ConsoleClient/HomeAssistantRunnerLight.cs | 144 ------------------ .../ConsoleClient/HomeAssistantRunnerMini.cs | 137 ----------------- src/debug/ConsoleClient/Program.cs | 43 +----- src/debug/ConsoleClient/appsettings.json | 9 ++ 16 files changed, 114 insertions(+), 367 deletions(-) delete mode 100644 src/HassModel/NetDaemon.HassModel/HassModelFactory.cs create mode 100644 src/debug/ConsoleClient/HaContextFactory.cs delete mode 100644 src/debug/ConsoleClient/HaSettings.cs delete mode 100644 src/debug/ConsoleClient/HomeAssistantRunnerLight.cs delete mode 100644 src/debug/ConsoleClient/HomeAssistantRunnerMini.cs create mode 100644 src/debug/ConsoleClient/appsettings.json diff --git a/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs b/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs index 8aa43161b..6c4b3b725 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, diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs index 1a3f4a5c0..8935a61c7 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/Internal/AppScopedHaContextProviderTest.cs @@ -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/HassModelFactory.cs b/src/HassModel/NetDaemon.HassModel/HassModelFactory.cs deleted file mode 100644 index ecdc979d6..000000000 --- a/src/HassModel/NetDaemon.HassModel/HassModelFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace NetDaemon.HassModel; - -public class HassModelFactory -{ - public static IHaContext Create(IHomeAssistantRunner runner) - { - var collection = new ServiceCollection(); - collection.AddScopedHaContext(); - collection.AddSingleton(runner); - return collection.BuildServiceProvider().GetRequiredService(); - } -} diff --git a/src/HassModel/NetDaemon.HassModel/ICacheManager.cs b/src/HassModel/NetDaemon.HassModel/ICacheManager.cs index f1cf70f5b..65c1835b7 100644 --- a/src/HassModel/NetDaemon.HassModel/ICacheManager.cs +++ b/src/HassModel/NetDaemon.HassModel/ICacheManager.cs @@ -8,7 +8,6 @@ public interface ICacheManager /// /// (re) Initializes the Hass Model internal caches from Home Assistant. Should be called /// - /// /// - 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..99ef33a29 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 IHomeAssistantConnection _homeAssistantConnection; - private readonly IHomeAssistantRunner _hassRunner; 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, + IHomeAssistantConnection homeAssistantConnection, IBackgroundTaskTracker backgroundTaskTracker, IServiceProvider serviceProvider, ILogger logger, IEntityFactory entityFactory) { _entityStateCache = entityStateCache; - _hassRunner = hassRunner; - _apiManager = apiManager; + _homeAssistantConnection = homeAssistantConnection; // Create QueuedObservable for this app // This makes sure we will unsubscribe when this ContextProvider is Disposed @@ -72,17 +69,16 @@ 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(_homeAssistantConnection.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"); + _ = _homeAssistantConnection ?? throw new InvalidOperationException("No connection to Home Assistant"); - var result = await _hassRunner.CurrentConnection + var result = await _homeAssistantConnection .CallServiceWithResponseAsync(domain, service, data, target?.Map(), _tokenSource.Token) .ConfigureAwait(false); return result?.Response; @@ -101,7 +97,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(_homeAssistantConnection?.SendEventAsync(eventType, _tokenSource.Token, data), "Error in sending event"); } public async ValueTask DisposeAsync() 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 591e5e70e..2f48a4ced 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("Home Assistant connection is not available when trying to initialize the StateCache."); - 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/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 index 01081cd13..21f73ca35 100644 --- a/src/debug/ConsoleClient/ConsoleClient.csproj +++ b/src/debug/ConsoleClient/ConsoleClient.csproj @@ -4,6 +4,7 @@ Exe enable enable + aefef09e-f5ac-4a3c-b8e3-04fb9f64768a @@ -11,4 +12,18 @@ + + + + PreserveNewest + + + + PreserveNewest + + + + + + diff --git a/src/debug/ConsoleClient/HaContextFactory.cs b/src/debug/ConsoleClient/HaContextFactory.cs new file mode 100644 index 000000000..a3fac217c --- /dev/null +++ b/src/debug/ConsoleClient/HaContextFactory.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NetDaemon.Client; +using NetDaemon.Client.Settings; +using NetDaemon.HassModel; + +public static class HaContextFactory +{ + public static async Task CreateHaContextAsync() + { + var connection = await CreateConnection(); + return await CreateHaContextAsync(connection); + } + + public static async Task CreateHaContextAsync(IHomeAssistantConnection connection) + { + var collection = new ServiceCollection(); + collection.AddLogging(); + collection.AddSingleton(connection); + collection.AddScopedHaContext(); + var serviceProvider = collection.BuildServiceProvider().CreateScope().ServiceProvider; + + var cacheManager = serviceProvider.GetRequiredService(); + await cacheManager.InitializeAsync(connection, CancellationToken.None); + + return serviceProvider.GetRequiredService(); + } + + private static async Task CreateConnection() + { + var config = GetConfigurationRoot([]); + + var homeassistantSettings = new HomeAssistantSettings(); + + config.GetSection("HomeAssistant").Bind(homeassistantSettings); + + var connection = await HomeAssistantClientConnector.ConnectClientAsync( + host: homeassistantSettings.Host, homeassistantSettings.Port, homeassistantSettings.Ssl, homeassistantSettings.Token, + "api/websocket", + CancellationToken.None); + return connection; + } + + private static IConfigurationRoot GetConfigurationRoot(string[] args) + { + var env = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + var builder = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile($"appsettings.{env}.json", optional: true) + .AddUserSecrets(typeof(HaContextFactory).Assembly, optional: true) + + // finally override with Environment vars or commandline + .AddEnvironmentVariables() + .AddCommandLine(args, new Dictionary + { + ["-host"] = "HomeAssistant:Host", + ["-port"] = "HomeAssistant:Port", + ["-ssl"] = "HomeAssistant:Ssl", + ["-token"] = "HomeAssistant:Token", + ["-bypass-cert"] = "HomeAssistant:InsecureBypassCertificateErrors", + }); + + return builder.Build(); + } +} diff --git a/src/debug/ConsoleClient/HaSettings.cs b/src/debug/ConsoleClient/HaSettings.cs deleted file mode 100644 index d188c0f40..000000000 --- a/src/debug/ConsoleClient/HaSettings.cs +++ /dev/null @@ -1,7 +0,0 @@ -class HaSettings -{ - public string Host { get; set; } = "localhost"; - public int Port { get; set; } = 8123; - public bool Ssl { get; set; } = false; - public string Token { get; set; } = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MDhlZjQ3NWRlOTU0YWJmYTYwNTRkZDc2YzRkZmJjNiIsImlhdCI6MTc0NjkxMTkxOSwiZXhwIjoyMDYyMjcxOTE5fQ.KSwYw1IER965EUcN2_7XPPgVikeIli-mTH8XreveWvA"; -} diff --git a/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs b/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs deleted file mode 100644 index fee11236d..000000000 --- a/src/debug/ConsoleClient/HomeAssistantRunnerLight.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Reactive.Linq; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NetDaemon.Client; -using NetDaemon.Client.Settings; -using NetDaemon.HassModel; - -namespace NetDaemon.Runtime.Internal; - -internal class NetDaemonRuntimeLight(IHomeAssistantRunner homeAssistantRunner, - IOptions settings, - ILogger logger, - ICacheManager cacheManager) -{ - 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 CancellationToken? _stoppingToken; - private CancellationTokenSource? _runnerCancellationSource; - - public bool IsConnected; - - private Task _runnerTask = Task.CompletedTask; - - public async Task StartAsync(CancellationToken stoppingToken) - { - Start(stoppingToken); - await WaitForInitializationAsync().ConfigureAwait(false); - } - - - public void Start(CancellationToken stoppingToken) - { - logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version); - - _stoppingToken = stoppingToken; - - homeAssistantRunner.OnConnect - .Select(async c => await OnHomeAssistantClientConnected(c, stoppingToken).ConfigureAwait(false)) - .Subscribe(); - homeAssistantRunner.OnDisconnect - .Select(async s => await OnHomeAssistantClientDisconnected(s).ConfigureAwait(false)) - .Subscribe(); - try - { - _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, - _haSettings.Ssl, - _haSettings.Token, - _haSettings.WebsocketPath, - TimeSpan.FromSeconds(TimeoutInSeconds), - _runnerCancellationSource.Token); - - // make sure we cancel the task if the stoppingToken is cancelled - stoppingToken.Register(() => _initializationTcs.TrySetCanceled()); - } - catch (OperationCanceledException) - { - // Ignore and just stop - } - } - - public Task WaitForInitializationAsync() => _initializationTcs.Task; - - private async Task OnHomeAssistantClientConnected( - IHomeAssistantConnection haConnection, - CancellationToken cancelToken) - { - try - { - logger.LogInformation("Successfully connected to Home Assistant"); - - IsConnected = true; - - //await cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); - - // Signal anyone waiting that the runtime is now initialized - _initializationTcs.TrySetResult(); - } - catch (Exception ex) - { - 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"); - } - } - - private async Task OnHomeAssistantClientDisconnected(DisconnectReason reason) - { - if (_stoppingToken?.IsCancellationRequested == true || reason == DisconnectReason.Client) - { - logger.LogInformation("HassClient disconnected cause of user stopping"); - } - else - { - var reasonString = reason switch - { - DisconnectReason.Remote => "home assistant closed the connection", - DisconnectReason.Error => "unknown error, set loglevel to debug to view details", - DisconnectReason.Unauthorized => "token not authorized", - DisconnectReason.NotReady => "home assistant not ready yet", - _ => "unknown error" - }; - logger.LogInformation("Home Assistant disconnected due to {Reason}", - reasonString ); - } - - if (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; - } - - private volatile bool _isDisposed; - public async ValueTask DisposeAsync() - { - if (_isDisposed) return; - _isDisposed = true; - - if (_runnerCancellationSource is not null) - await _runnerCancellationSource.CancelAsync(); - try - { - await _runnerTask.ConfigureAwait(false); - } - catch (OperationCanceledException) { } - _runnerCancellationSource?.Dispose(); - } -} diff --git a/src/debug/ConsoleClient/HomeAssistantRunnerMini.cs b/src/debug/ConsoleClient/HomeAssistantRunnerMini.cs deleted file mode 100644 index 4ed16c322..000000000 --- a/src/debug/ConsoleClient/HomeAssistantRunnerMini.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Reactive.Linq; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NetDaemon.Client; -using NetDaemon.Client.Settings; -using NetDaemon.HassModel; - -namespace NetDaemon.Runtime.Internal; - -internal class NetDaemonRuntimeMini(IHomeAssistantRunner homeAssistantRunner, - IOptions settings, - ILogger logger, - ICacheManager cacheManager) -{ - 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 CancellationToken? _stoppingToken; - private CancellationTokenSource? _runnerCancellationSource; - - public bool IsConnected; - - private Task _runnerTask = Task.CompletedTask; - - public void Start(CancellationToken stoppingToken) - { - logger.LogInformation("Starting NetDaemon runtime version {Version}.", Version); - - _stoppingToken = stoppingToken; - - homeAssistantRunner.OnConnect - .Select(async c => await OnHomeAssistantClientConnected(c, stoppingToken).ConfigureAwait(false)) - .Subscribe(); - homeAssistantRunner.OnDisconnect - .Select(async s => await OnHomeAssistantClientDisconnected(s).ConfigureAwait(false)) - .Subscribe(); - try - { - _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, - _haSettings.Ssl, - _haSettings.Token, - _haSettings.WebsocketPath, - TimeSpan.FromSeconds(TimeoutInSeconds), - _runnerCancellationSource.Token); - - // make sure we cancel the task if the stoppingToken is cancelled - stoppingToken.Register(() => _initializationTcs.TrySetCanceled()); - } - catch (OperationCanceledException) - { - // Ignore and just stop - } - } - - public Task WaitForInitializationAsync() => _initializationTcs.Task; - - private async Task OnHomeAssistantClientConnected( - IHomeAssistantConnection haConnection, - CancellationToken cancelToken) - { - try - { - logger.LogInformation("Successfully connected to Home Assistant"); - - IsConnected = true; - - await cacheManager.InitializeAsync(cancelToken).ConfigureAwait(false); - - // Signal anyone waiting that the runtime is now initialized - _initializationTcs.TrySetResult(); - } - catch (Exception ex) - { - 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"); - } - } - - private async Task OnHomeAssistantClientDisconnected(DisconnectReason reason) - { - if (_stoppingToken?.IsCancellationRequested == true || reason == DisconnectReason.Client) - { - logger.LogInformation("HassClient disconnected cause of user stopping"); - } - else - { - var reasonString = reason switch - { - DisconnectReason.Remote => "home assistant closed the connection", - DisconnectReason.Error => "unknown error, set loglevel to debug to view details", - DisconnectReason.Unauthorized => "token not authorized", - DisconnectReason.NotReady => "home assistant not ready yet", - _ => "unknown error" - }; - logger.LogInformation("Home Assistant disconnected due to {Reason}", - reasonString ); - } - - if (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; - } - - private volatile bool _isDisposed; - public async ValueTask DisposeAsync() - { - if (_isDisposed) return; - _isDisposed = true; - - if (_runnerCancellationSource is not null) - await _runnerCancellationSource.CancelAsync(); - try - { - await _runnerTask.ConfigureAwait(false); - } - catch (OperationCanceledException) { } - _runnerCancellationSource?.Dispose(); - } -} diff --git a/src/debug/ConsoleClient/Program.cs b/src/debug/ConsoleClient/Program.cs index 42267e3eb..e205a88d8 100644 --- a/src/debug/ConsoleClient/Program.cs +++ b/src/debug/ConsoleClient/Program.cs @@ -1,44 +1,7 @@ -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using NetDaemon.Client; -using NetDaemon.Client.Extensions; -using NetDaemon.HassModel; +var haContext = await HaContextFactory.CreateHaContextAsync(); -var haContext = await CreateHaContext(new HaSettings()); - -var state = haContext.GetState("sun.sun")?.State; -Console.WriteLine(state); +Console.WriteLine(haContext.Entity("sun.sun").State); haContext.Entity("input_button.test_button").StateAllChanges().Subscribe(s => Console.WriteLine($"Pressed {s.New?.State}")); - -while (!Console.KeyAvailable) -{ - await Task.Delay(1000); -} - -async Task CreateHaContext(HaSettings haSettings) -{ - var collection = new ServiceCollection(); - collection.AddHomeAssistantClient(); - collection.AddScopedHaContext(); - - var serviceProvider = collection.BuildServiceProvider().CreateScope().ServiceProvider; - - var runner = serviceProvider.GetRequiredService(); - - var connectedTask = runner.OnConnect.Take(1).ToTask(); - - var _ = runner.RunAsync(haSettings.Host, haSettings.Port, haSettings.Ssl, haSettings.Token, - "api/websocket", - TimeSpan.FromSeconds(30), - CancellationToken.None); - - await connectedTask.ConfigureAwait(false); - - var cacheManager = serviceProvider.GetRequiredService(); - await cacheManager.InitializeAsync(CancellationToken.None); - - return serviceProvider.GetRequiredService(); -} +await new StreamReader(Console.OpenStandardInput()).ReadLineAsync(); 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" + } +} From 2769ab3ba589cfc174ea56a8ccdb7c3b005de868 Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Fri, 25 Jul 2025 01:12:37 +0200 Subject: [PATCH 4/9] Remove not-needed changes --- .../Extensions/HostBuilderExtensions.cs | 20 +++++++------------ .../Common/INetDaemonRuntime.cs | 3 --- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs index 63c9673ee..9a53e2869 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/Extensions/HostBuilderExtensions.cs @@ -64,23 +64,17 @@ public static IHostBuilder RegisterYamlSettings(this IHostBuilder hostBuilder) /// public static IHostBuilder UseNetDaemonRuntime(this IHostBuilder hostBuilder) { - return hostBuilder + .UseAppScopedHaContext() .ConfigureServices((context, services) => { + services.AddLogging(); + services.AddHostedService(); + services.AddHomeAssistantClient(); services.Configure(context.Configuration.GetSection("HomeAssistant")); - AddNetDaemonRuntime(services); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); }); } - - public static void AddNetDaemonRuntime(this IServiceCollection services) - { - services.AddScopedHaContext(); - services.AddLogging(); - services.AddHostedService(); - services.AddHomeAssistantClient(); - 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 f8ad42262..d18d8e967 100644 --- a/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs +++ b/src/Runtime/NetDaemon.Runtime/Common/INetDaemonRuntime.cs @@ -8,8 +8,5 @@ public interface INetDaemonRuntime : IAsyncDisposable /// /// Method that can be awaited to ensure that the runtime is fully started and initialized (initial connection is created and cache is initialized). /// - - void Start(CancellationToken stoppingToken); - Task WaitForInitializationAsync(); } From 67bd19f832f596c8536bd32ababc888853a73853 Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Fri, 25 Jul 2025 21:50:43 +0200 Subject: [PATCH 5/9] Simplified --- .../Common/HomeAssistantClientConnector.cs | 17 +++++ .../Internal/HomeAssistantClient.cs | 9 ++- .../NetDaemon.HassModel/HaContextFactory.cs | 30 +++++++++ src/debug/ConsoleClient/HaContextFactory.cs | 65 ------------------- src/debug/ConsoleClient/Program.cs | 9 ++- 5 files changed, 59 insertions(+), 71 deletions(-) create mode 100644 src/HassModel/NetDaemon.HassModel/HaContextFactory.cs delete mode 100644 src/debug/ConsoleClient/HaContextFactory.cs diff --git a/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs b/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs index 6c4b3b725..4679d49b1 100644 --- a/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs +++ b/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs @@ -51,4 +51,21 @@ 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/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/HaContextFactory.cs b/src/HassModel/NetDaemon.HassModel/HaContextFactory.cs new file mode 100644 index 000000000..0acf6888e --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel/HaContextFactory.cs @@ -0,0 +1,30 @@ +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.AddScopedHaContext(); + var serviceProvider = collection.BuildServiceProvider().CreateScope().ServiceProvider; + + var cacheManager = serviceProvider.GetRequiredService(); + await cacheManager.InitializeAsync(connection, CancellationToken.None); + + return serviceProvider.GetRequiredService(); + } +} diff --git a/src/debug/ConsoleClient/HaContextFactory.cs b/src/debug/ConsoleClient/HaContextFactory.cs deleted file mode 100644 index a3fac217c..000000000 --- a/src/debug/ConsoleClient/HaContextFactory.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using NetDaemon.Client; -using NetDaemon.Client.Settings; -using NetDaemon.HassModel; - -public static class HaContextFactory -{ - public static async Task CreateHaContextAsync() - { - var connection = await CreateConnection(); - return await CreateHaContextAsync(connection); - } - - public static async Task CreateHaContextAsync(IHomeAssistantConnection connection) - { - var collection = new ServiceCollection(); - collection.AddLogging(); - collection.AddSingleton(connection); - collection.AddScopedHaContext(); - var serviceProvider = collection.BuildServiceProvider().CreateScope().ServiceProvider; - - var cacheManager = serviceProvider.GetRequiredService(); - await cacheManager.InitializeAsync(connection, CancellationToken.None); - - return serviceProvider.GetRequiredService(); - } - - private static async Task CreateConnection() - { - var config = GetConfigurationRoot([]); - - var homeassistantSettings = new HomeAssistantSettings(); - - config.GetSection("HomeAssistant").Bind(homeassistantSettings); - - var connection = await HomeAssistantClientConnector.ConnectClientAsync( - host: homeassistantSettings.Host, homeassistantSettings.Port, homeassistantSettings.Ssl, homeassistantSettings.Token, - "api/websocket", - CancellationToken.None); - return connection; - } - - private static IConfigurationRoot GetConfigurationRoot(string[] args) - { - var env = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); - var builder = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: true) - .AddJsonFile($"appsettings.{env}.json", optional: true) - .AddUserSecrets(typeof(HaContextFactory).Assembly, optional: true) - - // finally override with Environment vars or commandline - .AddEnvironmentVariables() - .AddCommandLine(args, new Dictionary - { - ["-host"] = "HomeAssistant:Host", - ["-port"] = "HomeAssistant:Port", - ["-ssl"] = "HomeAssistant:Ssl", - ["-token"] = "HomeAssistant:Token", - ["-bypass-cert"] = "HomeAssistant:InsecureBypassCertificateErrors", - }); - - return builder.Build(); - } -} diff --git a/src/debug/ConsoleClient/Program.cs b/src/debug/ConsoleClient/Program.cs index e205a88d8..279832ada 100644 --- a/src/debug/ConsoleClient/Program.cs +++ b/src/debug/ConsoleClient/Program.cs @@ -1,7 +1,6 @@ -var haContext = await HaContextFactory.CreateHaContextAsync(); +//#:package NetDaemon.HassModel@25.18.1 -Console.WriteLine(haContext.Entity("sun.sun").State); +var ha = await NetDaemon.HassModel.HaContextFactory.CreateAsync("ws://localhost:8123/api/websocket", "your_token_here"); -haContext.Entity("input_button.test_button").StateAllChanges().Subscribe(s => Console.WriteLine($"Pressed {s.New?.State}")); - -await new StreamReader(Console.OpenStandardInput()).ReadLineAsync(); +ha.Entity("input_boolean.dummy_switch").CallService("toggle"); +Console.WriteLine("done"); From 0420fb98ce9a43da9789d890c75d661d67f50fb2 Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Sat, 26 Jul 2025 23:37:48 +0200 Subject: [PATCH 6/9] Fix existing tests --- ...eaCachTests.cs => EntityAreaCacheTests.cs} | 82 ++++++++----------- .../Internal/EntityStateCacheTest.cs | 14 +--- .../Registry/RegistryNavigationTest.cs | 7 +- .../Internal/NetDaemonRuntimeTests.cs | 4 +- src/debug/ConsoleClient/ConsoleClient.csproj | 3 - 5 files changed, 44 insertions(+), 66 deletions(-) rename src/HassModel/NetDaemon.HassModel.Tests/Internal/{EntityAreaCachTests.cs => EntityAreaCacheTests.cs} (76%) 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/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/debug/ConsoleClient/ConsoleClient.csproj b/src/debug/ConsoleClient/ConsoleClient.csproj index 21f73ca35..714ce4e58 100644 --- a/src/debug/ConsoleClient/ConsoleClient.csproj +++ b/src/debug/ConsoleClient/ConsoleClient.csproj @@ -18,9 +18,6 @@ PreserveNewest - - PreserveNewest - From bafeda483ae585fa612849f3f2300289ea8c8e4b Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Sat, 26 Jul 2025 23:39:39 +0200 Subject: [PATCH 7/9] Add integration test for HaContextFactory --- .../HaContextFactoryTest.cs | 38 +++++++++++++++++++ .../Helpers/NetDaemonIntegrationBase.cs | 3 +- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs diff --git a/tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs b/tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs new file mode 100644 index 000000000..52a5be3e0 --- /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.Timeout(TimeSpan.FromSeconds(2)); // make the test fail if it takes too long + + 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) => From 98f44e720da99936d78259ed634feded493f2285 Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Sun, 27 Jul 2025 00:32:23 +0200 Subject: [PATCH 8/9] remove timeout --- .../NetDaemon.Tests.Integration/HaContextFactoryTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs b/tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs index 52a5be3e0..84cc79804 100644 --- a/tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs +++ b/tests/Integration/NetDaemon.Tests.Integration/HaContextFactoryTest.cs @@ -25,7 +25,7 @@ public async Task CreateAsync() inputText.CallService("set_value", new { value = testValue }); // Assert - var stateChange = await nextStateChange.Timeout(TimeSpan.FromSeconds(2)); // make the test fail if it takes too long + 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"); From 4f0812f197391b4919c88858d475afc895184a89 Mon Sep 17 00:00:00 2001 From: Frank Bakker Date: Mon, 11 Aug 2025 21:42:13 +0200 Subject: [PATCH 9/9] Refactor to use IHomeAssistantConnectionProvider.cs iso injecting connection directly --- .../Extensions/ServiceCollectionExtension.cs | 6 ++++++ .../Common/HomeAssistantClientConnector.cs | 1 - .../Common/IHomeAssistantConnectionProvider.cs | 12 ++++++++++++ .../Common/IHomeAssistantRunner.cs | 4 ++-- .../CodeGenerator/EntityFactoryGeneratorTest.cs | 6 +++--- .../Internal/AppScopedHaContextProviderTest.cs | 6 +++--- .../NetDaemon.HassModel/HaContextFactory.cs | 10 ++++++++++ .../NetDaemon.HassModel/ICacheManager.cs | 3 +-- .../Internal/AppScopedHaContextProvider.cs | 16 +++++++++------- .../Internal/BackgroundTaskTracker.cs | 7 +++---- .../Internal/EntityStateCache.cs | 4 ++-- .../Helpers/HomeAssistantRunnerMock.cs | 3 ++- .../Integration/TestRuntime.cs | 7 +++++++ src/debug/ConsoleClient/ConsoleClient.csproj | 9 --------- src/debug/ConsoleClient/Program.cs | 2 +- 15 files changed, 61 insertions(+), 35 deletions(-) create mode 100644 src/Client/NetDaemon.HassClient/Common/IHomeAssistantConnectionProvider.cs 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 4679d49b1..8e0c41ce3 100644 --- a/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs +++ b/src/Client/NetDaemon.HassClient/Common/HomeAssistantClientConnector.cs @@ -60,7 +60,6 @@ public static async Task ConnectClientAsync(string web return await ConnectClientAsync(new Uri(websocketUrl), token, cancelToken); } - /// /// Connect to Home Assistant /// 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/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 8935a61c7..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(); diff --git a/src/HassModel/NetDaemon.HassModel/HaContextFactory.cs b/src/HassModel/NetDaemon.HassModel/HaContextFactory.cs index 0acf6888e..5bb05f84d 100644 --- a/src/HassModel/NetDaemon.HassModel/HaContextFactory.cs +++ b/src/HassModel/NetDaemon.HassModel/HaContextFactory.cs @@ -19,6 +19,8 @@ public static async Task CreateAsync(string homeAssistantWebsocketUr 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; @@ -27,4 +29,12 @@ public static async Task CreateAsync(string homeAssistantWebsocketUr 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 65c1835b7..2cf86ac83 100644 --- a/src/HassModel/NetDaemon.HassModel/ICacheManager.cs +++ b/src/HassModel/NetDaemon.HassModel/ICacheManager.cs @@ -6,8 +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(IHomeAssistantConnection homeAssistantConnection, CancellationToken cancellationToken); } diff --git a/src/HassModel/NetDaemon.HassModel/Internal/AppScopedHaContextProvider.cs b/src/HassModel/NetDaemon.HassModel/Internal/AppScopedHaContextProvider.cs index 99ef33a29..38de1c2ac 100644 --- a/src/HassModel/NetDaemon.HassModel/Internal/AppScopedHaContextProvider.cs +++ b/src/HassModel/NetDaemon.HassModel/Internal/AppScopedHaContextProvider.cs @@ -12,8 +12,8 @@ internal class AppScopedHaContextProvider : IHaContext, IAsyncDisposable private volatile bool _isDisposed; private volatile bool _isDisposing; private readonly EntityStateCache _entityStateCache; - private readonly IHomeAssistantConnection _homeAssistantConnection; + private readonly IHomeAssistantConnectionProvider _connectionProvider; private readonly QueuedObservable _queuedObservable; private readonly IBackgroundTaskTracker _backgroundTaskTracker; private readonly IEntityFactory _entityFactory; @@ -22,14 +22,14 @@ internal class AppScopedHaContextProvider : IHaContext, IAsyncDisposable public AppScopedHaContextProvider( EntityStateCache entityStateCache, - IHomeAssistantConnection homeAssistantConnection, + IHomeAssistantConnectionProvider connectionProvider, IBackgroundTaskTracker backgroundTaskTracker, IServiceProvider serviceProvider, ILogger logger, IEntityFactory entityFactory) { _entityStateCache = entityStateCache; - _homeAssistantConnection = homeAssistantConnection; + _connectionProvider = connectionProvider; // Create QueuedObservable for this app // This makes sure we will unsubscribe when this ContextProvider is Disposed @@ -42,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; } @@ -70,15 +73,14 @@ public void CallService(string domain, string service, ServiceTarget? target = n { ObjectDisposedException.ThrowIf(_isDisposed, this); - _backgroundTaskTracker.TrackBackgroundTask(_homeAssistantConnection.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); - _ = _homeAssistantConnection ?? throw new InvalidOperationException("No connection to Home Assistant"); - var result = await _homeAssistantConnection + var result = await CurrentConnection .CallServiceWithResponseAsync(domain, service, data, target?.Map(), _tokenSource.Token) .ConfigureAwait(false); return result?.Response; @@ -97,7 +99,7 @@ public IObservable StateAllChanges() public void SendEvent(string eventType, object? data = null) { ObjectDisposedException.ThrowIf(_isDisposed, this); - _backgroundTaskTracker.TrackBackgroundTask(_homeAssistantConnection?.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/EntityStateCache.cs b/src/HassModel/NetDaemon.HassModel/Internal/EntityStateCache.cs index 2f48a4ced..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() : IDisposable +internal class EntityStateCache : IDisposable { private IDisposable? _eventSubscription; private readonly Subject _eventSubject = new(); @@ -18,7 +18,7 @@ internal class EntityStateCache() : IDisposable public async Task InitializeAsync(IHomeAssistantConnection homeAssistantConnection, CancellationToken cancellationToken) { - var events = await homeAssistantConnection!.SubscribeToHomeAssistantEventsAsync(null, cancellationToken).ConfigureAwait(false); + var events = await homeAssistantConnection.SubscribeToHomeAssistantEventsAsync(null, cancellationToken).ConfigureAwait(false); _eventSubscription = events.Subscribe(HandleEvent); var hassStates = await homeAssistantConnection.GetStatesAsync(cancellationToken).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/debug/ConsoleClient/ConsoleClient.csproj b/src/debug/ConsoleClient/ConsoleClient.csproj index 714ce4e58..ca6f315e8 100644 --- a/src/debug/ConsoleClient/ConsoleClient.csproj +++ b/src/debug/ConsoleClient/ConsoleClient.csproj @@ -1,26 +1,17 @@  - Exe enable enable - aefef09e-f5ac-4a3c-b8e3-04fb9f64768a - - PreserveNewest - - - - - diff --git a/src/debug/ConsoleClient/Program.cs b/src/debug/ConsoleClient/Program.cs index 279832ada..5baa206da 100644 --- a/src/debug/ConsoleClient/Program.cs +++ b/src/debug/ConsoleClient/Program.cs @@ -1,6 +1,6 @@ //#:package NetDaemon.HassModel@25.18.1 -var ha = await NetDaemon.HassModel.HaContextFactory.CreateAsync("ws://localhost:8123/api/websocket", "your_token_here"); +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");