Skip to content

Commit

Permalink
Lazy deserialization of StateChanges (#1146)
Browse files Browse the repository at this point in the history
  • Loading branch information
FrankBakkerNl authored Jul 18, 2024
1 parent 2965cd4 commit f0480a1
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 131 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using NetDaemon.Client.Internal.HomeAssistant.Commands;
using NetDaemon.HassModel.Tests.TestHelpers;
using Microsoft.Extensions.DependencyInjection;
using NetDaemon.Client.Internal.Extensions;
using NetDaemon.HassModel.Internal;

namespace NetDaemon.HassModel.Tests.Internal;
Expand All @@ -17,36 +18,26 @@ public async Task StateChangeEventIsFirstStoredInCacheThanForwarded()

// 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(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
))
.ReturnsAsync(new List<HassState>
{
new() {EntityId = entityId, State = "InitialState"}
});

_hassConnectionMock.Setup(n =>
n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(testSubject
);
hassConnectionMock
.Setup(m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
(It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new HassState {EntityId = entityId, State = "InitialState"}]);

var serviceColletion = new ServiceCollection();
_ = serviceColletion.AddTransient<IObservable<HassEvent>>(_ => testSubject);
var sp = serviceColletion.BuildServiceProvider();
hassConnectionMock
.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(testSubject);

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

var eventObserverMock = new Mock<IObserver<HassEvent>>();
cache.AllEvents.Subscribe(eventObserverMock.Object);

// ACT 1: after initialization of the cache it should show the values retieved from Hass
// ACT 1: after initialization of the cache it should show the values retrieved from Hass
await cache.InitializeAsync(CancellationToken.None);

cache.GetState(entityId)!.State.Should().Be("InitialState", "The initial value should be available");
Expand All @@ -66,7 +57,7 @@ public async Task StateChangeEventIsFirstStoredInCacheThanForwarded()
.Callback(() =>
{
#pragma warning disable 8602
cache.GetState(entityId).State.Should().Be("newState");
cache.GetState(entityId).State.Should().Be("newState", because: "The cache should already have the new value when the event handler runs");
#pragma warning restore 8602
});

Expand All @@ -87,57 +78,70 @@ public async Task AllEntityIds_returnsInitialPlusChangedEntities()
{
// 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);

_hassConnectionMock.Setup(
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
(
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
))
.ReturnsAsync(new List<HassState>
{
new() {EntityId = "sensor.sensor1", State = "InitialState"}
});
haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object);

var serviceColletion = new ServiceCollection();
hassConnectionMock
.Setup(m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
(It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new()
{
EntityId = "sensor.sensor1",
State = "InitialState",
AttributesJson = new { brightness = 100 }.ToJsonElement(),
}]);

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

var sp = serviceColletion.BuildServiceProvider();
.ReturnsAsync(testSubject);

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

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);

// Act 2: now fire a state change event
var changedEventData = new HassStateChangedEventData
// initial value for sensor.sensor1 shoul be visible right away
cache.GetState("sensor.sensor1")!.AttributesJson.GetValueOrDefault().GetProperty("brightness").GetInt32().Should().Be(100);

// Act 2: now fire 2 state change events
testSubject.OnNext(new HassEvent
{
EntityId = "sensor.sensor2",
OldState = new HassState(),
NewState = new HassState
EventType = "state_changed",
DataElement = new HassStateChangedEventData
{
State = "newState"
}
};
EntityId = "sensor.sensor1",
OldState = new HassState(),
NewState = new HassState
{
State = "newState",
AttributesJson = new {brightness = 200}.ToJsonElement()
}
}.AsJsonElement()
});

// Act
testSubject.OnNext(new HassEvent
{
EventType = "state_changed",
DataElement = changedEventData.AsJsonElement()
DataElement = new HassStateChangedEventData
{
EntityId = "sensor.sensor2",
OldState = new HassState(),
NewState = new HassState
{
State = "newState",
AttributesJson = new {brightness = 300}.ToJsonElement()
}
}.AsJsonElement()
});

// Assert
cache.AllEntityIds.Should().BeEquivalentTo("sensor.sensor1", "sensor.sensor2");
cache.GetState("sensor.sensor1")!.AttributesJson.GetValueOrDefault().GetProperty("brightness").GetInt32().Should().Be(200);
cache.GetState("sensor.sensor2")!.AttributesJson.GetValueOrDefault().GetProperty("brightness").GetInt32().Should().Be(300);
}
}
35 changes: 18 additions & 17 deletions src/HassModel/NetDeamon.HassModel/Entities/EntityState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,38 @@
public record EntityState
{
/// <summary>Unique id of the entity</summary>
public string EntityId { get; init; } = "";
[JsonPropertyName("entity_id")] public string EntityId { get; init; } = "";

/// <summary>The state </summary>
public string? State { get; init; }
[JsonPropertyName("state")] public string? State { get; init; }

/// <summary>The attributes as a JsonElement</summary>
public JsonElement? AttributesJson { get; init; }
[JsonPropertyName("attributes")] public JsonElement? AttributesJson { get; init; }

/// <summary>
/// The attributes
/// </summary>
public virtual object? Attributes => AttributesJson?.Deserialize<Dictionary<string, object>>() ?? new Dictionary<string, object>();

/// <summary>Last changed, when state changed from and to different values</summary>
public DateTime? LastChanged { get; init; }
[JsonPropertyName("last_changed")] public DateTime? LastChanged { get; init; }

/// <summary>Last updated, when entity state or attributes changed </summary>
public DateTime? LastUpdated { get; init; }
[JsonPropertyName("last_updated")] public DateTime? LastUpdated { get; init; }

/// <summary>Context</summary>
public Context? Context { get; init; }
[JsonPropertyName("context")] public Context? Context { get; init; }

internal static TEntityState? Map<TEntityState>(EntityState? state)
where TEntityState : class =>
state == null ? null : (TEntityState)Activator.CreateInstance(typeof(TEntityState), state)!; }

where TEntityState : class =>
state == null ? null : (TEntityState)Activator.CreateInstance(typeof(TEntityState), state)!;
}

/// <summary>
/// Generic EntityState with specific types of State and Attributes
/// </summary>
/// <typeparam name="TAttributes">The type of the Attributes Property</typeparam>
public record EntityState<TAttributes> : EntityState
public record EntityState<TAttributes> : EntityState
where TAttributes : class
{
private readonly Lazy<TAttributes?> _attributesLazy;
Expand All @@ -47,7 +48,7 @@ public record EntityState<TAttributes> : EntityState
/// <param name="source"></param>
public EntityState(EntityState source) : base(source)
{
_attributesLazy = new (() => AttributesJson?.Deserialize<TAttributes>() ?? default);
_attributesLazy = new (() => AttributesJson?.Deserialize<TAttributes>() ?? default);
}

/// <inheritdoc/>
Expand Down
34 changes: 26 additions & 8 deletions src/HassModel/NetDeamon.HassModel/Entities/StateChange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@
/// </summary>
public record StateChange
{
private readonly JsonElement _jsonElement;
private readonly IHaContext _haContext;
private Entity? _entity;
private EntityState? _old;
private EntityState? _new;

/// <summary>
/// Creates a StateChange from a jsonElement and lazy load the states
/// </summary>
/// <param name="jsonElement"></param>
/// <param name="haContext"></param>
internal StateChange(JsonElement jsonElement, IHaContext haContext)
{
_jsonElement = jsonElement;
_haContext = haContext;
}

/// <summary>
/// This should not be used under normal circumstances but can be used for unit testing of apps
/// </summary>
Expand All @@ -13,23 +30,24 @@ public record StateChange
/// <param name="new"></param>
public StateChange(Entity entity, EntityState? old, EntityState? @new)
{
Entity = entity;
New = @new;
Old = old;
_entity = entity;
_new = @new;
_old = old;
_haContext = null!; // haContext is not used when _entity is already initialized
}

/// <summary>The Entity that changed</summary>
public virtual Entity Entity { get; } = default!; // Somehow this is needed to avoid a warning about this field being initialized
public virtual Entity Entity => _entity ??= new Entity(_haContext, _jsonElement.GetProperty("entity_id").GetString() ?? throw new InvalidOperationException("No Entity_id in state_change event"));

/// <summary>The old state of the entity</summary>
public virtual EntityState? Old { get; }
public virtual EntityState? Old => _old ??= _jsonElement.GetProperty("old_state").Deserialize<EntityState>();

/// <summary>The new state of the entity</summary>
public virtual EntityState? New { get; }
public virtual EntityState? New => _new ??= _jsonElement.GetProperty("new_state").Deserialize<EntityState>();
}

/// <summary>
/// Represents a state change event for a strong typed entity and state
/// Represents a state change event for a strong typed entity and state
/// </summary>
/// <typeparam name="TEntity">The Type</typeparam>
/// <typeparam name="TEntityState"></typeparam>
Expand All @@ -55,4 +73,4 @@ public StateChange(TEntity entity, TEntityState? old, TEntityState? @new) : base

/// <inheritdoc/>
public override TEntityState? Old => (TEntityState?)base.Old;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public AppScopedHaContextProvider(

public EntityState? GetState(string entityId)
{
return _entityStateCache.GetState(entityId).Map();
return _entityStateCache.GetState(entityId);
}

[Obsolete("Use Registry to navigate Entities, Devices and Areas")]
Expand Down Expand Up @@ -86,10 +86,9 @@ public void CallService(string domain, string service, ServiceTarget? target = n

public IObservable<StateChange> StateAllChanges()
{
return _queuedObservable.Where(n =>
n.EventType == "state_changed")
.Select(n => n.ToStateChangedEvent()!)
.Select(e => e.Map(this));
return _queuedObservable
.Where(n => n.EventType == "state_changed")
.Select(n => new StateChange(n.DataElement.GetValueOrDefault(), this));
}

public IObservable<Event> Events => _queuedObservable
Expand Down
Loading

0 comments on commit f0480a1

Please sign in to comment.