diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/Selectors.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/Selectors.cs index 3d968609c..50982fa9e 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/Selectors.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/Selectors.cs @@ -6,7 +6,7 @@ namespace NetDaemon.HassModel.CodeGenerator.Model; internal record Selector() { public bool Multiple { get; init; } - + public string? Type { get; init; } } @@ -45,12 +45,18 @@ internal record NumberSelector : Selector [Required] public double Max { get; init; } - public float? Step { get; init; } + + // Step can also contain the string "any" which is not usefull for our purpose, se we deserialize as a string and then try to parse as a double + [JsonPropertyName("step")] + public string? StepValue { get; init; } + + [JsonIgnore] + public double? Step => double.TryParse(StepValue, out var d) ? d: null; public string? UnitOfMeasurement { get; init; } } -internal record TargetSelector : Selector +internal record TargetSelector : Selector { [JsonConverter(typeof(SingleObjectAsArrayConverter))] public EntitySelector[] Entity { get; init; } = Array.Empty(); diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs index be5cf4936..255046f1a 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs @@ -7,15 +7,20 @@ internal static class ServiceMetaDataParser PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance }; + + public static IReadOnlyCollection Parse(JsonElement element) => Parse(element, out _); + /// /// Parses all json elements to instance result from GetServices call /// /// JsonElement containing the result data - public static IReadOnlyCollection Parse(JsonElement element) + /// Outputs Any Exceptions during deserialization + public static IReadOnlyCollection Parse(JsonElement element, out List errors) { + errors = new List(); if (element.ValueKind != JsonValueKind.Object) throw new InvalidOperationException("Not expected result from the GetServices result"); - + var hassServiceDomains = new List(); foreach (var property in element.EnumerateObject()) { @@ -32,6 +37,7 @@ public static IReadOnlyCollection Parse(JsonElement element) { Console.Error.WriteLine($"JSON deserialization of {nameof(HassServiceDomain)} failed: {e.Message}"); Console.Error.WriteLine($"Deserialization source was: {property.Value}"); + errors.Add(e); } } return hassServiceDomains; @@ -41,10 +47,10 @@ private static IReadOnlyCollection GetServices(JsonElement element) { return element.EnumerateObject() .Select(serviceDomainProperty => - GetServiceFields(serviceDomainProperty.Name, serviceDomainProperty.Value)).ToList(); + GetService(serviceDomainProperty.Name, serviceDomainProperty.Value)).ToList(); } - private static HassService GetServiceFields(string service, JsonElement element) + private static HassService GetService(string service, JsonElement element) { var result = element.Deserialize(SnakeCaseNamingPolicySerializerOptions)! with { @@ -69,4 +75,4 @@ private static HassServiceField GetField(string fieldName, JsonElement element) Field = fieldName, }; } -} \ No newline at end of file +} diff --git a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServiceMetaDataParserTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServiceMetaDataParserTest.cs index 7ddfd0088..5837853d8 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServiceMetaDataParserTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServiceMetaDataParserTest.cs @@ -9,7 +9,7 @@ public class ServiceMetaDataParserTest [Fact] public void TestSomeBasicServicesCanBeParsed() { - var sample = """ + var sample = """ { "homeassistant": { "save_persistent_states": { @@ -36,13 +36,12 @@ public void TestSomeBasicServicesCanBeParsed() } } """; - var element = JsonDocument.Parse(sample).RootElement; - var res = ServiceMetaDataParser.Parse(element); + var res = Parse(sample); res.Should().HaveCount(1); res.First().Domain.Should().Be("homeassistant"); res.First().Services.ElementAt(1).Target!.Entity.SelectMany(e=>e.Domain).Should().BeEmpty(); } - + [Fact] public void TestMultiDomainTarget() { @@ -73,11 +72,11 @@ public void TestMultiDomainTarget() } """; var result = Parse(sample); - + result.First().Services.First().Fields!.First().Selector.Should() .BeAssignableTo().Which.Domain.Should().BeEquivalentTo("climate", "select"); } - + [Fact] public void TestMultiDomainTargetWithRequiredFieldAsString() { @@ -108,7 +107,7 @@ public void TestMultiDomainTargetWithRequiredFieldAsString() } """; var result = Parse(sample); - + result.First().Services.First().Fields!.First().Required.Should().BeTrue(); } @@ -125,10 +124,10 @@ public void DeserializeTargetEntityArray() "target":{ "entity":[ { - "domain":"targetdomain1" + "domain":"targetdomain1" }, { - "domain":["targetdomain2", "targetdomain3"] + "domain":["targetdomain2", "targetdomain3"] } ] @@ -144,9 +143,74 @@ public void DeserializeTargetEntityArray() } + + [Fact] + public void NumericStepCanBeAny() + { + var sample = """ + { + "homeassistant": + { + "set_location":{ + "name":"Set location", + "description":"Updates the Home Assistant location.", + "fields":{ + "latitude":{ + "required":true, + "example":32.87336, + "selector":{ + "number":{ + "mode":"box", + "min":-90, + "max":90, + "step":"any" + } + }, + "name":"Latitude", + "description":"Latitude of your location." + }, + "longitude":{ + "required":true, + "example":117.22743, + "selector":{ + "number":{ + "mode":"box", + "min":-180, + "max":180, + "step":"any" + } + }, + "name":"Longitude", + "description":"Longitude of your location." + }, + "elevation":{ + "required":false, + "example":120, + "selector":{ + "number":{ + "mode":"box", + "step":"1" + } + }, + "name":"Elevation", + "description":"Elevation of your location." + } + } + } + } + } + """; + var result = Parse(sample); + + var steps = result.Single().Services.Single().Fields!.Select(f => (f.Selector as NumberSelector)!.Step).ToArray(); + steps.Should().Equal(null, null, 1); // any is mapped to null + } + private static IReadOnlyCollection Parse(string sample) { - var element = JsonDocument.Parse(sample).RootElement; - return ServiceMetaDataParser.Parse(element); + var element = JsonDocument.Parse(sample).RootElement; + var result = ServiceMetaDataParser.Parse(element, out var errors); + errors.Should().BeEmpty(); + return result; } -} \ No newline at end of file +} diff --git a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServicesGeneratorTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServicesGeneratorTest.cs index b2a545d01..5debf4013 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServicesGeneratorTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServicesGeneratorTest.cs @@ -25,7 +25,7 @@ public void TestServicesGeneration() Service = "turn_off", Target = new TargetSelector { - Entity = new[] { new EntitySelector { Domain = new[] { "light" } } } + Entity = new[] { new EntitySelector { Domain = new[] { "light" } } } } }, @@ -33,11 +33,11 @@ public void TestServicesGeneration() Service = "turn_on", Fields = new HassServiceField[] { new() { Field = "transition", Selector = new NumberSelector(), }, - new() { Field = "brightness", Selector = new NumberSelector { Step = 0.2f }, } + new() { Field = "brightness", Selector = new NumberSelector { StepValue = "0.2" }, } }, Target = new TargetSelector { - Entity = new[] { new EntitySelector { Domain = new[] { "light" } } } + Entity = new[] { new EntitySelector { Domain = new[] { "light" } } } } } } @@ -79,7 +79,7 @@ public void Run(IHaContext ha) } } """; - + CodeGenTestHelper.AssertCodeCompiles(code.ToString(), appCode); } @@ -96,17 +96,17 @@ public void TestServiceWithoutAnyTargetEntity_ExtensionMethodSkipped() Domain = "smart_things", Services = new HassService[] { new() { - Service = "dig", + Service = "dig", Target = new TargetSelector { - Entity = new[] { new EntitySelector { Domain = new[] { "humidifiers" } } } + Entity = new[] { new EntitySelector { Domain = new[] { "humidifiers" } } } }, }, new() { Service = "orbit", Target = new TargetSelector { - Entity = new[] { new EntitySelector { Domain = new[] { "orbiter" } } } + Entity = new[] { new EntitySelector { Domain = new[] { "orbiter" } } } }, } }, @@ -127,20 +127,20 @@ public class Root { public void Run(Entities entities, Services services) { - // Test the Orbit extension Method exists + // Test the Orbit extension Method exists SmartThingsEntityExtensionMethods.Orbit(entities.Orbiter.Cassini); entities.Orbiter.Cassini.Orbit(); - + // Test the Methods on the service classes do exist services.SmartThings.Dig(new ServiceTarget()); services.SmartThings.Orbit(new ServiceTarget()); } } """; - + CodeGenTestHelper.AssertCodeCompiles(code.ToString(), appCode); - } - + } + [Fact] public void TestServiceWithoutAnyMethods_ClassSkipped() { @@ -153,21 +153,21 @@ public void TestServiceWithoutAnyMethods_ClassSkipped() Domain = "dumbthings", Services = new HassService[] { new() { - Service = "push_button", + Service = "push_button", Target = new TargetSelector { - Entity = new[] { new EntitySelector { Domain = new[] { "uselessbox" } } } + Entity = new[] { new EntitySelector { Domain = new[] { "uselessbox" } } } }, }, }, - } + } }; // Act: var code = CodeGenTestHelper.GenerateCompilationUnit(_settings, readOnlyCollection, hassServiceDomains); code.ToString().Should().NotContain("DumbthingsEntityExtensionMethods", because:"There is no entity for any of the services in dumbthings"); - + var appCode = """ using NetDaemon.HassModel; using NetDaemon.HassModel.Entities; @@ -182,10 +182,10 @@ public void Run(Entities entities, Services services) } } """; - + CodeGenTestHelper.AssertCodeCompiles(code.ToString(), appCode); - } - + } + [Fact] public void TestServiceWithKeyWordFieldName_ParamEscaped() { @@ -200,7 +200,7 @@ public void TestServiceWithKeyWordFieldName_ParamEscaped() new() { Service = "set_value", Target = new TargetSelector { - Entity = new[] { new EntitySelector { Domain = new[] { "light" } } } + Entity = new[] { new EntitySelector { Domain = new[] { "light" } } } }, Fields = new HassServiceField[] { new() { Field = "class", Selector = new NumberSelector(), }, @@ -212,7 +212,7 @@ public void TestServiceWithKeyWordFieldName_ParamEscaped() // Act: var code = CodeGenTestHelper.GenerateCompilationUnit(_settings, readOnlyCollection, hassServiceDomains); - + var appCode = """ using NetDaemon.HassModel; using NetDaemon.HassModel.Entities; @@ -227,7 +227,7 @@ public void Run(Entities entities, Services services) } } """; - + CodeGenTestHelper.AssertCodeCompiles(code.ToString(), appCode); } @@ -237,7 +237,7 @@ public void MultpileEntitySelector_ShouldGenerateArray() var states = new HassState[] { new() { EntityId = "media_player.group1" }, }; - + var serviceMetaData = """ { "media_player": { @@ -284,17 +284,18 @@ public void Run(Entities entities, Services services) """; var hassServiceDomains = Parse(serviceMetaData); - + // Act: var code = CodeGenTestHelper.GenerateCompilationUnit(_settings, states, hassServiceDomains); - CodeGenTestHelper.AssertCodeCompiles(code.ToString(), appCode); + CodeGenTestHelper.AssertCodeCompiles(code.ToString(), appCode); } - + private static IReadOnlyCollection Parse(string sample) { var element = JsonDocument.Parse(sample).RootElement; - return ServiceMetaDataParser.Parse(element); + var result = ServiceMetaDataParser.Parse(element, out var errors); + errors.Should().BeEmpty(); + return result; } -} - \ No newline at end of file +} diff --git a/tests/Integration/NetDaemon.Tests.Integration/CodegenIntegrationTests.cs b/tests/Integration/NetDaemon.Tests.Integration/CodegenIntegrationTests.cs index a5041690b..167a9a176 100644 --- a/tests/Integration/NetDaemon.Tests.Integration/CodegenIntegrationTests.cs +++ b/tests/Integration/NetDaemon.Tests.Integration/CodegenIntegrationTests.cs @@ -24,7 +24,9 @@ public async Task Codegen_ShouldBeAbleToParseServiceDescriptions() var haConnection = Services.GetRequiredService(); var element = await haConnection.GetServicesAsync(new CancellationTokenSource(TimeSpan.FromSeconds(20)).Token).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to get services"); - var serviceMetadata = ServiceMetaDataParser.Parse(element); + var serviceMetadata = ServiceMetaDataParser.Parse(element, out var errors); + + errors.Should().BeEmpty(); serviceMetadata.Count.Should().NotBe(0); var lightDomain = serviceMetadata.FirstOrDefault(n => n.Domain == "switch") ?? throw new InvalidOperationException("Expected domain light to be present");