diff --git a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassArea.cs b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassArea.cs index 2c06dae94..ae80633c7 100644 --- a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassArea.cs +++ b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassArea.cs @@ -3,6 +3,11 @@ public record HassArea { [JsonPropertyName("name")] public string? Name { get; init; } - [JsonPropertyName("area_id")] public string? Id { get; init; } -} \ No newline at end of file + + [JsonPropertyName("labels")] public IReadOnlyList Labels { get; init; } = Array.Empty(); + + [JsonPropertyName("floor_id")] public string? FloorId { get; init; } + + [JsonPropertyName("icon")] public string? Icon { get; init; } +} diff --git a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassDevice.cs b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassDevice.cs index b3e74f826..65926f7b3 100644 --- a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassDevice.cs +++ b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassDevice.cs @@ -16,4 +16,7 @@ public record HassDevice [JsonPropertyName("name")] public string? Name { get; init; } [JsonPropertyName("name_by_user")] public string? NameByUser { get; init; } -} \ No newline at end of file + + [JsonPropertyName("labels")] public IReadOnlyList Labels { get; init; } = Array.Empty(); + +} diff --git a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassEntity.cs b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassEntity.cs index a27be76c8..29d9b2cee 100644 --- a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassEntity.cs +++ b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassEntity.cs @@ -13,4 +13,6 @@ public record HassEntity [JsonPropertyName("icon")] public string? Icon { get; init; } [JsonPropertyName("platform")] public string? Platform { get; init; } -} \ No newline at end of file + + [JsonPropertyName("labels")] public IReadOnlyList Labels { get; init; } = Array.Empty(); +} diff --git a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassLabel.cs b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassLabel.cs index c7b56bcf3..2cfc8c13a 100644 --- a/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassLabel.cs +++ b/src/Client/NetDaemon.HassClient/Common/HomeAssistant/Model/HassLabel.cs @@ -1,4 +1,4 @@ -namespace NetDaemon.Client.HomeAssistant.Model; +namespace NetDaemon.Client.HomeAssistant.Model; public record HassLabel { diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Entities/EntityTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/Entities/EntityTest.cs index 0b43594f4..a59e7fdde 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/Entities/EntityTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/Entities/EntityTest.cs @@ -94,7 +94,7 @@ public void ShouldWrapAreaFromContext() { // Arrange var haContextMock = new Mock(); - haContextMock.Setup(t => t.GetAreaFromEntityId("domain.testEntity")).Returns(new Area() { Name = "Area Name" }); + haContextMock.Setup(t => t.GetEntityRegistration("domain.testEntity")).Returns(new EntityRegistration(){Area = new Area(Mock.Of()) { Name = "Area Name" }}); // Act var target = new TestEntity(haContextMock.Object, "domain.testEntity"); @@ -102,4 +102,4 @@ public void ShouldWrapAreaFromContext() // Assert Assert.Equal("Area Name", target.Area); } -} \ No newline at end of file +} diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCachTests.cs b/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCachTests.cs index 8967a5a42..caf8d7c7c 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCachTests.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityAreaCachTests.cs @@ -1,5 +1,7 @@ using System.Reactive.Subjects; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using NetDaemon.Client; using NetDaemon.Client.HomeAssistant.Model; using NetDaemon.Client.Internal.HomeAssistant.Commands; @@ -22,7 +24,7 @@ public async Task EntityIdWithArea_Returns_HassArea() _hassConnectionMock.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - + _hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync>( It.IsAny(), It.IsAny() @@ -51,12 +53,12 @@ public async Task EntityIdWithArea_Returns_HassArea() var serviceColletion = new ServiceCollection(); _ = serviceColletion.AddTransient(_ => new Mock>().Object); - using var cache = new EntityAreaCache(haRunnerMock.Object, serviceColletion.BuildServiceProvider()); + using var cache = new RegistryCache(haRunnerMock.Object, NullLogger.Instance); // Act await cache.InitializeAsync(CancellationToken.None); // Assert - var area = cache.GetArea("sensor.sensor1"); + var area = cache.GetAreaById(cache.GetHassEntityById("sensor.sensor1")!.AreaId); Assert.NotNull(area); Assert.Equal("Area Name", area!.Name); } @@ -74,7 +76,7 @@ public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea() _hassConnectionMock.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - + _hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync>( It.IsAny(), It.IsAny() @@ -107,13 +109,13 @@ public async Task EntityIdWithOutArea_ButDeviceArea_Returns_HassArea() var serviceColletion = new ServiceCollection(); _ = serviceColletion.AddTransient(_ => new Mock>().Object); - using var cache = new EntityAreaCache(haRunnerMock.Object, serviceColletion.BuildServiceProvider()); + using var cache = new RegistryCache(haRunnerMock.Object, Mock.Of>()); // Act await cache.InitializeAsync(CancellationToken.None); // Assert - var area = cache.GetArea("sensor.sensor1"); + var area = cache.GetAreaById(cache.GetHassEntityById("sensor.sensor1")!.AreaId); Assert.NotNull(area); Assert.Equal("Area Name", area!.Name); } @@ -131,7 +133,7 @@ public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea() _hassConnectionMock.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - + _hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync>( It.IsAny(), It.IsAny() @@ -164,13 +166,13 @@ public async Task EntityIdWithArea_AndDeviceArea_Returns_EntityHassArea() var serviceColletion = new ServiceCollection(); _ = serviceColletion.AddTransient(_ => new Mock>().Object); - using var cache = new EntityAreaCache(haRunnerMock.Object, serviceColletion.BuildServiceProvider()); + using var cache = new RegistryCache(haRunnerMock.Object, Mock.Of>()); // Act await cache.InitializeAsync(CancellationToken.None); // Assert - var area = cache.GetArea("sensor.sensor1"); + var area = cache.GetAreaById(cache.GetHassEntityById("sensor.sensor1")!.AreaId); Assert.NotNull(area); Assert.Equal("Area2 Name", area!.Name); } @@ -193,7 +195,7 @@ public async Task EntityArea_Updates() _hassConnectionMock.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(testSubject); - + _hassConnectionMock.Setup( m => m.SendCommandAndReturnResponseAsync> ( @@ -219,7 +221,7 @@ public async Task EntityArea_Updates() _ = serviceColletion.AddTransient>(_ => testSubject); var sp = serviceColletion.BuildServiceProvider(); - using var cache = new EntityAreaCache(haRunnerMock.Object, serviceColletion.BuildServiceProvider()); + using var cache = new RegistryCache(haRunnerMock.Object, Mock.Of>()); // Act 1: Init await cache.InitializeAsync(CancellationToken.None); @@ -236,11 +238,11 @@ public async Task EntityArea_Updates() }); // Act 3: now fire a area registry update - testSubject.OnNext(new HassEvent { EventType = "area_registry_updated" }); + testSubject.OnNext(new HassEvent { EventType = "entity_registry_updated" }); // Assert - var area = cache.GetArea("sensor.sensor1"); + var area = cache.GetAreaById(cache.GetHassEntityById("sensor.sensor1")!.AreaId); Assert.NotNull(area); Assert.Equal("Area2 Name", area!.Name); } -} \ No newline at end of file +} diff --git a/src/HassModel/NetDaemon.HassModel.Tests/Registry/RegistryNavigationTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/Registry/RegistryNavigationTest.cs new file mode 100644 index 000000000..07d82a666 --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.Tests/Registry/RegistryNavigationTest.cs @@ -0,0 +1,145 @@ +using System.Reactive.Subjects; +using Microsoft.Extensions.Logging.Abstractions; +using NetDaemon.Client; +using NetDaemon.Client.HomeAssistant.Model; +using NetDaemon.Client.Internal.HomeAssistant.Commands; +using NetDaemon.HassModel.Internal; + +namespace NetDaemon.HassModel.Tests.Registry; + +public class RegistryNavigationTest +{ + private readonly Mock _connectionMock; + + public RegistryNavigationTest() + { + _connectionMock = new Mock(); + _connectionMock.Setup(m => m.SubscribeToHomeAssistantEventsAsync(null, It.IsAny())).ReturnsAsync(new Subject()); + } + + 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 haContextMock = new Mock(); + return new HaRegistry(haContextMock.Object, cache); + } + + [Fact] + public async Task TestNavigateModel() + { + SetupCommandResult("config/entity_registry/list", + [ + new HassEntity { EntityId = "light.mb_nightlight", AreaId = "master_bedroom"}, + new HassEntity { EntityId = "light.babyroom_nightlight", AreaId = "baby_room", Labels = ["stay_on"] }, + new HassEntity { EntityId = "sensor.babyroom_humidity", DeviceId = "24:AB:1B:9A"}, + ]); + + SetupCommandResult("config/device_registry/list", + [ + new HassDevice { Id = "24:AB:1B:9A", Name = "SomeSensor" , AreaId = "baby_room"} + ]); + + SetupCommandResult("config/area_registry/list", + [ + new HassArea { Name = "Master Bedroom", Id = "master_bedroom", FloorId = "upstairs", Labels = ["bedroom"] }, + new HassArea { Name = "Baby room", Id = "baby_room", FloorId = "upstairs", Labels = ["bedroom"] }, + + new HassArea { Name = "Study", Id = "study", FloorId = "attic" }, + new HassArea { Name = "Storage", Id = "storage", FloorId = "attic" }, + ]); + + SetupCommandResult("config/floor_registry/list", + [ + new HassFloor { Id = "downstairs", Name = "DownStairs", Level = 0 }, + new HassFloor { Id = "upstairs", Name = "Upstairs", Level = 1 }, + new HassFloor { Id = "attic", Name = "Attic", Level = 2 }, + ] + ); + SetupCommandResult("config/label_registry/list", + [ + new HassLabel { Id = "bedroom", Name = "Bedroom", Description = "Areas that serve as bedrooms" }, + new HassLabel { Id = "stay_on", Name = "Stay On", Description = "Lights that should stay on at night" }, + ]); + + + // Act: + var registry = await InitializeCacheAndBuildRegistry(); + + // Assert, navigate the model + + registry.Entities.Should().BeEquivalentTo( + [ + new { Id = "light.mb_nightlight" }, + new { Id = "light.babyroom_nightlight" }, + new { Id = "sensor.babyroom_humidity" }, + ]); + + registry.Devices.Should().BeEquivalentTo( + [ + new { Name = "SomeSensor" }, + ]); + + registry.Areas.Should().BeEquivalentTo( + [ + new { Name = "Master Bedroom" }, + new { Name = "Baby room" }, + new { Name = "Study" }, + new { Name = "Storage" }, + ]); + + registry.Floors.Should().BeEquivalentTo( + [ + new { Name = "DownStairs" }, + new { Name = "Upstairs" }, + new { Name = "Attic" }, + ]); + + registry.Labels.Should().BeEquivalentTo( + [ + new { Name = "Bedroom" }, + new { Name = "Stay On" }, + ]); + + registry.GetFloor("attic")!.Areas.Should().BeEquivalentTo([ + new { Name = "Study" }, + new { Name = "Storage" }, + ]); + + registry.GetLabel("stay_on")!.Entities.Should().BeEquivalentTo( + [ + new { EntityId = "light.babyroom_nightlight" } + ]); + + + registry.GetLabel("stay_on")!.Entities.Should().Contain(e => e.EntityId == "light.babyroom_nightlight"); + + registry.GetEntityRegistration("light.mb_nightlight")!.Area!.Name.Should().Be("Master Bedroom"); + + registry.GetDevice("24:AB:1B:9A")!.Entities.Should().BeEquivalentTo( + [ + new { EntityId = "sensor.babyroom_humidity" } + ]); + + var area = registry.GetArea("baby_room")!; + + area.Devices.Should().BeEquivalentTo([ + new { Name = "SomeSensor" } + ]); + area.Entities.Should().BeEquivalentTo([ + new { EntityId = "light.babyroom_nightlight" }, + new { EntityId = "sensor.babyroom_humidity" }, + ]); + + } + + private void SetupCommandResult(string command, IReadOnlyCollection result) + { + _connectionMock.Setup(m => m.SendCommandAndReturnResponseAsync>( + new SimpleCommand(command), It.IsAny())).ReturnsAsync(result); + } +} diff --git a/src/HassModel/NetDeamon.HassModel/DependencyInjectionSetup.cs b/src/HassModel/NetDeamon.HassModel/DependencyInjectionSetup.cs index 216505ab9..158fbb2b3 100644 --- a/src/HassModel/NetDeamon.HassModel/DependencyInjectionSetup.cs +++ b/src/HassModel/NetDeamon.HassModel/DependencyInjectionSetup.cs @@ -9,7 +9,7 @@ namespace NetDaemon.HassModel; public static class DependencyInjectionSetup { /// - /// Registers services for using the IHaContext interface scoped to NetDeamonApps + /// Registers services for using the IHaContext interface scoped to NetDaemonApps /// public static IHostBuilder UseAppScopedHaContext(this IHostBuilder hostBuilder) { @@ -21,13 +21,17 @@ public static IHostBuilder UseAppScopedHaContext(this IHostBuilder hostBuilder) } /// - /// Registers services for using the IHaContext interface scoped to NetDeamonApps + /// Registers services for using the IHaContext interface scoped to NetDaemonApps /// public static void AddScopedHaContext(this IServiceCollection services) { services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService().Registry); + services.AddScoped(sp => sp.GetRequiredService().Registry); services.AddScoped(); services.AddScoped(s => s.GetRequiredService()); services.AddTransient(); diff --git a/src/HassModel/NetDeamon.HassModel/Entities/Area.cs b/src/HassModel/NetDeamon.HassModel/Entities/Area.cs deleted file mode 100644 index d08f25900..000000000 --- a/src/HassModel/NetDeamon.HassModel/Entities/Area.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace NetDaemon.HassModel.Entities; - -/// -/// Area detail class -/// -public record Area -{ - /// - /// The area's name - /// - public string? Name { get; init; } -} \ No newline at end of file diff --git a/src/HassModel/NetDeamon.HassModel/Entities/Entity.cs b/src/HassModel/NetDeamon.HassModel/Entities/Entity.cs index 0eb40c8e0..d46bc5625 100644 --- a/src/HassModel/NetDeamon.HassModel/Entities/Entity.cs +++ b/src/HassModel/NetDeamon.HassModel/Entities/Entity.cs @@ -31,11 +31,11 @@ public Entity(IEntityCore entity) HaContext = entity.HaContext; EntityId = entity.EntityId; } - + /// /// Area name of entity /// - public string? Area => HaContext.GetAreaFromEntityId(EntityId)?.Name; + public string? Area => Registration?.Area?.Name; /// The current state of this Entity public string? State => EntityState?.State; @@ -57,7 +57,7 @@ public Entity(IEntityCore entity) /// /// /// bedroomLight.StateAllChanges() - /// .Where(s => s.Old?.Attributes?.Brightness < 128 + /// .Where(s => s.Old?.Attributes?.Brightness < 128 /// && s.New?.Attributes?.Brightness >= 128) /// .Subscribe(e => HandleBrightnessOverHalf()); /// @@ -88,6 +88,11 @@ public virtual void CallService(string service, object? data = null) { EntityExtensions.CallService(this, service, data); } + + /// + /// Data from the HA Entity Registry, Like Device, Area and Labels regarding this Entity + /// + public EntityRegistration? Registration => HaContext.GetEntityRegistration(EntityId); } /// Represents a Home Assistant entity with its state, changes and services @@ -112,8 +117,8 @@ protected Entity(IHaContext haContext, string entityId) : base(haContext, entity /// public override IObservable> StateAllChanges() => - base.StateAllChanges().Select(e => new StateChange((TEntity)this, - Entities.EntityState.Map(e.Old), + base.StateAllChanges().Select(e => new StateChange((TEntity)this, + Entities.EntityState.Map(e.Old), Entities.EntityState.Map(e.New))); /// @@ -121,16 +126,16 @@ protected Entity(IHaContext haContext, string entityId) : base(haContext, entity private static TEntityState? MapState(EntityState? state) => Entities.EntityState.Map(state); } - + /// Represents a Home Assistant entity with its state, changes and services public record Entity : Entity, EntityState, TAttributes> where TAttributes : class { // This type is needed because the base type has a recursive type parameter so it can not be used as a return value - + /// Copy constructor from IEntityCore public Entity(IEntityCore entity) : base(entity) { } - + /// Constructor from haContext and entityId public Entity(IHaContext haContext, string entityId) : base(haContext, entityId) { } -} \ No newline at end of file +} diff --git a/src/HassModel/NetDeamon.HassModel/HaRegistry.cs b/src/HassModel/NetDeamon.HassModel/HaRegistry.cs new file mode 100644 index 000000000..bf309a623 --- /dev/null +++ b/src/HassModel/NetDeamon.HassModel/HaRegistry.cs @@ -0,0 +1,26 @@ +namespace NetDaemon.HassModel; + +internal class HaRegistry(IHaContext haContext, RegistryCache registryCache) : IHaRegistryNavigator, IHaRegistry +{ + // IHaRegistry Methods + public IReadOnlyCollection Entities => registryCache.GetEntities().Select(e => e.Map(this)).ToList(); + public IReadOnlyCollection Devices => registryCache.GetDevices().Select(d => d.Map(this)).ToList(); + public IReadOnlyCollection Areas => registryCache.GetAreas().Select(a => a.Map(this)).ToList(); + public IReadOnlyCollection Floors => registryCache.GetFloors().Select(f => f.Map(this)).ToList(); + public IReadOnlyCollection