Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 85 additions & 10 deletions Modspec.Model/Generation/ModspecModelGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand All @@ -77,7 +78,8 @@ namespace {schema.Name};
List<string> bufferInitialisers = [];
List<string> fieldInitialisers = [];
List<ConstructorParameter> 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)
{
Expand All @@ -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 BitfieldChangedCallback? _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("{");
Expand All @@ -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
Expand All @@ -133,7 +146,11 @@ namespace {schema.Name};
}
}

WriteFieldsAndConstructor(schema.Name + "Client", mainWriter, bufferInitialisers, fieldInitialisers, constructorParams);
if (hasChangeDetection)
{
mainWriter.WriteLine("\tprivate readonly BitfieldChangedCallback? _onBitfieldChanged;");
}
WriteFieldsAndConstructor(schema.Name + "Client", mainWriter, bufferInitialisers, fieldInitialisers, constructorParams, hasChangeDetection: hasChangeDetection);
mainWriter.WriteLine("}");
mainWriter.WriteLine();

Expand All @@ -142,17 +159,25 @@ namespace {schema.Name};
}
}

private static void WriteFieldsAndConstructor(string name, StringWriter mainWriter, List<string> bufferInitialisers, List<string> fieldInitialisers, List<ConstructorParameter> constructorParams, string indent = "")
private static void WriteFieldsAndConstructor(string name, StringWriter mainWriter, List<string> bufferInitialisers, List<string> fieldInitialisers, List<ConstructorParameter> constructorParams, string indent = "", bool hasChangeDetection = false)
{
mainWriter.Write($"\t{indent}public {name}(IModbusClient client");
if (constructorParams.Count > 0)
{
mainWriter.Write(", ");
mainWriter.Write(String.Join(", ", constructorParams.Select(cp => $"int {cp.Name}")));
}
if (hasChangeDetection)
{
mainWriter.Write(", BitfieldChangedCallback? 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}\");");
Expand All @@ -168,14 +193,25 @@ private static void WriteFieldsAndConstructor(string name, StringWriter mainWrit
mainWriter.WriteLine($"{indent}\t}}");
}

private static void WriteGroups(IReadOnlyCollection<Group> groups, StringWriter mainWriter, StringWriter appendixWriter, List<string> bufferInitialisers, List<string> fieldInitialisers, List<ConstructorParameter> constructorParams, string indent = "", string readOffsetField = "")
private static void WriteGroups(IReadOnlyCollection<Group> groups, StringWriter mainWriter, StringWriter appendixWriter, List<string> bufferInitialisers, List<string> fieldInitialisers, List<ConstructorParameter> 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<byte> {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<byte> {previousName};");
hasChangeDetection = true;
}
int maxOffset = 0;
string bufferSize = String.Empty;
List<BitfieldPointInfo> groupBitfieldPoints = [];
for (int i = 0; i < group.Points.Count; i++)
{
Point point = group.Points[i];
Expand All @@ -185,31 +221,68 @@ private static void WriteGroups(IReadOnlyCollection<Group> 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)");
mainWriter.WriteLine($"\t\t{indent}{{");
mainWriter.WriteLine($"\t\t\t{indent}Check{group.Name}();");
mainWriter.WriteLine($"\t\t{indent}}}");
}
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)");
mainWriter.WriteLine($"\t\t{indent}{{");
mainWriter.WriteLine($"\t\t\t{indent}Check{group.Name}();");
mainWriter.WriteLine($"\t\t{indent}}}");
}
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<byte> current = {bufferName}.Span;");
mainWriter.WriteLine($"\t\t{indent}Span<byte> 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}{bp.PointName} oldValue = ({bp.PointName}){bp.ReadMethod}(previous.Slice({bp.Offset}, {bp.SizeInBytes}));");
mainWriter.WriteLine($"\t\t\t{indent}_onBitfieldChanged!(\"{bp.PointName}\", oldValue, {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<string> fieldInitialisers, List<ConstructorParameter> constructorParams, ref int maxOffset, ref string bufferSize, string indent = "")
private static void WritePoint(Point point, string bufferName, Table table, StringWriter mainWriter, StringWriter appendixWriter, List<string> fieldInitialisers, List<ConstructorParameter> constructorParams, ref int maxOffset, ref string bufferSize, string indent = "", List<BitfieldPointInfo>? groupBitfieldPoints = null)
{
string type;
string readMethod;
Expand Down Expand Up @@ -337,6 +410,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, readMethod));
appendixWriter.WriteLine($"public static class {point.Name}Extensions");
appendixWriter.WriteLine("{");
appendixWriter.WriteLine($"\tpublic static Level GetLevel(this {point.Name} self)");
Expand Down Expand Up @@ -468,4 +542,5 @@ private static string Pluralise(string self)
}

private record ConstructorParameter(string Name, int Count);
private record BitfieldPointInfo(string PointName, int Offset, int SizeInBytes, string ReadMethod);
}
9 changes: 9 additions & 0 deletions Modspec.Model/IModbusClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@

namespace Modspec.Model;

/// <summary>
/// Callback invoked when a bitfield point with level annotations changes between reads.
/// </summary>
/// <param name="name">The name of the bitfield point that changed.</param>
/// <param name="oldValue">The previous value of the bitfield (boxed enum).</param>
/// <param name="newValue">The current value of the bitfield (boxed enum).</param>
/// <param name="level">The highest severity level among the currently set flags.</param>
public delegate void BitfieldChangedCallback(string name, object oldValue, object newValue, Level level);

/// <summary>
/// Interface for a Modbus client.
/// </summary>
Expand Down
68 changes: 68 additions & 0 deletions Modspec.Test/Tests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
Expand Down Expand Up @@ -87,6 +88,73 @@ public void TestErrorLevels()
Assert.That(errors1.GetLevel(), Is.EqualTo(Level.Emergency));
}

[Test]
public async Task TestInlineChangeDetectionNoChange()
{
MockModbusClient mockClient = new MockModbusClient();
List<(string name, object oldValue, object newValue, Level level)> changes = [];
SomeBmsClient bmsClient = new SomeBmsClient(mockClient, 2, 2, 480, 100,
onBitfieldChanged: (name, oldVal, newVal, level) => changes.Add((name, oldVal, newVal, 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, object oldValue, object newValue, Level level)> changes = [];
SomeBmsClient bmsClient = new SomeBmsClient(mockClient, 2, 2, 480, 100,
onBitfieldChanged: (name, oldVal, newVal, level) => changes.Add((name, oldVal, newVal, 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].oldValue, Is.EqualTo((StringErrors1)0));
Assert.That(changes[0].newValue, Is.EqualTo(StringErrors1.StringTerminalDischargeOverCurrentError));
Assert.That(changes[0].newValue.ToString(), Does.Contain("StringTerminalDischargeOverCurrentError"));
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, object oldValue, object newValue, Level level)> changes = [];
SomeBmsClient bmsClient = new SomeBmsClient(mockClient, 2, 2, 480, 100,
onBitfieldChanged: (name, oldVal, newVal, level) => changes.Add((name, oldVal, newVal, 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()
{
Expand Down
Loading