Skip to content

Commit 1cd5110

Browse files
Fix HA changed light attributes are all null iso missing for lights that are off (#1035)
1 parent 5a3f9f0 commit 1cd5110

File tree

7 files changed

+132
-71
lines changed

7 files changed

+132
-71
lines changed

src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/AttributeTypeGenerator.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ internal static class AttributeTypeGenerator
1616
///
1717
/// [JsonPropertyName("color_mode")]
1818
/// public string? ColorMode { get; init; }
19-
///
19+
///
2020
/// [JsonPropertyName("color_temp")]
2121
/// public double? ColorTemp { get; init; }
22-
/// }
22+
/// }
2323
/// </example>
2424
public static RecordDeclarationSyntax GenerateAttributeRecord(EntityDomainMetadata domain)
2525
{
2626
var propertyDeclarations = domain.Attributes
27-
.Select(a => AutoPropertyGetInit($"{a.ClrType.GetFriendlyName()}?", a.CSharpName)
27+
.Select(a => AutoPropertyGetInit($"{(a.ClrType ?? typeof(object)).GetFriendlyName()}?", a.CSharpName)
2828
.ToPublic()
2929
.WithJsonPropertyName(a.JsonName));
3030

@@ -34,4 +34,4 @@ public static RecordDeclarationSyntax GenerateAttributeRecord(EntityDomainMetada
3434

3535
return domain.AttributesBaseClass != null ? record.WithBase(SimplifyTypeName(domain.AttributesBaseClass)) : record;
3636
}
37-
}
37+
}

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/AttributeMetaDataGenerator.cs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,39 +19,39 @@ public static IEnumerable<EntityAttributeMetaData> GetMetaDataFromEntityStates(I
1919
CSharpName: group.Key.ToValidCSharpPascalCase(),
2020
ClrType: GetBestClrType(group.Select(g => g.Value))));
2121

22-
// We ignore possible duplicate CSharp names here, they will be handled later
22+
// We ignore possible duplicate CSharp names here, they will be handled later
2323
// by the MetaDataMerger
2424
return attributesByJsonName;
2525
}
26-
private static Type GetBestClrType(IEnumerable<JsonElement> valueKinds)
26+
private static Type? GetBestClrType(IEnumerable<JsonElement> valueKinds)
2727
{
2828
var distinctCrlTypes = valueKinds
2929
.Where(e => e.ValueKind != JsonValueKind.Null) // null fits in any type so we can ignore it for now
3030
.GroupBy(e => MapJsonType(e.ValueKind))
3131
.ToHashSet();
3232

33-
if (distinctCrlTypes.Count is 0 or > 1)
34-
{
35-
// Either all inputs where JsonValueKind.Null, or there are multiple possible
36-
// input types. In either case, the return must be object
37-
return typeof(object);
38-
}
33+
// If we have no items left that means all attributes had JsonValueKind.Null,
34+
// this can occur eg for light brightness if all lights are currently off
35+
// In that case we will not determine a clrType here because there might be a better type when this
36+
// metadata gets merged
37+
if (distinctCrlTypes.Count == 0) return null;
38+
39+
// Multiple types were found so we need to resort to object
40+
if (distinctCrlTypes.Count > 1) return typeof(object);
3941

4042
// For arrays, we want to enumerate the sub-elements of the array. If there's a single
4143
// element type, then we'll construct an IROList<subtype>, otherwise we'll construct
4244
// IROList<object>
4345
var clrTypeGroup = distinctCrlTypes.Single();
4446
if (clrTypeGroup.Key == typeof(IReadOnlyList<>))
4547
{
46-
var listSubType = GetBestClrType(clrTypeGroup.SelectMany(el => el.EnumerateArray()));
48+
var listSubType = GetBestClrType(clrTypeGroup.SelectMany(el => el.EnumerateArray())) ?? typeof(object);
4749
return clrTypeGroup.Key.MakeGenericType(listSubType);
4850
}
49-
else
50-
{
51-
return clrTypeGroup.Key;
52-
}
51+
52+
return clrTypeGroup.Key;
5353
}
54-
54+
5555
private static Type MapJsonType(JsonValueKind kind) =>
5656
kind switch
5757
{

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/ClrTypeJsonConverter.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@ namespace NetDaemon.HassModel.CodeGenerator;
55
/// <summary>
66
/// Json(De)Serializes a System.Type using a 'friendly name'
77
/// </summary>
8-
internal class ClrTypeJsonConverter : JsonConverter<Type>
8+
internal class ClrTypeJsonConverter : JsonConverter<Type?>
99
{
10-
public override Type Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
10+
public override Type? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
1111
{
1212
var typeName = reader.GetString();
13-
if (typeName == null) return typeof(object);
13+
if (typeName == null) return null;
1414

1515
return Type.GetType(typeName) ?? throw new InvalidOperationException($@"Type {typeName} is not found when deserializing");
1616
}
1717

18-
public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options)
18+
public override void Write(Utf8JsonWriter writer, Type? value, JsonSerializerOptions options)
1919
{
20-
writer.WriteStringValue(value.ToString());
20+
writer.WriteStringValue(value?.ToString());
2121
}
22-
}
22+
}

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityDomainMetadata.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,28 @@ record EntitiesMetaData
99

1010
record EntityDomainMetadata(
1111
string Domain,
12-
13-
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
12+
13+
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
1414
bool IsNumeric,
15-
15+
1616
IReadOnlyList<EntityMetaData> Entities,
17-
17+
1818
IReadOnlyList<EntityAttributeMetaData> Attributes
1919
)
2020
{
21-
private static readonly HashSet<string> CoreInterfaces =
21+
private static readonly HashSet<string> CoreInterfaces =
2222
typeof(IEntityCore).Assembly.GetTypes()
2323
.Where(t => t.IsInterface && t.IsAssignableTo(typeof(IEntityCore)))
2424
.Select(t => t.Name)
2525
.ToHashSet();
26-
26+
2727
private readonly string prefixedDomain = (IsNumeric && EntityIdHelper.MixedDomains.Contains(Domain) ? "numeric_" : "") + Domain;
2828

2929
[JsonIgnore]
3030
public string EntityClassName => $"{prefixedDomain}Entity".ToValidCSharpPascalCase();
3131

3232
/// <summary>
33-
/// Returns the name of the corresponding Core Interface if it exists, or null if it does not
33+
/// Returns the name of the corresponding Core Interface if it exists, or null if it does not
3434
/// </summary>
3535
[JsonIgnore]
3636
public string? CoreInterfaceName
@@ -54,4 +54,4 @@ public string? CoreInterfaceName
5454

5555
record EntityMetaData(string id, string? friendlyName, string cSharpName);
5656

57-
record EntityAttributeMetaData(string JsonName, string CSharpName, Type ClrType);
57+
record EntityAttributeMetaData(string JsonName, string CSharpName, Type? ClrType);

src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataMerger.cs

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,19 @@ internal static class EntityMetaDataMerger
99
#pragma warning disable CS0618 // Type or member is obsolete
1010
private static readonly Type[] _possibleBaseTypes = typeof(LightAttributesBase).Assembly.GetTypes();
1111
#pragma warning restore CS0618 // Type or member is obsolete
12-
12+
1313
// We need to merge the previously saved metadata with the current metadata from HA
1414
// We do this because sometimes entities do not provide all their attributes,
1515
// like a Light only has a brightness attribute when it is turned on
16-
//
16+
//
1717
// data structure:
1818
// [ {
1919
// "domain": "weather",
2020
// "isNumeric": false,
2121
// "entities": [ {
2222
// "id": "",
2323
// "friendlyName":""
24-
// }]
24+
// }]
2525
// "attributes" : [ {
2626
// "jsonName": "temperature",
2727
// "cSharpName": "Temperature",
@@ -49,7 +49,7 @@ public static EntitiesMetaData Merge(CodeGenerationSettings codeGenerationSettin
4949
previous = SetBaseTypes(previous);
5050
current = SetBaseTypes(current);
5151
}
52-
52+
5353
return previous with
5454
{
5555
Domains = FullOuterJoin(previous.Domains, current.Domains, p => (p.Domain.ToLowerInvariant(), p.IsNumeric), MergeDomains)
@@ -72,11 +72,11 @@ public static EntitiesMetaData SetBaseTypes(EntitiesMetaData entitiesMetaData)
7272
Domains = entitiesMetaData.Domains.Select(m => DeriveFromBasetype(m, _possibleBaseTypes)).ToList()
7373
};
7474
}
75-
75+
7676
private static EntityDomainMetadata DeriveFromBasetype(EntityDomainMetadata domainMetadata, IReadOnlyCollection<Type> possibleBaseTypes)
7777
{
7878
var baseType = possibleBaseTypes.FirstOrDefault(t => t.Name == domainMetadata.AttributesClassName + "Base");
79-
79+
8080
var basePropertyJsonNames = baseType?.GetProperties()
8181
.Select(p => p.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name ?? p.Name)
8282
.ToHashSet() ?? new HashSet<string>();
@@ -93,60 +93,57 @@ private static EntityDomainMetadata MergeDomains(EntityDomainMetadata previous,
9393
// Only keep entities from Current but merge the attributes
9494
return current with
9595
{
96-
Attributes = MergeAttributeSets(previous.Attributes, current.Attributes).ToList()
96+
Attributes = FullOuterJoin(previous.Attributes, current.Attributes, a => a.JsonName, MergeAttributes).ToList()
9797
};
9898
}
9999

100-
private static IEnumerable<EntityAttributeMetaData> MergeAttributeSets(
101-
IReadOnlyCollection<EntityAttributeMetaData> previousAttributes,
102-
IEnumerable<EntityAttributeMetaData> currentAttributes)
103-
{
104-
return FullOuterJoin(previousAttributes, currentAttributes, a => a.JsonName, MergeAttributes);
105-
}
106-
107100
private static EntityAttributeMetaData MergeAttributes(EntityAttributeMetaData previous, EntityAttributeMetaData current)
108101
{
109102
// for Attributes matching by the JsonName keep the previous
110-
// this makes sure the preferred CSharpName stays the same
103+
// this makes sure the preferred CSharpName stays the same, we only merge the types
111104
return previous with
112105
{
113-
// In case the ClrType derived from the current metadata is not the same as the previous
114-
// we will use 'object' for safety
115-
116-
ClrType = previous.ClrType == current.ClrType ? previous.ClrType : typeof(object),
117-
// There is a possible issue here when one of the ClrTypes is 'object'
118-
// It can be object because either all inputs where JsonValueKind.Null, or there are
119-
// multiple possible input types.
120-
// Right here we dont actually know which it was, we will assume there were multiple,
121-
// so the resulting type will stay object
106+
ClrType = MergeTypes(previous.ClrType, current.ClrType)
122107
};
123108
}
124109

110+
private static Type? MergeTypes(Type? previous, Type? current)
111+
{
112+
// null for previous or current type means we did not get any non-null values to determine a type from
113+
// so if previous or current is null we use the other.
114+
// if for some reason the type has changed we use object to support both.
115+
return
116+
previous == current ? previous :
117+
previous is null ? current :
118+
current is null ? previous :
119+
typeof(object);
120+
}
121+
125122
private static EntityDomainMetadata HandleDuplicateCSharpNames(EntityDomainMetadata entitiesMetaData)
126123
{
127124
// This hashset will initially have all Member names in the base class.
128-
// We will then also add all new names to this set so we are sure they will all be unique
125+
// We will then also add all new names to this set so we are sure they will all be unique
129126
var reservedCSharpNames = entitiesMetaData.AttributesBaseClass?
130127
.GetMembers().Select(p => p.Name).ToHashSet() ?? new HashSet<string>();
131128

132129
var withDeDuplicatedCSharpNames = entitiesMetaData.Attributes
133130
.GroupBy(t => t.CSharpName)
134131
.SelectMany(s => DeDuplicateCSharpNames(s.Key, s, reservedCSharpNames)).ToList();
135-
132+
136133
return entitiesMetaData with
137134
{
138135
Attributes = withDeDuplicatedCSharpNames
139136
};
140137
}
141-
138+
142139
private static IEnumerable<EntityAttributeMetaData> DeDuplicateCSharpNames(
143-
string preferredCSharpName, IEnumerable<EntityAttributeMetaData> items,
140+
string preferredCSharpName, IEnumerable<EntityAttributeMetaData> items,
144141
ISet<string> reservedCSharpNames)
145142
{
146143
var list = items.ToList();
147144
if (list.Count == 1 && reservedCSharpNames.Add(preferredCSharpName))
148145
{
149-
// Just one Attribute with this preferredCSharpName AND it was not taken yet
146+
// Just one Attribute with this preferredCSharpName AND it was not taken yet
150147
return new[] { list[0]};
151148
}
152149

@@ -167,28 +164,28 @@ string ReserveNextAvailableName()
167164
}
168165

169166
/// <summary>
170-
/// Full outer join two sets based on a key and merge the matches
167+
/// Full outer join two sets based on a key and merge the matches
171168
/// </summary>
172169
/// <returns>
173170
/// All items from previous and current that dont match
174171
/// A merged item for all matches based in the Merger delegate
175172
/// </returns>
176173
private static IEnumerable<T> FullOuterJoin<T, TKey>(
177174
IEnumerable<T> previous,
178-
IEnumerable<T> current,
179-
Func<T, TKey> keySelector,
175+
IEnumerable<T> current,
176+
Func<T, TKey> keySelector,
180177
Func<T,T,T> merger) where TKey : notnull
181178
{
182179
var previousLookup = previous.ToDictionary(keySelector);
183180
var currentLookup = current.ToLookup(keySelector);
184-
181+
185182
var inPrevious = previousLookup
186183
.Select(p => (previous: p.Value, current: currentLookup[p.Key].FirstOrDefault()))
187-
.Select(t => t.current == null
188-
? t.previous // Item in previous doe snot exist in current, return previous
184+
.Select(t => t.current == null
185+
? t.previous // Item in previous doe snot exist in current, return previous
189186
: merger(t.previous, t.current)); // match, so call merge delegate
190-
187+
191188
var onlyInCurrent = currentLookup.Where(l => !previousLookup.ContainsKey(l.Key)).SelectMany(l => l);
192189
return inPrevious.Concat(onlyInCurrent);
193190
}
194-
}
191+
}

src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/AttributeMetaDataGeneratorTest.cs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,52 @@ public void SameAttributeDifferentArrayTypes_obect()
3636
copy.Should().Be(metadata.First());
3737
}
3838

39+
[Fact]
40+
public void AllNullShouldBeNull()
41+
{
42+
var entityStates = new HassState[] {
43+
new() { AttributesJson = new { size = (object?)null, }.AsJsonElement() },
44+
new() { AttributesJson = new { size = (object?)null, }.AsJsonElement() },
45+
};
46+
47+
var metadata = AttributeMetaDataGenerator.GetMetaDataFromEntityStates(entityStates);
48+
49+
metadata.Should().BeEquivalentTo(new[] { new EntityAttributeMetaData("size", "Size", null) });
50+
}
51+
52+
[Fact]
53+
public void IncludeSaveAndMergeState()
54+
{
55+
// first we have a few lights that are ON and therefore have a brightness propert as a double
56+
var entityStates = new HassState[] {
57+
new() {EntityId = "light.attic", AttributesJson = new { brightness = 2.1d, }.AsJsonElement() },
58+
new() {EntityId = "light.livingroom", AttributesJson = new { brightness = 2.2d, }.AsJsonElement() },
59+
};
60+
61+
var metadata = AttributeMetaDataGenerator.GetMetaDataFromEntityStates(entityStates).ToList();
62+
// lets pretend it gets saved and reloaded from disk
63+
metadata = SerializeAndDeserialize(metadata);
64+
65+
// now get new metadata while the lights are off (brightness = null)
66+
var newEntityStates = new HassState[] {
67+
new() {EntityId = "light.attic", AttributesJson = new { brightness = (object?)null, }.AsJsonElement() },
68+
new() {EntityId = "light.livingroom", AttributesJson = new { brightness = (object?)null, }.AsJsonElement() },
69+
};
70+
71+
var newMetadata = AttributeMetaDataGenerator.GetMetaDataFromEntityStates(newEntityStates).ToList();
72+
73+
// merge the previous and current metadata
74+
var merged = EntityMetaDataMerger.Merge(new CodeGenerationSettings(),
75+
new EntitiesMetaData { Domains = new []{new EntityDomainMetadata("light", false, Array.Empty<EntityMetaData>(), metadata!)}},
76+
new EntitiesMetaData { Domains = new []{new EntityDomainMetadata("light", false, Array.Empty<EntityMetaData>(), newMetadata)}}
77+
);
78+
79+
// We should still have Brightness as a double
80+
merged.Domains.First().Attributes.Should().BeEquivalentTo(new[] { new EntityAttributeMetaData("brightness", "Brightness", typeof(double)) });
81+
}
82+
3983

40-
private static EntityAttributeMetaData? SerializeAndDeserialize(EntityAttributeMetaData input)
84+
private static T? SerializeAndDeserialize<T>(T input)
4185
{
4286
var serializeOptions = new JsonSerializerOptions()
4387
{
@@ -47,7 +91,7 @@ public void SameAttributeDifferentArrayTypes_obect()
4791
};
4892

4993
var json = JsonSerializer.Serialize(input, serializeOptions);
50-
return JsonSerializer.Deserialize<EntityAttributeMetaData>(json, serializeOptions);
94+
return JsonSerializer.Deserialize<T>(json, serializeOptions);
5195
}
5296

5397
}

0 commit comments

Comments
 (0)