Skip to content

Minimal NetDaemon #1315

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions NetDaemon.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ public static async Task<IHomeAssistantConnection> ConnectClientAsync(string hos
var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
var loggerConnect = loggerFactory.CreateLogger<IHomeAssistantConnection>();
var loggerClient = loggerFactory.CreateLogger<IHomeAssistantClient>();
var loggerResultMessageHandler = loggerFactory.CreateLogger<ResultMessageHandler>();
var settings = new HomeAssistantSettings
{
Host = host,
Expand All @@ -52,4 +51,21 @@ public static async Task<IHomeAssistantConnection> ConnectClientAsync(string hos

return await client.ConnectAsync(host, port, ssl, token, websocketPath, cancelToken).ConfigureAwait(false);
}

/// <summary>
/// Connect to Home Assistant
/// </summary>
public static async Task<IHomeAssistantConnection> ConnectClientAsync(string websocketUrl, string token, CancellationToken cancelToken)
{
return await ConnectClientAsync(new Uri(websocketUrl), token, cancelToken);
}


/// <summary>
/// Connect to Home Assistant
/// </summary>
public static async Task<IHomeAssistantConnection> ConnectClientAsync(Uri websocketUrl, string token, CancellationToken cancelToken)
{
return await ConnectClientAsync(websocketUrl.Host, websocketUrl.Port, websocketUrl.Scheme is "https" or "wss", token, websocketUrl.PathAndQuery, cancelToken);
Copy link
Preview

Copilot AI Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SSL detection logic is incorrect. WebSocket URLs use 'ws' and 'wss' schemes, not 'https'. The condition should only check for 'wss' to determine SSL usage.

Suggested change
return await ConnectClientAsync(websocketUrl.Host, websocketUrl.Port, websocketUrl.Scheme is "https" or "wss", token, websocketUrl.PathAndQuery, cancelToken);
return await ConnectClientAsync(websocketUrl.Host, websocketUrl.Port, websocketUrl.Scheme == "wss", token, websocketUrl.PathAndQuery, cancelToken);

Copilot uses AI. Check for mistakes.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> CheckIfRunning(IHomeAssistantConnection connection, CancellationToken cancelToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ private async Task<ServiceProvider> CreateServiceProvider()

var provider = serviceCollection.BuildServiceProvider();

await provider.GetRequiredService<ICacheManager>().InitializeAsync(CancellationToken.None);
await provider.GetRequiredService<ICacheManager>().InitializeAsync(_hassConnectionMock.Object, CancellationToken.None);

return provider;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HassEvent>();
var _hassConnectionMock = new Mock<IHomeAssistantConnection>();
var haRunnerMock = new Mock<IHomeAssistantRunner>();

haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object);
var hassConnectionMock = new Mock<IHomeAssistantConnection>();

_hassConnectionMock.Setup(n =>
hassConnectionMock.Setup(n =>
n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(testSubject);

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassDevice>>(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
)).ReturnsAsync(Array.Empty<HassDevice>());

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassArea>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
Expand All @@ -40,7 +37,7 @@ public async Task EntityIdWithArea_Returns_HassArea()
new() {Id = "AreaId", Name = "Area Name"}
});

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassEntity>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
Expand All @@ -53,31 +50,28 @@ public async Task EntityIdWithArea_Returns_HassArea()
var serviceColletion = new ServiceCollection();
_ = serviceColletion.AddTransient(_ => new Mock<IObservable<HassEvent>>().Object);

using var cache = new RegistryCache(haRunnerMock.Object, NullLogger<RegistryCache>.Instance);
using var cache = new RegistryCache(NullLogger<RegistryCache>.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]
public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea()
{
// Arrange
using var testSubject = new Subject<HassEvent>();
var _hassConnectionMock = new Mock<IHomeAssistantConnection>();
var haRunnerMock = new Mock<IHomeAssistantRunner>();
var hassConnectionMock = new Mock<IHomeAssistantConnection>();

haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object);

_hassConnectionMock.Setup(n =>
hassConnectionMock.Setup(n =>
n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(testSubject);

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassDevice>>(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
)).ReturnsAsync(
Expand All @@ -86,7 +80,7 @@ public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea()
new() {Id = "DeviceId", AreaId = "AreaId"}
});

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassArea>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
Expand All @@ -96,7 +90,7 @@ public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea()
new() {Id = "AreaId", Name = "Area Name"}
});

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassEntity>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
Expand All @@ -109,32 +103,32 @@ public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea()
var serviceColletion = new ServiceCollection();
_ = serviceColletion.AddTransient(_ => new Mock<IObservable<HassEvent>>().Object);

using var cache = new RegistryCache(haRunnerMock.Object, Mock.Of<ILogger<RegistryCache>>());
using var cache = new RegistryCache(Mock.Of<ILogger<RegistryCache>>());

// 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]
public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea()
{
// Arrange
using var testSubject = new Subject<HassEvent>();
var _hassConnectionMock = new Mock<IHomeAssistantConnection>();
var hassConnectionMock = new Mock<IHomeAssistantConnection>();
var haRunnerMock = new Mock<IHomeAssistantRunner>();

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<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(testSubject);

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassDevice>>(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
)).ReturnsAsync(
Expand All @@ -143,7 +137,7 @@ public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea()
new() {Id = "DeviceId", AreaId = "AreaId"}
});

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassArea>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
Expand All @@ -154,7 +148,7 @@ public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea()
new() {Id = "AreaId2", Name = "Area2 Name"}
});

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassEntity>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
Expand All @@ -166,37 +160,37 @@ public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea()

var serviceColletion = new ServiceCollection();
_ = serviceColletion.AddTransient(_ => new Mock<IObservable<HassEvent>>().Object);
using var cache = new RegistryCache(haRunnerMock.Object, Mock.Of<ILogger<RegistryCache>>());
using var cache = new RegistryCache(Mock.Of<ILogger<RegistryCache>>());

// 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]
public async Task EntityArea_Updates()
{
// Arrange
using var testSubject = new Subject<HassEvent>();
var _hassConnectionMock = new Mock<IHomeAssistantConnection>();
var hassConnectionMock = new Mock<IHomeAssistantConnection>();
var haRunnerMock = new Mock<IHomeAssistantRunner>();

haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object);
haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object);

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassDevice>>(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
)).ReturnsAsync(Array.Empty<HassDevice>());

_hassConnectionMock.Setup(n =>
hassConnectionMock.Setup(n =>
n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(testSubject);

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassArea>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
Expand All @@ -207,7 +201,7 @@ public async Task EntityArea_Updates()
new() {Id = "AreaId2", Name = "Area2 Name"}
});

_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassEntity>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
Expand All @@ -217,17 +211,13 @@ public async Task EntityArea_Updates()
new() {EntityId = "sensor.sensor1", AreaId = "AreaId"}
});

var serviceColletion = new ServiceCollection();
_ = serviceColletion.AddTransient<IObservable<HassEvent>>(_ => testSubject);
var sp = serviceColletion.BuildServiceProvider();

using var cache = new RegistryCache(haRunnerMock.Object, Mock.Of<ILogger<RegistryCache>>());
using var cache = new RegistryCache(Mock.Of<ILogger<RegistryCache>>());

// Act 1: Init
await cache.InitializeAsync(CancellationToken.None);
await cache.InitializeAsync(hassConnectionMock.Object, CancellationToken.None);

// Act/Rearrage
_hassConnectionMock.Setup(
hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassEntity>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ public async Task StateChangeEventIsFirstStoredInCacheThanForwarded()
// Arrange
using var testSubject = new Subject<HassEvent>();
var hassConnectionMock = new Mock<IHomeAssistantConnection>();
var haRunnerMock = new Mock<IHomeAssistantRunner>();

haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object);

hassConnectionMock
.Setup(m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
Expand All @@ -31,13 +28,13 @@ public async Task StateChangeEventIsFirstStoredInCacheThanForwarded()
.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(testSubject);

using var cache = new EntityStateCache(haRunnerMock.Object);
using var cache = new EntityStateCache();

var eventObserverMock = new Mock<IObserver<HassEvent>>();
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");

Expand Down Expand Up @@ -85,9 +82,6 @@ public async Task AllEntityIds_returnsInitialPlusChangedEntities()
// Arrange
using var testSubject = new Subject<HassEvent>();
var hassConnectionMock = new Mock<IHomeAssistantConnection>();
var haRunnerMock = new Mock<IHomeAssistantRunner>();

haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object);

hassConnectionMock
.Setup(m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
Expand All @@ -103,13 +97,13 @@ public async Task AllEntityIds_returnsInitialPlusChangedEntities()
n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(testSubject);

using var cache = new EntityStateCache(haRunnerMock.Object);
using var cache = new EntityStateCache();

var stateChangeObserverMock = new Mock<IObserver<HassEvent>>();
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,8 @@ public RegistryNavigationTest()

private async Task<HaRegistry> InitializeCacheAndBuildRegistry()
{
var runnerMock = new Mock<IHomeAssistantRunner>();
runnerMock.SetupGet(m => m.CurrentConnection).Returns(_connectionMock.Object);

var cache = new RegistryCache(runnerMock.Object, new NullLogger<RegistryCache>());
await cache.InitializeAsync(CancellationToken.None);
var cache = new RegistryCache(new NullLogger<RegistryCache>());
await cache.InitializeAsync(_connectionMock.Object, CancellationToken.None);

var haContextMock = new Mock<IHaContext> { CallBase = true };
return new HaRegistry(haContextMock.Object, cache);
Expand Down
Loading