diff --git a/Modspec.Model/Generation/ModspecModelGenerator.cs b/Modspec.Model/Generation/ModspecModelGenerator.cs index 913106f..28438c2 100644 --- a/Modspec.Model/Generation/ModspecModelGenerator.cs +++ b/Modspec.Model/Generation/ModspecModelGenerator.cs @@ -28,7 +28,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Select(static (model, cancellationToken) => { string path = model.Path; - ModelCompiler.TryGenerate(model.Path, out string? code); + ModelCompiler.TryGenerate(path, out string? code); return (path, code); }) .Where(static (pair) => !String.IsNullOrEmpty(pair.code)); @@ -59,6 +59,7 @@ public static bool TryGenerate(string path, [NotNullWhen(true)] out string? resu mainWriter.WriteLine($""" // This code has been generated by a tool. // Do not modify it. Your changes will be overwritten. +#nullable enable using System; using System.Buffers.Binary; using System.Collections.Generic; @@ -77,7 +78,8 @@ namespace {schema.Name}; List bufferInitialisers = []; List fieldInitialisers = []; List constructorParams = []; - WriteGroups(schema.Groups, mainWriter, appendixWriter, bufferInitialisers, fieldInitialisers, constructorParams); + bool hasChangeDetection = false; + WriteGroups(schema.Groups, mainWriter, appendixWriter, bufferInitialisers, fieldInitialisers, constructorParams, ref hasChangeDetection); foreach (RepeatingGroup repeatingGroup in schema.RepeatingGroups) { @@ -103,8 +105,18 @@ namespace {schema.Name}; mainWriter.WriteLine("\t\tprivate readonly IModbusClient _client;"); mainWriter.WriteLine("\t\tprivate readonly int _offset;"); // the offset to the base offset for this particular repeated element mainWriter.WriteLine(); - WriteGroups(repeatingGroup.Groups, mainWriter, appendixWriter, groupBufferInitialisers, groupFieldInitialisers, groupConstructorParams, "\t", "_offset + "); + bool groupHasChangeDetection = false; + WriteGroups(repeatingGroup.Groups, mainWriter, appendixWriter, groupBufferInitialisers, groupFieldInitialisers, groupConstructorParams, ref groupHasChangeDetection, "\t", "_offset + "); + if (groupHasChangeDetection) + { + hasChangeDetection = true; + mainWriter.WriteLine("\t\tprivate readonly Action? _onBitfieldChanged;"); + } groupFieldInitialisers.Add("_offset = offset;"); + if (groupHasChangeDetection) + { + groupFieldInitialisers.Add("_onBitfieldChanged = onBitfieldChanged;"); + } fieldInitialisers.Add($"{repeatingGroupFieldName} = new List<{repeatingGroup.Name}>();"); fieldInitialisers.Add($"for (int i = 0; i < {repeatingGroupCountParam.Name}; i++)"); fieldInitialisers.Add("{"); @@ -113,10 +125,11 @@ namespace {schema.Name}; { repeatingGroupParameterRefs = ", " + String.Join(", ", groupConstructorParams.Select(cp => cp.Name)); } - fieldInitialisers.Add($"\t{repeatingGroupFieldName}.Add(new {repeatingGroup.Name}(client, i * {repeatingGroup.Every}{repeatingGroupParameterRefs}));"); + string changeDetectionRef = groupHasChangeDetection ? ", onBitfieldChanged" : ""; + fieldInitialisers.Add($"\t{repeatingGroupFieldName}.Add(new {repeatingGroup.Name}(client, i * {repeatingGroup.Every}{repeatingGroupParameterRefs}{changeDetectionRef}));"); fieldInitialisers.Add("}"); groupConstructorParams.Insert(0, new("offset", UInt16.MaxValue)); - WriteFieldsAndConstructor(repeatingGroup.Name, mainWriter, groupBufferInitialisers, groupFieldInitialisers, groupConstructorParams, "\t"); + WriteFieldsAndConstructor(repeatingGroup.Name, mainWriter, groupBufferInitialisers, groupFieldInitialisers, groupConstructorParams, "\t", groupHasChangeDetection); mainWriter.WriteLine("\t}"); mainWriter.WriteLine(); // need to expose inner constructor parameters to parent client constructor @@ -133,7 +146,11 @@ namespace {schema.Name}; } } - WriteFieldsAndConstructor(schema.Name + "Client", mainWriter, bufferInitialisers, fieldInitialisers, constructorParams); + if (hasChangeDetection) + { + mainWriter.WriteLine("\tprivate readonly Action? _onBitfieldChanged;"); + } + WriteFieldsAndConstructor(schema.Name + "Client", mainWriter, bufferInitialisers, fieldInitialisers, constructorParams, hasChangeDetection: hasChangeDetection); mainWriter.WriteLine("}"); mainWriter.WriteLine(); @@ -142,7 +159,7 @@ namespace {schema.Name}; } } - private static void WriteFieldsAndConstructor(string name, StringWriter mainWriter, List bufferInitialisers, List fieldInitialisers, List constructorParams, string indent = "") + private static void WriteFieldsAndConstructor(string name, StringWriter mainWriter, List bufferInitialisers, List fieldInitialisers, List constructorParams, string indent = "", bool hasChangeDetection = false) { mainWriter.Write($"\t{indent}public {name}(IModbusClient client"); if (constructorParams.Count > 0) @@ -150,9 +167,17 @@ private static void WriteFieldsAndConstructor(string name, StringWriter mainWrit mainWriter.Write(", "); mainWriter.Write(String.Join(", ", constructorParams.Select(cp => $"int {cp.Name}"))); } + if (hasChangeDetection) + { + mainWriter.Write(", Action? onBitfieldChanged = null"); + } mainWriter.WriteLine(")"); mainWriter.WriteLine($"{indent}\t{{"); mainWriter.WriteLine($"{indent}\t\t_client = client;"); + if (hasChangeDetection) + { + mainWriter.WriteLine($"{indent}\t\t_onBitfieldChanged = onBitfieldChanged;"); + } foreach ((string constructorParamName, int maxCount) in constructorParams) { mainWriter.WriteLine($"{indent}\t\tif ({constructorParamName} > {maxCount}) throw new ArgumentException(\"{constructorParamName} is greater than the maximum permitted value ({maxCount}).\", \"{constructorParamName}\");"); @@ -168,14 +193,25 @@ private static void WriteFieldsAndConstructor(string name, StringWriter mainWrit mainWriter.WriteLine($"{indent}\t}}"); } - private static void WriteGroups(IReadOnlyCollection groups, StringWriter mainWriter, StringWriter appendixWriter, List bufferInitialisers, List fieldInitialisers, List constructorParams, string indent = "", string readOffsetField = "") + private static void WriteGroups(IReadOnlyCollection groups, StringWriter mainWriter, StringWriter appendixWriter, List bufferInitialisers, List fieldInitialisers, List constructorParams, ref bool hasChangeDetection, string indent = "", string readOffsetField = "") { foreach (Group group in groups) { string bufferName = $"_buffer{group.Name}"; + string previousName = $"_previous{group.Name}"; mainWriter.WriteLine($"{indent}\tprivate readonly Memory {bufferName};"); + // pre-scan to determine if this group has bitfield points with levels + bool groupHasLevels = group.Points.Any(p => + p.Type.IsBitfield() && p.Symbols is not null && + p.Symbols.Any(s => s.Level.HasValue && s.Level.Value != Level.None)); + if (groupHasLevels) + { + mainWriter.WriteLine($"{indent}\tprivate readonly Memory {previousName};"); + hasChangeDetection = true; + } int maxOffset = 0; string bufferSize = String.Empty; + List groupBitfieldPoints = []; for (int i = 0; i < group.Points.Count; i++) { Point point = group.Points[i]; @@ -185,31 +221,61 @@ private static void WriteGroups(IReadOnlyCollection groups, StringWriter // supplied count of elements, rather than max size of array) throw new InvalidOperationException($"An array must be the last (or only) element in a group."); } - WritePoint(point, bufferName, group.Table, mainWriter, appendixWriter, fieldInitialisers, constructorParams, ref maxOffset, ref bufferSize, indent); + WritePoint(point, bufferName, group.Table, mainWriter, appendixWriter, fieldInitialisers, constructorParams, ref maxOffset, ref bufferSize, indent, groupBitfieldPoints); } if (String.IsNullOrEmpty(bufferSize)) { bufferSize = $"{maxOffset}"; } bufferInitialisers.Add($"{bufferName} = new byte[{bufferSize}];"); + if (groupHasLevels) + { + bufferInitialisers.Add($"{previousName} = new byte[{bufferSize}];"); + } mainWriter.WriteLine(); // generate Read method for this group mainWriter.WriteLine($"\t{indent}public async ValueTask Read{group.Name}Async()"); mainWriter.WriteLine($"\t{indent}{{"); // note dependency between table name and Read...Async method on IModbusClient mainWriter.WriteLine($"\t\t{indent}await _client.Read{group.Table}Async({readOffsetField}{group.BaseRegister}, {bufferName});"); + if (groupBitfieldPoints.Count > 0) + { + mainWriter.WriteLine($"\t\t{indent}if (_onBitfieldChanged is not null) Check{group.Name}();"); + } mainWriter.WriteLine($"\t{indent}}}"); mainWriter.WriteLine(); mainWriter.WriteLine($"\t{indent}public void Read{group.Name}()"); mainWriter.WriteLine($"\t{indent}{{"); // note dependency between table name and Read... method on IModbusClient mainWriter.WriteLine($"\t\t{indent}_client.Read{group.Table}({readOffsetField}{group.BaseRegister}, {bufferName}.Span);"); + if (groupBitfieldPoints.Count > 0) + { + mainWriter.WriteLine($"\t\t{indent}if (_onBitfieldChanged is not null) Check{group.Name}();"); + } mainWriter.WriteLine($"\t{indent}}}"); mainWriter.WriteLine(); + // generate Check method for groups with leveled bitfields + if (groupBitfieldPoints.Count > 0) + { + mainWriter.WriteLine($"\t{indent}private void Check{group.Name}()"); + mainWriter.WriteLine($"\t{indent}{{"); + mainWriter.WriteLine($"\t\t{indent}Span current = {bufferName}.Span;"); + mainWriter.WriteLine($"\t\t{indent}Span previous = {previousName}.Span;"); + foreach (BitfieldPointInfo bp in groupBitfieldPoints) + { + mainWriter.WriteLine($"\t\t{indent}if (!current.Slice({bp.Offset}, {bp.SizeInBytes}).SequenceEqual(previous.Slice({bp.Offset}, {bp.SizeInBytes})))"); + mainWriter.WriteLine($"\t\t{indent}{{"); + mainWriter.WriteLine($"\t\t\t{indent}_onBitfieldChanged!(\"{bp.PointName}\", {bp.PointName}.GetLevel());"); + mainWriter.WriteLine($"\t\t{indent}}}"); + } + mainWriter.WriteLine($"\t\t{indent}current.CopyTo(previous);"); + mainWriter.WriteLine($"\t{indent}}}"); + mainWriter.WriteLine(); + } } } - private static void WritePoint(Point point, string bufferName, Table table, StringWriter mainWriter, StringWriter appendixWriter, List fieldInitialisers, List constructorParams, ref int maxOffset, ref string bufferSize, string indent = "") + private static void WritePoint(Point point, string bufferName, Table table, StringWriter mainWriter, StringWriter appendixWriter, List fieldInitialisers, List constructorParams, ref int maxOffset, ref string bufferSize, string indent = "", List? groupBitfieldPoints = null) { string type; string readMethod; @@ -337,6 +403,7 @@ private static void WritePoint(Point point, string bufferName, Table table, Stri appendixWriter.WriteLine(); if (isFlags && masksByLevel.Count > 0) { + groupBitfieldPoints?.Add(new BitfieldPointInfo(point.Name, maxOffset, point.SizeInBytes)); appendixWriter.WriteLine($"public static class {point.Name}Extensions"); appendixWriter.WriteLine("{"); appendixWriter.WriteLine($"\tpublic static Level GetLevel(this {point.Name} self)"); @@ -468,4 +535,5 @@ private static string Pluralise(string self) } private record ConstructorParameter(string Name, int Count); + private record BitfieldPointInfo(string PointName, int Offset, int SizeInBytes); } \ No newline at end of file diff --git a/Modspec.Test/Tests.cs b/Modspec.Test/Tests.cs index f97c020..01e617c 100644 --- a/Modspec.Test/Tests.cs +++ b/Modspec.Test/Tests.cs @@ -1,5 +1,6 @@ using System; using System.Buffers.Binary; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; @@ -87,6 +88,70 @@ public void TestErrorLevels() Assert.That(errors1.GetLevel(), Is.EqualTo(Level.Emergency)); } + [Test] + public async Task TestInlineChangeDetectionNoChange() + { + MockModbusClient mockClient = new MockModbusClient(); + List<(string name, Level level)> changes = []; + SomeBmsClient bmsClient = new SomeBmsClient(mockClient, 2, 2, 480, 100, + onBitfieldChanged: (name, level) => changes.Add((name, level))); + + // no errors — no callback + await bmsClient.ReadWarningsErrorsEmergenciesAsync(); + Assert.That(changes, Is.Empty); + + // read again with same state — still no callback + await bmsClient.ReadWarningsErrorsEmergenciesAsync(); + Assert.That(changes, Is.Empty); + } + + [Test] + public async Task TestInlineChangeDetectionDetectsChange() + { + MockModbusClient mockClient = new MockModbusClient(); + List<(string name, Level level)> changes = []; + SomeBmsClient bmsClient = new SomeBmsClient(mockClient, 2, 2, 480, 100, + onBitfieldChanged: (name, level) => changes.Add((name, level))); + + // introduce an error and read + mockClient.DiscreteInputs.Span[1] = 0b10000000; // StringTerminalDischargeOverCurrentError + await bmsClient.ReadWarningsErrorsEmergenciesAsync(); + Assert.That(changes, Has.Count.EqualTo(1)); + Assert.That(changes[0].name, Is.EqualTo("StringErrors1")); + Assert.That(changes[0].level, Is.EqualTo(Level.Error)); + + // same state again — no callback + changes.Clear(); + await bmsClient.ReadWarningsErrorsEmergenciesAsync(); + Assert.That(changes, Is.Empty); + } + + [Test] + public async Task TestInlineChangeDetectionReportsHighestLevel() + { + MockModbusClient mockClient = new MockModbusClient(); + List<(string name, Level level)> changes = []; + SomeBmsClient bmsClient = new SomeBmsClient(mockClient, 2, 2, 480, 100, + onBitfieldChanged: (name, level) => changes.Add((name, level))); + + // set both a warning (bit 0) and an emergency (bit 2) on StringErrors1 + mockClient.DiscreteInputs.Span[1] = 0b00000101; + await bmsClient.ReadWarningsErrorsEmergenciesAsync(); + Assert.That(changes, Has.Count.EqualTo(1)); + Assert.That(changes[0].level, Is.EqualTo(Level.Emergency)); + } + + [Test] + public async Task TestNoCallbackNoOverhead() + { + // constructing without callback should work fine — no change detection runs + MockModbusClient mockClient = new MockModbusClient(); + SomeBmsClient bmsClient = new SomeBmsClient(mockClient, 2, 2, 480, 100); + mockClient.DiscreteInputs.Span[1] = 0b10000000; + await bmsClient.ReadWarningsErrorsEmergenciesAsync(); + Assert.That(bmsClient.StringErrors1, Is.EqualTo(StringErrors1.StringTerminalDischargeOverCurrentError)); + } + [Test] public async Task TestRangeValidation() {