Skip to content

Commit f0480a1

Browse files
Lazy deserialization of StateChanges (#1146)
1 parent 2965cd4 commit f0480a1

File tree

6 files changed

+148
-131
lines changed

6 files changed

+148
-131
lines changed

src/HassModel/NetDaemon.HassModel.Tests/Internal/EntityStateCacheTest.cs

Lines changed: 55 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using NetDaemon.Client.Internal.HomeAssistant.Commands;
55
using NetDaemon.HassModel.Tests.TestHelpers;
66
using Microsoft.Extensions.DependencyInjection;
7+
using NetDaemon.Client.Internal.Extensions;
78
using NetDaemon.HassModel.Internal;
89

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

1819
// Arrange
1920
using var testSubject = new Subject<HassEvent>();
20-
var _hassConnectionMock = new Mock<IHomeAssistantConnection>();
21+
var hassConnectionMock = new Mock<IHomeAssistantConnection>();
2122
var haRunnerMock = new Mock<IHomeAssistantRunner>();
2223

23-
haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object);
24+
haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object);
2425

25-
_hassConnectionMock.Setup(
26-
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
27-
(
28-
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
29-
))
30-
.ReturnsAsync(new List<HassState>
31-
{
32-
new() {EntityId = entityId, State = "InitialState"}
33-
});
34-
35-
_hassConnectionMock.Setup(n =>
36-
n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
37-
.ReturnsAsync(testSubject
38-
);
26+
hassConnectionMock
27+
.Setup(m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
28+
(It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()))
29+
.ReturnsAsync([new HassState {EntityId = entityId, State = "InitialState"}]);
3930

40-
var serviceColletion = new ServiceCollection();
41-
_ = serviceColletion.AddTransient<IObservable<HassEvent>>(_ => testSubject);
42-
var sp = serviceColletion.BuildServiceProvider();
31+
hassConnectionMock
32+
.Setup(n => n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
33+
.ReturnsAsync(testSubject);
4334

44-
using var cache = new EntityStateCache(haRunnerMock.Object, sp);
35+
using var cache = new EntityStateCache(haRunnerMock.Object);
4536

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

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

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

@@ -87,57 +78,70 @@ public async Task AllEntityIds_returnsInitialPlusChangedEntities()
8778
{
8879
// Arrange
8980
using var testSubject = new Subject<HassEvent>();
90-
var _hassConnectionMock = new Mock<IHomeAssistantConnection>();
81+
var hassConnectionMock = new Mock<IHomeAssistantConnection>();
9182
var haRunnerMock = new Mock<IHomeAssistantRunner>();
9283

93-
haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(_hassConnectionMock.Object);
94-
95-
_hassConnectionMock.Setup(
96-
m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
97-
(
98-
It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()
99-
))
100-
.ReturnsAsync(new List<HassState>
101-
{
102-
new() {EntityId = "sensor.sensor1", State = "InitialState"}
103-
});
84+
haRunnerMock.SetupGet(n => n.CurrentConnection).Returns(hassConnectionMock.Object);
10485

105-
var serviceColletion = new ServiceCollection();
86+
hassConnectionMock
87+
.Setup(m => m.SendCommandAndReturnResponseAsync<SimpleCommand, IReadOnlyCollection<HassState>>
88+
(It.IsAny<SimpleCommand>(), It.IsAny<CancellationToken>()))
89+
.ReturnsAsync([new()
90+
{
91+
EntityId = "sensor.sensor1",
92+
State = "InitialState",
93+
AttributesJson = new { brightness = 100 }.ToJsonElement(),
94+
}]);
10695

107-
_hassConnectionMock.Setup(n =>
96+
hassConnectionMock.Setup(n =>
10897
n.SubscribeToHomeAssistantEventsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
109-
.ReturnsAsync(testSubject
110-
);
111-
112-
var sp = serviceColletion.BuildServiceProvider();
98+
.ReturnsAsync(testSubject);
11399

114-
using var cache = new EntityStateCache(haRunnerMock.Object, sp);
100+
using var cache = new EntityStateCache(haRunnerMock.Object);
115101

116102
var stateChangeObserverMock = new Mock<IObserver<HassEvent>>();
117103
cache.AllEvents.Subscribe(stateChangeObserverMock.Object);
118104

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

122-
// Act 2: now fire a state change event
123-
var changedEventData = new HassStateChangedEventData
108+
// initial value for sensor.sensor1 shoul be visible right away
109+
cache.GetState("sensor.sensor1")!.AttributesJson.GetValueOrDefault().GetProperty("brightness").GetInt32().Should().Be(100);
110+
111+
// Act 2: now fire 2 state change events
112+
testSubject.OnNext(new HassEvent
124113
{
125-
EntityId = "sensor.sensor2",
126-
OldState = new HassState(),
127-
NewState = new HassState
114+
EventType = "state_changed",
115+
DataElement = new HassStateChangedEventData
128116
{
129-
State = "newState"
130-
}
131-
};
117+
EntityId = "sensor.sensor1",
118+
OldState = new HassState(),
119+
NewState = new HassState
120+
{
121+
State = "newState",
122+
AttributesJson = new {brightness = 200}.ToJsonElement()
123+
}
124+
}.AsJsonElement()
125+
});
132126

133-
// Act
134127
testSubject.OnNext(new HassEvent
135128
{
136129
EventType = "state_changed",
137-
DataElement = changedEventData.AsJsonElement()
130+
DataElement = new HassStateChangedEventData
131+
{
132+
EntityId = "sensor.sensor2",
133+
OldState = new HassState(),
134+
NewState = new HassState
135+
{
136+
State = "newState",
137+
AttributesJson = new {brightness = 300}.ToJsonElement()
138+
}
139+
}.AsJsonElement()
138140
});
139141

140142
// Assert
141143
cache.AllEntityIds.Should().BeEquivalentTo("sensor.sensor1", "sensor.sensor2");
144+
cache.GetState("sensor.sensor1")!.AttributesJson.GetValueOrDefault().GetProperty("brightness").GetInt32().Should().Be(200);
145+
cache.GetState("sensor.sensor2")!.AttributesJson.GetValueOrDefault().GetProperty("brightness").GetInt32().Should().Be(300);
142146
}
143147
}

src/HassModel/NetDeamon.HassModel/Entities/EntityState.cs

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,38 @@
66
public record EntityState
77
{
88
/// <summary>Unique id of the entity</summary>
9-
public string EntityId { get; init; } = "";
10-
9+
[JsonPropertyName("entity_id")] public string EntityId { get; init; } = "";
10+
1111
/// <summary>The state </summary>
12-
public string? State { get; init; }
12+
[JsonPropertyName("state")] public string? State { get; init; }
1313

1414
/// <summary>The attributes as a JsonElement</summary>
15-
public JsonElement? AttributesJson { get; init; }
16-
15+
[JsonPropertyName("attributes")] public JsonElement? AttributesJson { get; init; }
16+
1717
/// <summary>
1818
/// The attributes
1919
/// </summary>
2020
public virtual object? Attributes => AttributesJson?.Deserialize<Dictionary<string, object>>() ?? new Dictionary<string, object>();
21-
21+
2222
/// <summary>Last changed, when state changed from and to different values</summary>
23-
public DateTime? LastChanged { get; init; }
24-
23+
[JsonPropertyName("last_changed")] public DateTime? LastChanged { get; init; }
24+
2525
/// <summary>Last updated, when entity state or attributes changed </summary>
26-
public DateTime? LastUpdated { get; init; }
27-
26+
[JsonPropertyName("last_updated")] public DateTime? LastUpdated { get; init; }
27+
2828
/// <summary>Context</summary>
29-
public Context? Context { get; init; }
30-
29+
[JsonPropertyName("context")] public Context? Context { get; init; }
30+
3131
internal static TEntityState? Map<TEntityState>(EntityState? state)
32-
where TEntityState : class =>
33-
state == null ? null : (TEntityState)Activator.CreateInstance(typeof(TEntityState), state)!; }
34-
32+
where TEntityState : class =>
33+
state == null ? null : (TEntityState)Activator.CreateInstance(typeof(TEntityState), state)!;
34+
}
35+
3536
/// <summary>
3637
/// Generic EntityState with specific types of State and Attributes
3738
/// </summary>
3839
/// <typeparam name="TAttributes">The type of the Attributes Property</typeparam>
39-
public record EntityState<TAttributes> : EntityState
40+
public record EntityState<TAttributes> : EntityState
4041
where TAttributes : class
4142
{
4243
private readonly Lazy<TAttributes?> _attributesLazy;
@@ -47,7 +48,7 @@ public record EntityState<TAttributes> : EntityState
4748
/// <param name="source"></param>
4849
public EntityState(EntityState source) : base(source)
4950
{
50-
_attributesLazy = new (() => AttributesJson?.Deserialize<TAttributes>() ?? default);
51+
_attributesLazy = new (() => AttributesJson?.Deserialize<TAttributes>() ?? default);
5152
}
5253

5354
/// <inheritdoc/>

src/HassModel/NetDeamon.HassModel/Entities/StateChange.cs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@
55
/// </summary>
66
public record StateChange
77
{
8+
private readonly JsonElement _jsonElement;
9+
private readonly IHaContext _haContext;
10+
private Entity? _entity;
11+
private EntityState? _old;
12+
private EntityState? _new;
13+
14+
/// <summary>
15+
/// Creates a StateChange from a jsonElement and lazy load the states
16+
/// </summary>
17+
/// <param name="jsonElement"></param>
18+
/// <param name="haContext"></param>
19+
internal StateChange(JsonElement jsonElement, IHaContext haContext)
20+
{
21+
_jsonElement = jsonElement;
22+
_haContext = haContext;
23+
}
24+
825
/// <summary>
926
/// This should not be used under normal circumstances but can be used for unit testing of apps
1027
/// </summary>
@@ -13,23 +30,24 @@ public record StateChange
1330
/// <param name="new"></param>
1431
public StateChange(Entity entity, EntityState? old, EntityState? @new)
1532
{
16-
Entity = entity;
17-
New = @new;
18-
Old = old;
33+
_entity = entity;
34+
_new = @new;
35+
_old = old;
36+
_haContext = null!; // haContext is not used when _entity is already initialized
1937
}
2038

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

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

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

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

5674
/// <inheritdoc/>
5775
public override TEntityState? Old => (TEntityState?)base.Old;
58-
}
76+
}

src/HassModel/NetDeamon.HassModel/Internal/AppScopedHaContextProvider.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public AppScopedHaContextProvider(
4949

5050
public EntityState? GetState(string entityId)
5151
{
52-
return _entityStateCache.GetState(entityId).Map();
52+
return _entityStateCache.GetState(entityId);
5353
}
5454

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

8787
public IObservable<StateChange> StateAllChanges()
8888
{
89-
return _queuedObservable.Where(n =>
90-
n.EventType == "state_changed")
91-
.Select(n => n.ToStateChangedEvent()!)
92-
.Select(e => e.Map(this));
89+
return _queuedObservable
90+
.Where(n => n.EventType == "state_changed")
91+
.Select(n => new StateChange(n.DataElement.GetValueOrDefault(), this));
9392
}
9493

9594
public IObservable<Event> Events => _queuedObservable

0 commit comments

Comments
 (0)