diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/AttributeTypeGenerator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/AttributeTypeGenerator.cs index 6fd4b2165..3611ed5bc 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/AttributeTypeGenerator.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/AttributeTypeGenerator.cs @@ -24,7 +24,7 @@ internal static class AttributeTypeGenerator public static RecordDeclarationSyntax GenerateAttributeRecord(EntityDomainMetadata domain) { var propertyDeclarations = domain.Attributes - .Select(a => Property($"{a.ClrType.GetFriendlyName()}?", a.CSharpName) + .Select(a => AutoPropertyGetInit($"{a.ClrType.GetFriendlyName()}?", a.CSharpName) .ToPublic() .WithJsonPropertyName(a.JsonName)); diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/EntitiesGenerator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/EntitiesGenerator.cs index 7ad2e58bf..39bc69ffd 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/EntitiesGenerator.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/EntitiesGenerator.cs @@ -34,29 +34,24 @@ private static TypeDeclarationSyntax GenerateRootEntitiesInterface(IEnumerable entitySet) + private static TypeDeclarationSyntax GenerateRootEntitiesClass(IEnumerable domains) { - var haContextNames = GetNames(); - - var properties = entitySet.DistinctBy(s=>s.Domain).Select(set => + var properties = domains.DistinctBy(s => s.Domain).Select(set => { var entitiesTypeName = GetEntitiesForDomainClassName(set.Domain); var entitiesPropertyName = set.Domain.ToPascalCase(); - return (MemberDeclarationSyntax)ParseProperty($"{entitiesTypeName} {entitiesPropertyName} => new(_{haContextNames.VariableName});") - .ToPublic(); + return PropertyWithExpressionBodyNew(entitiesTypeName, entitiesPropertyName, "_haContext"); }).ToArray(); - return ClassWithInjected(EntitiesClassName) - .ToPublic() - .AddModifiers(Token(SyntaxKind.PartialKeyword)) + return ClassWithInjectedHaContext(EntitiesClassName) .WithBase((string)"IEntities") .AddMembers(properties); } @@ -66,9 +61,7 @@ private static TypeDeclarationSyntax GenerateRootEntitiesClass(IEnumerable private static TypeDeclarationSyntax GenerateEntiesForDomainClass(string className, IEnumerable entitySets) { - var entityClass = ClassWithInjected(className) - .ToPublic() - .AddModifiers(Token(SyntaxKind.PartialKeyword)); + var entityClass = ClassWithInjectedHaContext(className); var entityProperty = entitySets.SelectMany(s=>s.Entities.Select(e => GenerateEntityProperty(e, s.EntityClassName))).ToArray(); @@ -79,16 +72,16 @@ private static MemberDeclarationSyntax GenerateEntityProperty(EntityMetaData ent { var entityName = EntityIdHelper.GetEntity(entity.id); - var propertyCode = $@"{className} {entityName.ToNormalizedPascalCase((string)"E_")} => new(_{GetNames().VariableName}, ""{entity.id}"");"; + var normalizedPascalCase = entityName.ToNormalizedPascalCase((string)"E_"); var name = entity.friendlyName; - return ParseProperty(propertyCode).ToPublic().WithSummaryComment(name); + return PropertyWithExpressionBodyNew(className, normalizedPascalCase, "_haContext", $"\"{entity.id}\"").WithSummaryComment(name); } /// /// Generates a record derived from Entity like ClimateEntity or SensorEntity for a specific set of entities /// - private static TypeDeclarationSyntax GenerateEntityType(EntityDomainMetadata domainMetaData) + private static MemberDeclarationSyntax GenerateEntityType(EntityDomainMetadata domainMetaData) { string attributesGeneric = domainMetaData.AttributesClassName; @@ -98,16 +91,18 @@ private static TypeDeclarationSyntax GenerateEntityType(EntityDomainMetadata dom var baseClass = $"{SimplifyTypeName(baseType)}<{domainMetaData.EntityClassName}, {SimplifyTypeName(entityStateType)}<{attributesGeneric}>, {attributesGeneric}>"; var (className, variableName) = GetNames(); - var classDeclaration = $@"record {domainMetaData.EntityClassName} : {baseClass} - {{ - public {domainMetaData.EntityClassName}({className} {variableName}, string entityId) : base({variableName}, entityId) - {{}} - - public {domainMetaData.EntityClassName}({SimplifyTypeName(typeof(Entity))} entity) : base(entity) - {{}} - }}"; - - return ParseRecord(classDeclaration) + var classDeclaration = $$""" + record {{domainMetaData.EntityClassName}} : {{baseClass}} + { + public {{domainMetaData.EntityClassName}}({{className}} {{variableName}}, string entityId) : base({{variableName}}, entityId) + {} + + public {{domainMetaData.EntityClassName}}({{SimplifyTypeName(typeof(Entity))}} entity) : base(entity) + {} + } + """; + + return ParseMemberDeclaration(classDeclaration)! .ToPublic() .AddModifiers(Token(SyntaxKind.PartialKeyword)); } diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ExtensionMethodsGenerator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ExtensionMethodsGenerator.cs index e6b356579..6e23fec31 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ExtensionMethodsGenerator.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ExtensionMethodsGenerator.cs @@ -1,4 +1,4 @@ -using Microsoft.CodeAnalysis.CSharp; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; namespace NetDaemon.HassModel.CodeGenerator; @@ -36,22 +36,27 @@ public static IEnumerable Generate(IEnumerable x.Service) - .SelectMany(service => GenerateExtensionMethods(serviceDomain.Domain, service, entityClassNameByDomain)) + .SelectMany(service => GenerateExtensionMethodsForService(serviceDomain.Domain, service, entityClassNameByDomain)) .ToArray(); if (!serviceMethodDeclarations.Any()) return null; - return SyntaxFactory.ClassDeclaration(GetEntityDomainExtensionMethodClassName(serviceDomain.Domain)) + return ClassDeclaration(GetEntityDomainExtensionMethodClassName(serviceDomain.Domain)) .AddMembers(serviceMethodDeclarations) .ToPublic() .ToStatic(); } - private static IEnumerable GenerateExtensionMethods(string domain, HassService service, ILookup entityClassNameByDomain) + private static IEnumerable GenerateExtensionMethodsForService(string domain, HassService service, ILookup entityClassNameByDomain) { - var targetEntityDomain = service.Target?.Entity?.Domain; - if (targetEntityDomain == null) yield break; + // There can be multiple Target Domains, so generate methods for each + var targetEntityDomains = service.Target?.Entity?.Domain ?? Array.Empty(); + return targetEntityDomains.SelectMany(targetEntityDomain => GenerateExtensionMethodsForService(domain, service, targetEntityDomain, entityClassNameByDomain)); + } + + private static IEnumerable GenerateExtensionMethodsForService(string domain, HassService service, string targetEntityDomain, ILookup entityClassNameByDomain) + { var entityTypeName = entityClassNameByDomain[targetEntityDomain].FirstOrDefault(); if (entityTypeName == null) yield break; @@ -74,41 +79,38 @@ private static IEnumerable GenerateExtensionMethods(str } } - private static GlobalStatementSyntax ExtensionMethodWithoutArguments(HassService service, string serviceName, string entityTypeName) + private static MemberDeclarationSyntax ExtensionMethodWithoutArguments(HassService service, string serviceName, string entityTypeName) { - return ParseMethod( - $@"void {GetServiceMethodName(serviceName)}(this {entityTypeName} target) - {{ - target.CallService(""{serviceName}""); - }}") - .ToPublic() - .ToStatic() + return ParseMemberDeclaration($$""" + public static void {{GetServiceMethodName(serviceName)}}(this {{entityTypeName}} target) + { + target.CallService("{{serviceName}}"); + } + """)! .WithSummaryComment(service.Description); } - private static GlobalStatementSyntax ExtensionMethodWithClassArgument(HassService service, string serviceName, string entityTypeName, ServiceArguments serviceArguments) + private static MemberDeclarationSyntax ExtensionMethodWithClassArgument(HassService service, string serviceName, string entityTypeName, ServiceArguments serviceArguments) { - return ParseMethod( - $@"void {GetServiceMethodName(serviceName)}(this {entityTypeName} target, {serviceArguments.TypeName} data) - {{ - target.CallService(""{serviceName}"", data); - }}") - .ToPublic() - .ToStatic() + return ParseMemberDeclaration($$""" + public static void {{GetServiceMethodName(serviceName)}}(this {{entityTypeName}} target, {{serviceArguments.TypeName}} data) + { + target.CallService("{{serviceName}}", data); + } + """)! .WithSummaryComment(service.Description); } private static MemberDeclarationSyntax ExtensionMethodWithSeparateArguments(HassService service, string serviceName, string entityTypeName, ServiceArguments serviceArguments) { - return ParseMethod( - $@"void {GetServiceMethodName(serviceName)}(this {entityTypeName} target, {serviceArguments.GetParametersList()}) - {{ - target.CallService(""{serviceName}"", {serviceArguments.GetNewServiceArgumentsTypeExpression()}); - }}") - .ToPublic() - .ToStatic() + return ParseMemberDeclaration($$""" + public static void {{GetServiceMethodName(serviceName)}}(this {{entityTypeName}} target, {{serviceArguments.GetParametersList()}}) + { + target.CallService("{{serviceName}}", {{serviceArguments.GetNewServiceArgumentsTypeExpression()}}); + } + """)! .WithSummaryComment(service.Description) - .WithParameterComment("target", $"The {entityTypeName} to call this service for") - .WithParameterComments(serviceArguments); + .AppendParameterComment("target", $"The {entityTypeName} to call this service for") + .AppendParameterComments(serviceArguments); } } diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/Generator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/Generator.cs index 3a1da8d5e..369d1d80a 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/Generator.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/Generator.cs @@ -1,4 +1,5 @@ -using Microsoft.CodeAnalysis.CSharp; +using System.Reflection; +using Microsoft.CodeAnalysis.CSharp; using NetDaemon.HassModel.CodeGenerator.CodeGeneration; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; @@ -6,28 +7,62 @@ namespace NetDaemon.HassModel.CodeGenerator; internal static class Generator { - public static IEnumerable GenerateTypes( - IReadOnlyCollection domains, + /// + /// Generates all Types from Entity and Services metadata + /// + public static MemberDeclarationSyntax[] GenerateTypes( + IReadOnlyCollection entityDomains, IReadOnlyCollection services) { var orderedServiceDomains = services.OrderBy(x => x.Domain).ToArray(); - var helpers = HelpersGenerator.Generate(domains, orderedServiceDomains); - var entityClasses = EntitiesGenerator.Generate(domains); + var helpers = HelpersGenerator.Generate(entityDomains, orderedServiceDomains); + var entityClasses = EntitiesGenerator.Generate(entityDomains); var serviceClasses = ServicesGenerator.Generate(orderedServiceDomains); - var extensionMethodClasses = ExtensionMethodsGenerator.Generate(orderedServiceDomains, domains); + var extensionMethodClasses = ExtensionMethodsGenerator.Generate(orderedServiceDomains, entityDomains); - return new[] {helpers, entityClasses, serviceClasses, extensionMethodClasses }.SelectMany(x => x).ToArray(); + return new[] { helpers, entityClasses, serviceClasses, extensionMethodClasses }.SelectMany(x => x).ToArray(); } public static CompilationUnitSyntax BuildCompilationUnit(string namespaceName, params MemberDeclarationSyntax[] generatedTypes) { return CompilationUnit() .AddUsings(UsingNamespaces.Select(u => UsingDirective(ParseName(u))).ToArray()) - .WithLeadingTrivia(TriviaHelper.GetFileHeader() + .WithLeadingTrivia(GetFileHeader() .Append(Trivia(NullableDirectiveTrivia(Token(SyntaxKind.EnableKeyword), true)))) .AddMembers(FileScopedNamespaceDeclaration(ParseName(namespaceName))) .AddMembers(generatedTypes) .NormalizeWhitespace(); } + + private static readonly string GeneratorVersion = Assembly.GetAssembly(typeof(Generator))!.GetName().Version!.ToString(); + + private static SyntaxTrivia[] GetFileHeader() + { + string headerText = @$" + //------------------------------------------------------------------------------ + // + // Generated using NetDaemon CodeGenerator nd-codegen v{GeneratorVersion} + // At: {DateTime.Now:O} + // + // *** Make sure the version of the codegen tool and your nugets Joysoftware.NetDaemon.* have the same version.*** + // You can use following command to keep it up to date with the latest version: + // dotnet tool update JoySoftware.NetDaemon.HassModel.CodeGen + // + // To update this file with latest entities run this command in your project directory: + // dotnet tool run nd-codegen + // + // In the template projects we provided a convenience powershell script that will update + // the codegen and nugets to latest versions update_all_dependencies.ps1. + // + // For more information: https://netdaemon.xyz/docs/v3/hass_model/hass_model_codegen + // For more information about NetDaemon: https://netdaemon.xyz/ + // + //------------------------------------------------------------------------------"; + + var lines = headerText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + SyntaxTrivia[] header = lines.SelectMany(l => new[] { Comment(l), LineFeed }).ToArray(); + return header; + } } \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServiceArguments.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServiceArguments.cs index d8e8825b8..e7c1059c9 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServiceArguments.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServiceArguments.cs @@ -2,23 +2,21 @@ internal record ServiceArgument { - public Type? Type { get; init; } - + public required string HaName { get; init; } + public required Type ClrType { get; init; } public bool Required { get; init; } - public string? HaName { get; init; } - - public string? TypeName => Type?.GetFriendlyName(); + public string? Comment { get; init; } - public string? ParameterTypeName => Required ? TypeName : $"{TypeName}?"; + public string TypeName => ClrType.GetFriendlyName(); - public string? PropertyName => HaName?.ToNormalizedPascalCase(); + public string ParameterTypeName => Required ? TypeName : $"{TypeName}?"; - public string? VariableName => HaName?.ToNormalizedCamelCase(); + public string PropertyName => HaName.ToNormalizedPascalCase(); - public string? ParameterVariableName => Required ? VariableName : $"{VariableName} = null"; + public string VariableName => HaName.ToNormalizedCamelCase(); - public string? Comment { get; init; } + public string ParameterVariableName => Required ? VariableName : $"{VariableName} = null"; } internal class ServiceArguments @@ -33,19 +31,19 @@ internal class ServiceArguments return null; } - return new ServiceArguments(domain, service.Service!, service.Fields); + return new ServiceArguments(domain, service.Service, service.Fields); } - - public ServiceArguments(string domain, string serviceName, IReadOnlyCollection serviceFields) + + private ServiceArguments(string domain, string serviceName, IReadOnlyCollection serviceFields) { _domain = domain; _serviceName = serviceName!; - Arguments = serviceFields.Select(HassServiceArgumentMapper.Map); + Arguments = serviceFields.Select(HassServiceArgumentMapper.Map).ToArray(); } public IEnumerable Arguments { get; } - public string TypeName => NamingHelper.GetServiceArgumentsTypeName(_domain, _serviceName); + public string TypeName => $"{_domain.ToNormalizedPascalCase()}{GetServiceMethodName(_serviceName)}Parameters"; public string GetParametersList() { diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServicesGenerator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServicesGenerator.cs index 226d69136..c48bb36c9 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServicesGenerator.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/ServicesGenerator.cs @@ -35,15 +35,9 @@ public static IEnumerable Generate(IReadOnlyList domains) { - var haContextNames = GetNames(); - var properties = domains.Select(domain => - { - var propertyCode = $"{GetServicesTypeName(domain)} {domain.ToPascalCase()} => new(_{haContextNames.VariableName});"; + var properties = domains.Select(domain => PropertyWithExpressionBodyNew(GetServicesTypeName(domain), domain.ToPascalCase(), "_haContext")).ToArray(); - return ParseProperty(propertyCode).ToPublic(); - }).ToArray(); - - return ClassWithInjected(ServicesClassName).WithBase((string)"IServices").AddMembers(properties).ToPublic(); + return ClassWithInjectedHaContext(ServicesClassName).WithBase((string)"IServices").AddMembers(properties); } private static TypeDeclarationSyntax GenerateRootServicesInterface(IEnumerable domains) @@ -53,15 +47,15 @@ private static TypeDeclarationSyntax GenerateRootServicesInterface(IEnumerable(); - return Interface("IServices").AddMembers(properties).ToPublic(); + return InterfaceDeclaration("IServices").WithMembers(List(properties)).ToPublic(); } private static TypeDeclarationSyntax GenerateServicesDomainType(string domain, IEnumerable services) { - var serviceTypeDeclaration = ClassWithInjected(GetServicesTypeName(domain)).ToPublic(); + var serviceTypeDeclaration = ClassWithInjectedHaContext(GetServicesTypeName(domain)); var serviceMethodDeclarations = services.SelectMany(service => GenerateServiceMethod(domain, service)).ToArray(); @@ -78,7 +72,8 @@ private static IEnumerable GenerateServiceArgsRecord(stri } var autoProperties = serviceArguments.Arguments - .Select(argument => Property($"{argument.TypeName!}?", argument.PropertyName!).ToPublic() + .Select(argument => AutoPropertyGetInit($"{argument.TypeName!}?", argument.PropertyName!) + .ToPublic() .WithJsonPropertyName(argument.HaName!).WithSummaryComment(argument.Comment)) .ToArray(); @@ -103,11 +98,12 @@ private static IEnumerable GenerateServiceMethod(string if (serviceArguments is null) { // method without arguments - yield return ParseMethod( - $@"void {serviceMethodName}({targetParam}) - {{ - {haContextVariableName}.CallService(""{domain}"", ""{serviceName}"", {targetArg}); - }}") + yield return ParseMemberDeclaration($$""" + void {{serviceMethodName}}({{targetParam}}) + { + {{haContextVariableName}}.CallService("{{domain}}", "{{serviceName}}", {{targetArg}}); + } + """)! .ToPublic() .WithSummaryComment(service.Description) .AppendTrivia(targetComment); @@ -115,25 +111,27 @@ private static IEnumerable GenerateServiceMethod(string else { // method using arguments object - yield return ParseMethod( - $@"void {serviceMethodName}({JoinList(targetParam, serviceArguments.TypeName)} data) - {{ - {haContextVariableName}.CallService(""{domain}"", ""{serviceName}"", {targetArg}, data); - }}") + yield return ParseMemberDeclaration($$""" + void {{serviceMethodName}}({{JoinList(targetParam, serviceArguments.TypeName)}} data) + { + {{haContextVariableName}}.CallService("{{domain}}", "{{serviceName}}", {{targetArg}}, data); + } + """)! .ToPublic() .WithSummaryComment(service.Description) .AppendTrivia(targetComment); // method using arguments as separate parameters - yield return ParseMethod( - $@"void {serviceMethodName}({JoinList(targetParam, serviceArguments.GetParametersList())}) - {{ - {haContextVariableName}.CallService(""{domain}"", ""{serviceName}"", {targetArg}, {serviceArguments.GetNewServiceArgumentsTypeExpression()}); - }}") + yield return ParseMemberDeclaration($$""" + void {{serviceMethodName}}({{JoinList(targetParam, serviceArguments.GetParametersList())}}) + { + {{haContextVariableName}}.CallService("{{domain}}", "{{serviceName}}", {{targetArg}}, {{serviceArguments.GetNewServiceArgumentsTypeExpression()}}); + } + """)! .ToPublic() .WithSummaryComment(service.Description) .AppendTrivia(targetComment) - .WithParameterComments(serviceArguments); + .AppendParameterComments(serviceArguments); } } diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/SyntaxFactoryHelper.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/SyntaxFactoryHelper.cs new file mode 100644 index 000000000..83c081b5f --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/CodeGeneration/SyntaxFactoryHelper.cs @@ -0,0 +1,135 @@ +using System.Text.Json.Serialization; +using Microsoft.CodeAnalysis.CSharp; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace NetDaemon.HassModel.CodeGenerator.Helpers; + +internal static class SyntaxFactoryHelper +{ + // [JsonPropertyName(name)] + public static MemberDeclarationSyntax WithJsonPropertyName(this MemberDeclarationSyntax input, string name) + { + var jsonPropertyName = SimplifyTypeName(typeof(JsonPropertyNameAttribute))[..^ "Attribute".Length]; + + var attributes = input.AttributeLists.Add(AttributeList(SeparatedList(new[] + { + Attribute(ParseName(jsonPropertyName), + AttributeArgumentList(SingletonSeparatedList( + AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(name))) + ))) + }))); + + return input.WithAttributeLists(attributes); + } + + /// + /// Creates a class with an injected IHaContext + /// + /// + /// public partial class Entities + /// { + /// private readonly IHaContext _haContext; + /// public Entities(IHaContext haContext) + /// { + /// _haContext = haContext; + /// } + /// } + /// + public static ClassDeclarationSyntax ClassWithInjectedHaContext(string className) + { + var newNameToken = Identifier(className); + return ClassTemplate.ReplaceTokens(CtorNameTokens, (_,_) => newNameToken); + } + + private static readonly ClassDeclarationSyntax ClassTemplate = (ClassDeclarationSyntax)ParseMemberDeclaration(""" + public partial class __TypeName__ + { + private readonly IHaContext _haContext; + + public __TypeName__(IHaContext haContext) + { + _haContext = haContext; + } + } + """)!; + + private static readonly SyntaxToken[] CtorNameTokens = ClassTemplate.DescendantTokens().Where(t => t.Text == "__TypeName__").ToArray(); + + + public static RecordDeclarationSyntax Record(string name, IEnumerable properties) + { + return RecordDeclaration(Token(SyntaxKind.RecordKeyword), name) + .WithOpenBraceToken(Token(SyntaxKind.OpenBraceToken)) + .WithMembers(List(properties)) + .WithCloseBraceToken(Token(SyntaxKind.CloseBraceToken)); + } + + public static T ToPublic(this T member) where T: MemberDeclarationSyntax => + (T)member.AddModifiers(Token(SyntaxKind.PublicKeyword)); + + public static T ToStatic(this T member) where T: MemberDeclarationSyntax => + (T)member.AddModifiers(Token(SyntaxKind.StaticKeyword)); + + public static T WithBase(this T member, string baseTypeName) where T: TypeDeclarationSyntax => + (T)member.WithBaseList(BaseList(SingletonSeparatedList(SimpleBaseType(IdentifierName(baseTypeName))))); + + public static T WithSummaryComment(this T node, string? summary) where T : SyntaxNode => + string.IsNullOrWhiteSpace(summary) ? node : node.WithLeadingTrivia(Comment($"///{summary.ReplaceLineEndings(" ")}")); + + public static T AppendParameterComments(this T node, ServiceArguments arguments) where T : SyntaxNode + => node.WithLeadingTrivia(node.GetLeadingTrivia().Concat(arguments.Arguments.Select(a => ParameterComment(a.VariableName, a.Comment)))); + + public static T AppendParameterComment(this T node, string? name, string? description) + where T : SyntaxNode + => node.AppendTrivia(ParameterComment(name, description)); + + public static SyntaxTrivia ParameterComment(string? paramName, string? comment) + => Comment($"///{comment?.ReplaceLineEndings(" ")}"); + + public static T AppendTrivia(this T node, SyntaxTrivia? trivia) + where T : SyntaxNode => + trivia is null ? node : node.WithLeadingTrivia(node.GetLeadingTrivia().Add(trivia.Value)); + + /// + /// Generates 'public {type} {name} => new(arg1, arg2);' + /// + public static MemberDeclarationSyntax PropertyWithExpressionBodyNew(string type, string name, params string[] args) + { + return + PropertyDeclaration(IdentifierName(type), Identifier(name)) + .WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword))) + .WithExpressionBody(ArrowExpressionClause( + ImplicitObjectCreationExpression() + .WithArgumentList(ArgumentList( + SeparatedList( + args.Select(a => Argument(IdentifierName(a)))))))) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)); + } + + /// + /// Generates '{type} {name} {{get; init;}}' + /// + public static PropertyDeclarationSyntax AutoPropertyGetInit(string typeName, string propertyName) + { + return PropertyDeclaration(IdentifierName(typeName), Identifier(propertyName)) + .WithAccessorList(AccessorList(List(new[] + { + AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)), + AccessorDeclaration(SyntaxKind.InitAccessorDeclaration) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) + }))); + } + + /// + /// Generates: `{type} {name} {{get;}}` + /// + public static PropertyDeclarationSyntax AutoPropertyGet(string typeName, string propertyName) + { + return PropertyDeclaration(IdentifierName(typeName), Identifier(propertyName)) + .WithAccessorList(AccessorList(SingletonList( + AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) + ))); + } +} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Controller.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Controller.cs index f74b7f2b2..5b194602d 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Controller.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Controller.cs @@ -36,9 +36,9 @@ public async Task RunAsync() await Save(mergedEntityMetaData, EntityMetaDataFileName).ConfigureAwait(false); await Save(servicesMetaData, ServicesMetaDataFileName).ConfigureAwait(false); - var generatedTypes = Generator.GenerateTypes(mergedEntityMetaData.Domains, servicesMetaData!.Value.ToServicesResult() ); + var generatedTypes = Generator.GenerateTypes(mergedEntityMetaData.Domains, ServiceMetaDataParser.Parse(servicesMetaData!.Value)); - SaveGeneratedCode(generatedTypes.ToList()); + SaveGeneratedCode(generatedTypes); } private async Task LoadEntitiesMetaDataAsync() @@ -70,7 +70,7 @@ private async Task Save(T merged, string fileName) Converters = { new ClrTypeJsonConverter() } }; - private void SaveGeneratedCode(IReadOnlyCollection generatedTypes) + private void SaveGeneratedCode(MemberDeclarationSyntax[] generatedTypes) { if (!_generationSettings.GenerateOneFilePerEntity) { @@ -97,7 +97,7 @@ private void SaveGeneratedCode(IReadOnlyCollection gene unit.WriteTo(writer); } - Console.WriteLine($"Generated {generatedTypes.Count} files."); + Console.WriteLine($"Generated {generatedTypes.Length} files."); Console.WriteLine(OutputFolder); } } diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/HassServiceArgumentMapper.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/HassServiceArgumentMapper.cs deleted file mode 100644 index f5b733637..000000000 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/HassServiceArgumentMapper.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace NetDaemon.HassModel.CodeGenerator.Helpers; - -internal static class HassServiceArgumentMapper -{ - public static ServiceArgument Map(HassServiceField field) - { - Type type = GetTypeFromSelector(field.Selector); - - return new ServiceArgument - { - HaName = field.Field!, - Type = type, - Required = field.Required == true, - Comment = field.Description + (string.IsNullOrWhiteSpace(field.Example?.ToString()) ? "" : $" eg: {field.Example}") - }; - } - private static Type GetTypeFromSelector(object? selectorObject) - { - return selectorObject switch - { - BooleanSelector => typeof(bool), - NumberSelector s when (s.Step ?? 1) % 1 != 0 => typeof(double), - NumberSelector => typeof(long), - TimeSelector => typeof(DateTime), - ObjectSelector or null => typeof(object), - _ => typeof(string) - }; - } -} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/NamingHelper.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/NamingHelper.cs index 466cdd932..ed3769c4c 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/NamingHelper.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/NamingHelper.cs @@ -43,11 +43,6 @@ public static string GetServiceMethodName(string serviceName) return $"{serviceName}"; } - public static string GetServiceArgumentsTypeName(string domain, string serviceName) - { - return $"{domain.ToNormalizedPascalCase()}{GetServiceMethodName(serviceName)}Parameters"; - } - public static (string TypeName, string VariableName) GetNames(string variablePrefix = "") { return GetNames(typeof(T), variablePrefix); diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/SyntaxFactoryHelper.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/SyntaxFactoryHelper.cs deleted file mode 100644 index 2fa020a16..000000000 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/SyntaxFactoryHelper.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Microsoft.CodeAnalysis.CSharp; -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; - -namespace NetDaemon.HassModel.CodeGenerator.Helpers; - -internal static class SyntaxFactoryHelper -{ - public static GlobalStatementSyntax ParseMethod(string code) - { - return Parse(code); - } - - public static PropertyDeclarationSyntax ParseProperty(string code) - { - return Parse(code); - } - - public static RecordDeclarationSyntax ParseRecord(string code) - { - return Parse(code); - } - - public static MemberDeclarationSyntax WithJsonPropertyName(this MemberDeclarationSyntax input, string name) - { - return input.WithAttribute(name); - } - - private static MemberDeclarationSyntax WithAttribute(this MemberDeclarationSyntax property, string value) where T: Attribute - { - var name = (NamingHelper.SimplifyTypeName(typeof(T))); - - name = Regex.Replace(name, "Attribute$", ""); - var args = ParseAttributeArgumentList($"(\"{value}\")"); - var attribute = Attribute(ParseName(name), args); - - return property.WithAttributes(attribute); - } - - private static MemberDeclarationSyntax WithAttributes(this MemberDeclarationSyntax property, params AttributeSyntax[]? attributeSyntaxes) - { - var attributes = property.AttributeLists.Add( - AttributeList(SeparatedList(attributeSyntaxes)).NormalizeWhitespace()); - - return property.WithAttributeLists(attributes); - } - - public static PropertyDeclarationSyntax Property(string typeName, string propertyName, bool init = true) - { - return ParseProperty($"{typeName} {propertyName} {{ get; {( init ? "init; " : string.Empty )}}}"); - } - - public static ClassDeclarationSyntax ClassWithInjected(string className) - { - var (typeName, variableName) = NamingHelper.GetNames(); - - var classCode = $@"class {className} - {{ - private readonly {typeName} _{variableName}; - - public {className}( {typeName} {variableName}) - {{ - _{variableName} = {variableName}; - }} - }}"; - - return ParseClass(classCode); - } - - public static TypeDeclarationSyntax Interface(string name) - { - return InterfaceDeclaration(name); - } - - public static RecordDeclarationSyntax Record(string name, IEnumerable properties) - { - return RecordDeclaration(Token(SyntaxKind.RecordKeyword), name) - .WithOpenBraceToken(Token(SyntaxKind.OpenBraceToken)) - .AddMembers(properties.ToArray()) - .WithCloseBraceToken(Token(SyntaxKind.CloseBraceToken)); - } - - public static T ToPublic(this T member) - where T: MemberDeclarationSyntax - { - return (T)member.AddModifiers(Token(SyntaxKind.PublicKeyword)); - } - - public static T ToStatic(this T member) - where T: MemberDeclarationSyntax - { - return (T)member.AddModifiers(Token(SyntaxKind.StaticKeyword)); - } - - public static T WithBase(this T member, string baseTypeName) - where T: TypeDeclarationSyntax - { - return (T)member.WithBaseList(BaseList(SingletonSeparatedList(SimpleBaseType(IdentifierName(baseTypeName))))); - } - - public static T WithSummaryComment(this T node, string? summary) - where T : SyntaxNode => - string.IsNullOrWhiteSpace(summary) ? node : node.AppendTrivia(Comment($"///{summary.ReplaceLineEndings(" ")}")); - - public static T WithParameterComments(this T node, ServiceArguments arguments) - where T : SyntaxNode - => arguments.Arguments.Aggregate(node, (n, f) => n.WithParameterComment(f.VariableName, f.Comment)); - - public static T WithParameterComment(this T node, string? name, string? description) - where T : SyntaxNode - => node.AppendTrivia(ParameterComment(name, description)); - - public static SyntaxTrivia ParameterComment(string? paramName, string? comment) - => Comment($"///{comment?.ReplaceLineEndings(" ")}"); - - public static T AppendTrivia(this T node, SyntaxTrivia? trivia) - where T : SyntaxNode => - trivia is null ? node : node.WithLeadingTrivia(node.GetLeadingTrivia().Add(trivia.Value)); - - private static T Parse(string text) - { - var node = CSharpSyntaxTree.ParseText(text).GetRoot().ChildNodes().OfType().FirstOrDefault(); - - if (node is null) - throw new ArgumentException($@"Text ""{text}"" contains invalid code", nameof(text)); - - return node; - } - - private static ClassDeclarationSyntax ParseClass(string code) - { - return Parse(code); - } -} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/TriviaHelper.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/TriviaHelper.cs deleted file mode 100644 index ecdc21bfc..000000000 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Helpers/TriviaHelper.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; - -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; - -namespace NetDaemon.HassModel.CodeGenerator.Helpers; -internal static class TriviaHelper -{ - internal static string GeneratorVersion = Assembly.GetAssembly(typeof(Generator))!.GetName().Version!.ToString(); - internal static SyntaxTrivia[] GetFileHeader() - { - string headerText = @$" - //------------------------------------------------------------------------------ - // - // Generated using NetDaemon CodeGenerator nd-codegen v{GeneratorVersion} - // - // *** Make sure the version of the codegen tool and your nugets Joysoftware.NetDaemon.* have the same version.*** - // You can use following command to keep it up to date with the latest version: - // dotnet tool update JoySoftware.NetDaemon.HassModel.CodeGen - // - // To update this file with latest entities run this command in your project directory: - // dotnet tool run nd-codegen - // - // In the template projects we provided a convenience powershell script that will update - // the codegen and nugets to latest versions update_all_dependencies.ps1. - // - // For more information: https://netdaemon.xyz/docs/v3/hass_model/hass_model_codegen - // For more information about NetDaemon: https://netdaemon.xyz/ - // - //------------------------------------------------------------------------------"; - - var lines = headerText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - SyntaxTrivia[] header = lines.SelectMany(l => new[] { Comment(l), LineFeed }).ToArray(); - return header; - } -} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/AttributeMetaDataGenerator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/AttributeMetaDataGenerator.cs similarity index 100% rename from src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/AttributeMetaDataGenerator.cs rename to src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/AttributeMetaDataGenerator.cs diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ClrTypeJsonConverter.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/ClrTypeJsonConverter.cs similarity index 100% rename from src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ClrTypeJsonConverter.cs rename to src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/ClrTypeJsonConverter.cs diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityDomainMetadata.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityDomainMetadata.cs similarity index 100% rename from src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityDomainMetadata.cs rename to src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityDomainMetadata.cs diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaDataGenerator.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataGenerator.cs similarity index 100% rename from src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaDataGenerator.cs rename to src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataGenerator.cs diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaDataMerger.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataMerger.cs similarity index 100% rename from src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaDataMerger.cs rename to src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/EntityMetaData/EntityMetaDataMerger.cs diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassService.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassService.cs new file mode 100644 index 000000000..39a73544d --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassService.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace NetDaemon.HassModel.CodeGenerator.Model; + +internal record HassService +{ + public string Service { get; init; } = ""; // cannot be required because the JsonSerializer will complain + public string? Description { get; init; } + + [JsonIgnore] + public IReadOnlyCollection? Fields { get; init; } + public TargetSelector? Target { get; init; } +} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassServiceArgumentMapper.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassServiceArgumentMapper.cs new file mode 100644 index 000000000..9ad41a470 --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassServiceArgumentMapper.cs @@ -0,0 +1,35 @@ +namespace NetDaemon.HassModel.CodeGenerator.Helpers; + +internal static class HassServiceArgumentMapper +{ + public static ServiceArgument Map(HassServiceField field) + { + return new ServiceArgument + { + HaName = field.Field, + ClrType = GetClrTypeFromSelector(field.Selector), + Required = field.Required == true, + Comment = field.Description + (string.IsNullOrWhiteSpace(field.Example?.ToString()) ? "" : $" eg: {field.Example}") + }; + } + private static Type GetClrTypeFromSelector(Selector? selectorObject) + { + return selectorObject switch + { + null => typeof(object), + NumberSelector s when (s.Step ?? 1) % 1 != 0 => typeof(double), + NumberSelector => typeof(long), + EntitySelector => typeof(string), + DeviceSelector => typeof(string), + AreaSelector => typeof(string), + _ => selectorObject.Type switch + { + "boolean" => typeof(bool), + "object" => typeof(object), + "time" => typeof(DateTime), // Maybe TimeOnly??, + "text" => typeof(string), + _ => typeof(object) + }, + }; + } +} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/HassServiceDomain.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassServiceDomain.cs similarity index 100% rename from src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/HassServiceDomain.cs rename to src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassServiceDomain.cs diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassServiceField.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassServiceField.cs new file mode 100644 index 000000000..7be0e8820 --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/HassServiceField.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace NetDaemon.HassModel.CodeGenerator.Model; + +internal record HassServiceField +{ + public string Field { get; init; } = ""; // cannot be required because the JsonSerializer will complain + public string? Description { get; init; } + public bool? Required { get; init; } + public object? Example { get; init; } + + [JsonConverter(typeof(SelectorConverter))] + public Selector? Selector { get; init; } +} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/SelectorConverter.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/SelectorConverter.cs new file mode 100644 index 000000000..baa5a6e2f --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/SelectorConverter.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using System.Text.Json.Serialization; + +namespace NetDaemon.HassModel.CodeGenerator.Model; + +class SelectorConverter : JsonConverter +{ + public override Selector? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonSerializer.Deserialize(ref reader); + var property = element.EnumerateObject().FirstOrDefault(); + return getSelector(property.Name, property.Value); + } + + public override void Write(Utf8JsonWriter writer, Selector value, JsonSerializerOptions options) => throw new NotSupportedException(); + + private static Selector? getSelector(string selectorName, JsonElement element) + { + // Find a matching Type for this selector + Type[] executingAssemblyTypes = Assembly.GetExecutingAssembly().GetTypes(); + var selectorType = executingAssemblyTypes.FirstOrDefault(x => string.Equals($"{selectorName}Selector", x.Name, StringComparison.OrdinalIgnoreCase)); + + if (selectorType is null) + { + return new Selector { Type = selectorName}; + } + + var deserialize = (Selector?)element.Deserialize(selectorType, ServiceMetaDataParser.SnakeCaseNamingPolicySerializerOptions); + deserialize ??= (Selector)Activator.CreateInstance(selectorType)!; + + return deserialize with { Type = selectorName }; + } +} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/Selectors.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/Selectors.cs similarity index 55% rename from src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/Selectors.cs rename to src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/Selectors.cs index 887018f6b..88e61ed98 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/Selectors.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/Selectors.cs @@ -3,26 +3,20 @@ namespace NetDaemon.HassModel.CodeGenerator.Model; -internal record ActionSelector +internal record Selector() { + public string? Type { get; init; } } -internal record AddonSelector -{ -} -internal record AreaSelector +internal record AreaSelector : Selector { public DeviceSelector? Device { get; init; } public EntitySelector? Entity { get; init; } } -internal record BooleanSelector -{ -} - -internal record DeviceSelector +internal record DeviceSelector : Selector { public string? Integration { get; init; } @@ -33,16 +27,15 @@ internal record DeviceSelector public EntitySelector? Entity { get; init; } } -internal record EntitySelector +internal record EntitySelector : Selector { public string? Integration { get; init; } - public string? Domain { get; init; } - - public string? DeviceClass { get; init; } + [JsonConverter(typeof(StringAsArrayConverter))] + public string[] Domain { get; init; } = Array.Empty(); } -internal record NumberSelector +internal record NumberSelector : Selector { [Required] public double Min { get; init; } @@ -53,22 +46,9 @@ internal record NumberSelector public float? Step { get; init; } public string? UnitOfMeasurement { get; init; } - - [JsonConverter(typeof(NullableEnumStringConverter))] - public NumberSelectorMode? Mode { get; init; } -} - -internal enum NumberSelectorMode -{ - Box, - Slider } -internal record ObjectSelector -{ -} - -internal record TargetSelector +internal record TargetSelector : Selector { public AreaSelector? Area { get; init; } @@ -77,11 +57,3 @@ internal record TargetSelector public EntitySelector? Entity { get; init; } } -internal record TextSelector -{ - public bool? Multiline { get; init; } -} - -internal record TimeSelector -{ -} diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs new file mode 100644 index 000000000..2c18d9110 --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/ServiceMetaDataParser.cs @@ -0,0 +1,61 @@ +namespace NetDaemon.HassModel.CodeGenerator.Model; + +internal static class ServiceMetaDataParser +{ + public static readonly JsonSerializerOptions SnakeCaseNamingPolicySerializerOptions = new() + { + PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance + }; + + /// + /// Parses all json elements to instance result from GetServices call + /// + /// JsonElement containing the result data + public static IReadOnlyCollection Parse(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("Not expected result from the GetServices result"); + + var result = element.EnumerateObject().Select(property => + new HassServiceDomain + { + Domain = property.Name, + Services = GetServices(property.Value) + }).ToList(); + + return result; + } + + private static IReadOnlyCollection GetServices(JsonElement element) + { + return element.EnumerateObject() + .Select(serviceDomainProperty => + GetServiceFields(serviceDomainProperty.Name, serviceDomainProperty.Value)).ToList(); + } + + private static HassService GetServiceFields(string service, JsonElement element) + { + var result = element.Deserialize(SnakeCaseNamingPolicySerializerOptions)! with + { + Service = service, + }; + + if (element.TryGetProperty("fields", out var fieldProperty)) + { + result = result with + { + Fields = fieldProperty.EnumerateObject().Select(p => GetField(p.Name, p.Value)).ToList() + }; + } + + return result; + } + + private static HassServiceField GetField(string fieldName, JsonElement element) + { + return element.Deserialize(SnakeCaseNamingPolicySerializerOptions)! with + { + Field = fieldName, + }; + } +} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/SnakeCaseNamingPolicy.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/SnakeCaseNamingPolicy.cs similarity index 80% rename from src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/SnakeCaseNamingPolicy.cs rename to src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/SnakeCaseNamingPolicy.cs index f98dc2534..80c4312ab 100644 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/SnakeCaseNamingPolicy.cs +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/SnakeCaseNamingPolicy.cs @@ -6,10 +6,7 @@ internal class SnakeCaseNamingPolicy : JsonNamingPolicy { public static SnakeCaseNamingPolicy Instance { get; } = new (); - public override string ConvertName(string name) - { - return ToSnakeCase(name); - } + public override string ConvertName(string name) => ToSnakeCase(name); private static string ToSnakeCase(string str) { diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsArrayConverter.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsArrayConverter.cs new file mode 100644 index 000000000..f8844bcb4 --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.CodeGenerator/MetaData/ServicesMetaData/StringAsArrayConverter.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using System.Text.Json.Serialization; + +namespace NetDaemon.HassModel.CodeGenerator.Model; + +/// +/// Converts a Json element that can be a string or a string array +/// +class StringAsArrayConverter : JsonConverter +{ + public override string[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return new []{ reader.GetString() ?? throw new UnreachableException("Token is expected to be a string") }; + } + + return JsonSerializer.Deserialize(ref reader, options); + } + + public override void Write(Utf8JsonWriter writer, string[] value, JsonSerializerOptions options) => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/HassService.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/HassService.cs deleted file mode 100644 index dad83e97b..000000000 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/HassService.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NetDaemon.HassModel.CodeGenerator.Model; - -internal record HassService -{ - public required string Service { get; init; } - public string? Description { get; init; } - public IReadOnlyCollection? Fields { get; init; } - public TargetSelector? Target { get; init; } -} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/HassServiceField.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/HassServiceField.cs deleted file mode 100644 index c5550d9c8..000000000 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/HassServiceField.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace NetDaemon.HassModel.CodeGenerator.Model; - -internal record HassServiceField -{ - public string? Field { get; init; } - public string? Description { get; init; } - public bool? Required { get; init; } - public object? Example { get; init; } - public object? Selector { get; init; } -} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/JsonExtensions.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/JsonExtensions.cs deleted file mode 100644 index fd7c13277..000000000 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/JsonExtensions.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System.Buffers; -using System.Reflection; - -namespace NetDaemon.HassModel.CodeGenerator.Model; - -internal static class JsonExtensions -{ - private static readonly JsonSerializerOptions SnakeCaseNamingPolicySerializerOptions = new() - { - PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance - }; - - /// - /// Parses all json elements to instance result from GetServices call - /// - /// JsonElement containing the result data - public static IReadOnlyCollection ToServicesResult(this JsonElement element) - { - var result = new List(); - Type[] executingAssemblyTypes = Assembly.GetExecutingAssembly().GetTypes(); - - if (element.ValueKind != JsonValueKind.Object) - throw new InvalidOperationException("Not expected result from the GetServices result"); - - foreach (var property in element.EnumerateObject()) - { - var serviceDomain = new HassServiceDomain - { - Domain = property.Name, - Services = getServices(property.Value) - }; - result.Add(serviceDomain); - } - - IReadOnlyCollection getServices(JsonElement element) - { - var servicesList = new List(); - foreach (var serviceDomainProperty in element.EnumerateObject()) - { - servicesList.Add(getServiceFields(serviceDomainProperty.Name, serviceDomainProperty.Value)); - } - return servicesList; - } - - HassService getServiceFields(string service, JsonElement element) - { - var serviceFields = new List(); - - string? serviceDescription = null; - TargetSelector? target = null; - - foreach (var serviceProperty in element.EnumerateObject()) - { - switch (serviceProperty.Name) - { - case "description": - serviceDescription = serviceProperty.Value.GetString(); - break; - case "fields": - if (serviceProperty.Value.ValueKind == JsonValueKind.Object) - { - foreach (var fieldsProperty in serviceProperty.Value.EnumerateObject()) - { - serviceFields.Add(getField(fieldsProperty.Name, fieldsProperty.Value)); - } - } - break; - case "target": - target = getSelector(serviceProperty.Name, serviceProperty.Value) as TargetSelector; - break; - } - } - return new HassService - { - Service = service, - Fields = serviceFields, - Description = serviceDescription, - Target = target - }; - } - - object? getSelector(string selectorName, JsonElement element) - { - var selectorType = executingAssemblyTypes.FirstOrDefault(x => string.Equals($"{selectorName}Selector", x.Name, StringComparison.OrdinalIgnoreCase)); - - if (selectorType is null) - { - return null; - } - - if (element.ValueKind == JsonValueKind.Object && !element.EnumerateObject().Any() || - element.ValueKind != JsonValueKind.Object && element.GetString() is null) - { - return Activator.CreateInstance(selectorType); - } - - var bufferWriter = new ArrayBufferWriter(); - using (var writer = new Utf8JsonWriter(bufferWriter)) - { - element.WriteTo(writer); - } - - return JsonSerializer.Deserialize(bufferWriter.WrittenSpan, - selectorType, - SnakeCaseNamingPolicySerializerOptions); - } - - HassServiceField getField(string fieldName, JsonElement element) - { - object? example = null; - string? fieldDescription = null; - bool? required = null; - object? selector = null; - - foreach (var fieldProperty in element.EnumerateObject()) - { - switch (fieldProperty.Name) - { - case "description": - fieldDescription = fieldProperty.Value.GetString(); - break; - case "required": - required = fieldProperty.Value.GetBoolean(); - break; - case "example": - switch (fieldProperty.Value.ValueKind) - { - case JsonValueKind.String: - example = fieldProperty.Value.GetString(); - break; - case JsonValueKind.Number: - if (fieldProperty.Value.TryGetInt64(out long longVal)) - example = longVal; - else - example = fieldProperty.Value.GetDouble(); - break; - case JsonValueKind.Object: - - example = fieldProperty.Value; - break; - case JsonValueKind.True: - example = true; - break; - case JsonValueKind.False: - example = false; - break; - case JsonValueKind.Array: - example = fieldProperty.Value; - break; - } - break; - case "selector": - var selectorProperty = fieldProperty.Value.EnumerateObject().First(); - selector = getSelector(selectorProperty.Name, selectorProperty.Value); - break; - } - } - return new HassServiceField - { - Field = fieldName, - Example = example, - Description = fieldDescription, - Required = required, - Selector = selector - }; - } - - return result; - } -} diff --git a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/NullableEnumStringConverter.cs b/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/NullableEnumStringConverter.cs deleted file mode 100644 index 98c3e45d5..000000000 --- a/src/HassModel/NetDaemon.HassModel.CodeGenerator/Model/NullableEnumStringConverter.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.ComponentModel; -using System.Text.Json.Serialization; - -namespace NetDaemon.HassModel.CodeGenerator.Model; - -internal class NullableEnumStringConverter : JsonConverter -{ - private readonly bool _isNullable; - private readonly Type _enumType; - - public NullableEnumStringConverter() { - _isNullable = Nullable.GetUnderlyingType(typeof(TEnum)) is not null; - - _enumType = _isNullable ? - Nullable.GetUnderlyingType(typeof(TEnum))! : - typeof(TEnum); - } - - public override TEnum? Read(ref Utf8JsonReader reader, - Type typeToConvert, JsonSerializerOptions options) - { - var value = reader.GetString(); - - if (_isNullable && string.IsNullOrEmpty(value)) - return default; - - if (string.IsNullOrEmpty(value)) - { - throw new InvalidEnumArgumentException( - $"A value must be provided for non-nullable enum property of type \"{_enumType.FullName}\""); - } - - if (Enum.TryParse(_enumType, value, true, out var result)) - { - return (TEnum) result!; - } - - return default; - } - - public override void Write(Utf8JsonWriter writer, - TEnum value, JsonSerializerOptions options) - { - throw new NotSupportedException($"Serialization not supported for the enum \"{_enumType.Name}\""); - } -} \ No newline at end of file diff --git a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGenTestHelper.cs b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGenTestHelper.cs index f420bd396..8316da431 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGenTestHelper.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGenTestHelper.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.Extensions.DependencyInjection; using NetDaemon.Client.HomeAssistant.Model; using NetDaemon.HassModel.CodeGenerator; using NetDaemon.HassModel.CodeGenerator.Model; @@ -31,7 +32,8 @@ public static void AssertCodeCompiles(string generated, string appCode) SyntaxFactory.ParseSyntaxTree(generated, path: "generated.cs"), SyntaxFactory.ParseSyntaxTree(appCode, path: "appcode.cs") }; - + var _ = typeof(IServiceCollection); // make sure this type is not removed + var compilation = CSharpCompilation.Create("tempAssembly", syntaxtrees, AppDomain.CurrentDomain.GetAssemblies().Where(a => !a.IsDynamic).Select(a => MetadataReference.CreateFromFile(a.Location)).ToArray(), @@ -53,8 +55,12 @@ public static void AssertCodeCompiles(string generated, string appCode) msg.AppendLine(); msg.AppendLine("generated.cs"); + // output the generated code including line numbers to help debugging - msg.AppendLine(string.Join(Environment.NewLine, generated.Split('\n').Select((l, i) => $"{i+1,4}: {l}"))); + var linesWithNumbers = generated.Split(new [] { "\r\n", "\r", "\n" }, StringSplitOptions.None) + .Select((l, i) => $"{i+1,5}: {l}"); + + msg.AppendJoin(Environment.NewLine, linesWithNumbers); Assert.Fail(msg.ToString()); } diff --git a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGeneratorTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGeneratorTest.cs index 610e53219..c5b261113 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGeneratorTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/CodeGeneratorTest.cs @@ -136,7 +136,7 @@ public void TestNumberExtensionMethodGeneration() new() { Service = "set_value", Target = new TargetSelector { - Entity = new() { Domain = "number" } + Entity = new() { Domain = new [] {"number"} } }, Fields = new HassServiceField[] { new() { Field = "value", Selector = new NumberSelector(), }, diff --git a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServiceMetaDataParserTest.cs b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServiceMetaDataParserTest.cs new file mode 100644 index 000000000..b2f230f0e --- /dev/null +++ b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServiceMetaDataParserTest.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Text.Json; +using NetDaemon.HassModel.CodeGenerator.Model; + +namespace NetDaemon.HassModel.Tests.CodeGenerator; + +public class ServiceMetaDataParserTest +{ + [Fact] + public void TestSomeBasicServicesCanBeParsed() + { + var sample = """ + { + "homeassistant": { + "save_persistent_states": { + "name": "Save Persistent States", + "description": "Save the persistent states (for entities derived from RestoreEntity) immediately. Maintain the normal periodic saving interval.", + "fields": {} + }, + "turn_off": { + "name": "Generic turn off", + "description": "Generic service to turn devices off under any domain.", + "fields": {}, + "target": { + "entity": {} + } + }, + "turn_on": { + "name": "Generic turn on", + "description": "Generic service to turn devices on under any domain.", + "fields": {}, + "target": { + "entity": {} + } + } + } + } + """; + var element = JsonDocument.Parse(sample).RootElement; + var res = ServiceMetaDataParser.Parse(element); + res.Should().HaveCount(1); + res.First().Domain.Should().Be("homeassistant"); + res.First().Services.ElementAt(1).Target!.Entity!.Domain.Should().BeEmpty(); + } + + [Fact] + public void TestMultiDomainTarget() + { + var sample = """ + { + "wiser": { + "get_schedule": { + "name": "Save Schedule to File", + "description": "Read the schedule from a room or device and write to an output file in yaml\n", + "fields": { + "entity_id": { + "name": "Entity", + "description": "A wiser entity", + "required": true, + "selector": { + "entity": { + "integration": "wiser", + "domain": [ + "climate", + "select" + ] + } + } + } + } + } + } + } + """; + var result = Parse(sample); + + result.First().Services.First().Fields!.First().Selector.Should() + .BeAssignableTo().Which.Domain.Should().BeEquivalentTo("climate", "select"); + } + + private static IReadOnlyCollection Parse(string sample) + { + var element = JsonDocument.Parse(sample).RootElement; + return ServiceMetaDataParser.Parse(element); + } +} \ 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 090c6569a..676b09a7e 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServicesGeneratorTest.cs +++ b/src/HassModel/NetDaemon.HassModel.Tests/CodeGenerator/ServicesGeneratorTest.cs @@ -1,4 +1,3 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; using NetDaemon.Client.HomeAssistant.Model; using NetDaemon.HassModel.CodeGenerator; using NetDaemon.HassModel.CodeGenerator.Model; @@ -22,7 +21,7 @@ public void TestServicesGeneration() Services = new HassService[] { new() { Service = "turn_off", - Target = new TargetSelector { Entity = new() { Domain = "light" } } + Target = new TargetSelector { Entity = new() { Domain = new [] {"light"} } } }, new() { Service = "turn_on", @@ -30,7 +29,7 @@ public void TestServicesGeneration() new() { Field = "transition", Selector = new NumberSelector(), }, new() { Field = "brightness", Selector = new NumberSelector { Step = 0.2f }, } }, - Target = new TargetSelector { Entity = new() { Domain = "light" } } + Target = new TargetSelector { Entity = new() { Domain = new [] {"light" } } } } } } @@ -84,11 +83,11 @@ public void TestServiceWithoutAnyTargetEntity_ExtensionMethodSkipped() Services = new HassService[] { new() { Service = "dig", - Target = new TargetSelector { Entity = new() { Domain = "rover" } }, + Target = new TargetSelector { Entity = new() { Domain = new [] {"humidifiers"} } }, }, new() { Service = "orbit", - Target = new TargetSelector { Entity = new() { Domain = "orbiter" } }, + Target = new TargetSelector { Entity = new() { Domain = new [] {"orbiter" }} }, } }, } @@ -135,7 +134,7 @@ public void TestServiceWithoutAnyMethods_ClassSkipped() Services = new HassService[] { new() { Service = "push_button", - Target = new TargetSelector { Entity = new() { Domain = "uselessbox" } }, + Target = new TargetSelector { Entity = new() { Domain = new [] {"uselessbox" }} }, }, }, } diff --git a/src/HassModel/NetDaemon.HassModel.Tests/NetDaemon.HassModel.Tests.csproj b/src/HassModel/NetDaemon.HassModel.Tests/NetDaemon.HassModel.Tests.csproj index 556daffe4..fec1c4f2f 100644 --- a/src/HassModel/NetDaemon.HassModel.Tests/NetDaemon.HassModel.Tests.csproj +++ b/src/HassModel/NetDaemon.HassModel.Tests/NetDaemon.HassModel.Tests.csproj @@ -10,6 +10,7 @@ +