From 913cd0b3c17fc9b82d74fa4d839a22ffea7027c7 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:33:48 +0200 Subject: [PATCH 01/35] Refactor project and build configuration: remove nuke files, introduce centralized package management, update to .NET 10. Adjust dependencies and descriptions across projects. --- Directory.Build.props | 10 +++ Directory.Packages.props | 18 +++++ SmartMeter.sln | 6 +- build/.editorconfig | 11 --- build/Build.cs | 81 ------------------- build/Directory.Build.props | 8 -- build/Directory.Build.targets | 8 -- build/_build.csproj | 18 ----- build/_build.csproj.DotSettings | 30 ------- global.json | 2 +- ...iveCoders.SmartMeter.DataProcessing.csproj | 10 +-- .../MqttValuePublisher.cs | 7 +- .../ValueHistoryDataSet.cs | 2 +- ...eativeCoders.SmartMeter.Server.Core.csproj | 10 +-- .../SmartMeterServer.cs | 4 +- ...ativeCoders.SmartMeter.Server.Linux.csproj | 6 +- .../CreativeCoders.SmartMeter.Sml.csproj | 10 +-- .../Reactive/ReactiveSerialPort.cs | 2 +- .../SmlValueReader.cs | 2 +- 19 files changed, 52 insertions(+), 193 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props delete mode 100644 build/.editorconfig delete mode 100644 build/Build.cs delete mode 100644 build/Directory.Build.props delete mode 100644 build/Directory.Build.targets delete mode 100644 build/_build.csproj delete mode 100644 build/_build.csproj.DotSettings diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..15e0d05 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + net10.0 + CreativeCoders + $(NoWarn);IDE0079 + https://github.com/CreativeCodersTeam/SmartMeter + enable + enable + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..31729eb --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,18 @@ + + + true + + + + + + + + + + + + + + + diff --git a/SmartMeter.sln b/SmartMeter.sln index 16832bd..799baba 100644 --- a/SmartMeter.sln +++ b/SmartMeter.sln @@ -10,12 +10,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__global", "__global", "{EA global.json = global.json LICENSE = LICENSE README.md = README.md + Directory.Build.props = Directory.Build.props EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.Sml", "source\CreativeCoders.SmartMeter.Sml\CreativeCoders.SmartMeter.Sml.csproj", "{3AE1B70E-C752-4E89-B0FC-D3FF85462C99}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "build\_build.csproj", "{9CFDCB58-C344-4BF8-9377-60B4D2A684A6}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_build", "_build", "{AEAE0BA3-481C-45C3-825C-71A5CE7F6A78}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.DataProcessing", "source\CreativeCoders.SmartMeter.DataProcessing\CreativeCoders.SmartMeter.DataProcessing.csproj", "{9452116E-6A8B-42D2-BBDD-BF465097AEA1}" @@ -31,14 +30,11 @@ Global EndGlobalSection GlobalSection(NestedProjects) = preSolution {3AE1B70E-C752-4E89-B0FC-D3FF85462C99} = {B259CB14-56CC-45FA-9756-64A195F4F789} - {9CFDCB58-C344-4BF8-9377-60B4D2A684A6} = {AEAE0BA3-481C-45C3-825C-71A5CE7F6A78} {9452116E-6A8B-42D2-BBDD-BF465097AEA1} = {B259CB14-56CC-45FA-9756-64A195F4F789} {7AD8C940-B783-4FE9-B437-CE7FB87A97CA} = {B259CB14-56CC-45FA-9756-64A195F4F789} {29638431-6971-4757-BE3B-A83D96300ED4} = {B259CB14-56CC-45FA-9756-64A195F4F789} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {9CFDCB58-C344-4BF8-9377-60B4D2A684A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9CFDCB58-C344-4BF8-9377-60B4D2A684A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|Any CPU.Build.0 = Debug|Any CPU {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/build/.editorconfig b/build/.editorconfig deleted file mode 100644 index 31e43dc..0000000 --- a/build/.editorconfig +++ /dev/null @@ -1,11 +0,0 @@ -[*.cs] -dotnet_style_qualification_for_field = false:warning -dotnet_style_qualification_for_property = false:warning -dotnet_style_qualification_for_method = false:warning -dotnet_style_qualification_for_event = false:warning -dotnet_style_require_accessibility_modifiers = never:warning - -csharp_style_expression_bodied_methods = true:silent -csharp_style_expression_bodied_properties = true:warning -csharp_style_expression_bodied_indexers = true:warning -csharp_style_expression_bodied_accessors = true:warning diff --git a/build/Build.cs b/build/Build.cs deleted file mode 100644 index f6ba960..0000000 --- a/build/Build.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Collections.Generic; -using CreativeCoders.Core.IO; -using CreativeCoders.NukeBuild.Components.Parameters; -using CreativeCoders.NukeBuild.Components.Targets; -using CreativeCoders.NukeBuild.Components.Targets.Settings; -using Nuke.Common; -using Nuke.Common.CI.GitHubActions; -using Nuke.Common.IO; - -[GitHubActions("integration", GitHubActionsImage.UbuntuLatest, - OnPushBranches = ["feature/**"], - OnPullRequestBranches = ["main"], - InvokedTargets = ["clean", "restore", "build", "publish"], - EnableGitHubToken = true, - PublishArtifacts = true, - FetchDepth = 0 -)] -[GitHubActions("main", GitHubActionsImage.UbuntuLatest, - OnPushBranches = ["main"], - InvokedTargets = ["clean", "restore", "build", "publish"], - EnableGitHubToken = true, - PublishArtifacts = true, - FetchDepth = 0 -)] -[GitHubActions(ReleaseWorkflow, GitHubActionsImage.UbuntuLatest, - OnPushTags = ["v**"], - InvokedTargets = ["clean", "restore", "build", "publish", "CreateDistPackages", "CreateGithubRelease"], - EnableGitHubToken = true, - PublishArtifacts = true, - FetchDepth = 0 -)] -class Build : NukeBuild, - IGitRepositoryParameter, - IConfigurationParameter, - IGitVersionParameter, - ISourceDirectoryParameter, - IArtifactsSettings, - ICleanTarget, IBuildTarget, IRestoreTarget, IPublishTarget, ICreateDistPackagesTarget, ICreateGithubReleaseTarget -{ - const string ReleaseWorkflow = "release"; - - [Parameter(Name = "GITHUB_TOKEN")] string GitHubToken; - - public Build() - { - FileSys.Directory.CreateDirectory(DistOutputPath); - } - - public static int Main() => Execute(x => ((IBuildTarget)x).Build); - - string GetVersion() => ((IGitVersionParameter)this).GitVersion?.NuGetVersionV2 ?? "0.1-unknown"; - - AbsolutePath GetSourceDir() => ((ISourceDirectoryParameter)this).SourceDirectory; - - AbsolutePath GetDistDir() => ((IArtifactsSettings)this).ArtifactsDirectory / "dist"; - - IEnumerable IPublishSettings.PublishingItems => - [ - new PublishingItem( - GetSourceDir() / "CreativeCoders.SmartMeter.Server.Linux" / "CreativeCoders.SmartMeter.Server.Linux.csproj", - GetDistDir() / "smartmetersrv") - ]; - - public IEnumerable DistPackages => - [ - new DistPackage($"smartmeter-{GetVersion()}", GetDistDir() / "smartmeter") { Format = DistPackageFormat.TarGz } - ]; - - public AbsolutePath DistOutputPath => GetDistDir() / "packages"; - - public string ReleaseName => $"Release {GetVersion()}"; - - public string ReleaseBody => $"Release {GetVersion()}"; - - public string ReleaseVersion => GetVersion(); - - public IEnumerable ReleaseAssets => - [ - new GithubReleaseAsset(DistOutputPath / $"smartmetersrv-{GetVersion()}.tar.gz") - ]; -} diff --git a/build/Directory.Build.props b/build/Directory.Build.props deleted file mode 100644 index e147d63..0000000 --- a/build/Directory.Build.props +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/build/Directory.Build.targets b/build/Directory.Build.targets deleted file mode 100644 index 2532609..0000000 --- a/build/Directory.Build.targets +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/build/_build.csproj b/build/_build.csproj deleted file mode 100644 index eec40b3..0000000 --- a/build/_build.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - Exe - net8.0 - - CS0649;CS0169 - .. - .. - 1 - - - - - - - - diff --git a/build/_build.csproj.DotSettings b/build/_build.csproj.DotSettings deleted file mode 100644 index 0306022..0000000 --- a/build/_build.csproj.DotSettings +++ /dev/null @@ -1,30 +0,0 @@ - - DO_NOT_SHOW - DO_NOT_SHOW - DO_NOT_SHOW - DO_NOT_SHOW - Implicit - Implicit - ExpressionBody - 0 - NEXT_LINE - True - False - 120 - IF_OWNER_IS_SINGLE_LINE - WRAP_IF_LONG - False - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> - <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb" /></Policy> - <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb" /></Policy> - True - True - True - True - True - True - True - True - True - True diff --git a/global.json b/global.json index 502b8f6..99b1511 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.404", + "version": "10.0.202", "rollForward": "latestFeature" } } diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj b/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj index c2fc72e..04a97ce 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj +++ b/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj @@ -1,15 +1,13 @@ - net8.0 - enable - enable + Data processing library for SmartMeter - - - + + + diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs index 6e4adae..bbd52c0 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs @@ -5,7 +5,6 @@ using CreativeCoders.Net; using Microsoft.Extensions.Logging; using MQTTnet; -using MQTTnet.Client; namespace CreativeCoders.SmartMeter.DataProcessing; @@ -23,10 +22,10 @@ public class MqttValuePublisher : IObserver public MqttValuePublisher(MqttPublisherOptions options, ILogger logger) { - _options = Ensure.NotNull(options, nameof(options)); - _logger = Ensure.NotNull(logger, nameof(logger)); + _options = Ensure.NotNull(options); + _logger = Ensure.NotNull(logger); - _client = new MqttFactory().CreateMqttClient(); + _client = new MqttClientFactory().CreateMqttClient(); _publishingQueue = new BlockingCollection(); diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs b/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs index c6e1d6d..0aec872 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs @@ -7,7 +7,7 @@ public class ValueHistoryDataSet { public ValueHistoryDataSet(SmlValue value) { - Value = Ensure.NotNull(value, nameof(value)); + Value = Ensure.NotNull(value); } public DateTimeOffset TimeStamp { get; init; } diff --git a/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj b/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj index fb70ea2..f078ee6 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj +++ b/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj @@ -1,15 +1,13 @@ - net8.0 - enable - enable + Core library for the SmartMeter server application - - - + + + diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs index 4d866e8..6acd9b5 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs @@ -26,9 +26,9 @@ public SmartMeterServer(ILogger logger, IOptions mqttPublisherOptions, ILogger publisherLogger) { - _logger = Ensure.NotNull(logger, nameof(logger)); + _logger = Ensure.NotNull(logger); _mqttPublisherOptions = mqttPublisherOptions.Value; - _publisherLogger = Ensure.NotNull(publisherLogger, nameof(publisherLogger)); + _publisherLogger = Ensure.NotNull(publisherLogger); _serialPort = new ReactiveSerialPort("/dev/ttyUSB0"); } diff --git a/source/CreativeCoders.SmartMeter.Server.Linux/CreativeCoders.SmartMeter.Server.Linux.csproj b/source/CreativeCoders.SmartMeter.Server.Linux/CreativeCoders.SmartMeter.Server.Linux.csproj index 21f7ef3..5414f8d 100644 --- a/source/CreativeCoders.SmartMeter.Server.Linux/CreativeCoders.SmartMeter.Server.Linux.csproj +++ b/source/CreativeCoders.SmartMeter.Server.Linux/CreativeCoders.SmartMeter.Server.Linux.csproj @@ -2,14 +2,12 @@ Exe - net8.0 - enable - enable + Daemon for Linux to run the smart meter server smartmetersrv - + diff --git a/source/CreativeCoders.SmartMeter.Sml/CreativeCoders.SmartMeter.Sml.csproj b/source/CreativeCoders.SmartMeter.Sml/CreativeCoders.SmartMeter.Sml.csproj index ed3d84f..c76d71f 100644 --- a/source/CreativeCoders.SmartMeter.Sml/CreativeCoders.SmartMeter.Sml.csproj +++ b/source/CreativeCoders.SmartMeter.Sml/CreativeCoders.SmartMeter.Sml.csproj @@ -1,15 +1,13 @@ - net8.0 - enable - enable + Classes for SML protocol implementation to access smart meters - - - + + + diff --git a/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs b/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs index afd6598..b03f1c6 100644 --- a/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs +++ b/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs @@ -15,7 +15,7 @@ public ReactiveSerialPort(string portName) : this(new SerialPort(portName)) public ReactiveSerialPort(SerialPort serialPort) { - _serialPort = Ensure.NotNull(serialPort, nameof(serialPort)); + _serialPort = Ensure.NotNull(serialPort); _dataObservable = Observable .FromEvent( diff --git a/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs b/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs index dc35bf1..afe3cb9 100644 --- a/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs +++ b/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs @@ -6,7 +6,7 @@ public class SmlValueReader : ISmlValueReader { public IEnumerable Read(byte[] data) { - Ensure.NotNull(data, nameof(data)); + Ensure.NotNull(data); for (var i = 0; i < data.Length; i++) { From e8fd3e746789a524499de156b3149aa17799f63d Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:10:56 +0200 Subject: [PATCH 02/35] Refactor `MqttValuePublisher` to use `ReadOnlySequence` for payloads; upgrade to .NET 10 and centralize test package versions. --- Directory.Packages.props | 11 ++++++++++ .../MqttValuePublisher.cs | 6 +++--- ...ers.SmartMeter.DataProcessing.Tests.csproj | 20 +++++++++---------- .../SmlValueProcessorTests.cs | 2 +- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 31729eb..17432e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,5 +14,16 @@ + + + + + + + + + + + diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs index 5855355..24a29ad 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs @@ -1,9 +1,9 @@ -using System.Collections.Concurrent; +using System.Buffers; +using System.Collections.Concurrent; using System.Globalization; using System.Text; using System.Text.Json; using CreativeCoders.Core; -using CreativeCoders.Net; using Microsoft.Extensions.Logging; using MQTTnet; @@ -88,7 +88,7 @@ private async Task DoWorkAsync() { Topic = string.Format(_options.TopicTemplate, value.Type), //ContentType = ContentMediaTypes.Application.Json, - Payload = Encoding.UTF8.GetBytes(payload) + Payload = new ReadOnlySequence(Encoding.UTF8.GetBytes(payload)) }; var publishResult = await SendMessageAsync(message); diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj index 75791c5..4534ff8 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj @@ -1,27 +1,27 @@  - net8.0 + net10.0 enable enable - - - - - - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs index 45db059..10ad480 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs @@ -1,6 +1,6 @@ using System.Reactive.Subjects; using CreativeCoders.SmartMeter.Sml; -using FluentAssertions; +using AwesomeAssertions; using Microsoft.Extensions.Time.Testing; using Xunit; From 285c8f7e02980b08dd6ee908b484070ef96b0648 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:16:36 +0200 Subject: [PATCH 03/35] Update test package versions and fix balance value sign logic in `SmlValueProcessor`. --- Directory.Packages.props | 14 +++++++------- .../SmlValueProcessorTests.cs | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 17432e2..835321a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,13 +17,13 @@ - - + + - - - - - + + + + + diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs index 10ad480..30d31d0 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs @@ -48,7 +48,7 @@ public void Subscribe_WithTwoPurchasedEnergyValues_ShouldReturnTotalAndCurrentAn { // Arrange var expectedBalanceValue = (smlValueValue2 - smlValueValue1) * 60; - if (smlValueType == SmlValueType.PurchasedEnergy) + if (smlValueType == SmlValueType.SoldEnergy) { expectedBalanceValue *= -1; } From fd44461b89ecb275b8da4b163a35358041722b85 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:01:58 +0200 Subject: [PATCH 04/35] Refactor `SmartMeterServer` constructor to leverage parameterized properties and streamline initialization logic. Simplify subscription disposal and improve logging. --- .../SmartMeterServer.cs | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs index 1a151a6..1dc0fc6 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs @@ -11,28 +11,19 @@ namespace CreativeCoders.SmartMeter.Server.Core; [UsedImplicitly] -public class SmartMeterServer : IDaemonService +public class SmartMeterServer( + ILogger logger, + IOptions mqttPublisherOptions, + ILoggerFactory loggerFactory) + : IDaemonService { - private readonly ILogger _logger; - - private readonly MqttPublisherOptions _mqttPublisherOptions; - private readonly ILogger _publisherLogger; - - private readonly ReactiveSerialPort _serialPort; + private readonly ILoggerFactory _loggerFactory = Ensure.NotNull(loggerFactory); + private readonly ILogger _logger = Ensure.NotNull(logger); + private readonly MqttPublisherOptions _mqttPublisherOptions = mqttPublisherOptions.Value; + private readonly ReactiveSerialPort _serialPort = new ReactiveSerialPort("/dev/ttyUSB0"); private IDisposable? _subscription; - public SmartMeterServer(ILogger logger, - IOptions mqttPublisherOptions, - ILogger publisherLogger) - { - _logger = Ensure.NotNull(logger); - _mqttPublisherOptions = mqttPublisherOptions.Value; - _publisherLogger = Ensure.NotNull(publisherLogger); - - _serialPort = new ReactiveSerialPort("/dev/ttyUSB0"); - } - private void CloseSerialPort() { _logger.LogInformation("Closing serial port..."); @@ -42,23 +33,26 @@ private void CloseSerialPort() private void DisposingSubscription() { - if (_subscription != null) + if (_subscription == null) { - _logger.LogInformation("Disposing subscription..."); + return; + } - _subscription.Dispose(); + _logger.LogInformation("Disposing subscription..."); - _logger.LogInformation("Subscription disposed"); + _subscription.Dispose(); - _subscription = null; - } + _logger.LogInformation("Subscription disposed"); + + _subscription = null; } public async Task StartAsync() { _logger.LogInformation("Starting SmartMeter server"); - var mqttValuePublisher = new MqttValuePublisher(_mqttPublisherOptions, _publisherLogger); + var mqttValuePublisher = + new MqttValuePublisher(_mqttPublisherOptions, _loggerFactory.CreateLogger()); await mqttValuePublisher.InitAsync(); From 17457193a7a105c63b260b22d7a0e4663c4c4da5 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:16:16 +0200 Subject: [PATCH 05/35] Add SmartMeter CLI project with reactive data producer and basic CLI functionality. --- Directory.Packages.props | 55 +++++++------- SmartMeter.sln | 7 ++ .../CreativeCoders.SmartMeter.Cli.csproj | 19 +++++ .../CreativeCoders.SmartMeter.Cli/Program.cs | 53 ++++++++++++++ .../ISmartMeterDataProducer.cs | 10 +++ .../SmartMeterDataProducer.cs | 72 +++++++++++++++++++ ...ers.SmartMeter.DataProcessing.Tests.csproj | 4 +- 7 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 source/CreativeCoders.SmartMeter.Cli/CreativeCoders.SmartMeter.Cli.csproj create mode 100644 source/CreativeCoders.SmartMeter.Cli/Program.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 835321a..068298d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,29 +1,28 @@ - - true - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SmartMeter.sln b/SmartMeter.sln index 9f44dc2..51b24e8 100644 --- a/SmartMeter.sln +++ b/SmartMeter.sln @@ -27,6 +27,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{675F198B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.DataProcessing.Tests", "tests\CreativeCoders.SmartMeter.DataProcessing.Tests\CreativeCoders.SmartMeter.DataProcessing.Tests.csproj", "{29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.Cli", "source\CreativeCoders.SmartMeter.Cli\CreativeCoders.SmartMeter.Cli.csproj", "{344911B2-1104-457A-BD41-91EF270748A9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +40,7 @@ Global {7AD8C940-B783-4FE9-B437-CE7FB87A97CA} = {B259CB14-56CC-45FA-9756-64A195F4F789} {29638431-6971-4757-BE3B-A83D96300ED4} = {B259CB14-56CC-45FA-9756-64A195F4F789} {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} + {344911B2-1104-457A-BD41-91EF270748A9} = {B259CB14-56CC-45FA-9756-64A195F4F789} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -60,5 +63,9 @@ Global {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|Any CPU.Build.0 = Debug|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|Any CPU.ActiveCfg = Release|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|Any CPU.Build.0 = Release|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/source/CreativeCoders.SmartMeter.Cli/CreativeCoders.SmartMeter.Cli.csproj b/source/CreativeCoders.SmartMeter.Cli/CreativeCoders.SmartMeter.Cli.csproj new file mode 100644 index 0000000..b636558 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Cli/CreativeCoders.SmartMeter.Cli.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + smc + + + + + + + + + + + diff --git a/source/CreativeCoders.SmartMeter.Cli/Program.cs b/source/CreativeCoders.SmartMeter.Cli/Program.cs new file mode 100644 index 0000000..a915add --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Cli/Program.cs @@ -0,0 +1,53 @@ +using CreativeCoders.SmartMeter.DataProcessing; +using CreativeCoders.SmartMeter.Server.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace CreativeCoders.SmartMeter.Cli; + +class Program +{ + static async Task Main(string[] args) + { + AnsiConsole.WriteLine("Starting Smart Meter CLI..."); + + var sp = new ServiceCollection() + .AddLogging(configure => configure.AddSimpleConsole(options => + { + options.SingleLine = true; + options.TimestampFormat = "hh:mm:ss "; + })) + .AddSingleton() + .BuildServiceProvider(); + + var dataProducer = sp.GetRequiredService(); + + await dataProducer.StartAsync(new SmartMeterConsoleOutput()); + + AnsiConsole.WriteLine("Press any key to stop..."); + await AnsiConsole.Console.Input.ReadKeyAsync(false, CancellationToken.None); + + await dataProducer.StopAsync(); + + AnsiConsole.WriteLine("Smart Meter CLI stopped"); + } +} + +internal class SmartMeterConsoleOutput : IObserver +{ + public void OnCompleted() + { + AnsiConsole.WriteLine("Data stream completed"); + } + + public void OnError(Exception error) + { + AnsiConsole.WriteLine($"Error: {error.Message}"); + } + + public void OnNext(SmartMeterValue value) + { + AnsiConsole.WriteLine($"Received value: {value.Type} = {value.Value}"); + } +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs new file mode 100644 index 0000000..38145a6 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs @@ -0,0 +1,10 @@ +using CreativeCoders.SmartMeter.DataProcessing; + +namespace CreativeCoders.SmartMeter.Server.Core; + +public interface ISmartMeterDataProducer +{ + Task StartAsync(IObserver observer); + + Task StopAsync(); +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs new file mode 100644 index 0000000..3c05ca9 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs @@ -0,0 +1,72 @@ +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using CreativeCoders.Core; +using CreativeCoders.SmartMeter.DataProcessing; +using CreativeCoders.SmartMeter.Sml.Reactive; +using Microsoft.Extensions.Logging; + +namespace CreativeCoders.SmartMeter.Server.Core; + +public class SmartMeterDataProducer(ILogger logger) : ISmartMeterDataProducer +{ + private readonly ILogger _logger = Ensure.NotNull(logger); + private readonly ReactiveSerialPort _serialPort = new ReactiveSerialPort("/dev/ttyUSB0"); + + private IDisposable? _subscription; + + public Task StartAsync(IObserver observer) + { + _logger.LogInformation("Starting SmartMeter data producer"); + + _subscription ??= _serialPort + .SelectSmlMessages() + .SelectSmlValues() + .SelectSmartMeterValues() + .SubscribeOn(new TaskPoolScheduler(new TaskFactory())) + .Subscribe(observer); + + _logger.LogInformation("SmartMeter data producer started"); + + _logger.LogInformation("Opening serial port..."); + _serialPort.Open(); + _logger.LogInformation("Serial port opened"); + + return Task.CompletedTask; + } + + public Task StopAsync() + { + _logger.LogInformation("Stopping SmartMeter data producer"); + + DisposingSubscription(); + + CloseSerialPort(); + + _logger.LogInformation("SmartMeter data producer stopped"); + + return Task.CompletedTask; + } + + private void CloseSerialPort() + { + _logger.LogInformation("Closing serial port..."); + _serialPort.Close(); + _logger.LogInformation("Serial port closed"); + } + + private void DisposingSubscription() + { + if (_subscription == null) + { + return; + } + + _logger.LogInformation("Disposing data producer subscription..."); + + _subscription.Dispose(); + + _logger.LogInformation("Subscription data producer disposed"); + + _subscription = null; + } +} diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj index 4534ff8..c8b6d12 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj @@ -1,9 +1,7 @@  - net10.0 - enable - enable + From 11a848e3eedcf991da89d94711e3ec538bb3d86a Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:51:05 +0200 Subject: [PATCH 06/35] Add debug logging for serial port data reception in `SmartMeterDataProducer` --- .../SmartMeterDataProducer.cs | 1 + source/CreativeCoders.SmartMeter.Server.Linux/Program.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs index 3c05ca9..1beb834 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs @@ -19,6 +19,7 @@ public Task StartAsync(IObserver observer) _logger.LogInformation("Starting SmartMeter data producer"); _subscription ??= _serialPort + .Do(_ => _logger.LogDebug("Data received from serial port")) .SelectSmlMessages() .SelectSmlValues() .SelectSmartMeterValues() diff --git a/source/CreativeCoders.SmartMeter.Server.Linux/Program.cs b/source/CreativeCoders.SmartMeter.Server.Linux/Program.cs index 07bc286..9595946 100644 --- a/source/CreativeCoders.SmartMeter.Server.Linux/Program.cs +++ b/source/CreativeCoders.SmartMeter.Server.Linux/Program.cs @@ -19,3 +19,4 @@ await SmartMeterDaemonHostBuilder.CreateSmartMeterDaemonHostBuilder(args) .Build() .RunAsync() .ConfigureAwait(false); + \ No newline at end of file From 16302cc45ee6e0be96d2cce6f48dfaca0b2bd24f Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:17:51 +0200 Subject: [PATCH 07/35] Add CRC-16/X-25 implementation, SML message framing, parsing, and unit tests. - Introduced `Crc16X25` class for calculating CRC-16/X-25 checksums. - Added `SmlFrame`, `SmlMessageDetector`, and related classes for framing and detecting SML transport v1 messages. - Implemented parsing logic with `ObisValue` to support SML GetListResponse payloads. - Created unit tests for framing, parsing, and end-to-end workflows. - Added utilities for OBIS code parsing and smart meter unlocking strategies with comprehensive options and result enums. --- Directory.Packages.props | 1 + SmartMeter.sln | 101 ++++- ...CreativeCoders.SmartMessageLanguage.csproj | 19 + .../Framing/Crc16X25.cs | 54 +++ .../Framing/ISmlMessageDetector.cs | 17 + .../Framing/SmlFrame.cs | 25 ++ .../Framing/SmlMessageDetector.cs | 298 ++++++++++++++ .../Framing/SmlMessageDetectorLog.cs | 35 ++ .../Framing/SmlMessageEventArgs.cs | 20 + .../Parsing/ISmlParser.cs | 14 + .../Parsing/ObisValue.cs | 21 + .../Parsing/SmlParseResult.cs | 10 + .../Parsing/SmlParser.cs | 383 ++++++++++++++++++ .../Parsing/SmlParserLog.cs | 43 ++ .../SmlServiceCollectionExtensions.cs | 17 + .../Tlv/SmlTlvElement.cs | 85 ++++ .../Tlv/SmlTlvReader.cs | 143 +++++++ .../Tlv/SmlValueType.cs | 31 ++ .../Units/SmlUnit.cs | 107 +++++ .../CreativeCoders.SmartMeter.Cli/Program.cs | 22 + ...eativeCoders.SmartMeter.Server.Core.csproj | 9 +- .../ISmartMeterDataProducer.cs | 16 + .../ISmartMeterReactiveDataPipeline.cs | 7 + .../SmartMeterDataProducer.cs | 259 +++++++++++- .../SmartMeterReactiveDataPipeline.cs | 61 +++ ...tMeterServerServiceCollectionExtensions.cs | 16 + .../Unlock/ObisCodeScanner.cs | 92 +++++ .../Unlock/SmartMeterPinStrategy.cs | 26 ++ .../Unlock/SmartMeterUnlockOptions.cs | 47 +++ .../Unlock/SmartMeterUnlockOutcome.cs | 22 + .../Unlock/SmartMeterUnlockResult.cs | 16 + .../Reactive/ReactiveSerialPort.cs | 9 + ...veCoders.SmartMessageLanguage.Tests.csproj | 29 ++ .../EndToEndTests.cs | 39 ++ .../Fixtures/FrameBuilder.cs | 61 +++ .../Fixtures/SampleSmlFile.cs | 83 ++++ .../Fixtures/TlvBuilder.cs | 120 ++++++ .../Framing/Crc16X25Tests.cs | 28 ++ .../Framing/SmlMessageDetectorLoggingTests.cs | 56 +++ .../Framing/SmlMessageDetectorTests.cs | 150 +++++++ .../Parsing/SmlParserLoggingTests.cs | 30 ++ .../Parsing/SmlParserTests.cs | 44 ++ .../TestSupport/LoggerCallAssertions.cs | 32 ++ .../Tlv/SmlTlvReaderTests.cs | 124 ++++++ 44 files changed, 2801 insertions(+), 21 deletions(-) create mode 100644 source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj create mode 100644 source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Framing/ISmlMessageDetector.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Framing/SmlFrame.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetector.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetectorLog.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageEventArgs.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Parsing/ISmlParser.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Parsing/ObisValue.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParseResult.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/SmlServiceCollectionExtensions.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvElement.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvReader.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Tlv/SmlValueType.cs create mode 100644 source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterReactiveDataPipeline.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/Unlock/ObisCodeScanner.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterPinStrategy.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOptions.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOutcome.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockResult.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/CreativeCoders.SmartMessageLanguage.Tests.csproj create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/EndToEndTests.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/FrameBuilder.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/SampleSmlFile.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/TlvBuilder.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/Crc16X25Tests.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorLoggingTests.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorTests.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserLoggingTests.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/TestSupport/LoggerCallAssertions.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 068298d..8ca2e56 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/SmartMeter.sln b/SmartMeter.sln index 51b24e8..f38b9c1 100644 --- a/SmartMeter.sln +++ b/SmartMeter.sln @@ -29,43 +29,128 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.Cli", "source\CreativeCoders.SmartMeter.Cli\CreativeCoders.SmartMeter.Cli.csproj", "{344911B2-1104-457A-BD41-91EF270748A9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMessageLanguage", "source\CreativeCoders.SmartMessageLanguage\CreativeCoders.SmartMessageLanguage.csproj", "{B70158C0-A913-4877-9414-5CD5F39772F0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMessageLanguage.Tests", "tests\CreativeCoders.SmartMessageLanguage.Tests\CreativeCoders.SmartMessageLanguage.Tests.csproj", "{D956F76F-3826-4A51-8D05-8B35C062605F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99} = {B259CB14-56CC-45FA-9756-64A195F4F789} - {9452116E-6A8B-42D2-BBDD-BF465097AEA1} = {B259CB14-56CC-45FA-9756-64A195F4F789} - {7AD8C940-B783-4FE9-B437-CE7FB87A97CA} = {B259CB14-56CC-45FA-9756-64A195F4F789} - {29638431-6971-4757-BE3B-A83D96300ED4} = {B259CB14-56CC-45FA-9756-64A195F4F789} - {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} - {344911B2-1104-457A-BD41-91EF270748A9} = {B259CB14-56CC-45FA-9756-64A195F4F789} + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|x64.ActiveCfg = Debug|Any CPU + {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|x64.Build.0 = Debug|Any CPU + {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|x86.ActiveCfg = Debug|Any CPU + {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|x86.Build.0 = Debug|Any CPU {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|Any CPU.ActiveCfg = Release|Any CPU {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|Any CPU.Build.0 = Release|Any CPU + {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|x64.ActiveCfg = Release|Any CPU + {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|x64.Build.0 = Release|Any CPU + {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|x86.ActiveCfg = Release|Any CPU + {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|x86.Build.0 = Release|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|x64.ActiveCfg = Debug|Any CPU + {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|x64.Build.0 = Debug|Any CPU + {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|x86.ActiveCfg = Debug|Any CPU + {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|x86.Build.0 = Debug|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|Any CPU.ActiveCfg = Release|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|Any CPU.Build.0 = Release|Any CPU + {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|x64.ActiveCfg = Release|Any CPU + {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|x64.Build.0 = Release|Any CPU + {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|x86.ActiveCfg = Release|Any CPU + {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|x86.Build.0 = Release|Any CPU {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|x64.Build.0 = Debug|Any CPU + {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|x86.Build.0 = Debug|Any CPU {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|Any CPU.ActiveCfg = Release|Any CPU {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|Any CPU.Build.0 = Release|Any CPU + {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|x64.ActiveCfg = Release|Any CPU + {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|x64.Build.0 = Release|Any CPU + {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|x86.ActiveCfg = Release|Any CPU + {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|x86.Build.0 = Release|Any CPU {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|x64.ActiveCfg = Debug|Any CPU + {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|x64.Build.0 = Debug|Any CPU + {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|x86.ActiveCfg = Debug|Any CPU + {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|x86.Build.0 = Debug|Any CPU {29638431-6971-4757-BE3B-A83D96300ED4}.Release|Any CPU.ActiveCfg = Release|Any CPU {29638431-6971-4757-BE3B-A83D96300ED4}.Release|Any CPU.Build.0 = Release|Any CPU + {29638431-6971-4757-BE3B-A83D96300ED4}.Release|x64.ActiveCfg = Release|Any CPU + {29638431-6971-4757-BE3B-A83D96300ED4}.Release|x64.Build.0 = Release|Any CPU + {29638431-6971-4757-BE3B-A83D96300ED4}.Release|x86.ActiveCfg = Release|Any CPU + {29638431-6971-4757-BE3B-A83D96300ED4}.Release|x86.Build.0 = Release|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|x64.ActiveCfg = Debug|Any CPU + {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|x64.Build.0 = Debug|Any CPU + {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|x86.ActiveCfg = Debug|Any CPU + {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|x86.Build.0 = Debug|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|Any CPU.ActiveCfg = Release|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|Any CPU.Build.0 = Release|Any CPU + {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|x64.ActiveCfg = Release|Any CPU + {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|x64.Build.0 = Release|Any CPU + {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|x86.ActiveCfg = Release|Any CPU + {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|x86.Build.0 = Release|Any CPU {344911B2-1104-457A-BD41-91EF270748A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {344911B2-1104-457A-BD41-91EF270748A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Debug|x64.Build.0 = Debug|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Debug|x86.ActiveCfg = Debug|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Debug|x86.Build.0 = Debug|Any CPU {344911B2-1104-457A-BD41-91EF270748A9}.Release|Any CPU.ActiveCfg = Release|Any CPU {344911B2-1104-457A-BD41-91EF270748A9}.Release|Any CPU.Build.0 = Release|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Release|x64.ActiveCfg = Release|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Release|x64.Build.0 = Release|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Release|x86.ActiveCfg = Release|Any CPU + {344911B2-1104-457A-BD41-91EF270748A9}.Release|x86.Build.0 = Release|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|x64.ActiveCfg = Debug|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|x64.Build.0 = Debug|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|x86.ActiveCfg = Debug|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|x86.Build.0 = Debug|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|Any CPU.Build.0 = Release|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|x64.ActiveCfg = Release|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|x64.Build.0 = Release|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|x86.ActiveCfg = Release|Any CPU + {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|x86.Build.0 = Release|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|x64.ActiveCfg = Debug|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|x64.Build.0 = Debug|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|x86.ActiveCfg = Debug|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|x86.Build.0 = Debug|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|Any CPU.Build.0 = Release|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x64.ActiveCfg = Release|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x64.Build.0 = Release|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x86.ActiveCfg = Release|Any CPU + {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {3AE1B70E-C752-4E89-B0FC-D3FF85462C99} = {B259CB14-56CC-45FA-9756-64A195F4F789} + {9452116E-6A8B-42D2-BBDD-BF465097AEA1} = {B259CB14-56CC-45FA-9756-64A195F4F789} + {7AD8C940-B783-4FE9-B437-CE7FB87A97CA} = {B259CB14-56CC-45FA-9756-64A195F4F789} + {29638431-6971-4757-BE3B-A83D96300ED4} = {B259CB14-56CC-45FA-9756-64A195F4F789} + {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} + {344911B2-1104-457A-BD41-91EF270748A9} = {B259CB14-56CC-45FA-9756-64A195F4F789} + {B70158C0-A913-4877-9414-5CD5F39772F0} = {B259CB14-56CC-45FA-9756-64A195F4F789} + {D956F76F-3826-4A51-8D05-8B35C062605F} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} EndGlobalSection EndGlobal diff --git a/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj b/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj new file mode 100644 index 0000000..3e195a4 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj @@ -0,0 +1,19 @@ + + + + Streaming detector and parser for the Smart Message Language (SML) protocol + CreativeCoders.SmartMessageLanguage + + + + + + + + + + + + + + diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs new file mode 100644 index 0000000..cae1359 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs @@ -0,0 +1,54 @@ +namespace CreativeCoders.SmartMessageLanguage.Framing; + +/// +/// CRC-16/X-25 implementation as used by the SML transport v1 protocol. +/// +/// +/// Parameters: polynomial 0x1021, initial value 0xFFFF, reflected input +/// and output, final XOR 0xFFFF. After computation SML stores the CRC little-endian +/// in the wire, so reading the two final bytes as a little-endian +/// yields the computed value directly. +/// +internal static class Crc16X25 +{ + private static readonly ushort[] _table = BuildTable(); + + /// Computes the CRC-16/X-25 over the given buffer. + /// Bytes to compute the CRC over. + /// The 16-bit CRC value. + public static ushort Compute(ReadOnlySpan data) + { + ushort crc = 0xFFFF; + + foreach (var b in data) + { + crc = (ushort)((crc >> 8) ^ _table[(crc ^ b) & 0xFF]); + } + + return (ushort)(crc ^ 0xFFFF); + } + + private static ushort[] BuildTable() + { + // Reflected polynomial for CRC-16/X-25 (0x1021 reversed = 0x8408). + const ushort reflectedPoly = 0x8408; + + var table = new ushort[256]; + + for (var i = 0; i < 256; i++) + { + var value = (ushort)i; + + for (var bit = 0; bit < 8; bit++) + { + value = (value & 1) != 0 + ? (ushort)((value >> 1) ^ reflectedPoly) + : (ushort)(value >> 1); + } + + table[i] = value; + } + + return table; + } +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/ISmlMessageDetector.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/ISmlMessageDetector.cs new file mode 100644 index 0000000..8751c5a --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Framing/ISmlMessageDetector.cs @@ -0,0 +1,17 @@ +namespace CreativeCoders.SmartMessageLanguage.Framing; + +public interface ISmlMessageDetector : IDisposable +{ + /// Raised once for every complete frame detected in the stream. + event EventHandler? MessageReceived; + + /// Observable stream of detected frames; fires in parallel with . + IObservable Messages { get; } + + /// Appends a chunk of bytes from the transport and extracts any newly completed frames. + /// Bytes received from the underlying stream. + void Append(ReadOnlySpan data); + + /// Clears any partial buffered data and resets the internal state. + void Reset(); +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/SmlFrame.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlFrame.cs new file mode 100644 index 0000000..ef7f715 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlFrame.cs @@ -0,0 +1,25 @@ +namespace CreativeCoders.SmartMessageLanguage.Framing; + +/// +/// A complete SML transport v1 frame extracted from a byte stream. +/// +/// +/// Raw bytes of the frame as received, including start escape (1B1B1B1B 01010101), +/// original escape doubling of 0x1B runs in the body, padding, and end escape +/// (1B1B1B1B 1A <pad> <crc-lo> <crc-hi>). +/// +/// +/// Message body between start and end escape, already de-escaped (any doubled 0x1B +/// runs collapsed to single runs) and with the trailing padding zeros stripped. +/// Ready to feed into SmlTlvReader. +/// +/// +/// true when the CRC-16/X-25 computed over the frame (up to and including the +/// pad byte) matches the two CRC bytes at the end of the frame. +/// +/// Number of 0x00 padding bytes at the end of the body (0-3). +public sealed record SmlFrame( + byte[] MessageBytes, + byte[] PayloadBytes, + bool IsCrcValid, + int PaddingBytes); diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetector.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetector.cs new file mode 100644 index 0000000..2d660ea --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetector.cs @@ -0,0 +1,298 @@ +using System.Reactive.Subjects; +using CreativeCoders.Core; +using Microsoft.Extensions.Logging; + +namespace CreativeCoders.SmartMessageLanguage.Framing; + +/// +/// Streaming detector for SML transport v1 frames. +/// +/// +/// Callers feed raw bytes via as they arrive from a serial line, +/// TCP socket or similar source. The detector locates the start escape sequence +/// (1B1B1B1B 01010101), tracks the end escape (1B1B1B1B 1A <pad> <crc-lo> <crc-hi>) +/// while correctly handling doubled 0x1B runs inside the body, validates the +/// CRC-16/X-25 and raises both a classic event and the +/// observable for every complete frame. Frames with an invalid +/// CRC are still emitted with set to false. +/// +public sealed class SmlMessageDetector : ISmlMessageDetector +{ + // Defensive cap to prevent unbounded buffer growth if the peer never closes a frame. + private const int MaxBufferSize = 64 * 1024; + + private static readonly byte[] _startEscape = + [0x1B, 0x1B, 0x1B, 0x1B, 0x01, 0x01, 0x01, 0x01]; + + private readonly Subject _subject = new Subject(); + private readonly ILogger _logger; + + private byte[] _buffer = []; + private int _length; + private bool _startFound; + + /// Creates a new detector and routes diagnostic events to . + /// Logger used for streaming/diagnostic events; pass + /// to silence logging. + public SmlMessageDetector(ILogger logger) + { + _logger = Ensure.NotNull(logger); + } + + /// Raised once for every complete frame detected in the stream. + public event EventHandler? MessageReceived; + + /// Observable stream of detected frames; fires in parallel with . + public IObservable Messages => _subject; + + /// Appends a chunk of bytes from the transport and extracts any newly completed frames. + /// Bytes received from the underlying stream. + public void Append(ReadOnlySpan data) + { + if (data.IsEmpty) + { + return; + } + + AppendToBuffer(data); + SmlMessageDetectorLog.BytesAppended(_logger, data.Length, _length); + + while (TryExtractFrame(out var frame)) + { + if (!frame.IsCrcValid) + { + SmlMessageDetectorLog.InvalidCrc(_logger, frame.MessageBytes.Length); + } + + SmlMessageDetectorLog.FrameDetected(_logger, frame.MessageBytes.Length, + frame.PayloadBytes.Length, frame.IsCrcValid); + + MessageReceived?.Invoke(this, new SmlMessageEventArgs(frame)); + _subject.OnNext(frame); + } + + // If the buffer grows unbounded without a valid frame, discard to avoid DoS. + if (_length > MaxBufferSize) + { + SmlMessageDetectorLog.BufferOverflow(_logger, MaxBufferSize); + Reset(); + } + } + + /// Clears any partial buffered data and resets the internal state. + public void Reset() + { + _length = 0; + _startFound = false; + SmlMessageDetectorLog.DetectorReset(_logger); + } + + /// + void IDisposable.Dispose() + { + _subject.OnCompleted(); + _subject.Dispose(); + } + + private void AppendToBuffer(ReadOnlySpan data) + { + var required = _length + data.Length; + + if (required > _buffer.Length) + { + var newSize = Math.Max(required, Math.Max(_buffer.Length * 2, 256)); + Array.Resize(ref _buffer, newSize); + } + + data.CopyTo(_buffer.AsSpan(_length)); + _length += data.Length; + } + + private void Consume(int count) + { + var remaining = _length - count; + + if (remaining > 0) + { + Buffer.BlockCopy(_buffer, count, _buffer, 0, remaining); + } + + _length = remaining; + } + + private bool TryExtractFrame(out SmlFrame frame) + { + frame = null!; + + if (!_startFound && !TryAnchorOnStart()) + { + return false; + } + + // Scan from just after the 8-byte start escape for the end escape, while + // correctly stepping over doubled 0x1B escape runs inside the body. + var pos = _startEscape.Length; + + while (pos + 4 <= _length) + { + if (!IsEscapeMark(_buffer, pos)) + { + pos++; + continue; + } + + // Need 4 more bytes to disambiguate between escaped run and end marker. + if (pos + 8 > _length) + { + return false; + } + + if (IsEscapeMark(_buffer, pos + 4)) + { + // Doubled 0x1B run: payload literal, skip past all eight bytes. + pos += 8; + continue; + } + + if (_buffer[pos + 4] == 0x1A) + { + var frameLength = pos + 8; + frame = BuildFrame(frameLength); + Consume(frameLength); + _startFound = false; + + return true; + } + + // Malformed escape sequence (e.g. stray 1B1B1B1B followed by junk). + // Discard the start escape and resync on the next candidate. + SmlMessageDetectorLog.MalformedEscape(_logger, pos); + Consume(1); + _startFound = false; + + return TryExtractFrame(out frame); + } + + return false; + } + + private bool TryAnchorOnStart() + { + var idx = IndexOf(_buffer.AsSpan(0, _length), _startEscape); + + if (idx < 0) + { + // Keep only the last (startEscape.Length - 1) bytes so a start escape + // spanning two Append calls can still be detected. + var keep = Math.Min(_startEscape.Length - 1, _length); + var drop = _length - keep; + + if (drop > 0) + { + Consume(drop); + } + + return false; + } + + if (idx > 0) + { + Consume(idx); + } + + _startFound = true; + SmlMessageDetectorLog.AnchoredOnStart(_logger, idx); + + return true; + } + + private SmlFrame BuildFrame(int frameLength) + { + // Raw bytes as transmitted (incl. start/end escape and any escape doubling). + var messageBytes = _buffer.AsSpan(0, frameLength).ToArray(); + + var paddingBytes = messageBytes[frameLength - 3]; + var storedCrc = (ushort)(messageBytes[frameLength - 2] | (messageBytes[frameLength - 1] << 8)); + var computedCrc = Crc16X25.Compute(messageBytes.AsSpan(0, frameLength - 2)); + var isCrcValid = storedCrc == computedCrc; + + var payload = ExtractPayload(messageBytes, paddingBytes); + + return new SmlFrame(messageBytes, payload, isCrcValid, paddingBytes); + } + + private static byte[] ExtractPayload(byte[] frame, int paddingBytes) + { + // Body lies between the 8-byte start escape and the 8-byte end escape, + // minus any trailing 0x00 padding bytes used to align to a 4-byte boundary. + var bodyStart = _startEscape.Length; + var bodyEnd = frame.Length - 8 - paddingBytes; + + if (bodyEnd < bodyStart) + { + return []; + } + + var body = frame.AsSpan(bodyStart, bodyEnd - bodyStart); + + // De-escape: any 8-byte run of 0x1B was originally 4 bytes of 0x1B in the payload. + var output = new byte[body.Length]; + var written = 0; + var i = 0; + + while (i < body.Length) + { + if (i + 8 <= body.Length + && IsEscapeMark(body, i) + && IsEscapeMark(body, i + 4)) + { + output[written++] = 0x1B; + output[written++] = 0x1B; + output[written++] = 0x1B; + output[written++] = 0x1B; + i += 8; + } + else + { + output[written++] = body[i++]; + } + } + + if (written == output.Length) + { + return output; + } + + var trimmed = new byte[written]; + Array.Copy(output, trimmed, written); + + return trimmed; + } + + private static bool IsEscapeMark(ReadOnlySpan data, int offset) => + data[offset] == 0x1B + && data[offset + 1] == 0x1B + && data[offset + 2] == 0x1B + && data[offset + 3] == 0x1B; + + private static bool IsEscapeMark(byte[] data, int offset) => + IsEscapeMark(data.AsSpan(), offset); + + private static int IndexOf(ReadOnlySpan haystack, ReadOnlySpan needle) + { + if (needle.Length == 0 || haystack.Length < needle.Length) + { + return -1; + } + + for (var i = 0; i <= haystack.Length - needle.Length; i++) + { + if (haystack.Slice(i, needle.Length).SequenceEqual(needle)) + { + return i; + } + } + + return -1; + } +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetectorLog.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetectorLog.cs new file mode 100644 index 0000000..d2a5880 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetectorLog.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; + +namespace CreativeCoders.SmartMessageLanguage.Framing; + +// Source-generated, allocation-free logging helpers for SmlMessageDetector. +// Event IDs 1000-1099 are reserved for the framing layer. +internal static partial class SmlMessageDetectorLog +{ + [LoggerMessage(EventId = 1001, Level = LogLevel.Debug, + Message = "Appended {ByteCount} bytes to SML buffer (buffered={BufferedBytes})")] + public static partial void BytesAppended(ILogger logger, int byteCount, int bufferedBytes); + + [LoggerMessage(EventId = 1002, Level = LogLevel.Debug, + Message = "Anchored on SML start escape at buffer offset {Offset}")] + public static partial void AnchoredOnStart(ILogger logger, int offset); + + [LoggerMessage(EventId = 1003, Level = LogLevel.Information, + Message = "Detected SML frame: frameLength={FrameLength}, payloadLength={PayloadLength}, crcValid={CrcValid}")] + public static partial void FrameDetected(ILogger logger, int frameLength, int payloadLength, bool crcValid); + + [LoggerMessage(EventId = 1010, Level = LogLevel.Warning, + Message = "SML frame failed CRC check (frameLength={FrameLength})")] + public static partial void InvalidCrc(ILogger logger, int frameLength); + + [LoggerMessage(EventId = 1011, Level = LogLevel.Warning, + Message = "Malformed SML escape sequence at buffer offset {Offset}; resyncing")] + public static partial void MalformedEscape(ILogger logger, int offset); + + [LoggerMessage(EventId = 1012, Level = LogLevel.Warning, + Message = "SML buffer exceeded {MaxBufferSize} bytes without a complete frame; discarding buffer")] + public static partial void BufferOverflow(ILogger logger, int maxBufferSize); + + [LoggerMessage(EventId = 1020, Level = LogLevel.Debug, Message = "SML detector reset")] + public static partial void DetectorReset(ILogger logger); +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageEventArgs.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageEventArgs.cs new file mode 100644 index 0000000..e52f3a8 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageEventArgs.cs @@ -0,0 +1,20 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.SmartMessageLanguage.Framing; + +/// +/// Event arguments raised by when a complete +/// SML transport frame has been detected in the byte stream. +/// +public sealed class SmlMessageEventArgs : EventArgs +{ + /// Creates a new instance wrapping the given frame. + /// The detected frame. + public SmlMessageEventArgs(SmlFrame frame) + { + Frame = Ensure.NotNull(frame); + } + + /// The detected SML frame. + public SmlFrame Frame { get; } +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/ISmlParser.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/ISmlParser.cs new file mode 100644 index 0000000..d2995d2 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/ISmlParser.cs @@ -0,0 +1,14 @@ +using CreativeCoders.SmartMessageLanguage.Framing; + +namespace CreativeCoders.SmartMessageLanguage.Parsing; + +public interface ISmlParser +{ + /// Parses all OBIS values contained in the given frame. + /// Frame produced by . + SmlParseResult Parse(SmlFrame frame); + + /// Parses all OBIS values contained in the given de-escaped payload. + /// Payload bytes of an SML frame (without start/end escape). + SmlParseResult Parse(ReadOnlySpan payload); +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/ObisValue.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/ObisValue.cs new file mode 100644 index 0000000..f8db9e7 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/ObisValue.cs @@ -0,0 +1,21 @@ +using CreativeCoders.SmartMessageLanguage.Tlv; +using CreativeCoders.SmartMessageLanguage.Units; + +namespace CreativeCoders.SmartMessageLanguage.Parsing; + +/// +/// Represents a single OBIS value extracted from an SML GetListResponse. +/// +/// OBIS code formatted as A-B:C.D.E*F. +/// Scaled numeric value if the raw value is numeric, otherwise null. +/// Unit enum; when absent or unrecognised. +/// Decimal scaler applied to produce (Value = raw * 10^Scaler). +/// Raw bytes of the value element as sent on the wire. +/// TLV primitive type of the raw value. +public sealed record ObisValue( + string ObisCode, + decimal? Value, + SmlUnit Unit, + sbyte Scaler, + byte[] RawValue, + SmlValueType RawType); diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParseResult.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParseResult.cs new file mode 100644 index 0000000..10dab8c --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParseResult.cs @@ -0,0 +1,10 @@ +namespace CreativeCoders.SmartMessageLanguage.Parsing; + +/// +/// Result of parsing an SML frame. +/// +/// All OBIS values extracted from the frame. +/// Non-fatal issues encountered during parsing. +public sealed record SmlParseResult( + IReadOnlyList Values, + IReadOnlyList Warnings); diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs new file mode 100644 index 0000000..4f1de12 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs @@ -0,0 +1,383 @@ +using System.Globalization; +using CreativeCoders.Core; +using CreativeCoders.SmartMessageLanguage.Framing; +using CreativeCoders.SmartMessageLanguage.Tlv; +using CreativeCoders.SmartMessageLanguage.Units; +using Microsoft.Extensions.Logging; + +namespace CreativeCoders.SmartMessageLanguage.Parsing; + +/// +/// High-level parser that extracts OBIS values from an SML frame. +/// +/// +/// The parser walks the top-level SML_File / SML_Message structure, +/// locates every SML_GetList.Res body (message type 0x00000701) and +/// turns each entry of the valList into an . Other +/// message body types are skipped. Unknown units or unexpected value types are +/// reported via ; the parser never throws on +/// well-formed but semantically unusual input. +/// +public sealed class SmlParser : ISmlParser +{ + private const uint GetListResponseId = 0x00000701; + + private readonly ILogger _logger; + + /// Creates a parser and routes diagnostic events to . + /// Logger used for diagnostic events; pass + /// to silence logging. + public SmlParser(ILogger logger) + { + _logger = Ensure.NotNull(logger); + } + + /// Parses all OBIS values contained in the given frame. + /// Frame produced by . + public SmlParseResult Parse(SmlFrame frame) + { + Ensure.NotNull(frame); + + return Parse(frame.PayloadBytes); + } + + /// Parses all OBIS values contained in the given de-escaped payload. + /// Payload bytes of an SML frame (without start/end escape). + public SmlParseResult Parse(ReadOnlySpan payload) + { + SmlParserLog.ParseStarted(_logger, payload.Length); + + var values = new List(); + var warnings = new List(); + var reader = new SmlTlvReader(payload); + + while (reader.Read()) + { + var element = reader.Current; + + if (element.IsEndOfMessage) + { + continue; + } + + if (element.Type != SmlValueType.List) + { + // Top level must be a sequence of messages (lists); anything else is stray. + var message = $"Unexpected top-level TLV type {element.Type}"; + warnings.Add(message); + SmlParserLog.EnvelopeError(_logger, message); + + continue; + } + + ProcessMessage(ref reader, element.ListLength, values, warnings); + } + + SmlParserLog.ParseCompleted(_logger, values.Count, warnings.Count); + + return new SmlParseResult(values, warnings); + } + + private void ProcessMessage(ref SmlTlvReader reader, int entryCount, + List values, List warnings) + { + // SML_Message = List of 6: transactionId, groupNo, abortOnError, messageBody, crc16, endOfSmlMsg + // messageBody = List of 2: messageBodyType (Unsigned), messageBodyChoice (type-specific List) + if (entryCount != 6) + { + SkipListEntries(ref reader, entryCount); + + return; + } + + // Fields 1-3 are header values we don't care about; skip each. + for (var i = 0; i < 3; i++) + { + if (!reader.Read()) + { + return; + } + + reader.SkipCurrent(); + } + + // Field 4: messageBody list. + if (!reader.Read() || reader.Current.Type != SmlValueType.List || reader.Current.ListLength != 2) + { + warnings.Add("Malformed SML_Message body wrapper"); + SkipListEntries(ref reader, 2); + + return; + } + + if (!reader.Read() || reader.Current.Type != SmlValueType.Unsigned) + { + warnings.Add("Missing messageBody type tag"); + SkipListEntries(ref reader, 3); + + return; + } + + var messageBodyType = (uint)reader.Current.GetUInt64(); + + if (!reader.Read()) + { + return; + } + + if (messageBodyType == GetListResponseId && reader.Current.Type == SmlValueType.List) + { + SmlParserLog.GetListResponseFound(_logger, reader.Current.ListLength); + ProcessGetListResponse(ref reader, reader.Current.ListLength, values, warnings); + } + else + { + reader.SkipCurrent(); + } + + // Fields 5 and 6: crc16 + endOfSmlMsg. + for (var i = 0; i < 2; i++) + { + if (!reader.Read()) + { + return; + } + + reader.SkipCurrent(); + } + } + + private void ProcessGetListResponse(ref SmlTlvReader reader, int entryCount, + List values, List warnings) + { + // SML_GetList.Res = List of 7: clientId, serverId, listName, actSensorTime, + // valList, listSignature, actGatewayTime. + if (entryCount != 7) + { + warnings.Add($"GetListResponse with unexpected field count {entryCount}"); + SkipListEntries(ref reader, entryCount); + + return; + } + + // Skip first 4 fields. + for (var i = 0; i < 4; i++) + { + if (!reader.Read()) + { + return; + } + + reader.SkipCurrent(); + } + + // Field 5: valList (or OPTIONAL null). When present it is a List. + if (!reader.Read()) + { + return; + } + + if (reader.Current.Type == SmlValueType.List) + { + var listCount = reader.Current.ListLength; + + for (var i = 0; i < listCount; i++) + { + ReadValListEntry(ref reader, values, warnings); + } + } + else + { + // valList is OPTIONAL; a missing list is encoded as an empty octet string. + // Any other type is surprising. + if (reader.Current.Type != SmlValueType.OctetString) + { + warnings.Add($"Unexpected valList type {reader.Current.Type}"); + SmlParserLog.UnexpectedTlvType(_logger, reader.Current.Type, "valList"); + } + } + + // Skip remaining fields 6 & 7. + for (var i = 0; i < 2; i++) + { + if (!reader.Read()) + { + return; + } + + reader.SkipCurrent(); + } + } + + private void ReadValListEntry(ref SmlTlvReader reader, List values, + List warnings) + { + // SML_ListEntry = List of 7: objName (OctetString), status, valTime, + // unit (Unsigned8), scaler (Integer8), value, valueSignature. + if (!reader.Read() || reader.Current.Type != SmlValueType.List) + { + warnings.Add("valList entry is not a list"); + + return; + } + + var entryCount = reader.Current.ListLength; + + if (entryCount < 6) + { + warnings.Add($"valList entry too short ({entryCount} fields)"); + SkipListEntries(ref reader, entryCount); + + return; + } + + // objName + if (!reader.Read() || reader.Current.Type != SmlValueType.OctetString) + { + warnings.Add("valList entry missing objName"); + SkipListEntries(ref reader, entryCount - 1); + + return; + } + + var obisCode = FormatObisCode(reader.Current.Raw); + + // status + valTime: skip. + for (var i = 0; i < 2; i++) + { + if (!reader.Read()) + { + return; + } + + reader.SkipCurrent(); + } + + // unit + if (!reader.Read()) + { + return; + } + + var unit = SmlUnit.Unknown; + + if (reader.Current.Type == SmlValueType.Unsigned) + { + var unitCode = (byte)reader.Current.GetUInt64(); + unit = Enum.IsDefined((SmlUnit)unitCode) ? (SmlUnit)unitCode : SmlUnit.Unknown; + + if (unit == SmlUnit.Unknown && unitCode != 0) + { + warnings.Add($"Unknown unit code {unitCode} for {obisCode}"); + SmlParserLog.UnknownUnit(_logger, unitCode, obisCode); + } + } + + // scaler + if (!reader.Read()) + { + return; + } + + sbyte scaler = 0; + + if (reader.Current.Type == SmlValueType.Integer) + { + scaler = (sbyte)reader.Current.GetInt64(); + } + + // value + if (!reader.Read()) + { + return; + } + + var rawType = reader.Current.Type; + var rawBytes = reader.Current.GetOctetString(); + var decimalValue = ComputeDecimalValue(reader.Current, scaler, warnings, obisCode); + + values.Add(new ObisValue(obisCode, decimalValue, unit, scaler, rawBytes, rawType)); + SmlParserLog.ObisValueParsed(_logger, obisCode, decimalValue, unit, scaler); + + // Remaining fields (valueSignature and any extras). + for (var i = 6; i < entryCount; i++) + { + if (!reader.Read()) + { + return; + } + + reader.SkipCurrent(); + } + } + + private decimal? ComputeDecimalValue(SmlTlvElement value, sbyte scaler, + List warnings, string obisCode) + { + switch (value.Type) + { + case SmlValueType.Unsigned: + return ApplyScaler(value.GetUInt64(), scaler); + case SmlValueType.Integer: + return ApplyScaler(value.GetInt64(), scaler); + case SmlValueType.Boolean: + return value.GetBool() ? 1m : 0m; + case SmlValueType.OctetString: + // Non-numeric (e.g. server ID as octet string); caller can read RawValue. + return null; + default: + warnings.Add($"Unsupported value type {value.Type} for {obisCode}"); + SmlParserLog.UnsupportedValueType(_logger, value.Type, obisCode); + + return null; + } + } + + private static decimal ApplyScaler(long raw, sbyte scaler) => + scaler == 0 ? raw : raw * (decimal)Math.Pow(10, scaler); + + private static decimal ApplyScaler(ulong raw, sbyte scaler) => + scaler == 0 ? raw : raw * (decimal)Math.Pow(10, scaler); + + private static string FormatObisCode(ReadOnlySpan raw) + { + // Standard OBIS identifier is 6 bytes: A-B:C.D.E*F. Some meters omit F; default to 255. + if (raw.Length < 5) + { + return ToHex(raw); + } + + var a = raw[0]; + var b = raw[1]; + var c = raw[2]; + var d = raw[3]; + var e = raw[4]; + var f = raw.Length >= 6 ? raw[5] : (byte)255; + + return string.Create(CultureInfo.InvariantCulture, + $"{a}-{b}:{c}.{d}.{e}*{f}"); + } + + private static string ToHex(ReadOnlySpan data) + { + if (data.IsEmpty) + { + return string.Empty; + } + + return Convert.ToHexString(data); + } + + private static void SkipListEntries(ref SmlTlvReader reader, int count) + { + for (var i = 0; i < count; i++) + { + if (!reader.Read()) + { + return; + } + + reader.SkipCurrent(); + } + } +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs new file mode 100644 index 0000000..091df31 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs @@ -0,0 +1,43 @@ +using CreativeCoders.SmartMessageLanguage.Tlv; +using CreativeCoders.SmartMessageLanguage.Units; +using Microsoft.Extensions.Logging; + +namespace CreativeCoders.SmartMessageLanguage.Parsing; + +// Source-generated, allocation-free logging helpers for SmlParser. +// Event IDs 2000-2099 are reserved for the parsing layer. +internal static partial class SmlParserLog +{ + [LoggerMessage(EventId = 2001, Level = LogLevel.Debug, + Message = "Starting SML parse for payload of {PayloadLength} bytes")] + public static partial void ParseStarted(ILogger logger, int payloadLength); + + [LoggerMessage(EventId = 2002, Level = LogLevel.Debug, + Message = "Found SML_GetList.Res with {EntryCount} entries")] + public static partial void GetListResponseFound(ILogger logger, int entryCount); + + [LoggerMessage(EventId = 2003, Level = LogLevel.Debug, + Message = "Parsed OBIS value {ObisCode} = {Value} {Unit} (scaler={Scaler})")] + public static partial void ObisValueParsed(ILogger logger, string obisCode, decimal? value, + SmlUnit unit, sbyte scaler); + + [LoggerMessage(EventId = 2004, Level = LogLevel.Information, + Message = "SML parse completed: {ValueCount} OBIS values, {WarningCount} warnings")] + public static partial void ParseCompleted(ILogger logger, int valueCount, int warningCount); + + [LoggerMessage(EventId = 2010, Level = LogLevel.Warning, + Message = "Unknown SML unit code {UnitCode} for OBIS {ObisCode}")] + public static partial void UnknownUnit(ILogger logger, byte unitCode, string obisCode); + + [LoggerMessage(EventId = 2011, Level = LogLevel.Warning, + Message = "Unexpected SML TLV type {TlvType} ({Context})")] + public static partial void UnexpectedTlvType(ILogger logger, SmlValueType tlvType, string context); + + [LoggerMessage(EventId = 2012, Level = LogLevel.Warning, + Message = "Unsupported SML value type {TlvType} for OBIS {ObisCode}")] + public static partial void UnsupportedValueType(ILogger logger, SmlValueType tlvType, string obisCode); + + [LoggerMessage(EventId = 2020, Level = LogLevel.Error, + Message = "Malformed SML envelope: {Reason}")] + public static partial void EnvelopeError(ILogger logger, string reason); +} diff --git a/source/CreativeCoders.SmartMessageLanguage/SmlServiceCollectionExtensions.cs b/source/CreativeCoders.SmartMessageLanguage/SmlServiceCollectionExtensions.cs new file mode 100644 index 0000000..b4bff3f --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/SmlServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using CreativeCoders.SmartMessageLanguage.Framing; +using CreativeCoders.SmartMessageLanguage.Parsing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CreativeCoders.SmartMessageLanguage; + +public static class SmlServiceCollectionExtensions +{ + public static IServiceCollection AddSml(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvElement.cs b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvElement.cs new file mode 100644 index 0000000..bf2ff84 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvElement.cs @@ -0,0 +1,85 @@ +namespace CreativeCoders.SmartMessageLanguage.Tlv; + +/// +/// Descriptor of a single TLV element read by . +/// +/// +/// Primitive elements carry their raw payload bytes in . Lists expose +/// their declared entry count in ; use the enclosing +/// to descend into them with subsequent Read calls. +/// +public readonly ref struct SmlTlvElement +{ + internal SmlTlvElement(SmlValueType type, int listLength, ReadOnlySpan raw) + { + Type = type; + ListLength = listLength; + Raw = raw; + } + + /// The TLV primitive or structural type. + public SmlValueType Type { get; } + + /// Declared number of entries when is . + public int ListLength { get; } + + /// + /// Raw payload bytes for primitive elements (empty for + /// and ). + /// + public ReadOnlySpan Raw { get; } + + /// true if this element is the end-of-message marker (0x00). + public bool IsEndOfMessage => Type == SmlValueType.EndOfMessage; + + /// Parses as a big-endian unsigned integer (1-8 bytes). + public ulong GetUInt64() + { + ulong value = 0; + + foreach (var b in Raw) + { + value = (value << 8) | b; + } + + return value; + } + + /// + /// Parses as a big-endian two's-complement signed integer (1-8 bytes). + /// + public long GetInt64() + { + if (Raw.IsEmpty) + { + return 0; + } + + // Sign-extend the most significant byte to a full 64-bit register. + long value = (sbyte)Raw[0]; + + for (var i = 1; i < Raw.Length; i++) + { + value = (value << 8) | Raw[i]; + } + + return value; + } + + /// Returns the boolean value. Any non-zero byte is treated as true. + public bool GetBool() + { + foreach (var b in Raw) + { + if (b != 0) + { + return true; + } + } + + return false; + } + + /// Returns the octet string bytes as a new array. + public byte[] GetOctetString() => Raw.ToArray(); +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvReader.cs b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvReader.cs new file mode 100644 index 0000000..4604de5 --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvReader.cs @@ -0,0 +1,143 @@ +namespace CreativeCoders.SmartMessageLanguage.Tlv; + +/// +/// Low-level, allocation-free forward walker over SML TLV data. +/// +/// +/// The reader decodes the TL header (type nibble + length nibble) and any chained +/// length extension bytes (0x8x continuation nibbles). For primitive types the +/// element exposes the raw payload slice. For lists, +/// gives the entry count and subsequent +/// calls descend depth-first into the children. +/// +public ref struct SmlTlvReader +{ + private readonly ReadOnlySpan _data; + private int _position; + private SmlTlvElement _current; + + /// Creates a new reader positioned at the start of . + public SmlTlvReader(ReadOnlySpan data) + { + _data = data; + _position = 0; + _current = default; + } + + /// The element most recently produced by . + public SmlTlvElement Current => _current; + + /// Current byte offset inside the underlying data span. + public int Position => _position; + + /// true if the reader has consumed all bytes. + public readonly bool EndOfData => _position >= _data.Length; + + /// Advances to the next element. Returns false when no more data is available. + public bool Read() + { + if (_position >= _data.Length) + { + return false; + } + + var first = _data[_position]; + + // End-of-message marker. + if (first == 0x00) + { + _position++; + _current = new SmlTlvElement(SmlValueType.EndOfMessage, 0, ReadOnlySpan.Empty); + + return true; + } + + var typeNibble = (first >> 4) & 0x07; + // High bit of the type nibble set signals a length extension byte follows. + var hasMoreLengthBytes = (first & 0x80) != 0; + var length = first & 0x0F; + var lengthBytesConsumed = 1; + + while (hasMoreLengthBytes) + { + if (_position + lengthBytesConsumed >= _data.Length) + { + throw new InvalidOperationException("Truncated TLV length field"); + } + + var next = _data[_position + lengthBytesConsumed]; + hasMoreLengthBytes = (next & 0x80) != 0; + length = (length << 4) | (next & 0x0F); + lengthBytesConsumed++; + } + + var headerLength = lengthBytesConsumed; + + switch (typeNibble) + { + case 0x7: + { + // For lists, the 'length' encodes the number of child elements, not a byte count. + _position += headerLength; + _current = new SmlTlvElement(SmlValueType.List, length, ReadOnlySpan.Empty); + + return true; + } + case 0x0: + case 0x4: + case 0x5: + case 0x6: + { + var resolvedType = typeNibble switch + { + 0x0 => SmlValueType.OctetString, + 0x4 => SmlValueType.Boolean, + 0x5 => SmlValueType.Integer, + _ => SmlValueType.Unsigned + }; + + // Declared length includes the header byte(s); payload length is length - header. + var payloadLength = length - headerLength; + + if (payloadLength < 0 || _position + headerLength + payloadLength > _data.Length) + { + throw new InvalidOperationException("Truncated TLV element"); + } + + var payload = _data.Slice(_position + headerLength, payloadLength); + _position += headerLength + payloadLength; + _current = new SmlTlvElement(resolvedType, 0, payload); + + return true; + } + default: + throw new InvalidOperationException($"Unknown SML TLV type nibble 0x{typeNibble:X}"); + } + } + + /// + /// Skips the element most recently returned by . For lists this recursively + /// consumes all children. + /// + public void SkipCurrent() + { + if (_current.Type != SmlValueType.List) + { + // Primitives have already been fully consumed by Read(). + return; + } + + var remaining = _current.ListLength; + + while (remaining > 0) + { + if (!Read()) + { + throw new InvalidOperationException("Unexpected end of data while skipping list"); + } + + SkipCurrent(); + remaining--; + } + } +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlValueType.cs b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlValueType.cs new file mode 100644 index 0000000..16c014c --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlValueType.cs @@ -0,0 +1,31 @@ +namespace CreativeCoders.SmartMessageLanguage.Tlv; + +/// +/// Primitive type classification for an . +/// +/// +/// Enum values are stable identifiers for API consumers; they intentionally differ +/// from the SML TLV type nibble because the end-of-message marker (0x00) shares +/// the same wire nibble (0x0) as an octet string. The reader discriminates on +/// the full first byte when deciding which enum value to assign. +/// +public enum SmlValueType +{ + /// End-of-message marker (single 0x00 byte). + EndOfMessage = 0, + + /// Octet string (type nibble 0x0). + OctetString = 1, + + /// Boolean (type nibble 0x4). + Boolean = 2, + + /// Two's complement signed integer (type nibble 0x5). + Integer = 3, + + /// Unsigned integer (type nibble 0x6). + Unsigned = 4, + + /// List of nested TLV elements (type nibble 0x7). + List = 5 +} diff --git a/source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs b/source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs new file mode 100644 index 0000000..21f50ae --- /dev/null +++ b/source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs @@ -0,0 +1,107 @@ +namespace CreativeCoders.SmartMessageLanguage.Units; + +/// +/// Subset of DLMS/IEC 62056-62 unit codes commonly used by electricity meters. +/// The numeric values match the SML SML_Unit codes as received on the wire. +/// +public enum SmlUnit : byte +{ + /// Unknown or unassigned unit. + Unknown = 0, + + /// Year (a). + Year = 1, + + /// Month (mo). + Month = 2, + + /// Week (wk). + Week = 3, + + /// Day (d). + Day = 4, + + /// Hour (h). + Hour = 5, + + /// Minute (min). + Minute = 6, + + /// Second (s). + Second = 7, + + /// Degree (°, phase angle). + Degree = 8, + + /// Degree Celsius (°C). + DegreeCelsius = 9, + + /// Metre (m). + Metre = 11, + + /// Metre per second (m/s). + MetrePerSecond = 12, + + /// Cubic metre (). + CubicMetre = 13, + + /// Kilogram (kg). + Kilogram = 17, + + /// Newton (N). + Newton = 19, + + /// Pascal (Pa). + Pascal = 22, + + /// Watt (W, active power). + Watt = 27, + + /// Volt-ampere (VA, apparent power). + VoltAmpere = 28, + + /// Volt-ampere reactive (var, reactive power). + Var = 29, + + /// Watt-hour (Wh, active energy). + WattHour = 30, + + /// Volt-ampere-hour (VAh, apparent energy). + VoltAmpereHour = 31, + + /// Volt-ampere reactive hour (varh, reactive energy). + VarHour = 32, + + /// Ampere (A, current). + Ampere = 33, + + /// Coulomb (C, charge). + Coulomb = 34, + + /// Volt (V, voltage). + Volt = 35, + + /// Volt per metre (V/m). + VoltPerMetre = 36, + + /// Farad (F). + Farad = 37, + + /// Ohm (Ω). + Ohm = 38, + + /// Power factor (cos φ, dimensionless). + PowerFactor = 43, + + /// Hertz (Hz, frequency). + Hertz = 44, + + /// Percent (%). + Percent = 56, + + /// Ampere-hour (Ah). + AmpereHour = 57, + + /// Count (dimensionless). + Count = 255 +} diff --git a/source/CreativeCoders.SmartMeter.Cli/Program.cs b/source/CreativeCoders.SmartMeter.Cli/Program.cs index a915add..1cdcfd1 100644 --- a/source/CreativeCoders.SmartMeter.Cli/Program.cs +++ b/source/CreativeCoders.SmartMeter.Cli/Program.cs @@ -1,5 +1,6 @@ using CreativeCoders.SmartMeter.DataProcessing; using CreativeCoders.SmartMeter.Server.Core; +using CreativeCoders.SmartMeter.Server.Core.Unlock; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -19,10 +20,22 @@ static async Task Main(string[] args) options.TimestampFormat = "hh:mm:ss "; })) .AddSingleton() + .AddSmartMeterServer() .BuildServiceProvider(); var dataProducer = sp.GetRequiredService(); + if (args.Length > 1 && args[0].Equals("unlock", StringComparison.OrdinalIgnoreCase)) + { + AnsiConsole.WriteLine("Unlocking Smart Meter with provided PIN..."); + + var pin = args[1]; + + await SendPinAsync(dataProducer, pin); + + return; + } + await dataProducer.StartAsync(new SmartMeterConsoleOutput()); AnsiConsole.WriteLine("Press any key to stop..."); @@ -32,6 +45,15 @@ static async Task Main(string[] args) AnsiConsole.WriteLine("Smart Meter CLI stopped"); } + + private static async Task SendPinAsync(ISmartMeterDataProducer dataProducer, string pin) + { + AnsiConsole.WriteLine($"Sending PIN: {pin}"); + await dataProducer.UnlockAsync(pin, new SmartMeterUnlockOptions + { + Strategy = SmartMeterPinStrategy.EmhAsciiBlock + }); + } } internal class SmartMeterConsoleOutput : IObserver diff --git a/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj b/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj index f078ee6..ab16ccc 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj +++ b/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj @@ -5,13 +5,14 @@ - - - + + + - + + diff --git a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs index 38145a6..39301d1 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs @@ -1,4 +1,5 @@ using CreativeCoders.SmartMeter.DataProcessing; +using CreativeCoders.SmartMeter.Server.Core.Unlock; namespace CreativeCoders.SmartMeter.Server.Core; @@ -7,4 +8,19 @@ public interface ISmartMeterDataProducer Task StartAsync(IObserver observer); Task StopAsync(); + + /// + /// Sends the given PIN to the smart meter via the optical coupler connected + /// to the serial port in order to unlock the extended data set (instantaneous + /// power, per-phase values, voltages, ...). Optionally verifies the unlock by + /// observing the incoming byte stream for extended OBIS codes. + /// + /// PIN as printable ASCII digits. Must not be empty. + /// Transport and verification options. Defaults target EMH / eHZ meters. + /// Cancels the operation. + /// A structured describing the outcome. + Task UnlockAsync( + string pin, + SmartMeterUnlockOptions? options = null, + CancellationToken cancellationToken = default); } diff --git a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterReactiveDataPipeline.cs new file mode 100644 index 0000000..8377a81 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterReactiveDataPipeline.cs @@ -0,0 +1,7 @@ +using CreativeCoders.SmartMeter.DataProcessing; + +namespace CreativeCoders.SmartMeter.Server.Core; + +public interface ISmartMeterReactiveDataPipeline : IObserver, IObservable +{ +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs index 1beb834..4eb7d28 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs @@ -1,14 +1,20 @@ +using System.Diagnostics; using System.Reactive.Concurrency; using System.Reactive.Linq; +using System.Text; using CreativeCoders.Core; using CreativeCoders.SmartMeter.DataProcessing; +using CreativeCoders.SmartMeter.Server.Core.Unlock; using CreativeCoders.SmartMeter.Sml.Reactive; using Microsoft.Extensions.Logging; namespace CreativeCoders.SmartMeter.Server.Core; -public class SmartMeterDataProducer(ILogger logger) : ISmartMeterDataProducer +public class SmartMeterDataProducer( + ISmartMeterReactiveDataPipeline reactiveDataPipeline, + ILogger logger) : ISmartMeterDataProducer { + private readonly ISmartMeterReactiveDataPipeline _reactiveDataPipeline = Ensure.NotNull(reactiveDataPipeline); private readonly ILogger _logger = Ensure.NotNull(logger); private readonly ReactiveSerialPort _serialPort = new ReactiveSerialPort("/dev/ttyUSB0"); @@ -18,21 +24,25 @@ public Task StartAsync(IObserver observer) { _logger.LogInformation("Starting SmartMeter data producer"); - _subscription ??= _serialPort - .Do(_ => _logger.LogDebug("Data received from serial port")) - .SelectSmlMessages() - .SelectSmlValues() - .SelectSmartMeterValues() + _reactiveDataPipeline .SubscribeOn(new TaskPoolScheduler(new TaskFactory())) .Subscribe(observer); - _logger.LogInformation("SmartMeter data producer started"); + _subscription ??= _serialPort + .Subscribe(_reactiveDataPipeline); + + _logger.LogInformation("SmartMeter data producer initialized"); + + OpenSerialPort(); + + return Task.CompletedTask; + } + private void OpenSerialPort() + { _logger.LogInformation("Opening serial port..."); _serialPort.Open(); _logger.LogInformation("Serial port opened"); - - return Task.CompletedTask; } public Task StopAsync() @@ -48,6 +58,197 @@ public Task StopAsync() return Task.CompletedTask; } + public async Task UnlockAsync( + string pin, + SmartMeterUnlockOptions? options = null, + CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(pin); + + options ??= new SmartMeterUnlockOptions(); + + var stopwatch = Stopwatch.StartNew(); + + _logger.LogInformation( + "Unlocking smart meter via {Strategy}, pinLength={PinLength}, verify={Verify}, verificationTimeout={Timeout}", + options.Strategy, pin.Length, options.Verify, options.VerificationTimeout); + + // Ensure the port is open so we can write and observe responses. Don't close + // it here, so the caller can continue using the same producer afterwards. + if (!_serialPort.IsOpen) + { + _logger.LogInformation("Serial port is closed, opening it for unlock procedure..."); + _serialPort.Open(); + _logger.LogInformation("Serial port opened"); + } + + try + { + if (options.InitialDelay > TimeSpan.Zero) + { + _logger.LogDebug("Waiting initial delay {Delay} before sending PIN", options.InitialDelay); + + await Task.Delay(options.InitialDelay, cancellationToken).ConfigureAwait(false); + } + + var detected = new HashSet(StringComparer.Ordinal); + var verificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expectAck = options.Strategy == SmartMeterPinStrategy.IskraAsciiBlock; + + IDisposable? verificationSubscription = null; + + if (options.Verify) + { + verificationSubscription = _serialPort.Subscribe(new VerificationObserver( + options.ExpectedObisCodes, + expectAck, + (code, isAck) => + { + if (isAck) + { + _logger.LogDebug("ACK byte (0x06) received from smart meter"); + } + else if (code is not null && detected.Add(code)) + { + _logger.LogDebug("Detected extended OBIS code {ObisCode}", code); + } + + verificationTcs.TrySetResult(true); + })); + } + + try + { + await SendPinAsync(pin, options, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + verificationSubscription?.Dispose(); + + _logger.LogWarning("Unlock cancelled while sending PIN"); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.Cancelled, [], stopwatch.Elapsed, "Cancelled"); + } + catch (Exception ex) + { + verificationSubscription?.Dispose(); + + _logger.LogError(ex, "Failed to send PIN to smart meter"); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.WriteFailed, [], stopwatch.Elapsed, ex.Message); + } + + if (!options.Verify) + { + _logger.LogInformation( + "PIN sent, verification skipped by options. Elapsed={Elapsed}", stopwatch.Elapsed); + + return new SmartMeterUnlockResult( + true, SmartMeterUnlockOutcome.VerificationSkipped, [], stopwatch.Elapsed, + "Verification skipped"); + } + + _logger.LogInformation( + "PIN sent, awaiting verification evidence (timeout={Timeout})", options.VerificationTimeout); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(options.VerificationTimeout); + + using var _ = timeoutCts.Token.Register(() => verificationTcs.TrySetResult(false)); + + var verified = await verificationTcs.Task.ConfigureAwait(false); + + verificationSubscription?.Dispose(); + + stopwatch.Stop(); + + if (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("Unlock cancelled while waiting for verification"); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.Cancelled, detected.ToArray(), stopwatch.Elapsed, + "Cancelled"); + } + + if (verified) + { + _logger.LogInformation( + "Smart meter unlocked. Detected codes: [{Codes}], elapsed={Elapsed}", + string.Join(", ", detected), stopwatch.Elapsed); + + return new SmartMeterUnlockResult( + true, SmartMeterUnlockOutcome.PinAccepted, detected.ToArray(), stopwatch.Elapsed); + } + + _logger.LogWarning( + "Unlock verification timed out after {Timeout}. No extended OBIS codes observed. " + + "Possible causes: incorrect PIN, wrong strategy ({Strategy}) for this meter, " + + "optical coupler not aligned, or serial port misconfigured.", + options.VerificationTimeout, options.Strategy); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.VerificationTimeout, detected.ToArray(), stopwatch.Elapsed, + "Verification timeout"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("Unlock cancelled"); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.Cancelled, [], stopwatch.Elapsed, "Cancelled"); + } + } + + private async Task SendPinAsync(string pin, SmartMeterUnlockOptions options, CancellationToken cancellationToken) + { + switch (options.Strategy) + { + case SmartMeterPinStrategy.EmhAsciiBlock: + case SmartMeterPinStrategy.IskraAsciiBlock: + { + var payload = Encoding.ASCII.GetBytes(pin + options.LineEnding); + + _logger.LogDebug( + "Writing PIN as ASCII block ({Bytes} bytes, lineEnding={LineEndingLength}b)", + payload.Length, options.LineEnding.Length); + + _serialPort.Write(payload); + + break; + } + + case SmartMeterPinStrategy.EasymeterDigitByDigit: + { + _logger.LogDebug( + "Writing PIN digit-by-digit ({Digits} digits, delay={Delay})", + pin.Length, options.DigitDelay); + + for (var i = 0; i < pin.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var digit = Encoding.ASCII.GetBytes(pin.AsSpan(i, 1).ToArray()); + + _serialPort.Write(digit); + + if (i < pin.Length - 1 && options.DigitDelay > TimeSpan.Zero) + { + await Task.Delay(options.DigitDelay, cancellationToken).ConfigureAwait(false); + } + } + + break; + } + + default: + throw new ArgumentOutOfRangeException( + nameof(options), options.Strategy, "Unsupported PIN strategy"); + } + } + private void CloseSerialPort() { _logger.LogInformation("Closing serial port..."); @@ -70,4 +271,44 @@ private void DisposingSubscription() _subscription = null; } + + /// + /// Observes raw serial data and reports verification events + /// (extended OBIS code detected or ACK byte received). + /// + private sealed class VerificationObserver : IObserver + { + private readonly IReadOnlyList _expectedObisCodes; + private readonly bool _expectAck; + private readonly Action _onHit; + + public VerificationObserver( + IReadOnlyList expectedObisCodes, bool expectAck, Action onHit) + { + _expectedObisCodes = expectedObisCodes; + _expectAck = expectAck; + _onHit = onHit; + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void OnNext(byte[] value) + { + if (_expectAck && Array.IndexOf(value, (byte)0x06) >= 0) + { + _onHit(null, true); + } + + foreach (var code in ObisCodeScanner.FindMatches(value, _expectedObisCodes)) + { + _onHit(code, false); + } + } + } } diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs new file mode 100644 index 0000000..8516468 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs @@ -0,0 +1,61 @@ +using System.Reactive.Linq; +using CreativeCoders.Core; +using CreativeCoders.Core.Weak; +using CreativeCoders.SmartMessageLanguage.Framing; +using CreativeCoders.SmartMessageLanguage.Parsing; +using CreativeCoders.SmartMeter.DataProcessing; +using Microsoft.Extensions.Logging; + +namespace CreativeCoders.SmartMeter.Server.Core; + +public class SmartMeterReactiveDataPipeline : ISmartMeterReactiveDataPipeline +{ + private readonly ISmlParser _smlParser; + private readonly ISmlMessageDetector _smlMessageDetector; + private readonly ILogger _logger; + + public SmartMeterReactiveDataPipeline(ISmlParser smlParser, ISmlMessageDetector smlMessageDetector, + ILogger logger) + { + _smlParser = Ensure.NotNull(smlParser); + _smlMessageDetector = Ensure.NotNull(smlMessageDetector); + _logger = Ensure.NotNull(logger); + + _smlMessageDetector.Messages.Subscribe(message => + { + _logger.LogInformation("SML message detected. Length: {Length}", message.PayloadBytes.Length); + }); + } + + public void OnCompleted() + { + //throw new NotImplementedException(); + } + + public void OnError(Exception error) + { + //throw new NotImplementedException(); + } + + public void OnNext(byte[] value) + { + _logger.LogInformation("Received data. Length: {Length}", value.Length); + + _smlMessageDetector.Append(value); + } + + public IDisposable Subscribe(IObserver observer) + { + _smlMessageDetector.Messages.Select(message => _smlParser.Parse(message.PayloadBytes)).Subscribe(smlMessage => + { + _logger.LogInformation("Parsed SML message. Values count: {Count}", smlMessage.Values.Count); + + foreach (var value in smlMessage.Values) + { + _logger.LogInformation("Publishing value. Obis: {Obis}, Value: {Value}", value.ObisCode, value.Value); + } + }); + + return new NullDisposable(); + } +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs new file mode 100644 index 0000000..f2e99b5 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using CreativeCoders.SmartMessageLanguage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CreativeCoders.SmartMeter.Server.Core; + +public static class SmartMeterServerServiceCollectionExtensions +{ + public static IServiceCollection AddSmartMeterServer(this IServiceCollection services) + { + services.AddSml(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ObisCodeScanner.cs b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ObisCodeScanner.cs new file mode 100644 index 0000000..bcba2f1 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ObisCodeScanner.cs @@ -0,0 +1,92 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.SmartMeter.Server.Core.Unlock; + +/// +/// Searches raw SML byte streams for occurrences of 6-byte OBIS identifiers +/// (A B C D E F). Used to verify a successful PIN unlock without extending +/// the SML parser itself. +/// +internal static class ObisCodeScanner +{ + /// Parses an OBIS string of the form "A-B:C.D.E*F" into its 6 bytes. + public static byte[] ParseObis(string obis) + { + Ensure.IsNotNullOrWhitespace(obis); + + var colon = obis.IndexOf(':'); + var dash = obis.IndexOf('-'); + var star = obis.IndexOf('*'); + + if (dash < 0 || colon < 0 || star < 0 || dash > colon) + { + throw new FormatException($"Invalid OBIS code '{obis}'. Expected format 'A-B:C.D.E*F'."); + } + + var a = byte.Parse(obis.AsSpan(0, dash)); + var b = byte.Parse(obis.AsSpan(dash + 1, colon - dash - 1)); + + var cde = obis.Substring(colon + 1, star - colon - 1).Split('.'); + + if (cde.Length != 3) + { + throw new FormatException($"Invalid OBIS code '{obis}'. Expected three dot-separated values between ':' and '*'."); + } + + var c = byte.Parse(cde[0]); + var d = byte.Parse(cde[1]); + var e = byte.Parse(cde[2]); + var f = byte.Parse(obis.AsSpan(star + 1)); + + return [a, b, c, d, e, f]; + } + + /// + /// Returns the subset of whose 6-byte OBIS pattern + /// appears in . + /// + public static IEnumerable FindMatches(byte[] data, IReadOnlyList expected) + { + Ensure.NotNull(data); + Ensure.NotNull(expected); + + foreach (var code in expected) + { + var pattern = ParseObis(code); + + if (ContainsPattern(data, pattern)) + { + yield return code; + } + } + } + + private static bool ContainsPattern(byte[] data, byte[] pattern) + { + if (pattern.Length == 0 || data.Length < pattern.Length) + { + return false; + } + + for (var i = 0; i <= data.Length - pattern.Length; i++) + { + var match = true; + + for (var j = 0; j < pattern.Length; j++) + { + if (data[i + j] != pattern[j]) + { + match = false; + break; + } + } + + if (match) + { + return true; + } + } + + return false; + } +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterPinStrategy.cs b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterPinStrategy.cs new file mode 100644 index 0000000..75fb9a3 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterPinStrategy.cs @@ -0,0 +1,26 @@ +namespace CreativeCoders.SmartMeter.Server.Core.Unlock; + +/// +/// Defines how the PIN is transmitted over the optical interface of a smart meter. +/// Different vendors require different wire formats. +/// +public enum SmartMeterPinStrategy +{ + /// + /// PIN is sent as a single ASCII block terminated by a configurable line ending + /// (typical for EMH / eHZ meters). + /// + EmhAsciiBlock, + + /// + /// PIN digits are sent one-by-one with a configurable delay between them + /// (typical for Easymeter Q3A/Q3B/Q3D). + /// + EasymeterDigitByDigit, + + /// + /// PIN is sent as a single ASCII block; a 0x06 ACK byte is treated as an + /// immediate success indicator (typical for some ISKRA MT-series meters). + /// + IskraAsciiBlock +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOptions.cs b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOptions.cs new file mode 100644 index 0000000..e90d998 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOptions.cs @@ -0,0 +1,47 @@ +namespace CreativeCoders.SmartMeter.Server.Core.Unlock; + +/// +/// Options controlling the PIN unlock procedure for the smart meter's optical +/// interface. Defaults target a typical EMH / eHZ meter. +/// +public sealed record SmartMeterUnlockOptions +{ + /// Wire-format strategy used to transmit the PIN. + public SmartMeterPinStrategy Strategy { get; init; } = SmartMeterPinStrategy.EmhAsciiBlock; + + /// Line ending appended after an ASCII-block PIN (EMH / ISKRA strategies). + public string LineEnding { get; init; } = "\r\n"; + + /// Delay between individual digits when using . + public TimeSpan DigitDelay { get; init; } = TimeSpan.FromSeconds(1); + + /// Wait time after opening the port before sending the PIN. + public TimeSpan InitialDelay { get; init; } = TimeSpan.FromMilliseconds(200); + + /// Maximum time to wait for verification evidence (extended OBIS codes / ACK) after the PIN has been sent. + public TimeSpan VerificationTimeout { get; init; } = TimeSpan.FromSeconds(10); + + /// + /// When true, the data stream is observed after sending the PIN and the + /// result reflects whether verification evidence was found. When false, + /// the method returns immediately after writing with . + /// + public bool Verify { get; init; } = true; + + /// + /// OBIS codes (format "A-B:C.D.E*F") whose appearance in the raw byte stream is + /// interpreted as a successful unlock. Defaults cover instantaneous power, sum + /// active power, voltages and per-phase powers. + /// + public IReadOnlyList ExpectedObisCodes { get; init; } = + [ + "1-0:1.7.0*255", + "1-0:16.7.0*255", + "1-0:21.7.0*255", + "1-0:41.7.0*255", + "1-0:61.7.0*255", + "1-0:32.7.0*255", + "1-0:52.7.0*255", + "1-0:72.7.0*255" + ]; +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOutcome.cs b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOutcome.cs new file mode 100644 index 0000000..37c8991 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOutcome.cs @@ -0,0 +1,22 @@ +namespace CreativeCoders.SmartMeter.Server.Core.Unlock; + +/// +/// Result category of a PIN unlock attempt. +/// +public enum SmartMeterUnlockOutcome +{ + /// The meter emitted data that indicates a successful unlock. + PinAccepted, + + /// Verification was skipped by configuration. + VerificationSkipped, + + /// PIN was sent but no extended data / ACK arrived within the timeout. + VerificationTimeout, + + /// Sending the PIN over the serial port failed. + WriteFailed, + + /// The operation was cancelled by the caller. + Cancelled +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockResult.cs b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockResult.cs new file mode 100644 index 0000000..3fb7e15 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockResult.cs @@ -0,0 +1,16 @@ +namespace CreativeCoders.SmartMeter.Server.Core.Unlock; + +/// +/// Structured result of a PIN unlock attempt on the smart meter. +/// +/// True if the PIN is considered to have been accepted. +/// Categorised outcome of the attempt. +/// Extended OBIS codes detected on the stream after sending the PIN. +/// Wall-clock time spent in UnlockAsync from start to result. +/// Optional human-readable detail (e.g. exception message). +public sealed record SmartMeterUnlockResult( + bool Success, + SmartMeterUnlockOutcome Outcome, + IReadOnlyList DetectedObisCodes, + TimeSpan Elapsed, + string? Message = null); diff --git a/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs b/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs index 324c65b..24ef777 100644 --- a/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs +++ b/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs @@ -48,6 +48,8 @@ private IEnumerable ReadAllBytes() return buffer.Take(bytesRead); } + public bool IsOpen => _serialPort.IsOpen; + public void Open() { _serialPort.Open(); @@ -58,6 +60,13 @@ public void Close() _serialPort.Close(); } + public void Write(byte[] data) + { + Ensure.NotNull(data); + + _serialPort.Write(data, 0, data.Length); + } + public void Dispose() { _serialPort.Dispose(); diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/CreativeCoders.SmartMessageLanguage.Tests.csproj b/tests/CreativeCoders.SmartMessageLanguage.Tests/CreativeCoders.SmartMessageLanguage.Tests.csproj new file mode 100644 index 0000000..b2df383 --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/CreativeCoders.SmartMessageLanguage.Tests.csproj @@ -0,0 +1,29 @@ + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/EndToEndTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/EndToEndTests.cs new file mode 100644 index 0000000..5ed7c01 --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/EndToEndTests.cs @@ -0,0 +1,39 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Framing; +using CreativeCoders.SmartMessageLanguage.Parsing; +using CreativeCoders.SmartMessageLanguage.Tests.Fixtures; +using CreativeCoders.SmartMessageLanguage.Units; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CreativeCoders.SmartMessageLanguage.Tests; + +public class EndToEndTests +{ + [Fact] + public void DetectorToParser_RoundTripsToExpectedObisValues() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + SmlFrame? detected = null; + detector.MessageReceived += (_, e) => detected = e.Frame; + + // Feed in two chunks to exercise the streaming path. + detector.Append(frameBytes.AsSpan(0, 10)); + detector.Append(frameBytes.AsSpan(10)); + + detected.Should().NotBeNull(); + detected!.IsCrcValid.Should().BeTrue(); + + var parser = new SmlParser(NullLogger.Instance); + var result = parser.Parse(detected); + + result.Values.Should().HaveCount(2); + result.Values.Select(v => v.ObisCode).Should() + .BeEquivalentTo(["1-0:1.8.0*255", "1-0:16.7.0*255"]); + result.Values.Single(v => v.Unit == SmlUnit.WattHour).Value.Should().Be(12345.6m); + result.Values.Single(v => v.Unit == SmlUnit.Watt).Value.Should().Be(567m); + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/FrameBuilder.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/FrameBuilder.cs new file mode 100644 index 0000000..14371c6 --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/FrameBuilder.cs @@ -0,0 +1,61 @@ +using CreativeCoders.SmartMessageLanguage.Framing; + +namespace CreativeCoders.SmartMessageLanguage.Tests.Fixtures; + +/// +/// Wraps a raw (de-escaped) payload into a complete SML transport v1 frame with +/// correct escape-doubling, padding and CRC-16/X-25. +/// +internal static class FrameBuilder +{ + public static byte[] BuildFrame(byte[] payload) + { + // Escape doubling: any four consecutive 0x1B bytes in payload become eight on the wire. + var escaped = EscapePayload(payload); + + // 4-byte alignment padding is computed on the escaped body length (libSML convention). + var paddingBytes = (4 - (escaped.Count % 4)) % 4; + + var body = new List(); + body.AddRange([0x1B, 0x1B, 0x1B, 0x1B, 0x01, 0x01, 0x01, 0x01]); + body.AddRange(escaped); + + for (var i = 0; i < paddingBytes; i++) + { + body.Add(0x00); + } + + body.AddRange([0x1B, 0x1B, 0x1B, 0x1B, 0x1A, (byte)paddingBytes]); + + var preCrc = body.ToArray(); + var crc = Crc16X25.Compute(preCrc); + body.Add((byte)(crc & 0xFF)); + body.Add((byte)((crc >> 8) & 0xFF)); + + return body.ToArray(); + } + + private static List EscapePayload(byte[] payload) + { + var output = new List(payload.Length); + var i = 0; + + while (i < payload.Length) + { + if (i + 4 <= payload.Length + && payload[i] == 0x1B && payload[i + 1] == 0x1B + && payload[i + 2] == 0x1B && payload[i + 3] == 0x1B) + { + output.AddRange([0x1B, 0x1B, 0x1B, 0x1B, 0x1B, 0x1B, 0x1B, 0x1B]); + i += 4; + } + else + { + output.Add(payload[i]); + i++; + } + } + + return output; + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/SampleSmlFile.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/SampleSmlFile.cs new file mode 100644 index 0000000..55e51b6 --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/SampleSmlFile.cs @@ -0,0 +1,83 @@ +namespace CreativeCoders.SmartMessageLanguage.Tests.Fixtures; + +/// +/// Builds a canonical SML_GetList.Res payload usable both by the parser +/// and (after wrapping via ) by the detector. +/// +internal static class SampleSmlFile +{ + // OBIS 1-0:1.8.0*255 — Positive active energy total. + public static readonly byte[] ObisEnergy = [0x01, 0x00, 0x01, 0x08, 0x00, 0xFF]; + + // OBIS 1-0:16.7.0*255 — Sum active instantaneous power. + public static readonly byte[] ObisPower = [0x01, 0x00, 0x10, 0x07, 0x00, 0xFF]; + + public const ulong EnergyRaw = 123456UL; // → 12345.6 Wh with scaler -1 + public const int PowerRaw = 567; // → 567 W with scaler 0 + public const byte UnitWattHour = 30; + public const byte UnitWatt = 27; + + public static byte[] BuildGetListResponsePayload() + { + var valList = new TlvBuilder() + .List(2) + // Entry 1: energy. + .List(7) + .OctetString(ObisEnergy) + .Null() // status + .Null() // valTime + .UInt8(UnitWattHour) + .Int8(-1) + .UInt64(EnergyRaw) + .Null() // valueSignature + // Entry 2: power. + .List(7) + .OctetString(ObisPower) + .Null() + .Null() + .UInt8(UnitWatt) + .Int8(0) + .Int32(PowerRaw) + .Null() + .ToArray(); + + var getListBody = new TlvBuilder() + .List(7) + .OctetString([0x01]) // clientId + .OctetString([0x02]) // serverId + .Null() // listName + .Null() // actSensorTime + .ToArray(); + + var getListTail = new TlvBuilder() + .Null() // listSignature + .Null() // actGatewayTime + .ToArray(); + + var body = new List(); + body.AddRange(getListBody); + body.AddRange(valList); + body.AddRange(getListTail); + + var message = new TlvBuilder() + .List(6) + .OctetString([0xAA, 0xBB]) // transactionId + .UInt8(0x00) // groupNo + .UInt8(0x00) // abortOnError + .List(2) + .UInt32(0x00000701) // messageBodyType = GetList.Res + .ToArray(); + + var afterBody = new TlvBuilder() + .UInt8(0x00) // crc16 placeholder + .ToArray(); + + var full = new List(); + full.AddRange(message); + full.AddRange(body); + full.AddRange(afterBody); + full.Add(0x00); // endOfSmlMsg + + return full.ToArray(); + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/TlvBuilder.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/TlvBuilder.cs new file mode 100644 index 0000000..4cb884f --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/TlvBuilder.cs @@ -0,0 +1,120 @@ +using System.Buffers.Binary; + +namespace CreativeCoders.SmartMessageLanguage.Tests.Fixtures; + +/// +/// Minimal builder producing SML TLV encoded byte sequences for tests. +/// Only supports lengths up to 15 (single-byte TL header), which is sufficient +/// for the structures exercised by the parser tests. +/// +internal sealed class TlvBuilder +{ + private readonly List _bytes = []; + + public TlvBuilder List(int count) + { + if (count > 0x0F) + { + // Simple multi-byte length for lists: 0x7? 0x0? with continuation bit. + _bytes.Add((byte)(0xF0 | ((count >> 4) & 0x0F))); + _bytes.Add((byte)(0x00 | (count & 0x0F))); + + return this; + } + + _bytes.Add((byte)(0x70 | (count & 0x0F))); + + return this; + } + + public TlvBuilder OctetString(byte[] data) + { + AddPrimitive(0x00, data); + + return this; + } + + public TlvBuilder Bool(bool value) + { + AddPrimitive(0x40, [value ? (byte)0x01 : (byte)0x00]); + + return this; + } + + public TlvBuilder Int8(sbyte value) + { + AddPrimitive(0x50, [(byte)value]); + + return this; + } + + public TlvBuilder Int32(int value) + { + Span buf = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(buf, value); + AddPrimitive(0x50, buf.ToArray()); + + return this; + } + + public TlvBuilder UInt8(byte value) + { + AddPrimitive(0x60, [value]); + + return this; + } + + public TlvBuilder UInt32(uint value) + { + Span buf = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(buf, value); + AddPrimitive(0x60, buf.ToArray()); + + return this; + } + + public TlvBuilder UInt64(ulong value) + { + Span buf = stackalloc byte[8]; + BinaryPrimitives.WriteUInt64BigEndian(buf, value); + AddPrimitive(0x60, buf.ToArray()); + + return this; + } + + public TlvBuilder EndOfMessage() + { + _bytes.Add(0x00); + + return this; + } + + public TlvBuilder Null() + { + // OPTIONAL absent value is encoded as a zero-length octet string (0x01). + _bytes.Add(0x01); + + return this; + } + + public byte[] ToArray() => _bytes.ToArray(); + + private void AddPrimitive(byte typeNibble, byte[] payload) + { + // Declared length in the TL header is header bytes + payload bytes. + var total = payload.Length + 1; + + if (total > 0x0F) + { + // Two-byte length header with continuation bit set on the first byte. + _bytes.Add((byte)(0x80 | typeNibble | (((total + 1) >> 4) & 0x0F))); + _bytes.Add((byte)((total + 1) & 0x0F)); + } + else + { + _bytes.Add((byte)(typeNibble | (total & 0x0F))); + } + + _bytes.AddRange(payload); + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/Crc16X25Tests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/Crc16X25Tests.cs new file mode 100644 index 0000000..6c066bd --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/Crc16X25Tests.cs @@ -0,0 +1,28 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Framing; +using Xunit; + +namespace CreativeCoders.SmartMessageLanguage.Tests.Framing; + +public class Crc16X25Tests +{ + [Fact] + public void Compute_WithStandardCheckString_ReturnsKnownCheckValue() + { + // Standard CRC-16/X-25 check value for ASCII "123456789" is 0x906E. + var bytes = "123456789"u8.ToArray(); + + var actual = Crc16X25.Compute(bytes); + + actual.Should().Be((ushort)0x906E); + } + + [Fact] + public void Compute_WithEmptyBuffer_ReturnsZero() + { + var actual = Crc16X25.Compute([]); + + // Empty input: initial 0xFFFF XOR final 0xFFFF = 0x0000. + actual.Should().Be((ushort)0x0000); + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorLoggingTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorLoggingTests.cs new file mode 100644 index 0000000..795d55f --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorLoggingTests.cs @@ -0,0 +1,56 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Framing; +using CreativeCoders.SmartMessageLanguage.Tests.Fixtures; +using CreativeCoders.SmartMessageLanguage.Tests.TestSupport; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace CreativeCoders.SmartMessageLanguage.Tests.Framing; + +public class SmlMessageDetectorLoggingTests +{ + [Fact] + public void Append_ValidFrame_LogsAnchorAndInformationFrameDetected() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + var logger = LoggerCallAssertions.CreateEnabledLogger(); + + using var detector = new SmlMessageDetector(logger); + detector.Append(frameBytes); + + // Anchor (Debug, 1002) + FrameDetected (Information, 1003) both appear once. + LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 1002).Should().Be(1); + LoggerCallAssertions.CountCalls(logger, LogLevel.Information, 1003).Should().Be(1); + LoggerCallAssertions.CountCalls(logger, LogLevel.Warning).Should().Be(0); + } + + [Fact] + public void Append_CorruptCrc_LogsInvalidCrcWarning() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + // Flip a CRC byte to force a CRC mismatch. + frameBytes[^1] ^= 0xFF; + + var logger = LoggerCallAssertions.CreateEnabledLogger(); + + using var detector = new SmlMessageDetector(logger); + detector.Append(frameBytes); + + LoggerCallAssertions.CountCalls(logger, LogLevel.Warning, 1010).Should().Be(1); + } + + [Fact] + public void Append_BufferOverflow_LogsBufferOverflowWarning() + { + // Send a start escape followed by 70k bytes of garbage so no end is found. + var logger = LoggerCallAssertions.CreateEnabledLogger(); + + using var detector = new SmlMessageDetector(logger); + detector.Append([0x1B, 0x1B, 0x1B, 0x1B, 0x01, 0x01, 0x01, 0x01]); + detector.Append(new byte[70 * 1024]); + + LoggerCallAssertions.CountCalls(logger, LogLevel.Warning, 1012).Should().Be(1); + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorTests.cs new file mode 100644 index 0000000..e1a85a0 --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorTests.cs @@ -0,0 +1,150 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Framing; +using CreativeCoders.SmartMessageLanguage.Tests.Fixtures; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CreativeCoders.SmartMessageLanguage.Tests.Framing; + +public class SmlMessageDetectorTests +{ + [Fact] + public void Append_FullFrameInOneCall_RaisesOneEventWithValidCrc() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append(frameBytes); + + received.Should().HaveCount(1); + received[0].IsCrcValid.Should().BeTrue(); + received[0].MessageBytes.Should().Equal(frameBytes); + } + + [Fact] + public void Append_FrameByteByByte_StillProducesSingleFrame() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + foreach (var b in frameBytes) + { + detector.Append([b]); + } + + received.Should().HaveCount(1); + received[0].IsCrcValid.Should().BeTrue(); + } + + [Fact] + public void Append_GarbageBeforeStartEscape_DiscardsJunkAndEmitsFrame() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append([0xAA, 0xBB, 0xCC, 0xDD]); + detector.Append(frameBytes); + + received.Should().HaveCount(1); + received[0].IsCrcValid.Should().BeTrue(); + } + + [Fact] + public void Append_TwoConsecutiveFrames_EmitsBothInOrder() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + var combined = frameBytes.Concat(frameBytes).ToArray(); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append(combined); + + received.Should().HaveCount(2); + received.Should().AllSatisfy(f => f.IsCrcValid.Should().BeTrue()); + } + + [Fact] + public void Append_EscapedPayload_DeEscapesAndValidatesCrc() + { + // Payload that contains a literal run of four 0x1B bytes. + var payload = new byte[] { 0x1B, 0x1B, 0x1B, 0x1B, 0x09, 0x08 }; + var frameBytes = FrameBuilder.BuildFrame(payload); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append(frameBytes); + + received.Should().HaveCount(1); + received[0].IsCrcValid.Should().BeTrue(); + // De-escaped payload should start with the original 4x0x1B run. + received[0].PayloadBytes.AsSpan(0, 6).ToArray().Should() + .Equal(0x1B, 0x1B, 0x1B, 0x1B, 0x09, 0x08); + } + + [Fact] + public void Append_TruncatedFrame_DoesNotEmitUntilCompletion() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append(frameBytes.AsSpan(0, frameBytes.Length - 4)); + received.Should().BeEmpty(); + + detector.Append(frameBytes.AsSpan(frameBytes.Length - 4)); + received.Should().HaveCount(1); + } + + [Fact] + public void Append_CorruptCrc_EmitsFrameWithIsCrcValidFalse() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + frameBytes[^1] ^= 0xFF; + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append(frameBytes); + + received.Should().HaveCount(1); + received[0].IsCrcValid.Should().BeFalse(); + } + + [Fact] + public void Messages_Observable_EmitsSameFrameAsEvent() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var viaObservable = new List(); + using var sub = detector.Messages.Subscribe(viaObservable.Add); + + detector.Append(frameBytes); + + viaObservable.Should().HaveCount(1); + viaObservable[0].MessageBytes.Should().Equal(frameBytes); + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserLoggingTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserLoggingTests.cs new file mode 100644 index 0000000..1fdc46b --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserLoggingTests.cs @@ -0,0 +1,30 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Parsing; +using CreativeCoders.SmartMessageLanguage.Tests.Fixtures; +using CreativeCoders.SmartMessageLanguage.Tests.TestSupport; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace CreativeCoders.SmartMessageLanguage.Tests.Parsing; + +public class SmlParserLoggingTests +{ + [Fact] + public void Parse_ValidGetListResponse_LogsValuesAndCompletion() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var logger = LoggerCallAssertions.CreateEnabledLogger(); + var parser = new SmlParser(logger); + + var result = parser.Parse(payload); + + result.Values.Should().HaveCount(2); + // ParseStarted + GetListResponseFound + 2x ObisValueParsed = 4 Debug entries minimum. + LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 2001).Should().Be(1); + LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 2002).Should().Be(1); + LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 2003).Should().Be(2); + LoggerCallAssertions.CountCalls(logger, LogLevel.Information, 2004).Should().Be(1); + LoggerCallAssertions.CountCalls(logger, LogLevel.Warning).Should().Be(0); + LoggerCallAssertions.CountCalls(logger, LogLevel.Error).Should().Be(0); + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs new file mode 100644 index 0000000..259624c --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs @@ -0,0 +1,44 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Parsing; +using CreativeCoders.SmartMessageLanguage.Tests.Fixtures; +using CreativeCoders.SmartMessageLanguage.Units; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CreativeCoders.SmartMessageLanguage.Tests.Parsing; + +public class SmlParserTests +{ + [Fact] + public void Parse_GetListResponsePayload_ExtractsAllObisValues() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + + var parser = new SmlParser(NullLogger.Instance); + var result = parser.Parse(payload); + + result.Warnings.Should().BeEmpty(); + result.Values.Should().HaveCount(2); + + var energy = result.Values.Single(v => v.ObisCode == "1-0:1.8.0*255"); + energy.Unit.Should().Be(SmlUnit.WattHour); + energy.Scaler.Should().Be((sbyte)-1); + energy.Value.Should().Be(12345.6m); + + var power = result.Values.Single(v => v.ObisCode == "1-0:16.7.0*255"); + power.Unit.Should().Be(SmlUnit.Watt); + power.Scaler.Should().Be((sbyte)0); + power.Value.Should().Be(567m); + } + + [Fact] + public void Parse_EmptyPayload_ReturnsEmptyResult() + { + var parser = new SmlParser(NullLogger.Instance); + + var result = parser.Parse([]); + + result.Values.Should().BeEmpty(); + result.Warnings.Should().BeEmpty(); + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/TestSupport/LoggerCallAssertions.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/TestSupport/LoggerCallAssertions.cs new file mode 100644 index 0000000..81fad1f --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/TestSupport/LoggerCallAssertions.cs @@ -0,0 +1,32 @@ +using FakeItEasy; +using Microsoft.Extensions.Logging; + +namespace CreativeCoders.SmartMessageLanguage.Tests.TestSupport; + +// Helpers to inspect calls made by source-generated [LoggerMessage] partials. +// These funnel through ILogger.Log(level, eventId, state, exception, formatter). +internal static class LoggerCallAssertions +{ + public static int CountCalls(ILogger logger, LogLevel level) + { + return Fake.GetCalls(logger) + .Where(call => call.Method.Name == nameof(ILogger.Log)) + .Count(call => Equals(call.Arguments[0], level)); + } + + public static int CountCalls(ILogger logger, LogLevel level, int eventId) + { + return Fake.GetCalls(logger) + .Where(call => call.Method.Name == nameof(ILogger.Log)) + .Where(call => Equals(call.Arguments[0], level)) + .Count(call => call.Arguments[1] is EventId id && id.Id == eventId); + } + + public static ILogger CreateEnabledLogger() + { + var logger = A.Fake>(); + A.CallTo(() => logger.IsEnabled(A._)).Returns(true); + + return logger; + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs new file mode 100644 index 0000000..f3f8663 --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs @@ -0,0 +1,124 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Tlv; +using Xunit; + +namespace CreativeCoders.SmartMessageLanguage.Tests.Tlv; + +public class SmlTlvReaderTests +{ + [Fact] + public void Read_OctetString_ReturnsPayload() + { + // 0x04 header = OctetString of declared length 4 → 3 payload bytes. + var data = new byte[] { 0x04, 0xDE, 0xAD, 0xBE }; + + var reader = new SmlTlvReader(data); + reader.Read().Should().BeTrue(); + + reader.Current.Type.Should().Be(SmlValueType.OctetString); + reader.Current.Raw.ToArray().Should().Equal(0xDE, 0xAD, 0xBE); + } + + [Fact] + public void Read_Unsigned_ReturnsBigEndianValue() + { + // 0x63 header = Unsigned of declared length 3 → 2 payload bytes (big-endian). + var data = new byte[] { 0x63, 0x01, 0x02 }; + + var reader = new SmlTlvReader(data); + reader.Read().Should().BeTrue(); + + reader.Current.Type.Should().Be(SmlValueType.Unsigned); + reader.Current.GetUInt64().Should().Be(0x0102UL); + } + + [Fact] + public void Read_SignedInteger_SignExtends() + { + // Int8 with value -1 (0xFF). + var data = new byte[] { 0x52, 0xFF }; + + var reader = new SmlTlvReader(data); + reader.Read().Should().BeTrue(); + + reader.Current.Type.Should().Be(SmlValueType.Integer); + reader.Current.GetInt64().Should().Be(-1); + } + + [Fact] + public void Read_Bool_ReturnsTrue() + { + var data = new byte[] { 0x42, 0x01 }; + + var reader = new SmlTlvReader(data); + reader.Read().Should().BeTrue(); + + reader.Current.Type.Should().Be(SmlValueType.Boolean); + reader.Current.GetBool().Should().BeTrue(); + } + + [Fact] + public void Read_List_ReportsEntryCountAndIteratesChildren() + { + // List(2) of [Unsigned8(1), Unsigned8(2)]. + var data = new byte[] { 0x72, 0x62, 0x01, 0x62, 0x02 }; + + var reader = new SmlTlvReader(data); + reader.Read().Should().BeTrue(); + reader.Current.Type.Should().Be(SmlValueType.List); + reader.Current.ListLength.Should().Be(2); + + reader.Read().Should().BeTrue(); + reader.Current.GetUInt64().Should().Be(1UL); + + reader.Read().Should().BeTrue(); + reader.Current.GetUInt64().Should().Be(2UL); + + reader.Read().Should().BeFalse(); + } + + [Fact] + public void Read_EndOfMessage_IsRecognised() + { + var data = new byte[] { 0x00 }; + + var reader = new SmlTlvReader(data); + reader.Read().Should().BeTrue(); + + reader.Current.IsEndOfMessage.Should().BeTrue(); + } + + [Fact] + public void Read_MultiByteLength_ParsesCorrectly() + { + // 0x81 declares continuation, low nibble 1; next byte 0x02 gives total = (1<<4)|2 = 18. + // So OctetString with 18-byte total header+payload → 16 payload bytes. + var payload = Enumerable.Range(0, 16).Select(i => (byte)i).ToArray(); + var data = new byte[] { 0x81, 0x02 }.Concat(payload).ToArray(); + + var reader = new SmlTlvReader(data); + reader.Read().Should().BeTrue(); + + reader.Current.Type.Should().Be(SmlValueType.OctetString); + reader.Current.Raw.Length.Should().Be(16); + } + + [Fact] + public void SkipCurrent_OnList_ConsumesAllChildrenRecursively() + { + // Nested: List(1) containing List(2) containing UInt8(1), UInt8(2), then a trailing UInt8(9). + var data = new byte[] + { + 0x71, 0x72, 0x62, 0x01, 0x62, 0x02, + 0x62, 0x09 + }; + + var reader = new SmlTlvReader(data); + reader.Read().Should().BeTrue(); + reader.Current.Type.Should().Be(SmlValueType.List); + reader.SkipCurrent(); + + reader.Read().Should().BeTrue(); + reader.Current.GetUInt64().Should().Be(9UL); + } +} From 87836e19e45510d75f3c24852efc4aad5c9f7b88 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:44:54 +0200 Subject: [PATCH 08/35] Refactor `SmartMeterReactiveDataPipeline` to use `Subject` for reactive value streams, improve OBIS code filtering logic, and simplify logging to debug level. --- .../SmartMeterReactiveDataPipeline.cs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs index 8516468..f4ec115 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs @@ -1,18 +1,23 @@ using System.Reactive.Linq; +using System.Reactive.Subjects; using CreativeCoders.Core; -using CreativeCoders.Core.Weak; using CreativeCoders.SmartMessageLanguage.Framing; using CreativeCoders.SmartMessageLanguage.Parsing; using CreativeCoders.SmartMeter.DataProcessing; +using CreativeCoders.SmartMeter.Sml; using Microsoft.Extensions.Logging; namespace CreativeCoders.SmartMeter.Server.Core; public class SmartMeterReactiveDataPipeline : ISmartMeterReactiveDataPipeline { + private const string ObisCodeEnergyActiveImport = "1-0:1.8.0"; + private const string ObisCodeEnergyActiveExport = "1-0:2.8.0"; + private readonly ISmlParser _smlParser; private readonly ISmlMessageDetector _smlMessageDetector; private readonly ILogger _logger; + private readonly Subject _valueSubject = new Subject(); public SmartMeterReactiveDataPipeline(ISmlParser smlParser, ISmlMessageDetector smlMessageDetector, ILogger logger) @@ -23,7 +28,7 @@ public SmartMeterReactiveDataPipeline(ISmlParser smlParser, ISmlMessageDetector _smlMessageDetector.Messages.Subscribe(message => { - _logger.LogInformation("SML message detected. Length: {Length}", message.PayloadBytes.Length); + _logger.LogDebug("SML message detected. Length: {Length}", message.PayloadBytes.Length); }); } @@ -39,8 +44,6 @@ public void OnError(Exception error) public void OnNext(byte[] value) { - _logger.LogInformation("Received data. Length: {Length}", value.Length); - _smlMessageDetector.Append(value); } @@ -48,14 +51,23 @@ public IDisposable Subscribe(IObserver observer) { _smlMessageDetector.Messages.Select(message => _smlParser.Parse(message.PayloadBytes)).Subscribe(smlMessage => { - _logger.LogInformation("Parsed SML message. Values count: {Count}", smlMessage.Values.Count); + _logger.LogDebug("Parsed SML message. Values count: {Count}", smlMessage.Values.Count); - foreach (var value in smlMessage.Values) + foreach (var value in smlMessage.Values.Where(v => v.Value.HasValue)) { - _logger.LogInformation("Publishing value. Obis: {Obis}, Value: {Value}", value.ObisCode, value.Value); + if (value.ObisCode.StartsWith(ObisCodeEnergyActiveImport)) + { + _valueSubject.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) + { Value = value.Value!.Value }); + } + else if (value.ObisCode.StartsWith(ObisCodeEnergyActiveExport)) + { + _valueSubject.OnNext(new SmlValue(SmlValueType.SoldEnergy) + { Value = value.Value!.Value }); + } } }); - return new NullDisposable(); + return _valueSubject.SelectSmartMeterValues().Subscribe(observer); } } From f3aceb83d43544f19218c14d43774f22d0778b27 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:01:26 +0200 Subject: [PATCH 09/35] Add `SmartMeterOptions` to inject configurable energy offsets and refactor `SmartMeterReactiveDataPipeline` to apply these offsets. Adjust log levels to debug and fix power balance sign logic in `SmlValueProcessor`. --- .../Framing/SmlMessageDetectorLog.cs | 2 +- .../Parsing/SmlParserLog.cs | 2 +- .../CreativeCoders.SmartMeter.Cli/Program.cs | 2 +- .../SmlValueProcessor.cs | 4 +-- .../SmartMeterOptions.cs | 8 +++++ .../SmartMeterReactiveDataPipeline.cs | 29 +++++++++++++++++-- .../SmlValueProcessorTests.cs | 2 +- 7 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/SmartMeterOptions.cs diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetectorLog.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetectorLog.cs index d2a5880..e957c45 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetectorLog.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetectorLog.cs @@ -14,7 +14,7 @@ internal static partial class SmlMessageDetectorLog Message = "Anchored on SML start escape at buffer offset {Offset}")] public static partial void AnchoredOnStart(ILogger logger, int offset); - [LoggerMessage(EventId = 1003, Level = LogLevel.Information, + [LoggerMessage(EventId = 1003, Level = LogLevel.Debug, Message = "Detected SML frame: frameLength={FrameLength}, payloadLength={PayloadLength}, crcValid={CrcValid}")] public static partial void FrameDetected(ILogger logger, int frameLength, int payloadLength, bool crcValid); diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs index 091df31..f345b74 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs @@ -21,7 +21,7 @@ internal static partial class SmlParserLog public static partial void ObisValueParsed(ILogger logger, string obisCode, decimal? value, SmlUnit unit, sbyte scaler); - [LoggerMessage(EventId = 2004, Level = LogLevel.Information, + [LoggerMessage(EventId = 2004, Level = LogLevel.Debug, Message = "SML parse completed: {ValueCount} OBIS values, {WarningCount} warnings")] public static partial void ParseCompleted(ILogger logger, int valueCount, int warningCount); diff --git a/source/CreativeCoders.SmartMeter.Cli/Program.cs b/source/CreativeCoders.SmartMeter.Cli/Program.cs index 1cdcfd1..356b958 100644 --- a/source/CreativeCoders.SmartMeter.Cli/Program.cs +++ b/source/CreativeCoders.SmartMeter.Cli/Program.cs @@ -70,6 +70,6 @@ public void OnError(Exception error) public void OnNext(SmartMeterValue value) { - AnsiConsole.WriteLine($"Received value: {value.Type} = {value.Value}"); + AnsiConsole.WriteLine($"Received value: {value.Type} = {value.Value:N}"); } } diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs index 099cc49..5478b59 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs @@ -83,14 +83,14 @@ private void PushNewCurrentValue(SmartMeterValue value) case SmartMeterValueType.CurrentPurchasingPower: _valueSubject.OnNext(new SmartMeterValue(SmartMeterValueType.GridPowerBalance) { - Value = value.Value, + Value = value.Value * -1, WriteAsJson = false }); break; case SmartMeterValueType.CurrentSellingPower: _valueSubject.OnNext(new SmartMeterValue(SmartMeterValueType.GridPowerBalance) { - Value = value.Value * -1, + Value = value.Value, WriteAsJson = false }); break; diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterOptions.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterOptions.cs new file mode 100644 index 0000000..d8fd6e7 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterOptions.cs @@ -0,0 +1,8 @@ +namespace CreativeCoders.SmartMeter.Server.Core; + +public class SmartMeterOptions +{ + public decimal SoldEnergyOffset { get; set; } = 23_367_605; + + public decimal PurchasedEnergyOffset { get; set; } = 18_261_046; +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs index f4ec115..7372488 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs @@ -6,6 +6,7 @@ using CreativeCoders.SmartMeter.DataProcessing; using CreativeCoders.SmartMeter.Sml; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace CreativeCoders.SmartMeter.Server.Core; @@ -19,12 +20,15 @@ public class SmartMeterReactiveDataPipeline : ISmartMeterReactiveDataPipeline private readonly ILogger _logger; private readonly Subject _valueSubject = new Subject(); + private readonly SmartMeterOptions _smartMeterOptions; + public SmartMeterReactiveDataPipeline(ISmlParser smlParser, ISmlMessageDetector smlMessageDetector, - ILogger logger) + IOptions smartMeterOptions, ILogger logger) { _smlParser = Ensure.NotNull(smlParser); _smlMessageDetector = Ensure.NotNull(smlMessageDetector); _logger = Ensure.NotNull(logger); + _smartMeterOptions = Ensure.NotNull(smartMeterOptions).Value; _smlMessageDetector.Messages.Subscribe(message => { @@ -68,6 +72,27 @@ public IDisposable Subscribe(IObserver observer) } }); - return _valueSubject.SelectSmartMeterValues().Subscribe(observer); + return _valueSubject.Select(value => + { + if (value.ValueType == SmlValueType.PurchasedEnergy) + { + return new SmlValue(SmlValueType.PurchasedEnergy) + { + Value = value.Value + _smartMeterOptions.PurchasedEnergyOffset + }; + } + + if (value.ValueType == SmlValueType.SoldEnergy) + { + return new SmlValue(SmlValueType.SoldEnergy) + { + Value = value.Value + _smartMeterOptions.SoldEnergyOffset + }; + } + + return value; + }) + .SelectSmartMeterValues() + .Subscribe(observer); } } diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs index 30d31d0..10ad480 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs @@ -48,7 +48,7 @@ public void Subscribe_WithTwoPurchasedEnergyValues_ShouldReturnTotalAndCurrentAn { // Arrange var expectedBalanceValue = (smlValueValue2 - smlValueValue1) * 60; - if (smlValueType == SmlValueType.SoldEnergy) + if (smlValueType == SmlValueType.PurchasedEnergy) { expectedBalanceValue *= -1; } From 97533b2f8ee0201e2bf3433f24a36fafef675a9b Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:13:07 +0200 Subject: [PATCH 10/35] Adjust logging levels in SmlMessageDetectorLoggingTests and SmlParserLoggingTests to use Debug consistently. --- .../Framing/SmlMessageDetectorLoggingTests.cs | 4 ++-- .../Parsing/SmlParserLoggingTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorLoggingTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorLoggingTests.cs index 795d55f..f80e929 100644 --- a/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorLoggingTests.cs +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorLoggingTests.cs @@ -19,9 +19,9 @@ public void Append_ValidFrame_LogsAnchorAndInformationFrameDetected() using var detector = new SmlMessageDetector(logger); detector.Append(frameBytes); - // Anchor (Debug, 1002) + FrameDetected (Information, 1003) both appear once. + // Anchor (Debug, 1002) + FrameDetected (Debug, 1003) both appear once. LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 1002).Should().Be(1); - LoggerCallAssertions.CountCalls(logger, LogLevel.Information, 1003).Should().Be(1); + LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 1003).Should().Be(1); LoggerCallAssertions.CountCalls(logger, LogLevel.Warning).Should().Be(0); } diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserLoggingTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserLoggingTests.cs index 1fdc46b..cf7a3a6 100644 --- a/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserLoggingTests.cs +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserLoggingTests.cs @@ -23,7 +23,7 @@ public void Parse_ValidGetListResponse_LogsValuesAndCompletion() LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 2001).Should().Be(1); LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 2002).Should().Be(1); LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 2003).Should().Be(2); - LoggerCallAssertions.CountCalls(logger, LogLevel.Information, 2004).Should().Be(1); + LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 2004).Should().Be(1); LoggerCallAssertions.CountCalls(logger, LogLevel.Warning).Should().Be(0); LoggerCallAssertions.CountCalls(logger, LogLevel.Error).Should().Be(0); } From 8635746e0c1054a5d67bc7ba7ab35c3349a16de3 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:40:03 +0200 Subject: [PATCH 11/35] Refactor exception logging and initialization logic, and improve cancellation token handling for `SmartMeterDataProducer`, `ValueHistoryDataSet`, and `SmartMeterValue`. --- .../SmartMeterValue.cs | 9 ++------- .../ValueHistoryDataSet.cs | 9 ++------- .../SmartMeterDataProducer.cs | 10 +++++----- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs index 2182b65..ed3e272 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs @@ -1,13 +1,8 @@ namespace CreativeCoders.SmartMeter.DataProcessing; -public class SmartMeterValue +public class SmartMeterValue(SmartMeterValueType type) { - public SmartMeterValue(SmartMeterValueType type) - { - Type = type; - } - - public SmartMeterValueType Type { get; } + public SmartMeterValueType Type { get; } = type; public decimal Value { get; init; } diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs b/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs index d0de85a..5573809 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs @@ -3,14 +3,9 @@ namespace CreativeCoders.SmartMeter.DataProcessing; -public class ValueHistoryDataSet +public class ValueHistoryDataSet(SmlValue value) { - public ValueHistoryDataSet(SmlValue value) - { - Value = Ensure.NotNull(value); - } - public DateTimeOffset TimeStamp { get; init; } - public SmlValue Value { get; } + public SmlValue Value { get; } = Ensure.NotNull(value); } diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs index 4eb7d28..f1b971e 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs @@ -121,11 +121,11 @@ public async Task UnlockAsync( { await SendPinAsync(pin, options, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { verificationSubscription?.Dispose(); - _logger.LogWarning("Unlock cancelled while sending PIN"); + _logger.LogWarning(ex, "Unlock cancelled while sending PIN"); return new SmartMeterUnlockResult( false, SmartMeterUnlockOutcome.Cancelled, [], stopwatch.Elapsed, "Cancelled"); @@ -156,7 +156,7 @@ public async Task UnlockAsync( using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(options.VerificationTimeout); - using var _ = timeoutCts.Token.Register(() => verificationTcs.TrySetResult(false)); + await using var _ = timeoutCts.Token.Register(() => verificationTcs.TrySetResult(false)); var verified = await verificationTcs.Task.ConfigureAwait(false); @@ -193,9 +193,9 @@ public async Task UnlockAsync( false, SmartMeterUnlockOutcome.VerificationTimeout, detected.ToArray(), stopwatch.Elapsed, "Verification timeout"); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { - _logger.LogWarning("Unlock cancelled"); + _logger.LogWarning(ex, "Unlock cancelled"); return new SmartMeterUnlockResult( false, SmartMeterUnlockOutcome.Cancelled, [], stopwatch.Elapsed, "Cancelled"); From daafd8d5e334430fc88b2b782117fd012ce01474 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:44:09 +0200 Subject: [PATCH 12/35] Refactor `SmartMeterDataProducer` to implement `IDisposable`, improve resource management, and replace custom subscription handling in `SmartMeterServer` with `ISmartMeterDataProducer`. --- .../ISmartMeterDataProducer.cs | 2 +- .../SmartMeterDataProducer.cs | 18 +++++++- .../SmartMeterServer.cs | 45 +++---------------- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs index 39301d1..ab4bd4b 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs @@ -3,7 +3,7 @@ namespace CreativeCoders.SmartMeter.Server.Core; -public interface ISmartMeterDataProducer +public interface ISmartMeterDataProducer : IDisposable { Task StartAsync(IObserver observer); diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs index f1b971e..c9cacbf 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs @@ -10,7 +10,7 @@ namespace CreativeCoders.SmartMeter.Server.Core; -public class SmartMeterDataProducer( +public sealed class SmartMeterDataProducer( ISmartMeterReactiveDataPipeline reactiveDataPipeline, ILogger logger) : ISmartMeterDataProducer { @@ -311,4 +311,20 @@ public void OnNext(byte[] value) } } } + + public void Dispose() + { + _serialPort.Dispose(); + + if (_subscription == null) + { + return; + } + + _logger.LogDebug("Disposing subscription..."); + _subscription.Dispose(); + _logger.LogDebug("Subscription disposed"); + + _subscription = null; + } } diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs index 1dc0fc6..a3f5eb4 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs @@ -14,39 +14,17 @@ namespace CreativeCoders.SmartMeter.Server.Core; public class SmartMeterServer( ILogger logger, IOptions mqttPublisherOptions, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + ISmartMeterDataProducer smartMeterDataProducer) : IDaemonService { + private readonly ISmartMeterDataProducer _smartMeterDataProducer = Ensure.NotNull(smartMeterDataProducer); private readonly ILoggerFactory _loggerFactory = Ensure.NotNull(loggerFactory); private readonly ILogger _logger = Ensure.NotNull(logger); private readonly MqttPublisherOptions _mqttPublisherOptions = mqttPublisherOptions.Value; - private readonly ReactiveSerialPort _serialPort = new ReactiveSerialPort("/dev/ttyUSB0"); private IDisposable? _subscription; - private void CloseSerialPort() - { - _logger.LogInformation("Closing serial port..."); - _serialPort.Close(); - _logger.LogInformation("Serial port closed"); - } - - private void DisposingSubscription() - { - if (_subscription == null) - { - return; - } - - _logger.LogInformation("Disposing subscription..."); - - _subscription.Dispose(); - - _logger.LogInformation("Subscription disposed"); - - _subscription = null; - } - public async Task StartAsync() { _logger.LogInformation("Starting SmartMeter server"); @@ -56,26 +34,15 @@ public async Task StartAsync() await mqttValuePublisher.InitAsync(); - _subscription ??= _serialPort - .SelectSmlMessages() - .SelectSmlValues() - .SelectSmartMeterValues() - .SubscribeOn(new TaskPoolScheduler(new TaskFactory())) - .Subscribe(mqttValuePublisher); - - _serialPort.Open(); + await _smartMeterDataProducer.StartAsync(mqttValuePublisher); } - public Task StopAsync() + public async Task StopAsync() { _logger.LogInformation("Stopping SmartMeter server"); - DisposingSubscription(); - - CloseSerialPort(); + await _smartMeterDataProducer.StopAsync(); _logger.LogInformation("SmartMeter server stopped"); - - return Task.CompletedTask; } } From d90c96487c25929cea6e5d39301cc135e10155b6 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:56:00 +0200 Subject: [PATCH 13/35] Move SmartMeter-related classes to `SmlData` namespace for clearer organization and improved code structure. --- source/CreativeCoders.SmartMeter.Cli/Program.cs | 1 + .../CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs | 1 + .../SmartMeterServerServiceCollectionExtensions.cs | 1 + .../{ => SmlData}/ISmartMeterDataProducer.cs | 2 +- .../{ => SmlData}/ISmartMeterReactiveDataPipeline.cs | 2 +- .../{ => SmlData}/SmartMeterDataProducer.cs | 2 +- .../{ => SmlData}/SmartMeterReactiveDataPipeline.cs | 2 +- 7 files changed, 7 insertions(+), 4 deletions(-) rename source/CreativeCoders.SmartMeter.Server.Core/{ => SmlData}/ISmartMeterDataProducer.cs (95%) rename source/CreativeCoders.SmartMeter.Server.Core/{ => SmlData}/ISmartMeterReactiveDataPipeline.cs (72%) rename source/CreativeCoders.SmartMeter.Server.Core/{ => SmlData}/SmartMeterDataProducer.cs (99%) rename source/CreativeCoders.SmartMeter.Server.Core/{ => SmlData}/SmartMeterReactiveDataPipeline.cs (98%) diff --git a/source/CreativeCoders.SmartMeter.Cli/Program.cs b/source/CreativeCoders.SmartMeter.Cli/Program.cs index 356b958..0e8f00e 100644 --- a/source/CreativeCoders.SmartMeter.Cli/Program.cs +++ b/source/CreativeCoders.SmartMeter.Cli/Program.cs @@ -1,5 +1,6 @@ using CreativeCoders.SmartMeter.DataProcessing; using CreativeCoders.SmartMeter.Server.Core; +using CreativeCoders.SmartMeter.Server.Core.SmlData; using CreativeCoders.SmartMeter.Server.Core.Unlock; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs index a3f5eb4..1777297 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs @@ -3,6 +3,7 @@ using CreativeCoders.Core; using CreativeCoders.Daemon; using CreativeCoders.SmartMeter.DataProcessing; +using CreativeCoders.SmartMeter.Server.Core.SmlData; using CreativeCoders.SmartMeter.Sml.Reactive; using JetBrains.Annotations; using Microsoft.Extensions.Logging; diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs index f2e99b5..73e7a4e 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CreativeCoders.SmartMessageLanguage; +using CreativeCoders.SmartMeter.Server.Core.SmlData; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs similarity index 95% rename from source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs rename to source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs index ab4bd4b..bacdd26 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs @@ -1,7 +1,7 @@ using CreativeCoders.SmartMeter.DataProcessing; using CreativeCoders.SmartMeter.Server.Core.Unlock; -namespace CreativeCoders.SmartMeter.Server.Core; +namespace CreativeCoders.SmartMeter.Server.Core.SmlData; public interface ISmartMeterDataProducer : IDisposable { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterReactiveDataPipeline.cs similarity index 72% rename from source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterReactiveDataPipeline.cs rename to source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterReactiveDataPipeline.cs index 8377a81..522a137 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/ISmartMeterReactiveDataPipeline.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterReactiveDataPipeline.cs @@ -1,6 +1,6 @@ using CreativeCoders.SmartMeter.DataProcessing; -namespace CreativeCoders.SmartMeter.Server.Core; +namespace CreativeCoders.SmartMeter.Server.Core.SmlData; public interface ISmartMeterReactiveDataPipeline : IObserver, IObservable { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs similarity index 99% rename from source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs rename to source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs index c9cacbf..3ae753f 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs @@ -8,7 +8,7 @@ using CreativeCoders.SmartMeter.Sml.Reactive; using Microsoft.Extensions.Logging; -namespace CreativeCoders.SmartMeter.Server.Core; +namespace CreativeCoders.SmartMeter.Server.Core.SmlData; public sealed class SmartMeterDataProducer( ISmartMeterReactiveDataPipeline reactiveDataPipeline, diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs similarity index 98% rename from source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs rename to source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs index 7372488..10270c3 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterReactiveDataPipeline.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace CreativeCoders.SmartMeter.Server.Core; +namespace CreativeCoders.SmartMeter.Server.Core.SmlData; public class SmartMeterReactiveDataPipeline : ISmartMeterReactiveDataPipeline { From 38f7f1a5c5b1bfc1d872c4c4e80fec3f2d1a64fa Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:58:16 +0200 Subject: [PATCH 14/35] Extract unlock functionality into `ISmartMeterUnlocker` and `SmartMeterUnlocker`; refactor `SmartMeterDataProducer` to remove redundant unlock logic and simplify interface. --- .../CreativeCoders.SmartMeter.Cli/Program.cs | 7 +- ...tMeterServerServiceCollectionExtensions.cs | 2 + .../SmlData/ISmartMeterDataProducer.cs | 16 -- .../SmlData/SmartMeterDataProducer.cs | 234 ---------------- .../Unlock/ISmartMeterUnlocker.cs | 19 ++ .../Unlock/SmartMeterUnlocker.cs | 252 ++++++++++++++++++ 6 files changed, 277 insertions(+), 253 deletions(-) create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/Unlock/ISmartMeterUnlocker.cs create mode 100644 source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlocker.cs diff --git a/source/CreativeCoders.SmartMeter.Cli/Program.cs b/source/CreativeCoders.SmartMeter.Cli/Program.cs index 0e8f00e..1ae1628 100644 --- a/source/CreativeCoders.SmartMeter.Cli/Program.cs +++ b/source/CreativeCoders.SmartMeter.Cli/Program.cs @@ -31,8 +31,9 @@ static async Task Main(string[] args) AnsiConsole.WriteLine("Unlocking Smart Meter with provided PIN..."); var pin = args[1]; + var unlocker = sp.GetRequiredService(); - await SendPinAsync(dataProducer, pin); + await SendPinAsync(unlocker, pin); return; } @@ -47,10 +48,10 @@ static async Task Main(string[] args) AnsiConsole.WriteLine("Smart Meter CLI stopped"); } - private static async Task SendPinAsync(ISmartMeterDataProducer dataProducer, string pin) + private static async Task SendPinAsync(ISmartMeterUnlocker unlocker, string pin) { AnsiConsole.WriteLine($"Sending PIN: {pin}"); - await dataProducer.UnlockAsync(pin, new SmartMeterUnlockOptions + await unlocker.UnlockAsync(pin, new SmartMeterUnlockOptions { Strategy = SmartMeterPinStrategy.EmhAsciiBlock }); diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs index 73e7a4e..b64dfee 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using CreativeCoders.SmartMessageLanguage; using CreativeCoders.SmartMeter.Server.Core.SmlData; +using CreativeCoders.SmartMeter.Server.Core.Unlock; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -11,6 +12,7 @@ public static IServiceCollection AddSmartMeterServer(this IServiceCollection ser { services.AddSml(); services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs index bacdd26..d9e47e3 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs @@ -1,5 +1,4 @@ using CreativeCoders.SmartMeter.DataProcessing; -using CreativeCoders.SmartMeter.Server.Core.Unlock; namespace CreativeCoders.SmartMeter.Server.Core.SmlData; @@ -8,19 +7,4 @@ public interface ISmartMeterDataProducer : IDisposable Task StartAsync(IObserver observer); Task StopAsync(); - - /// - /// Sends the given PIN to the smart meter via the optical coupler connected - /// to the serial port in order to unlock the extended data set (instantaneous - /// power, per-phase values, voltages, ...). Optionally verifies the unlock by - /// observing the incoming byte stream for extended OBIS codes. - /// - /// PIN as printable ASCII digits. Must not be empty. - /// Transport and verification options. Defaults target EMH / eHZ meters. - /// Cancels the operation. - /// A structured describing the outcome. - Task UnlockAsync( - string pin, - SmartMeterUnlockOptions? options = null, - CancellationToken cancellationToken = default); } diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs index 3ae753f..1187348 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs @@ -1,10 +1,7 @@ -using System.Diagnostics; using System.Reactive.Concurrency; using System.Reactive.Linq; -using System.Text; using CreativeCoders.Core; using CreativeCoders.SmartMeter.DataProcessing; -using CreativeCoders.SmartMeter.Server.Core.Unlock; using CreativeCoders.SmartMeter.Sml.Reactive; using Microsoft.Extensions.Logging; @@ -58,197 +55,6 @@ public Task StopAsync() return Task.CompletedTask; } - public async Task UnlockAsync( - string pin, - SmartMeterUnlockOptions? options = null, - CancellationToken cancellationToken = default) - { - Ensure.IsNotNullOrWhitespace(pin); - - options ??= new SmartMeterUnlockOptions(); - - var stopwatch = Stopwatch.StartNew(); - - _logger.LogInformation( - "Unlocking smart meter via {Strategy}, pinLength={PinLength}, verify={Verify}, verificationTimeout={Timeout}", - options.Strategy, pin.Length, options.Verify, options.VerificationTimeout); - - // Ensure the port is open so we can write and observe responses. Don't close - // it here, so the caller can continue using the same producer afterwards. - if (!_serialPort.IsOpen) - { - _logger.LogInformation("Serial port is closed, opening it for unlock procedure..."); - _serialPort.Open(); - _logger.LogInformation("Serial port opened"); - } - - try - { - if (options.InitialDelay > TimeSpan.Zero) - { - _logger.LogDebug("Waiting initial delay {Delay} before sending PIN", options.InitialDelay); - - await Task.Delay(options.InitialDelay, cancellationToken).ConfigureAwait(false); - } - - var detected = new HashSet(StringComparer.Ordinal); - var verificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var expectAck = options.Strategy == SmartMeterPinStrategy.IskraAsciiBlock; - - IDisposable? verificationSubscription = null; - - if (options.Verify) - { - verificationSubscription = _serialPort.Subscribe(new VerificationObserver( - options.ExpectedObisCodes, - expectAck, - (code, isAck) => - { - if (isAck) - { - _logger.LogDebug("ACK byte (0x06) received from smart meter"); - } - else if (code is not null && detected.Add(code)) - { - _logger.LogDebug("Detected extended OBIS code {ObisCode}", code); - } - - verificationTcs.TrySetResult(true); - })); - } - - try - { - await SendPinAsync(pin, options, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) - { - verificationSubscription?.Dispose(); - - _logger.LogWarning(ex, "Unlock cancelled while sending PIN"); - - return new SmartMeterUnlockResult( - false, SmartMeterUnlockOutcome.Cancelled, [], stopwatch.Elapsed, "Cancelled"); - } - catch (Exception ex) - { - verificationSubscription?.Dispose(); - - _logger.LogError(ex, "Failed to send PIN to smart meter"); - - return new SmartMeterUnlockResult( - false, SmartMeterUnlockOutcome.WriteFailed, [], stopwatch.Elapsed, ex.Message); - } - - if (!options.Verify) - { - _logger.LogInformation( - "PIN sent, verification skipped by options. Elapsed={Elapsed}", stopwatch.Elapsed); - - return new SmartMeterUnlockResult( - true, SmartMeterUnlockOutcome.VerificationSkipped, [], stopwatch.Elapsed, - "Verification skipped"); - } - - _logger.LogInformation( - "PIN sent, awaiting verification evidence (timeout={Timeout})", options.VerificationTimeout); - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(options.VerificationTimeout); - - await using var _ = timeoutCts.Token.Register(() => verificationTcs.TrySetResult(false)); - - var verified = await verificationTcs.Task.ConfigureAwait(false); - - verificationSubscription?.Dispose(); - - stopwatch.Stop(); - - if (cancellationToken.IsCancellationRequested) - { - _logger.LogWarning("Unlock cancelled while waiting for verification"); - - return new SmartMeterUnlockResult( - false, SmartMeterUnlockOutcome.Cancelled, detected.ToArray(), stopwatch.Elapsed, - "Cancelled"); - } - - if (verified) - { - _logger.LogInformation( - "Smart meter unlocked. Detected codes: [{Codes}], elapsed={Elapsed}", - string.Join(", ", detected), stopwatch.Elapsed); - - return new SmartMeterUnlockResult( - true, SmartMeterUnlockOutcome.PinAccepted, detected.ToArray(), stopwatch.Elapsed); - } - - _logger.LogWarning( - "Unlock verification timed out after {Timeout}. No extended OBIS codes observed. " + - "Possible causes: incorrect PIN, wrong strategy ({Strategy}) for this meter, " + - "optical coupler not aligned, or serial port misconfigured.", - options.VerificationTimeout, options.Strategy); - - return new SmartMeterUnlockResult( - false, SmartMeterUnlockOutcome.VerificationTimeout, detected.ToArray(), stopwatch.Elapsed, - "Verification timeout"); - } - catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) - { - _logger.LogWarning(ex, "Unlock cancelled"); - - return new SmartMeterUnlockResult( - false, SmartMeterUnlockOutcome.Cancelled, [], stopwatch.Elapsed, "Cancelled"); - } - } - - private async Task SendPinAsync(string pin, SmartMeterUnlockOptions options, CancellationToken cancellationToken) - { - switch (options.Strategy) - { - case SmartMeterPinStrategy.EmhAsciiBlock: - case SmartMeterPinStrategy.IskraAsciiBlock: - { - var payload = Encoding.ASCII.GetBytes(pin + options.LineEnding); - - _logger.LogDebug( - "Writing PIN as ASCII block ({Bytes} bytes, lineEnding={LineEndingLength}b)", - payload.Length, options.LineEnding.Length); - - _serialPort.Write(payload); - - break; - } - - case SmartMeterPinStrategy.EasymeterDigitByDigit: - { - _logger.LogDebug( - "Writing PIN digit-by-digit ({Digits} digits, delay={Delay})", - pin.Length, options.DigitDelay); - - for (var i = 0; i < pin.Length; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var digit = Encoding.ASCII.GetBytes(pin.AsSpan(i, 1).ToArray()); - - _serialPort.Write(digit); - - if (i < pin.Length - 1 && options.DigitDelay > TimeSpan.Zero) - { - await Task.Delay(options.DigitDelay, cancellationToken).ConfigureAwait(false); - } - } - - break; - } - - default: - throw new ArgumentOutOfRangeException( - nameof(options), options.Strategy, "Unsupported PIN strategy"); - } - } - private void CloseSerialPort() { _logger.LogInformation("Closing serial port..."); @@ -272,46 +78,6 @@ private void DisposingSubscription() _subscription = null; } - /// - /// Observes raw serial data and reports verification events - /// (extended OBIS code detected or ACK byte received). - /// - private sealed class VerificationObserver : IObserver - { - private readonly IReadOnlyList _expectedObisCodes; - private readonly bool _expectAck; - private readonly Action _onHit; - - public VerificationObserver( - IReadOnlyList expectedObisCodes, bool expectAck, Action onHit) - { - _expectedObisCodes = expectedObisCodes; - _expectAck = expectAck; - _onHit = onHit; - } - - public void OnCompleted() - { - } - - public void OnError(Exception error) - { - } - - public void OnNext(byte[] value) - { - if (_expectAck && Array.IndexOf(value, (byte)0x06) >= 0) - { - _onHit(null, true); - } - - foreach (var code in ObisCodeScanner.FindMatches(value, _expectedObisCodes)) - { - _onHit(code, false); - } - } - } - public void Dispose() { _serialPort.Dispose(); diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ISmartMeterUnlocker.cs b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ISmartMeterUnlocker.cs new file mode 100644 index 0000000..4467b13 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ISmartMeterUnlocker.cs @@ -0,0 +1,19 @@ +namespace CreativeCoders.SmartMeter.Server.Core.Unlock; + +public interface ISmartMeterUnlocker : IDisposable +{ + /// + /// Sends the given PIN to the smart meter via the optical coupler connected + /// to the serial port in order to unlock the extended data set (instantaneous + /// power, per-phase values, voltages, ...). Optionally verifies the unlock by + /// observing the incoming byte stream for extended OBIS codes. + /// + /// PIN as printable ASCII digits. Must not be empty. + /// Transport and verification options. Defaults target EMH / eHZ meters. + /// Cancels the operation. + /// A structured describing the outcome. + Task UnlockAsync( + string pin, + SmartMeterUnlockOptions? options = null, + CancellationToken cancellationToken = default); +} diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlocker.cs b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlocker.cs new file mode 100644 index 0000000..5241552 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlocker.cs @@ -0,0 +1,252 @@ +using System.Diagnostics; +using System.Text; +using CreativeCoders.Core; +using CreativeCoders.SmartMeter.Sml.Reactive; +using Microsoft.Extensions.Logging; + +namespace CreativeCoders.SmartMeter.Server.Core.Unlock; + +public sealed class SmartMeterUnlocker(ILogger logger) : ISmartMeterUnlocker +{ + private readonly ILogger _logger = Ensure.NotNull(logger); + + // Own serial port instance used exclusively for the unlock procedure. The port + // is used to write the PIN payload and to observe the incoming bytes for + // verification evidence (extended OBIS codes / ACK byte). + private readonly ReactiveSerialPort _serialPort = new ReactiveSerialPort("/dev/ttyUSB0"); + + public async Task UnlockAsync( + string pin, + SmartMeterUnlockOptions? options = null, + CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(pin); + + options ??= new SmartMeterUnlockOptions(); + + var stopwatch = Stopwatch.StartNew(); + + _logger.LogInformation( + "Unlocking smart meter via {Strategy}, pinLength={PinLength}, verify={Verify}, verificationTimeout={Timeout}", + options.Strategy, pin.Length, options.Verify, options.VerificationTimeout); + + // Ensure the port is open so we can write and observe responses. + if (!_serialPort.IsOpen) + { + _logger.LogInformation("Serial port is closed, opening it for unlock procedure..."); + _serialPort.Open(); + _logger.LogInformation("Serial port opened"); + } + + try + { + if (options.InitialDelay > TimeSpan.Zero) + { + _logger.LogDebug("Waiting initial delay {Delay} before sending PIN", options.InitialDelay); + + await Task.Delay(options.InitialDelay, cancellationToken).ConfigureAwait(false); + } + + var detected = new HashSet(StringComparer.Ordinal); + var verificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var expectAck = options.Strategy == SmartMeterPinStrategy.IskraAsciiBlock; + + IDisposable? verificationSubscription = null; + + if (options.Verify) + { + verificationSubscription = _serialPort.Subscribe(new VerificationObserver( + options.ExpectedObisCodes, + expectAck, + (code, isAck) => + { + if (isAck) + { + _logger.LogDebug("ACK byte (0x06) received from smart meter"); + } + else if (code is not null && detected.Add(code)) + { + _logger.LogDebug("Detected extended OBIS code {ObisCode}", code); + } + + verificationTcs.TrySetResult(true); + })); + } + + try + { + await SendPinAsync(pin, options, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + verificationSubscription?.Dispose(); + + _logger.LogWarning(ex, "Unlock cancelled while sending PIN"); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.Cancelled, [], stopwatch.Elapsed, "Cancelled"); + } + catch (Exception ex) + { + verificationSubscription?.Dispose(); + + _logger.LogError(ex, "Failed to send PIN to smart meter"); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.WriteFailed, [], stopwatch.Elapsed, ex.Message); + } + + if (!options.Verify) + { + _logger.LogInformation( + "PIN sent, verification skipped by options. Elapsed={Elapsed}", stopwatch.Elapsed); + + return new SmartMeterUnlockResult( + true, SmartMeterUnlockOutcome.VerificationSkipped, [], stopwatch.Elapsed, + "Verification skipped"); + } + + _logger.LogInformation( + "PIN sent, awaiting verification evidence (timeout={Timeout})", options.VerificationTimeout); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(options.VerificationTimeout); + + await using var _ = timeoutCts.Token.Register(() => verificationTcs.TrySetResult(false)); + + var verified = await verificationTcs.Task.ConfigureAwait(false); + + verificationSubscription?.Dispose(); + + stopwatch.Stop(); + + if (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("Unlock cancelled while waiting for verification"); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.Cancelled, detected.ToArray(), stopwatch.Elapsed, + "Cancelled"); + } + + if (verified) + { + _logger.LogInformation( + "Smart meter unlocked. Detected codes: [{Codes}], elapsed={Elapsed}", + string.Join(", ", detected), stopwatch.Elapsed); + + return new SmartMeterUnlockResult( + true, SmartMeterUnlockOutcome.PinAccepted, detected.ToArray(), stopwatch.Elapsed); + } + + _logger.LogWarning( + "Unlock verification timed out after {Timeout}. No extended OBIS codes observed. " + + "Possible causes: incorrect PIN, wrong strategy ({Strategy}) for this meter, " + + "optical coupler not aligned, or serial port misconfigured.", + options.VerificationTimeout, options.Strategy); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.VerificationTimeout, detected.ToArray(), stopwatch.Elapsed, + "Verification timeout"); + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning(ex, "Unlock cancelled"); + + return new SmartMeterUnlockResult( + false, SmartMeterUnlockOutcome.Cancelled, [], stopwatch.Elapsed, "Cancelled"); + } + } + + private async Task SendPinAsync(string pin, SmartMeterUnlockOptions options, CancellationToken cancellationToken) + { + switch (options.Strategy) + { + case SmartMeterPinStrategy.EmhAsciiBlock: + case SmartMeterPinStrategy.IskraAsciiBlock: + { + var payload = Encoding.ASCII.GetBytes(pin + options.LineEnding); + + _logger.LogDebug( + "Writing PIN as ASCII block ({Bytes} bytes, lineEnding={LineEndingLength}b)", + payload.Length, options.LineEnding.Length); + + _serialPort.Write(payload); + + break; + } + + case SmartMeterPinStrategy.EasymeterDigitByDigit: + { + _logger.LogDebug( + "Writing PIN digit-by-digit ({Digits} digits, delay={Delay})", + pin.Length, options.DigitDelay); + + for (var i = 0; i < pin.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var digit = Encoding.ASCII.GetBytes(pin.AsSpan(i, 1).ToArray()); + + _serialPort.Write(digit); + + if (i < pin.Length - 1 && options.DigitDelay > TimeSpan.Zero) + { + await Task.Delay(options.DigitDelay, cancellationToken).ConfigureAwait(false); + } + } + + break; + } + + default: + throw new ArgumentOutOfRangeException( + nameof(options), options.Strategy, "Unsupported PIN strategy"); + } + } + + /// + /// Observes raw serial data and reports verification events + /// (extended OBIS code detected or ACK byte received). + /// + private sealed class VerificationObserver : IObserver + { + private readonly IReadOnlyList _expectedObisCodes; + private readonly bool _expectAck; + private readonly Action _onHit; + + public VerificationObserver( + IReadOnlyList expectedObisCodes, bool expectAck, Action onHit) + { + _expectedObisCodes = expectedObisCodes; + _expectAck = expectAck; + _onHit = onHit; + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void OnNext(byte[] value) + { + if (_expectAck && Array.IndexOf(value, (byte)0x06) >= 0) + { + _onHit(null, true); + } + + foreach (var code in ObisCodeScanner.FindMatches(value, _expectedObisCodes)) + { + _onHit(code, false); + } + } + } + + public void Dispose() + { + _serialPort.Dispose(); + } +} From fc59f32a860b27af1d4d8ccd17013ea0ba2827d9 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:04:35 +0200 Subject: [PATCH 15/35] Refactor `SmlValueType` to `SmlMessageValueType` for improved clarity and consistency; update references across parsing, reactive data pipeline, and testing layers. --- .../Parsing/ObisValue.cs | 2 +- .../Parsing/SmlParser.cs | 28 +-- .../Parsing/SmlParserLog.cs | 4 +- ...SmlValueType.cs => SmlMessageValueType.cs} | 2 +- .../Tlv/SmlTlvElement.cs | 12 +- .../Tlv/SmlTlvReader.cs | 14 +- .../SmlValue.cs | 4 +- .../SmlValueType.cs | 2 +- .../SmartMeterServer.cs | 5 - .../SmlData/SmartMeterReactiveDataPipeline.cs | 1 - .../ISmlValueReader.cs | 12 +- .../Reactive/SmlDataReader.cs | 218 +++++++++--------- .../Reactive/SmlReactiveExtensions.cs | 68 +++--- .../SmlReadDataMode.cs | 16 +- .../SmlValueReader.cs | 64 ++--- .../Tlv/SmlTlvReaderTests.cs | 14 +- 16 files changed, 235 insertions(+), 231 deletions(-) rename source/CreativeCoders.SmartMessageLanguage/Tlv/{SmlValueType.cs => SmlMessageValueType.cs} (97%) rename source/{CreativeCoders.SmartMeter.Sml => CreativeCoders.SmartMeter.DataProcessing}/SmlValue.cs (78%) rename source/{CreativeCoders.SmartMeter.Sml => CreativeCoders.SmartMeter.DataProcessing}/SmlValueType.cs (55%) diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/ObisValue.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/ObisValue.cs index f8db9e7..db0d8dd 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Parsing/ObisValue.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/ObisValue.cs @@ -18,4 +18,4 @@ public sealed record ObisValue( SmlUnit Unit, sbyte Scaler, byte[] RawValue, - SmlValueType RawType); + SmlMessageValueType RawType); diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs index 4f1de12..c6a56f0 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs @@ -60,7 +60,7 @@ public SmlParseResult Parse(ReadOnlySpan payload) continue; } - if (element.Type != SmlValueType.List) + if (element.Type != SmlMessageValueType.List) { // Top level must be a sequence of messages (lists); anything else is stray. var message = $"Unexpected top-level TLV type {element.Type}"; @@ -102,7 +102,7 @@ private void ProcessMessage(ref SmlTlvReader reader, int entryCount, } // Field 4: messageBody list. - if (!reader.Read() || reader.Current.Type != SmlValueType.List || reader.Current.ListLength != 2) + if (!reader.Read() || reader.Current.Type != SmlMessageValueType.List || reader.Current.ListLength != 2) { warnings.Add("Malformed SML_Message body wrapper"); SkipListEntries(ref reader, 2); @@ -110,7 +110,7 @@ private void ProcessMessage(ref SmlTlvReader reader, int entryCount, return; } - if (!reader.Read() || reader.Current.Type != SmlValueType.Unsigned) + if (!reader.Read() || reader.Current.Type != SmlMessageValueType.Unsigned) { warnings.Add("Missing messageBody type tag"); SkipListEntries(ref reader, 3); @@ -125,7 +125,7 @@ private void ProcessMessage(ref SmlTlvReader reader, int entryCount, return; } - if (messageBodyType == GetListResponseId && reader.Current.Type == SmlValueType.List) + if (messageBodyType == GetListResponseId && reader.Current.Type == SmlMessageValueType.List) { SmlParserLog.GetListResponseFound(_logger, reader.Current.ListLength); ProcessGetListResponse(ref reader, reader.Current.ListLength, values, warnings); @@ -177,7 +177,7 @@ private void ProcessGetListResponse(ref SmlTlvReader reader, int entryCount, return; } - if (reader.Current.Type == SmlValueType.List) + if (reader.Current.Type == SmlMessageValueType.List) { var listCount = reader.Current.ListLength; @@ -190,7 +190,7 @@ private void ProcessGetListResponse(ref SmlTlvReader reader, int entryCount, { // valList is OPTIONAL; a missing list is encoded as an empty octet string. // Any other type is surprising. - if (reader.Current.Type != SmlValueType.OctetString) + if (reader.Current.Type != SmlMessageValueType.OctetString) { warnings.Add($"Unexpected valList type {reader.Current.Type}"); SmlParserLog.UnexpectedTlvType(_logger, reader.Current.Type, "valList"); @@ -214,7 +214,7 @@ private void ReadValListEntry(ref SmlTlvReader reader, List values, { // SML_ListEntry = List of 7: objName (OctetString), status, valTime, // unit (Unsigned8), scaler (Integer8), value, valueSignature. - if (!reader.Read() || reader.Current.Type != SmlValueType.List) + if (!reader.Read() || reader.Current.Type != SmlMessageValueType.List) { warnings.Add("valList entry is not a list"); @@ -232,7 +232,7 @@ private void ReadValListEntry(ref SmlTlvReader reader, List values, } // objName - if (!reader.Read() || reader.Current.Type != SmlValueType.OctetString) + if (!reader.Read() || reader.Current.Type != SmlMessageValueType.OctetString) { warnings.Add("valList entry missing objName"); SkipListEntries(ref reader, entryCount - 1); @@ -261,7 +261,7 @@ private void ReadValListEntry(ref SmlTlvReader reader, List values, var unit = SmlUnit.Unknown; - if (reader.Current.Type == SmlValueType.Unsigned) + if (reader.Current.Type == SmlMessageValueType.Unsigned) { var unitCode = (byte)reader.Current.GetUInt64(); unit = Enum.IsDefined((SmlUnit)unitCode) ? (SmlUnit)unitCode : SmlUnit.Unknown; @@ -281,7 +281,7 @@ private void ReadValListEntry(ref SmlTlvReader reader, List values, sbyte scaler = 0; - if (reader.Current.Type == SmlValueType.Integer) + if (reader.Current.Type == SmlMessageValueType.Integer) { scaler = (sbyte)reader.Current.GetInt64(); } @@ -316,13 +316,13 @@ private void ReadValListEntry(ref SmlTlvReader reader, List values, { switch (value.Type) { - case SmlValueType.Unsigned: + case SmlMessageValueType.Unsigned: return ApplyScaler(value.GetUInt64(), scaler); - case SmlValueType.Integer: + case SmlMessageValueType.Integer: return ApplyScaler(value.GetInt64(), scaler); - case SmlValueType.Boolean: + case SmlMessageValueType.Boolean: return value.GetBool() ? 1m : 0m; - case SmlValueType.OctetString: + case SmlMessageValueType.OctetString: // Non-numeric (e.g. server ID as octet string); caller can read RawValue. return null; default: diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs index f345b74..34b9c8c 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParserLog.cs @@ -31,11 +31,11 @@ public static partial void ObisValueParsed(ILogger logger, string obisCode, deci [LoggerMessage(EventId = 2011, Level = LogLevel.Warning, Message = "Unexpected SML TLV type {TlvType} ({Context})")] - public static partial void UnexpectedTlvType(ILogger logger, SmlValueType tlvType, string context); + public static partial void UnexpectedTlvType(ILogger logger, SmlMessageValueType tlvType, string context); [LoggerMessage(EventId = 2012, Level = LogLevel.Warning, Message = "Unsupported SML value type {TlvType} for OBIS {ObisCode}")] - public static partial void UnsupportedValueType(ILogger logger, SmlValueType tlvType, string obisCode); + public static partial void UnsupportedValueType(ILogger logger, SmlMessageValueType tlvType, string obisCode); [LoggerMessage(EventId = 2020, Level = LogLevel.Error, Message = "Malformed SML envelope: {Reason}")] diff --git a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlValueType.cs b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlMessageValueType.cs similarity index 97% rename from source/CreativeCoders.SmartMessageLanguage/Tlv/SmlValueType.cs rename to source/CreativeCoders.SmartMessageLanguage/Tlv/SmlMessageValueType.cs index 16c014c..793d3df 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlValueType.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlMessageValueType.cs @@ -9,7 +9,7 @@ namespace CreativeCoders.SmartMessageLanguage.Tlv; /// the same wire nibble (0x0) as an octet string. The reader discriminates on /// the full first byte when deciding which enum value to assign. /// -public enum SmlValueType +public enum SmlMessageValueType { /// End-of-message marker (single 0x00 byte). EndOfMessage = 0, diff --git a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvElement.cs b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvElement.cs index bf2ff84..4955df9 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvElement.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvElement.cs @@ -10,7 +10,7 @@ namespace CreativeCoders.SmartMessageLanguage.Tlv; /// public readonly ref struct SmlTlvElement { - internal SmlTlvElement(SmlValueType type, int listLength, ReadOnlySpan raw) + internal SmlTlvElement(SmlMessageValueType type, int listLength, ReadOnlySpan raw) { Type = type; ListLength = listLength; @@ -18,19 +18,19 @@ internal SmlTlvElement(SmlValueType type, int listLength, ReadOnlySpan raw } /// The TLV primitive or structural type. - public SmlValueType Type { get; } + public SmlMessageValueType Type { get; } - /// Declared number of entries when is . + /// Declared number of entries when is . public int ListLength { get; } /// - /// Raw payload bytes for primitive elements (empty for - /// and ). + /// Raw payload bytes for primitive elements (empty for + /// and ). /// public ReadOnlySpan Raw { get; } /// true if this element is the end-of-message marker (0x00). - public bool IsEndOfMessage => Type == SmlValueType.EndOfMessage; + public bool IsEndOfMessage => Type == SmlMessageValueType.EndOfMessage; /// Parses as a big-endian unsigned integer (1-8 bytes). public ulong GetUInt64() diff --git a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvReader.cs b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvReader.cs index 4604de5..40f694c 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvReader.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvReader.cs @@ -47,7 +47,7 @@ public bool Read() if (first == 0x00) { _position++; - _current = new SmlTlvElement(SmlValueType.EndOfMessage, 0, ReadOnlySpan.Empty); + _current = new SmlTlvElement(SmlMessageValueType.EndOfMessage, 0, ReadOnlySpan.Empty); return true; } @@ -79,7 +79,7 @@ public bool Read() { // For lists, the 'length' encodes the number of child elements, not a byte count. _position += headerLength; - _current = new SmlTlvElement(SmlValueType.List, length, ReadOnlySpan.Empty); + _current = new SmlTlvElement(SmlMessageValueType.List, length, ReadOnlySpan.Empty); return true; } @@ -90,10 +90,10 @@ public bool Read() { var resolvedType = typeNibble switch { - 0x0 => SmlValueType.OctetString, - 0x4 => SmlValueType.Boolean, - 0x5 => SmlValueType.Integer, - _ => SmlValueType.Unsigned + 0x0 => SmlMessageValueType.OctetString, + 0x4 => SmlMessageValueType.Boolean, + 0x5 => SmlMessageValueType.Integer, + _ => SmlMessageValueType.Unsigned }; // Declared length includes the header byte(s); payload length is length - header. @@ -121,7 +121,7 @@ public bool Read() /// public void SkipCurrent() { - if (_current.Type != SmlValueType.List) + if (_current.Type != SmlMessageValueType.List) { // Primitives have already been fully consumed by Read(). return; diff --git a/source/CreativeCoders.SmartMeter.Sml/SmlValue.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValue.cs similarity index 78% rename from source/CreativeCoders.SmartMeter.Sml/SmlValue.cs rename to source/CreativeCoders.SmartMeter.DataProcessing/SmlValue.cs index 5bfc2ea..b196808 100644 --- a/source/CreativeCoders.SmartMeter.Sml/SmlValue.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValue.cs @@ -1,4 +1,4 @@ -namespace CreativeCoders.SmartMeter.Sml; +namespace CreativeCoders.SmartMeter.DataProcessing; public class SmlValue { @@ -10,4 +10,4 @@ public SmlValue(SmlValueType valueType) public decimal Value { get; init; } public SmlValueType ValueType { get; } -} \ No newline at end of file +} diff --git a/source/CreativeCoders.SmartMeter.Sml/SmlValueType.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueType.cs similarity index 55% rename from source/CreativeCoders.SmartMeter.Sml/SmlValueType.cs rename to source/CreativeCoders.SmartMeter.DataProcessing/SmlValueType.cs index 63f47da..d9e9ba5 100644 --- a/source/CreativeCoders.SmartMeter.Sml/SmlValueType.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueType.cs @@ -1,4 +1,4 @@ -namespace CreativeCoders.SmartMeter.Sml; +namespace CreativeCoders.SmartMeter.DataProcessing; public enum SmlValueType { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs index 1777297..97e4187 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs @@ -1,10 +1,7 @@ -using System.Reactive.Concurrency; -using System.Reactive.Linq; using CreativeCoders.Core; using CreativeCoders.Daemon; using CreativeCoders.SmartMeter.DataProcessing; using CreativeCoders.SmartMeter.Server.Core.SmlData; -using CreativeCoders.SmartMeter.Sml.Reactive; using JetBrains.Annotations; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -24,8 +21,6 @@ public class SmartMeterServer( private readonly ILogger _logger = Ensure.NotNull(logger); private readonly MqttPublisherOptions _mqttPublisherOptions = mqttPublisherOptions.Value; - private IDisposable? _subscription; - public async Task StartAsync() { _logger.LogInformation("Starting SmartMeter server"); diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs index 10270c3..b3915de 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs @@ -4,7 +4,6 @@ using CreativeCoders.SmartMessageLanguage.Framing; using CreativeCoders.SmartMessageLanguage.Parsing; using CreativeCoders.SmartMeter.DataProcessing; -using CreativeCoders.SmartMeter.Sml; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs b/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs index acaa98e..ec3edfc 100644 --- a/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs +++ b/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs @@ -1,6 +1,8 @@ -namespace CreativeCoders.SmartMeter.Sml; +// namespace CreativeCoders.SmartMeter.Sml; +// +// public interface ISmlValueReader +// { +// IEnumerable Read(byte[] data); +// } + -public interface ISmlValueReader -{ - IEnumerable Read(byte[] data); -} diff --git a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlDataReader.cs b/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlDataReader.cs index c1df24b..ae8e08f 100644 --- a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlDataReader.cs +++ b/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlDataReader.cs @@ -1,110 +1,112 @@ -using CreativeCoders.Core.Collections; +// using CreativeCoders.Core.Collections; +// +// namespace CreativeCoders.SmartMeter.Sml.Reactive; +// +// public class SmlDataReader +// { +// private const byte EscapeChar = 0x1B; +// +// private const byte DocBeginChar = 0x01; +// +// private static readonly byte[] DocBeginSeq = +// { +// EscapeChar, EscapeChar, EscapeChar, EscapeChar, +// DocBeginChar, DocBeginChar, DocBeginChar, DocBeginChar +// }; +// +// private readonly List _buffer = []; +// +// private readonly List _currentBlock = []; +// +// private SmlMessage? _currentMessage; +// +// private SmlReadDataMode _currentMode = SmlReadDataMode.WaitForBegin; +// +// private Action _handleMessage = _ => { }; +// +// public void AddHandler(Action handleMessage) +// { +// _handleMessage = handleMessage; +// } +// +// public void Parse(IEnumerable data) +// { +// data.ForEach(b => +// { +// switch (_currentMode) +// { +// case SmlReadDataMode.WaitForBegin: +// if (b != EscapeChar && b != DocBeginChar) +// { +// _buffer.Clear(); +// break; +// } +// +// _buffer.Add(b); +// if (_buffer.Count == 8) +// { +// if (_buffer.SequenceEqual(DocBeginSeq)) +// { +// _currentMode = SmlReadDataMode.InData; +// _currentBlock.Clear(); +// _buffer.Clear(); +// _currentMode = SmlReadDataMode.InData; +// break; +// } +// +// _buffer.Clear(); +// break; +// } +// +// if (_buffer.Count > 8) +// { +// _buffer.Clear(); +// } +// +// break; +// case SmlReadDataMode.InData: +// if (b != EscapeChar) +// { +// _currentBlock.AddRange(_buffer); +// _buffer.Clear(); +// _currentBlock.Add(b); +// break; +// } +// +// _buffer.Add(b); +// +// if (_buffer.Count == 4) +// { +// _currentMessage = new SmlMessage(_currentBlock.ToArray()); +// _buffer.Clear(); +// _currentBlock.Clear(); +// _currentMode = SmlReadDataMode.ReadDataEnd; +// } +// +// break; +// case SmlReadDataMode.ReadDataEnd: +// _buffer.Add(b); +// +// if (_buffer.Count == 4) +// { +// if (_currentMessage != null) +// { +// _currentMessage.FillByteCount = _buffer[1]; +// _currentMessage.Crc16Checksum = BitConverter.ToUInt16(_buffer.ToArray(), 2); +// _buffer.Clear(); +// _handleMessage(_currentMessage); +// _currentMessage = null; +// } +// +// _currentMode = SmlReadDataMode.WaitForBegin; +// } +// +// break; +// default: +// throw new ArgumentOutOfRangeException(nameof(_currentMode), "Unknown parser mode"); +// } +// }); +// } +// } -namespace CreativeCoders.SmartMeter.Sml.Reactive; -public class SmlDataReader -{ - private const byte EscapeChar = 0x1B; - - private const byte DocBeginChar = 0x01; - - private static readonly byte[] DocBeginSeq = - { - EscapeChar, EscapeChar, EscapeChar, EscapeChar, - DocBeginChar, DocBeginChar, DocBeginChar, DocBeginChar - }; - - private readonly List _buffer = []; - - private readonly List _currentBlock = []; - - private SmlMessage? _currentMessage; - - private SmlReadDataMode _currentMode = SmlReadDataMode.WaitForBegin; - - private Action _handleMessage = _ => { }; - - public void AddHandler(Action handleMessage) - { - _handleMessage = handleMessage; - } - - public void Parse(IEnumerable data) - { - data.ForEach(b => - { - switch (_currentMode) - { - case SmlReadDataMode.WaitForBegin: - if (b != EscapeChar && b != DocBeginChar) - { - _buffer.Clear(); - break; - } - - _buffer.Add(b); - if (_buffer.Count == 8) - { - if (_buffer.SequenceEqual(DocBeginSeq)) - { - _currentMode = SmlReadDataMode.InData; - _currentBlock.Clear(); - _buffer.Clear(); - _currentMode = SmlReadDataMode.InData; - break; - } - - _buffer.Clear(); - break; - } - - if (_buffer.Count > 8) - { - _buffer.Clear(); - } - - break; - case SmlReadDataMode.InData: - if (b != EscapeChar) - { - _currentBlock.AddRange(_buffer); - _buffer.Clear(); - _currentBlock.Add(b); - break; - } - - _buffer.Add(b); - - if (_buffer.Count == 4) - { - _currentMessage = new SmlMessage(_currentBlock.ToArray()); - _buffer.Clear(); - _currentBlock.Clear(); - _currentMode = SmlReadDataMode.ReadDataEnd; - } - - break; - case SmlReadDataMode.ReadDataEnd: - _buffer.Add(b); - - if (_buffer.Count == 4) - { - if (_currentMessage != null) - { - _currentMessage.FillByteCount = _buffer[1]; - _currentMessage.Crc16Checksum = BitConverter.ToUInt16(_buffer.ToArray(), 2); - _buffer.Clear(); - _handleMessage(_currentMessage); - _currentMessage = null; - } - - _currentMode = SmlReadDataMode.WaitForBegin; - } - - break; - default: - throw new ArgumentOutOfRangeException(nameof(_currentMode), "Unknown parser mode"); - } - }); - } -} diff --git a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlReactiveExtensions.cs b/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlReactiveExtensions.cs index c2c6077..3a39b3e 100644 --- a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlReactiveExtensions.cs +++ b/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlReactiveExtensions.cs @@ -1,35 +1,37 @@ -using System.Reactive.Linq; -using System.Reactive.Subjects; +// using System.Reactive.Linq; +// using System.Reactive.Subjects; +// +// namespace CreativeCoders.SmartMeter.Sml.Reactive; +// +// public static class SmlReactiveExtensions +// { +// // public static IObservable SelectSmlMessages(this IObservable observable) +// // { +// // var messageSubject = new Subject(); +// // +// // var smlDataReader = new SmlDataReader(); +// // +// // smlDataReader.AddHandler(msg => +// // { +// // if (msg.IsValid()) +// // { +// // messageSubject.OnNext(msg); +// // } +// // }); +// // +// // observable.Subscribe(data => smlDataReader.Parse(data)); +// // +// // return messageSubject; +// // } +// +// // public static IObservable SelectSmlValues(this IObservable observable) +// // { +// // var valueReader = new SmlValueReader(); +// // +// // return observable +// // .SelectMany(msg => valueReader.Read(msg.Data)) +// // .Where(x => x.Value < int.MaxValue); +// // } +// } -namespace CreativeCoders.SmartMeter.Sml.Reactive; -public static class SmlReactiveExtensions -{ - public static IObservable SelectSmlMessages(this IObservable observable) - { - var messageSubject = new Subject(); - - var smlDataReader = new SmlDataReader(); - - smlDataReader.AddHandler(msg => - { - if (msg.IsValid()) - { - messageSubject.OnNext(msg); - } - }); - - observable.Subscribe(data => smlDataReader.Parse(data)); - - return messageSubject; - } - - public static IObservable SelectSmlValues(this IObservable observable) - { - var valueReader = new SmlValueReader(); - - return observable - .SelectMany(msg => valueReader.Read(msg.Data)) - .Where(x => x.Value < int.MaxValue); - } -} diff --git a/source/CreativeCoders.SmartMeter.Sml/SmlReadDataMode.cs b/source/CreativeCoders.SmartMeter.Sml/SmlReadDataMode.cs index 92516fe..a7d3386 100644 --- a/source/CreativeCoders.SmartMeter.Sml/SmlReadDataMode.cs +++ b/source/CreativeCoders.SmartMeter.Sml/SmlReadDataMode.cs @@ -1,8 +1,10 @@ -namespace CreativeCoders.SmartMeter.Sml; +// namespace CreativeCoders.SmartMeter.Sml; +// +// public enum SmlReadDataMode +// { +// WaitForBegin, +// InData, +// ReadDataEnd +// } + -public enum SmlReadDataMode -{ - WaitForBegin, - InData, - ReadDataEnd -} diff --git a/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs b/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs index afe3cb9..d02f402 100644 --- a/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs +++ b/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs @@ -1,33 +1,35 @@ -using CreativeCoders.Core; +// using CreativeCoders.Core; +// +// namespace CreativeCoders.SmartMeter.Sml; +// +// public class SmlValueReader : ISmlValueReader +// { +// public IEnumerable Read(byte[] data) +// { +// Ensure.NotNull(data); +// +// for (var i = 0; i < data.Length; i++) +// { +// if (data.Skip(i).Take(4).SequenceEqual(new byte[] { 0xFF, 0x01, 0x01, 0x62 })) +// { +// var valueData = data.Skip(i + 8).Take(8).ToArray(); +// +// decimal value = BitConverter.ToUInt64(valueData.Reverse().ToArray()); +// +// yield return new SmlValue(SmlValueType.SoldEnergy) { Value = value / 10 }; +// } +// +// //if (data.Skip(i).Take(4).SequenceEqual(new byte[] { 0x59, 0x04, 0x01, 0x62 })) +// if (data.Skip(i).Take(3).SequenceEqual(new byte[] { 0x04, 0x01, 0x62 })) +// { +// var valueData = data.Skip(i + 7).Take(8).ToArray(); +// +// decimal value = BitConverter.ToUInt64(valueData.Reverse().ToArray()); +// +// yield return new SmlValue(SmlValueType.PurchasedEnergy) { Value = value / 10 }; +// } +// } +// } +// } -namespace CreativeCoders.SmartMeter.Sml; -public class SmlValueReader : ISmlValueReader -{ - public IEnumerable Read(byte[] data) - { - Ensure.NotNull(data); - - for (var i = 0; i < data.Length; i++) - { - if (data.Skip(i).Take(4).SequenceEqual(new byte[] { 0xFF, 0x01, 0x01, 0x62 })) - { - var valueData = data.Skip(i + 8).Take(8).ToArray(); - - decimal value = BitConverter.ToUInt64(valueData.Reverse().ToArray()); - - yield return new SmlValue(SmlValueType.SoldEnergy) { Value = value / 10 }; - } - - //if (data.Skip(i).Take(4).SequenceEqual(new byte[] { 0x59, 0x04, 0x01, 0x62 })) - if (data.Skip(i).Take(3).SequenceEqual(new byte[] { 0x04, 0x01, 0x62 })) - { - var valueData = data.Skip(i + 7).Take(8).ToArray(); - - decimal value = BitConverter.ToUInt64(valueData.Reverse().ToArray()); - - yield return new SmlValue(SmlValueType.PurchasedEnergy) { Value = value / 10 }; - } - } - } -} \ No newline at end of file diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs index f3f8663..0f35c8b 100644 --- a/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs @@ -15,7 +15,7 @@ public void Read_OctetString_ReturnsPayload() var reader = new SmlTlvReader(data); reader.Read().Should().BeTrue(); - reader.Current.Type.Should().Be(SmlValueType.OctetString); + reader.Current.Type.Should().Be(SmlMessageValueType.OctetString); reader.Current.Raw.ToArray().Should().Equal(0xDE, 0xAD, 0xBE); } @@ -28,7 +28,7 @@ public void Read_Unsigned_ReturnsBigEndianValue() var reader = new SmlTlvReader(data); reader.Read().Should().BeTrue(); - reader.Current.Type.Should().Be(SmlValueType.Unsigned); + reader.Current.Type.Should().Be(SmlMessageValueType.Unsigned); reader.Current.GetUInt64().Should().Be(0x0102UL); } @@ -41,7 +41,7 @@ public void Read_SignedInteger_SignExtends() var reader = new SmlTlvReader(data); reader.Read().Should().BeTrue(); - reader.Current.Type.Should().Be(SmlValueType.Integer); + reader.Current.Type.Should().Be(SmlMessageValueType.Integer); reader.Current.GetInt64().Should().Be(-1); } @@ -53,7 +53,7 @@ public void Read_Bool_ReturnsTrue() var reader = new SmlTlvReader(data); reader.Read().Should().BeTrue(); - reader.Current.Type.Should().Be(SmlValueType.Boolean); + reader.Current.Type.Should().Be(SmlMessageValueType.Boolean); reader.Current.GetBool().Should().BeTrue(); } @@ -65,7 +65,7 @@ public void Read_List_ReportsEntryCountAndIteratesChildren() var reader = new SmlTlvReader(data); reader.Read().Should().BeTrue(); - reader.Current.Type.Should().Be(SmlValueType.List); + reader.Current.Type.Should().Be(SmlMessageValueType.List); reader.Current.ListLength.Should().Be(2); reader.Read().Should().BeTrue(); @@ -99,7 +99,7 @@ public void Read_MultiByteLength_ParsesCorrectly() var reader = new SmlTlvReader(data); reader.Read().Should().BeTrue(); - reader.Current.Type.Should().Be(SmlValueType.OctetString); + reader.Current.Type.Should().Be(SmlMessageValueType.OctetString); reader.Current.Raw.Length.Should().Be(16); } @@ -115,7 +115,7 @@ public void SkipCurrent_OnList_ConsumesAllChildrenRecursively() var reader = new SmlTlvReader(data); reader.Read().Should().BeTrue(); - reader.Current.Type.Should().Be(SmlValueType.List); + reader.Current.Type.Should().Be(SmlMessageValueType.List); reader.SkipCurrent(); reader.Read().Should().BeTrue(); From f1dd914a07660c98a947f9282102ae104aed2fa5 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:14:09 +0200 Subject: [PATCH 16/35] Move `ValueHistory`-related classes to `History` namespace for better organization and consistency with project structure. --- .../{ => History}/ValueHistory.cs | 3 +-- .../{ => History}/ValueHistoryData.cs | 4 +--- .../{ => History}/ValueHistoryDataSet.cs | 3 +-- .../SmlValueProcessor.cs | 1 + 4 files changed, 4 insertions(+), 7 deletions(-) rename source/CreativeCoders.SmartMeter.DataProcessing/{ => History}/ValueHistory.cs (86%) rename source/CreativeCoders.SmartMeter.DataProcessing/{ => History}/ValueHistoryData.cs (71%) rename source/CreativeCoders.SmartMeter.DataProcessing/{ => History}/ValueHistoryDataSet.cs (68%) diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistory.cs b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs similarity index 86% rename from source/CreativeCoders.SmartMeter.DataProcessing/ValueHistory.cs rename to source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs index 1fe8f44..0c75368 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistory.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; -using CreativeCoders.SmartMeter.Sml; -namespace CreativeCoders.SmartMeter.DataProcessing; +namespace CreativeCoders.SmartMeter.DataProcessing.History; public class ValueHistory { diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryData.cs b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistoryData.cs similarity index 71% rename from source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryData.cs rename to source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistoryData.cs index 19211e1..02c2cd3 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryData.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistoryData.cs @@ -1,6 +1,4 @@ -using CreativeCoders.SmartMeter.Sml; - -namespace CreativeCoders.SmartMeter.DataProcessing; +namespace CreativeCoders.SmartMeter.DataProcessing.History; public class ValueHistoryData { diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistoryDataSet.cs similarity index 68% rename from source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs rename to source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistoryDataSet.cs index 5573809..4c8f944 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistoryDataSet.cs @@ -1,7 +1,6 @@ using CreativeCoders.Core; -using CreativeCoders.SmartMeter.Sml; -namespace CreativeCoders.SmartMeter.DataProcessing; +namespace CreativeCoders.SmartMeter.DataProcessing.History; public class ValueHistoryDataSet(SmlValue value) { diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs index 5478b59..8019c9d 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs @@ -1,5 +1,6 @@ using System.Reactive.Linq; using System.Reactive.Subjects; +using CreativeCoders.SmartMeter.DataProcessing.History; using CreativeCoders.SmartMeter.Sml; namespace CreativeCoders.SmartMeter.DataProcessing; From 5fdd0d6a27f7b3eac9b46c25feb054e09b56ff6e Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:16:34 +0200 Subject: [PATCH 17/35] Remove obsolete SML-related classes (`ISmlValueReader`, `SmlDataReader`, `SmlMessage`, `SmlReadDataMode`, `SmlValueReader`, and reactive extensions) to clean up and streamline the codebase. --- .../ISmlValueReader.cs | 8 -- .../Reactive/SmlDataReader.cs | 112 ------------------ .../Reactive/SmlReactiveExtensions.cs | 37 ------ .../SmlMessage.cs | 63 ---------- .../SmlReadDataMode.cs | 10 -- .../SmlValueReader.cs | 35 ------ 6 files changed, 265 deletions(-) delete mode 100644 source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs delete mode 100644 source/CreativeCoders.SmartMeter.Sml/Reactive/SmlDataReader.cs delete mode 100644 source/CreativeCoders.SmartMeter.Sml/Reactive/SmlReactiveExtensions.cs delete mode 100644 source/CreativeCoders.SmartMeter.Sml/SmlMessage.cs delete mode 100644 source/CreativeCoders.SmartMeter.Sml/SmlReadDataMode.cs delete mode 100644 source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs diff --git a/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs b/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs deleted file mode 100644 index ec3edfc..0000000 --- a/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs +++ /dev/null @@ -1,8 +0,0 @@ -// namespace CreativeCoders.SmartMeter.Sml; -// -// public interface ISmlValueReader -// { -// IEnumerable Read(byte[] data); -// } - - diff --git a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlDataReader.cs b/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlDataReader.cs deleted file mode 100644 index ae8e08f..0000000 --- a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlDataReader.cs +++ /dev/null @@ -1,112 +0,0 @@ -// using CreativeCoders.Core.Collections; -// -// namespace CreativeCoders.SmartMeter.Sml.Reactive; -// -// public class SmlDataReader -// { -// private const byte EscapeChar = 0x1B; -// -// private const byte DocBeginChar = 0x01; -// -// private static readonly byte[] DocBeginSeq = -// { -// EscapeChar, EscapeChar, EscapeChar, EscapeChar, -// DocBeginChar, DocBeginChar, DocBeginChar, DocBeginChar -// }; -// -// private readonly List _buffer = []; -// -// private readonly List _currentBlock = []; -// -// private SmlMessage? _currentMessage; -// -// private SmlReadDataMode _currentMode = SmlReadDataMode.WaitForBegin; -// -// private Action _handleMessage = _ => { }; -// -// public void AddHandler(Action handleMessage) -// { -// _handleMessage = handleMessage; -// } -// -// public void Parse(IEnumerable data) -// { -// data.ForEach(b => -// { -// switch (_currentMode) -// { -// case SmlReadDataMode.WaitForBegin: -// if (b != EscapeChar && b != DocBeginChar) -// { -// _buffer.Clear(); -// break; -// } -// -// _buffer.Add(b); -// if (_buffer.Count == 8) -// { -// if (_buffer.SequenceEqual(DocBeginSeq)) -// { -// _currentMode = SmlReadDataMode.InData; -// _currentBlock.Clear(); -// _buffer.Clear(); -// _currentMode = SmlReadDataMode.InData; -// break; -// } -// -// _buffer.Clear(); -// break; -// } -// -// if (_buffer.Count > 8) -// { -// _buffer.Clear(); -// } -// -// break; -// case SmlReadDataMode.InData: -// if (b != EscapeChar) -// { -// _currentBlock.AddRange(_buffer); -// _buffer.Clear(); -// _currentBlock.Add(b); -// break; -// } -// -// _buffer.Add(b); -// -// if (_buffer.Count == 4) -// { -// _currentMessage = new SmlMessage(_currentBlock.ToArray()); -// _buffer.Clear(); -// _currentBlock.Clear(); -// _currentMode = SmlReadDataMode.ReadDataEnd; -// } -// -// break; -// case SmlReadDataMode.ReadDataEnd: -// _buffer.Add(b); -// -// if (_buffer.Count == 4) -// { -// if (_currentMessage != null) -// { -// _currentMessage.FillByteCount = _buffer[1]; -// _currentMessage.Crc16Checksum = BitConverter.ToUInt16(_buffer.ToArray(), 2); -// _buffer.Clear(); -// _handleMessage(_currentMessage); -// _currentMessage = null; -// } -// -// _currentMode = SmlReadDataMode.WaitForBegin; -// } -// -// break; -// default: -// throw new ArgumentOutOfRangeException(nameof(_currentMode), "Unknown parser mode"); -// } -// }); -// } -// } - - diff --git a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlReactiveExtensions.cs b/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlReactiveExtensions.cs deleted file mode 100644 index 3a39b3e..0000000 --- a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlReactiveExtensions.cs +++ /dev/null @@ -1,37 +0,0 @@ -// using System.Reactive.Linq; -// using System.Reactive.Subjects; -// -// namespace CreativeCoders.SmartMeter.Sml.Reactive; -// -// public static class SmlReactiveExtensions -// { -// // public static IObservable SelectSmlMessages(this IObservable observable) -// // { -// // var messageSubject = new Subject(); -// // -// // var smlDataReader = new SmlDataReader(); -// // -// // smlDataReader.AddHandler(msg => -// // { -// // if (msg.IsValid()) -// // { -// // messageSubject.OnNext(msg); -// // } -// // }); -// // -// // observable.Subscribe(data => smlDataReader.Parse(data)); -// // -// // return messageSubject; -// // } -// -// // public static IObservable SelectSmlValues(this IObservable observable) -// // { -// // var valueReader = new SmlValueReader(); -// // -// // return observable -// // .SelectMany(msg => valueReader.Read(msg.Data)) -// // .Where(x => x.Value < int.MaxValue); -// // } -// } - - diff --git a/source/CreativeCoders.SmartMeter.Sml/SmlMessage.cs b/source/CreativeCoders.SmartMeter.Sml/SmlMessage.cs deleted file mode 100644 index 0921931..0000000 --- a/source/CreativeCoders.SmartMeter.Sml/SmlMessage.cs +++ /dev/null @@ -1,63 +0,0 @@ -using CreativeCoders.Core.Collections; - -namespace CreativeCoders.SmartMeter.Sml; - -public class SmlMessage -{ - public SmlMessage(byte[] data) - { - Data = data; - } - - public ushort CalcCrc16() - { - var dataForCalc = new List(); - dataForCalc.AddRange([0x1b, 0x1b, 0x1b, 0x1b]); - dataForCalc.AddRange([0x01, 0x01, 0x01, 0x01]); - dataForCalc.AddRange(Data); - dataForCalc.AddRange([0x1b, 0x1b, 0x1b, 0x1b]); - dataForCalc.Add(0x1a); - dataForCalc.Add(FillByteCount); - - return CalcCrc16X25(dataForCalc.ToArray(), dataForCalc.Count); - } - - public byte[] GetCompleteData() - { - var completeData = new List(); - - completeData.AddRange([0x1b, 0x1b, 0x1b, 0x1b]); - completeData.AddRange([0x01, 0x01, 0x01, 0x01]); - completeData.AddRange(Data); - completeData.AddRange([0x1b, 0x1b, 0x1b, 0x1b]); - completeData.Add(0x1a); - completeData.Add(FillByteCount); - BitConverter.GetBytes(Crc16Checksum).Reverse().ForEach(x => completeData.Add(x)); - - return completeData.ToArray(); - } - - public bool IsValid() - { - return CalcCrc16() == Crc16Checksum; - } - - private ushort CalcCrc16X25(IReadOnlyList data, int len) - { - ushort crc = 0xffff; - for (var i = 0; i < len; i++) - { - crc ^= data[i]; - for (uint k = 0; k < 8; k++) - crc = (ushort)((crc & 1) != 0 ? (crc >> 1) ^ 0x8408 : crc >> 1); - } - - return (ushort)~crc; - } - - public byte[] Data { get; } - - public byte FillByteCount { get; set; } - - public ushort Crc16Checksum { get; set; } -} diff --git a/source/CreativeCoders.SmartMeter.Sml/SmlReadDataMode.cs b/source/CreativeCoders.SmartMeter.Sml/SmlReadDataMode.cs deleted file mode 100644 index a7d3386..0000000 --- a/source/CreativeCoders.SmartMeter.Sml/SmlReadDataMode.cs +++ /dev/null @@ -1,10 +0,0 @@ -// namespace CreativeCoders.SmartMeter.Sml; -// -// public enum SmlReadDataMode -// { -// WaitForBegin, -// InData, -// ReadDataEnd -// } - - diff --git a/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs b/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs deleted file mode 100644 index d02f402..0000000 --- a/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs +++ /dev/null @@ -1,35 +0,0 @@ -// using CreativeCoders.Core; -// -// namespace CreativeCoders.SmartMeter.Sml; -// -// public class SmlValueReader : ISmlValueReader -// { -// public IEnumerable Read(byte[] data) -// { -// Ensure.NotNull(data); -// -// for (var i = 0; i < data.Length; i++) -// { -// if (data.Skip(i).Take(4).SequenceEqual(new byte[] { 0xFF, 0x01, 0x01, 0x62 })) -// { -// var valueData = data.Skip(i + 8).Take(8).ToArray(); -// -// decimal value = BitConverter.ToUInt64(valueData.Reverse().ToArray()); -// -// yield return new SmlValue(SmlValueType.SoldEnergy) { Value = value / 10 }; -// } -// -// //if (data.Skip(i).Take(4).SequenceEqual(new byte[] { 0x59, 0x04, 0x01, 0x62 })) -// if (data.Skip(i).Take(3).SequenceEqual(new byte[] { 0x04, 0x01, 0x62 })) -// { -// var valueData = data.Skip(i + 7).Take(8).ToArray(); -// -// decimal value = BitConverter.ToUInt64(valueData.Reverse().ToArray()); -// -// yield return new SmlValue(SmlValueType.PurchasedEnergy) { Value = value / 10 }; -// } -// } -// } -// } - - From 0b7e151a275fc900d64e534c0c77ef199fb1d6ef Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:23:16 +0200 Subject: [PATCH 18/35] Migrate SML-related components from `CreativeCoders.SmartMeter.Sml` to `CreativeCoders.SmartMeter.Core`; remove obsolete references, update namespaces, and streamline project structure. --- SmartMeter.sln | 30 +++++++++---------- .../CreativeCoders.SmartMeter.Cli/Program.cs | 7 +++-- .../CreativeCoders.SmartMeter.Core.csproj | 17 +++++++++++ .../ReactiveSerialPort.cs | 2 +- .../SmartMeterOptions.cs | 2 +- ...tMeterServerServiceCollectionExtensions.cs | 6 ++-- .../SmlData/ISmartMeterDataProducer.cs | 2 +- .../ISmartMeterReactiveDataPipeline.cs | 2 +- .../SmlData/SmartMeterDataProducer.cs | 3 +- .../SmlData/SmartMeterReactiveDataPipeline.cs | 2 +- .../Unlock/ISmartMeterUnlocker.cs | 2 +- .../Unlock/ObisCodeScanner.cs | 5 ++-- .../Unlock/SmartMeterPinStrategy.cs | 2 +- .../Unlock/SmartMeterUnlockOptions.cs | 2 +- .../Unlock/SmartMeterUnlockOutcome.cs | 2 +- .../Unlock/SmartMeterUnlockResult.cs | 2 +- .../Unlock/SmartMeterUnlocker.cs | 3 +- ...iveCoders.SmartMeter.DataProcessing.csproj | 11 +++---- .../SmartMeterReactiveExtensions.cs | 4 +-- .../SmlValueProcessor.cs | 1 - ...eativeCoders.SmartMeter.Server.Core.csproj | 1 + .../SmartMeterServer.cs | 2 +- .../CreativeCoders.SmartMeter.Sml.csproj | 13 -------- ...ers.SmartMeter.DataProcessing.Tests.csproj | 3 +- .../SmlValueProcessorTests.cs | 1 - 25 files changed, 62 insertions(+), 65 deletions(-) create mode 100644 source/CreativeCoders.SmartMeter.Core/CreativeCoders.SmartMeter.Core.csproj rename source/{CreativeCoders.SmartMeter.Sml/Reactive => CreativeCoders.SmartMeter.Core}/ReactiveSerialPort.cs (97%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/SmartMeterOptions.cs (77%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/SmartMeterServerServiceCollectionExtensions.cs (78%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/SmlData/ISmartMeterDataProducer.cs (76%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/SmlData/ISmartMeterReactiveDataPipeline.cs (72%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/SmlData/SmartMeterDataProducer.cs (96%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/SmlData/SmartMeterReactiveDataPipeline.cs (98%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/Unlock/ISmartMeterUnlocker.cs (94%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/Unlock/ObisCodeScanner.cs (92%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/Unlock/SmartMeterPinStrategy.cs (93%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/Unlock/SmartMeterUnlockOptions.cs (97%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/Unlock/SmartMeterUnlockOutcome.cs (91%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/Unlock/SmartMeterUnlockResult.cs (93%) rename source/{CreativeCoders.SmartMeter.Server.Core => CreativeCoders.SmartMeter.Core}/Unlock/SmartMeterUnlocker.cs (98%) delete mode 100644 source/CreativeCoders.SmartMeter.Sml/CreativeCoders.SmartMeter.Sml.csproj diff --git a/SmartMeter.sln b/SmartMeter.sln index f38b9c1..4a70ca3 100644 --- a/SmartMeter.sln +++ b/SmartMeter.sln @@ -13,8 +13,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__global", "__global", "{EA Directory.Build.props = Directory.Build.props EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.Sml", "source\CreativeCoders.SmartMeter.Sml\CreativeCoders.SmartMeter.Sml.csproj", "{3AE1B70E-C752-4E89-B0FC-D3FF85462C99}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_build", "_build", "{AEAE0BA3-481C-45C3-825C-71A5CE7F6A78}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.DataProcessing", "source\CreativeCoders.SmartMeter.DataProcessing\CreativeCoders.SmartMeter.DataProcessing.csproj", "{9452116E-6A8B-42D2-BBDD-BF465097AEA1}" @@ -33,6 +31,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMessage EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMessageLanguage.Tests", "tests\CreativeCoders.SmartMessageLanguage.Tests\CreativeCoders.SmartMessageLanguage.Tests.csproj", "{D956F76F-3826-4A51-8D05-8B35C062605F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.Core", "source\CreativeCoders.SmartMeter.Core\CreativeCoders.SmartMeter.Core.csproj", "{1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,18 +43,6 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|x64.ActiveCfg = Debug|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|x64.Build.0 = Debug|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|x86.ActiveCfg = Debug|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Debug|x86.Build.0 = Debug|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|Any CPU.Build.0 = Release|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|x64.ActiveCfg = Release|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|x64.Build.0 = Release|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|x86.ActiveCfg = Release|Any CPU - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|x86.Build.0 = Release|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|Any CPU.Build.0 = Debug|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -139,12 +127,23 @@ Global {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x64.Build.0 = Release|Any CPU {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x86.ActiveCfg = Release|Any CPU {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x86.Build.0 = Release|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|x64.Build.0 = Debug|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|x86.Build.0 = Debug|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|Any CPU.Build.0 = Release|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x64.ActiveCfg = Release|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x64.Build.0 = Release|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x86.ActiveCfg = Release|Any CPU + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {3AE1B70E-C752-4E89-B0FC-D3FF85462C99} = {B259CB14-56CC-45FA-9756-64A195F4F789} {9452116E-6A8B-42D2-BBDD-BF465097AEA1} = {B259CB14-56CC-45FA-9756-64A195F4F789} {7AD8C940-B783-4FE9-B437-CE7FB87A97CA} = {B259CB14-56CC-45FA-9756-64A195F4F789} {29638431-6971-4757-BE3B-A83D96300ED4} = {B259CB14-56CC-45FA-9756-64A195F4F789} @@ -152,5 +151,6 @@ Global {344911B2-1104-457A-BD41-91EF270748A9} = {B259CB14-56CC-45FA-9756-64A195F4F789} {B70158C0-A913-4877-9414-5CD5F39772F0} = {B259CB14-56CC-45FA-9756-64A195F4F789} {D956F76F-3826-4A51-8D05-8B35C062605F} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} + {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3} = {B259CB14-56CC-45FA-9756-64A195F4F789} EndGlobalSection EndGlobal diff --git a/source/CreativeCoders.SmartMeter.Cli/Program.cs b/source/CreativeCoders.SmartMeter.Cli/Program.cs index 1ae1628..143e597 100644 --- a/source/CreativeCoders.SmartMeter.Cli/Program.cs +++ b/source/CreativeCoders.SmartMeter.Cli/Program.cs @@ -1,7 +1,8 @@ -using CreativeCoders.SmartMeter.DataProcessing; +using CreativeCoders.SmartMeter.Core; +using CreativeCoders.SmartMeter.Core.SmlData; +using CreativeCoders.SmartMeter.Core.Unlock; +using CreativeCoders.SmartMeter.DataProcessing; using CreativeCoders.SmartMeter.Server.Core; -using CreativeCoders.SmartMeter.Server.Core.SmlData; -using CreativeCoders.SmartMeter.Server.Core.Unlock; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; diff --git a/source/CreativeCoders.SmartMeter.Core/CreativeCoders.SmartMeter.Core.csproj b/source/CreativeCoders.SmartMeter.Core/CreativeCoders.SmartMeter.Core.csproj new file mode 100644 index 0000000..ab352fe --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Core/CreativeCoders.SmartMeter.Core.csproj @@ -0,0 +1,17 @@ + + + + Core library for the Smart Meter project. + + + + + + + + + + + + + diff --git a/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs b/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs similarity index 97% rename from source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs rename to source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs index 24ef777..b1dbc71 100644 --- a/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs +++ b/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs @@ -2,7 +2,7 @@ using System.Reactive.Linq; using CreativeCoders.Core; -namespace CreativeCoders.SmartMeter.Sml.Reactive; +namespace CreativeCoders.SmartMeter.Core; public sealed class ReactiveSerialPort : IObservable, IDisposable { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterOptions.cs b/source/CreativeCoders.SmartMeter.Core/SmartMeterOptions.cs similarity index 77% rename from source/CreativeCoders.SmartMeter.Server.Core/SmartMeterOptions.cs rename to source/CreativeCoders.SmartMeter.Core/SmartMeterOptions.cs index d8fd6e7..afd67a0 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterOptions.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmartMeterOptions.cs @@ -1,4 +1,4 @@ -namespace CreativeCoders.SmartMeter.Server.Core; +namespace CreativeCoders.SmartMeter.Core; public class SmartMeterOptions { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs b/source/CreativeCoders.SmartMeter.Core/SmartMeterServerServiceCollectionExtensions.cs similarity index 78% rename from source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs rename to source/CreativeCoders.SmartMeter.Core/SmartMeterServerServiceCollectionExtensions.cs index b64dfee..fab3d8a 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServerServiceCollectionExtensions.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmartMeterServerServiceCollectionExtensions.cs @@ -1,10 +1,10 @@ using CreativeCoders.SmartMessageLanguage; -using CreativeCoders.SmartMeter.Server.Core.SmlData; -using CreativeCoders.SmartMeter.Server.Core.Unlock; +using CreativeCoders.SmartMeter.Core.SmlData; +using CreativeCoders.SmartMeter.Core.Unlock; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace CreativeCoders.SmartMeter.Server.Core; +namespace CreativeCoders.SmartMeter.Core; public static class SmartMeterServerServiceCollectionExtensions { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterDataProducer.cs similarity index 76% rename from source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs rename to source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterDataProducer.cs index d9e47e3..c07b79d 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterDataProducer.cs @@ -1,6 +1,6 @@ using CreativeCoders.SmartMeter.DataProcessing; -namespace CreativeCoders.SmartMeter.Server.Core.SmlData; +namespace CreativeCoders.SmartMeter.Core.SmlData; public interface ISmartMeterDataProducer : IDisposable { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs similarity index 72% rename from source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterReactiveDataPipeline.cs rename to source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs index 522a137..e81f3eb 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/ISmartMeterReactiveDataPipeline.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs @@ -1,6 +1,6 @@ using CreativeCoders.SmartMeter.DataProcessing; -namespace CreativeCoders.SmartMeter.Server.Core.SmlData; +namespace CreativeCoders.SmartMeter.Core.SmlData; public interface ISmartMeterReactiveDataPipeline : IObserver, IObservable { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs similarity index 96% rename from source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs rename to source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs index 1187348..6a0436e 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs @@ -2,10 +2,9 @@ using System.Reactive.Linq; using CreativeCoders.Core; using CreativeCoders.SmartMeter.DataProcessing; -using CreativeCoders.SmartMeter.Sml.Reactive; using Microsoft.Extensions.Logging; -namespace CreativeCoders.SmartMeter.Server.Core.SmlData; +namespace CreativeCoders.SmartMeter.Core.SmlData; public sealed class SmartMeterDataProducer( ISmartMeterReactiveDataPipeline reactiveDataPipeline, diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterReactiveDataPipeline.cs similarity index 98% rename from source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs rename to source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterReactiveDataPipeline.cs index b3915de..ae53dcc 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmlData/SmartMeterReactiveDataPipeline.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterReactiveDataPipeline.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace CreativeCoders.SmartMeter.Server.Core.SmlData; +namespace CreativeCoders.SmartMeter.Core.SmlData; public class SmartMeterReactiveDataPipeline : ISmartMeterReactiveDataPipeline { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ISmartMeterUnlocker.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/ISmartMeterUnlocker.cs similarity index 94% rename from source/CreativeCoders.SmartMeter.Server.Core/Unlock/ISmartMeterUnlocker.cs rename to source/CreativeCoders.SmartMeter.Core/Unlock/ISmartMeterUnlocker.cs index 4467b13..b22e239 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ISmartMeterUnlocker.cs +++ b/source/CreativeCoders.SmartMeter.Core/Unlock/ISmartMeterUnlocker.cs @@ -1,4 +1,4 @@ -namespace CreativeCoders.SmartMeter.Server.Core.Unlock; +namespace CreativeCoders.SmartMeter.Core.Unlock; public interface ISmartMeterUnlocker : IDisposable { diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ObisCodeScanner.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/ObisCodeScanner.cs similarity index 92% rename from source/CreativeCoders.SmartMeter.Server.Core/Unlock/ObisCodeScanner.cs rename to source/CreativeCoders.SmartMeter.Core/Unlock/ObisCodeScanner.cs index bcba2f1..da8a973 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/ObisCodeScanner.cs +++ b/source/CreativeCoders.SmartMeter.Core/Unlock/ObisCodeScanner.cs @@ -1,6 +1,6 @@ using CreativeCoders.Core; -namespace CreativeCoders.SmartMeter.Server.Core.Unlock; +namespace CreativeCoders.SmartMeter.Core.Unlock; /// /// Searches raw SML byte streams for occurrences of 6-byte OBIS identifiers @@ -30,7 +30,8 @@ public static byte[] ParseObis(string obis) if (cde.Length != 3) { - throw new FormatException($"Invalid OBIS code '{obis}'. Expected three dot-separated values between ':' and '*'."); + throw new FormatException( + $"Invalid OBIS code '{obis}'. Expected three dot-separated values between ':' and '*'."); } var c = byte.Parse(cde[0]); diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterPinStrategy.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterPinStrategy.cs similarity index 93% rename from source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterPinStrategy.cs rename to source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterPinStrategy.cs index 75fb9a3..31c709f 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterPinStrategy.cs +++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterPinStrategy.cs @@ -1,4 +1,4 @@ -namespace CreativeCoders.SmartMeter.Server.Core.Unlock; +namespace CreativeCoders.SmartMeter.Core.Unlock; /// /// Defines how the PIN is transmitted over the optical interface of a smart meter. diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOptions.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOptions.cs similarity index 97% rename from source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOptions.cs rename to source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOptions.cs index e90d998..2e19d54 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOptions.cs +++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOptions.cs @@ -1,4 +1,4 @@ -namespace CreativeCoders.SmartMeter.Server.Core.Unlock; +namespace CreativeCoders.SmartMeter.Core.Unlock; /// /// Options controlling the PIN unlock procedure for the smart meter's optical diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOutcome.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOutcome.cs similarity index 91% rename from source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOutcome.cs rename to source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOutcome.cs index 37c8991..f1b2b85 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockOutcome.cs +++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOutcome.cs @@ -1,4 +1,4 @@ -namespace CreativeCoders.SmartMeter.Server.Core.Unlock; +namespace CreativeCoders.SmartMeter.Core.Unlock; /// /// Result category of a PIN unlock attempt. diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockResult.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockResult.cs similarity index 93% rename from source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockResult.cs rename to source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockResult.cs index 3fb7e15..9c41726 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlockResult.cs +++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockResult.cs @@ -1,4 +1,4 @@ -namespace CreativeCoders.SmartMeter.Server.Core.Unlock; +namespace CreativeCoders.SmartMeter.Core.Unlock; /// /// Structured result of a PIN unlock attempt on the smart meter. diff --git a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlocker.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlocker.cs similarity index 98% rename from source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlocker.cs rename to source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlocker.cs index 5241552..9308cce 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/Unlock/SmartMeterUnlocker.cs +++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlocker.cs @@ -1,10 +1,9 @@ using System.Diagnostics; using System.Text; using CreativeCoders.Core; -using CreativeCoders.SmartMeter.Sml.Reactive; using Microsoft.Extensions.Logging; -namespace CreativeCoders.SmartMeter.Server.Core.Unlock; +namespace CreativeCoders.SmartMeter.Core.Unlock; public sealed class SmartMeterUnlocker(ILogger logger) : ISmartMeterUnlocker { diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj b/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj index 04a97ce..888029a 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj +++ b/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj @@ -5,13 +5,10 @@ - - - - - - - + + + + diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterReactiveExtensions.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterReactiveExtensions.cs index 770c7aa..4e7430b 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterReactiveExtensions.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterReactiveExtensions.cs @@ -1,6 +1,4 @@ -using CreativeCoders.SmartMeter.Sml; - -namespace CreativeCoders.SmartMeter.DataProcessing; +namespace CreativeCoders.SmartMeter.DataProcessing; public static class SmartMeterReactiveExtensions { diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs index 8019c9d..377b6f2 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs @@ -1,7 +1,6 @@ using System.Reactive.Linq; using System.Reactive.Subjects; using CreativeCoders.SmartMeter.DataProcessing.History; -using CreativeCoders.SmartMeter.Sml; namespace CreativeCoders.SmartMeter.DataProcessing; diff --git a/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj b/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj index ab16ccc..f208107 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj +++ b/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj @@ -12,6 +12,7 @@ + diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs index 97e4187..2aba254 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs @@ -1,7 +1,7 @@ using CreativeCoders.Core; using CreativeCoders.Daemon; +using CreativeCoders.SmartMeter.Core.SmlData; using CreativeCoders.SmartMeter.DataProcessing; -using CreativeCoders.SmartMeter.Server.Core.SmlData; using JetBrains.Annotations; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/source/CreativeCoders.SmartMeter.Sml/CreativeCoders.SmartMeter.Sml.csproj b/source/CreativeCoders.SmartMeter.Sml/CreativeCoders.SmartMeter.Sml.csproj deleted file mode 100644 index c76d71f..0000000 --- a/source/CreativeCoders.SmartMeter.Sml/CreativeCoders.SmartMeter.Sml.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - Classes for SML protocol implementation to access smart meters - - - - - - - - - diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj index c8b6d12..26db94e 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj @@ -1,7 +1,7 @@  - + @@ -24,7 +24,6 @@ - diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs index 10ad480..e13ab9f 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs @@ -1,5 +1,4 @@ using System.Reactive.Subjects; -using CreativeCoders.SmartMeter.Sml; using AwesomeAssertions; using Microsoft.Extensions.Time.Testing; using Xunit; From d2e68c2ecfadb78be5fe4e1496f539a74b68facd Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:00:04 +0200 Subject: [PATCH 19/35] Refactor `SmartMeterReactiveDataPipeline` to handle observer completion and error logging properly; fix test assertion syntax; replace `lock(this)` with dedicated sync object in `ValueHistory`; minor cleanup in CLI entry point. --- source/CreativeCoders.SmartMeter.Cli/Program.cs | 3 +-- .../SmlData/SmartMeterReactiveDataPipeline.cs | 4 ++-- .../History/ValueHistory.cs | 10 +++++++--- .../EndToEndTests.cs | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/source/CreativeCoders.SmartMeter.Cli/Program.cs b/source/CreativeCoders.SmartMeter.Cli/Program.cs index 143e597..f90227e 100644 --- a/source/CreativeCoders.SmartMeter.Cli/Program.cs +++ b/source/CreativeCoders.SmartMeter.Cli/Program.cs @@ -2,14 +2,13 @@ using CreativeCoders.SmartMeter.Core.SmlData; using CreativeCoders.SmartMeter.Core.Unlock; using CreativeCoders.SmartMeter.DataProcessing; -using CreativeCoders.SmartMeter.Server.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; namespace CreativeCoders.SmartMeter.Cli; -class Program +internal static class Program { static async Task Main(string[] args) { diff --git a/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterReactiveDataPipeline.cs index ae53dcc..e225283 100644 --- a/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterReactiveDataPipeline.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterReactiveDataPipeline.cs @@ -37,12 +37,12 @@ public SmartMeterReactiveDataPipeline(ISmlParser smlParser, ISmlMessageDetector public void OnCompleted() { - //throw new NotImplementedException(); + _valueSubject.OnCompleted(); } public void OnError(Exception error) { - //throw new NotImplementedException(); + _logger.LogError(error, "Observer error in SmartMeterReactiveDataPipeline"); } public void OnNext(byte[] value) diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs index 0c75368..efd41f9 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs @@ -1,14 +1,18 @@ using System.Collections.Concurrent; +using CreativeCoders.Core; namespace CreativeCoders.SmartMeter.DataProcessing.History; public class ValueHistory { - private readonly ConcurrentDictionary _data = new ConcurrentDictionary(); + private readonly Lock _syncObj = new Lock(); + + private readonly ConcurrentDictionary _data = + new ConcurrentDictionary(); public ValueHistoryData GetHistoryData(SmlValueType valueType) { - lock (this) + lock (_syncObj) { if (_data.TryGetValue(valueType, out var dataList)) { @@ -22,4 +26,4 @@ public ValueHistoryData GetHistoryData(SmlValueType valueType) return historyData; } } -} \ No newline at end of file +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/EndToEndTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/EndToEndTests.cs index 5ed7c01..d4603d6 100644 --- a/tests/CreativeCoders.SmartMessageLanguage.Tests/EndToEndTests.cs +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/EndToEndTests.cs @@ -32,7 +32,7 @@ public void DetectorToParser_RoundTripsToExpectedObisValues() result.Values.Should().HaveCount(2); result.Values.Select(v => v.ObisCode).Should() - .BeEquivalentTo(["1-0:1.8.0*255", "1-0:16.7.0*255"]); + .BeEquivalentTo("1-0:1.8.0*255", "1-0:16.7.0*255"); result.Values.Single(v => v.Unit == SmlUnit.WattHour).Value.Should().Be(12345.6m); result.Values.Single(v => v.Unit == SmlUnit.Watt).Value.Should().Be(567m); } From 79cb11dbd17f64ad641bf80f5de5654090361daa Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:51:27 +0200 Subject: [PATCH 20/35] Add unit tests for SmartMeter core, server, and data processing components; introduce test fixtures and streamline test project organization. --- SmartMeter.sln | 30 ++ .../CreativeCoders.SmartMeter.Core.csproj | 4 + .../IReactiveSerialPort.cs | 20 + .../IReactiveSerialPortFactory.cs | 12 + .../ReactiveSerialPort.cs | 2 +- .../ReactiveSerialPortFactory.cs | 10 + .../SmartMeterOptions.cs | 7 + ...tMeterServerServiceCollectionExtensions.cs | 1 + .../SmlData/SmartMeterDataProducer.cs | 25 +- .../Unlock/SmartMeterUnlocker.cs | 19 +- .../IMqttValuePublisher.cs | 10 + .../MqttValuePublisher.cs | 14 +- .../SmartMeterDaemonHostBuilder.cs | 12 +- .../SmartMeterServer.cs | 16 +- .../Fixtures/TlvBuilder.cs | 7 + .../Framing/SmlMessageDetectorTests.cs | 107 +++- .../Parsing/SmlParserTests.cs | 465 +++++++++++++++++- .../Tlv/SmlTlvElementTests.cs | 101 ++++ .../Tlv/SmlTlvReaderTests.cs | 100 ++++ ...reativeCoders.SmartMeter.Core.Tests.csproj | 30 ++ .../Fixtures/FakeReactiveSerialPort.cs | 60 +++ .../SmlData/SmartMeterDataProducerTests.cs | 134 +++++ .../SmartMeterReactiveDataPipelineTests.cs | 219 +++++++++ .../Unlock/ObisCodeScannerTests.cs | 137 ++++++ .../Unlock/SmartMeterUnlockerTests.cs | 228 +++++++++ .../History/ValueHistoryTests.cs | 56 +++ .../MqttValuePublisherTests.cs | 228 +++++++++ .../SmlValueProcessorTests.cs | 120 +++++ ...Coders.SmartMeter.Server.Core.Tests.csproj | 30 ++ .../SmartMeterServerTests.cs | 91 ++++ 30 files changed, 2264 insertions(+), 31 deletions(-) create mode 100644 source/CreativeCoders.SmartMeter.Core/IReactiveSerialPort.cs create mode 100644 source/CreativeCoders.SmartMeter.Core/IReactiveSerialPortFactory.cs create mode 100644 source/CreativeCoders.SmartMeter.Core/ReactiveSerialPortFactory.cs create mode 100644 source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs create mode 100644 tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvElementTests.cs create mode 100644 tests/CreativeCoders.SmartMeter.Core.Tests/CreativeCoders.SmartMeter.Core.Tests.csproj create mode 100644 tests/CreativeCoders.SmartMeter.Core.Tests/Fixtures/FakeReactiveSerialPort.cs create mode 100644 tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs create mode 100644 tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs create mode 100644 tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs create mode 100644 tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs create mode 100644 tests/CreativeCoders.SmartMeter.DataProcessing.Tests/History/ValueHistoryTests.cs create mode 100644 tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs create mode 100644 tests/CreativeCoders.SmartMeter.Server.Core.Tests/CreativeCoders.SmartMeter.Server.Core.Tests.csproj create mode 100644 tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs diff --git a/SmartMeter.sln b/SmartMeter.sln index 4a70ca3..452be46 100644 --- a/SmartMeter.sln +++ b/SmartMeter.sln @@ -33,6 +33,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMessage EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.Core", "source\CreativeCoders.SmartMeter.Core\CreativeCoders.SmartMeter.Core.csproj", "{1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.Core.Tests", "tests\CreativeCoders.SmartMeter.Core.Tests\CreativeCoders.SmartMeter.Core.Tests.csproj", "{63623865-60E4-428D-AFDA-5B309A69A69D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.Server.Core.Tests", "tests\CreativeCoders.SmartMeter.Server.Core.Tests\CreativeCoders.SmartMeter.Server.Core.Tests.csproj", "{3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -139,6 +143,30 @@ Global {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x64.Build.0 = Release|Any CPU {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x86.ActiveCfg = Release|Any CPU {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x86.Build.0 = Release|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|x64.ActiveCfg = Debug|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|x64.Build.0 = Debug|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|x86.ActiveCfg = Debug|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|x86.Build.0 = Debug|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|Any CPU.Build.0 = Release|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|x64.ActiveCfg = Release|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|x64.Build.0 = Release|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|x86.ActiveCfg = Release|Any CPU + {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|x86.Build.0 = Release|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|x64.ActiveCfg = Debug|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|x64.Build.0 = Debug|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|x86.ActiveCfg = Debug|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|x86.Build.0 = Debug|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|Any CPU.Build.0 = Release|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|x64.ActiveCfg = Release|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|x64.Build.0 = Release|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|x86.ActiveCfg = Release|Any CPU + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -152,5 +180,7 @@ Global {B70158C0-A913-4877-9414-5CD5F39772F0} = {B259CB14-56CC-45FA-9756-64A195F4F789} {D956F76F-3826-4A51-8D05-8B35C062605F} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3} = {B259CB14-56CC-45FA-9756-64A195F4F789} + {63623865-60E4-428D-AFDA-5B309A69A69D} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} + {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} EndGlobalSection EndGlobal diff --git a/source/CreativeCoders.SmartMeter.Core/CreativeCoders.SmartMeter.Core.csproj b/source/CreativeCoders.SmartMeter.Core/CreativeCoders.SmartMeter.Core.csproj index ab352fe..f77b33f 100644 --- a/source/CreativeCoders.SmartMeter.Core/CreativeCoders.SmartMeter.Core.csproj +++ b/source/CreativeCoders.SmartMeter.Core/CreativeCoders.SmartMeter.Core.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/source/CreativeCoders.SmartMeter.Core/IReactiveSerialPort.cs b/source/CreativeCoders.SmartMeter.Core/IReactiveSerialPort.cs new file mode 100644 index 0000000..562823b --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Core/IReactiveSerialPort.cs @@ -0,0 +1,20 @@ +namespace CreativeCoders.SmartMeter.Core; + +/// +/// Observable byte stream of an opened serial port. Abstracts +/// so that callers can be unit-tested without a real hardware port. +/// +public interface IReactiveSerialPort : IObservable, IDisposable +{ + /// True when the underlying port is open and ready for I/O. + bool IsOpen { get; } + + /// Opens the underlying serial port. + void Open(); + + /// Closes the underlying serial port without disposing it. + void Close(); + + /// Writes the given bytes to the serial port. + void Write(byte[] data); +} diff --git a/source/CreativeCoders.SmartMeter.Core/IReactiveSerialPortFactory.cs b/source/CreativeCoders.SmartMeter.Core/IReactiveSerialPortFactory.cs new file mode 100644 index 0000000..5b58f87 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Core/IReactiveSerialPortFactory.cs @@ -0,0 +1,12 @@ +namespace CreativeCoders.SmartMeter.Core; + +/// +/// Factory abstraction that builds an for a given port name. +/// Used so services that need a dedicated port (unlocker, data producer) can be tested with +/// fakes instead of real instances. +/// +public interface IReactiveSerialPortFactory +{ + /// Creates a new serial port wrapper for the specified OS-level port name. + IReactiveSerialPort Create(string portName); +} diff --git a/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs b/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs index b1dbc71..0d9a12f 100644 --- a/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs +++ b/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs @@ -4,7 +4,7 @@ namespace CreativeCoders.SmartMeter.Core; -public sealed class ReactiveSerialPort : IObservable, IDisposable +public sealed class ReactiveSerialPort : IReactiveSerialPort { private readonly IObservable _dataObservable; diff --git a/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPortFactory.cs b/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPortFactory.cs new file mode 100644 index 0000000..5485c5e --- /dev/null +++ b/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPortFactory.cs @@ -0,0 +1,10 @@ +namespace CreativeCoders.SmartMeter.Core; + +/// +/// Default that creates real +/// instances backed by . +/// +public sealed class ReactiveSerialPortFactory : IReactiveSerialPortFactory +{ + public IReactiveSerialPort Create(string portName) => new ReactiveSerialPort(portName); +} diff --git a/source/CreativeCoders.SmartMeter.Core/SmartMeterOptions.cs b/source/CreativeCoders.SmartMeter.Core/SmartMeterOptions.cs index afd67a0..461836f 100644 --- a/source/CreativeCoders.SmartMeter.Core/SmartMeterOptions.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmartMeterOptions.cs @@ -2,6 +2,13 @@ namespace CreativeCoders.SmartMeter.Core; public class SmartMeterOptions { + /// + /// OS-level device path of the serial port that connects to the smart meter's optical coupler. + /// The same device is shared by the data producer and the unlock procedure; only one of them + /// keeps it open at a time. + /// + public string PortName { get; set; } = "/dev/ttyUSB0"; + public decimal SoldEnergyOffset { get; set; } = 23_367_605; public decimal PurchasedEnergyOffset { get; set; } = 18_261_046; diff --git a/source/CreativeCoders.SmartMeter.Core/SmartMeterServerServiceCollectionExtensions.cs b/source/CreativeCoders.SmartMeter.Core/SmartMeterServerServiceCollectionExtensions.cs index fab3d8a..796c635 100644 --- a/source/CreativeCoders.SmartMeter.Core/SmartMeterServerServiceCollectionExtensions.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmartMeterServerServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ public static class SmartMeterServerServiceCollectionExtensions public static IServiceCollection AddSmartMeterServer(this IServiceCollection services) { services.AddSml(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs index 6a0436e..82cefcd 100644 --- a/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs @@ -3,19 +3,32 @@ using CreativeCoders.Core; using CreativeCoders.SmartMeter.DataProcessing; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace CreativeCoders.SmartMeter.Core.SmlData; -public sealed class SmartMeterDataProducer( - ISmartMeterReactiveDataPipeline reactiveDataPipeline, - ILogger logger) : ISmartMeterDataProducer +public sealed class SmartMeterDataProducer : ISmartMeterDataProducer { - private readonly ISmartMeterReactiveDataPipeline _reactiveDataPipeline = Ensure.NotNull(reactiveDataPipeline); - private readonly ILogger _logger = Ensure.NotNull(logger); - private readonly ReactiveSerialPort _serialPort = new ReactiveSerialPort("/dev/ttyUSB0"); + private readonly ISmartMeterReactiveDataPipeline _reactiveDataPipeline; + private readonly ILogger _logger; + private readonly IReactiveSerialPort _serialPort; private IDisposable? _subscription; + public SmartMeterDataProducer( + ISmartMeterReactiveDataPipeline reactiveDataPipeline, + ILogger logger, + IReactiveSerialPortFactory serialPortFactory, + IOptions smartMeterOptions) + { + _reactiveDataPipeline = Ensure.NotNull(reactiveDataPipeline); + _logger = Ensure.NotNull(logger); + Ensure.NotNull(serialPortFactory); + var options = Ensure.NotNull(smartMeterOptions).Value; + + _serialPort = serialPortFactory.Create(options.PortName); + } + public Task StartAsync(IObserver observer) { _logger.LogInformation("Starting SmartMeter data producer"); diff --git a/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlocker.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlocker.cs index 9308cce..06dfd39 100644 --- a/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlocker.cs +++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlocker.cs @@ -2,17 +2,30 @@ using System.Text; using CreativeCoders.Core; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace CreativeCoders.SmartMeter.Core.Unlock; -public sealed class SmartMeterUnlocker(ILogger logger) : ISmartMeterUnlocker +public sealed class SmartMeterUnlocker : ISmartMeterUnlocker { - private readonly ILogger _logger = Ensure.NotNull(logger); + private readonly ILogger _logger; // Own serial port instance used exclusively for the unlock procedure. The port // is used to write the PIN payload and to observe the incoming bytes for // verification evidence (extended OBIS codes / ACK byte). - private readonly ReactiveSerialPort _serialPort = new ReactiveSerialPort("/dev/ttyUSB0"); + private readonly IReactiveSerialPort _serialPort; + + public SmartMeterUnlocker( + ILogger logger, + IReactiveSerialPortFactory serialPortFactory, + IOptions smartMeterOptions) + { + _logger = Ensure.NotNull(logger); + Ensure.NotNull(serialPortFactory); + var options = Ensure.NotNull(smartMeterOptions).Value; + + _serialPort = serialPortFactory.Create(options.PortName); + } public async Task UnlockAsync( string pin, diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs b/source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs new file mode 100644 index 0000000..756ae10 --- /dev/null +++ b/source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs @@ -0,0 +1,10 @@ +namespace CreativeCoders.SmartMeter.DataProcessing; + +/// +/// Observer that publishes instances to an MQTT broker. +/// +public interface IMqttValuePublisher : IObserver +{ + /// Connects to the broker and starts the background publishing loop. + Task InitAsync(); +} diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs index 24a29ad..e562d59 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs @@ -9,7 +9,7 @@ namespace CreativeCoders.SmartMeter.DataProcessing; -public class MqttValuePublisher : IObserver +public class MqttValuePublisher : IMqttValuePublisher { private readonly IMqttClient _client; @@ -21,12 +21,18 @@ public class MqttValuePublisher : IObserver private readonly Thread _workerThread; + /// Creates a publisher using a real MQTT client produced by . public MqttValuePublisher(MqttPublisherOptions options, ILogger logger) + : this(options, logger, new MqttClientFactory().CreateMqttClient()) + { + } + + /// Creates a publisher with an injected (used by tests). + public MqttValuePublisher(MqttPublisherOptions options, ILogger logger, IMqttClient client) { _options = Ensure.NotNull(options); _logger = Ensure.NotNull(logger); - - _client = new MqttClientFactory().CreateMqttClient(); + _client = Ensure.NotNull(client); _publishingQueue = new BlockingCollection(); @@ -34,7 +40,7 @@ public MqttValuePublisher(MqttPublisherOptions options, ILogger(sp => new MqttValuePublisher( + sp.GetRequiredService>().Value, + sp.GetRequiredService>())); } } \ No newline at end of file diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs index 2aba254..64e6e07 100644 --- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs +++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs @@ -4,40 +4,34 @@ using CreativeCoders.SmartMeter.DataProcessing; using JetBrains.Annotations; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace CreativeCoders.SmartMeter.Server.Core; [UsedImplicitly] public class SmartMeterServer( ILogger logger, - IOptions mqttPublisherOptions, - ILoggerFactory loggerFactory, + IMqttValuePublisher mqttValuePublisher, ISmartMeterDataProducer smartMeterDataProducer) : IDaemonService { private readonly ISmartMeterDataProducer _smartMeterDataProducer = Ensure.NotNull(smartMeterDataProducer); - private readonly ILoggerFactory _loggerFactory = Ensure.NotNull(loggerFactory); + private readonly IMqttValuePublisher _mqttValuePublisher = Ensure.NotNull(mqttValuePublisher); private readonly ILogger _logger = Ensure.NotNull(logger); - private readonly MqttPublisherOptions _mqttPublisherOptions = mqttPublisherOptions.Value; public async Task StartAsync() { _logger.LogInformation("Starting SmartMeter server"); - var mqttValuePublisher = - new MqttValuePublisher(_mqttPublisherOptions, _loggerFactory.CreateLogger()); + await _mqttValuePublisher.InitAsync().ConfigureAwait(false); - await mqttValuePublisher.InitAsync(); - - await _smartMeterDataProducer.StartAsync(mqttValuePublisher); + await _smartMeterDataProducer.StartAsync(_mqttValuePublisher).ConfigureAwait(false); } public async Task StopAsync() { _logger.LogInformation("Stopping SmartMeter server"); - await _smartMeterDataProducer.StopAsync(); + await _smartMeterDataProducer.StopAsync().ConfigureAwait(false); _logger.LogInformation("SmartMeter server stopped"); } diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/TlvBuilder.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/TlvBuilder.cs index 4cb884f..e2d3585 100644 --- a/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/TlvBuilder.cs +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/TlvBuilder.cs @@ -97,6 +97,13 @@ public TlvBuilder Null() return this; } + public TlvBuilder Append(TlvBuilder other) + { + _bytes.AddRange(other._bytes); + + return this; + } + public byte[] ToArray() => _bytes.ToArray(); private void AddPrimitive(byte typeNibble, byte[] payload) diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorTests.cs index e1a85a0..981f53f 100644 --- a/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorTests.cs +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorTests.cs @@ -93,7 +93,6 @@ public void Append_EscapedPayload_DeEscapesAndValidatesCrc() received.Should().HaveCount(1); received[0].IsCrcValid.Should().BeTrue(); - // De-escaped payload should start with the original 4x0x1B run. received[0].PayloadBytes.AsSpan(0, 6).ToArray().Should() .Equal(0x1B, 0x1B, 0x1B, 0x1B, 0x09, 0x08); } @@ -147,4 +146,110 @@ public void Messages_Observable_EmitsSameFrameAsEvent() viaObservable.Should().HaveCount(1); viaObservable[0].MessageBytes.Should().Equal(frameBytes); } + + [Fact] + public void Append_EmptyData_IsNoOp() + { + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append(ReadOnlySpan.Empty); + + received.Should().BeEmpty(); + } + + [Fact] + public void Append_StartEscapeSplitAcrossCalls_StillDetectsFrame() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frameBytes = FrameBuilder.BuildFrame(payload); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + // Split inside the 8-byte start escape sequence so the detector has to keep + // a small tail buffer across Append calls. + detector.Append(frameBytes.AsSpan(0, 3)); + detector.Append(frameBytes.AsSpan(3)); + + received.Should().HaveCount(1); + received[0].IsCrcValid.Should().BeTrue(); + } + + [Fact] + public void Reset_AfterPartialStartEscape_DropsBufferedData() + { + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append([0x1B, 0x1B, 0x1B, 0x1B, 0x01, 0x01]); + detector.Reset(); + + // After reset a new full frame must still be detected normally. + var payload = SampleSmlFile.BuildGetListResponsePayload(); + detector.Append(FrameBuilder.BuildFrame(payload)); + + received.Should().HaveCount(1); + } + + [Fact] + public void Append_MalformedEndEscape_RecoversAndFindsNextFrame() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var good = FrameBuilder.BuildFrame(payload); + + // Build a malformed frame: start escape + 4 body bytes + stray 4x 0x1B followed by a + // non-0x1A, non-0x1B byte that is not part of a valid end escape. The detector must + // resync and then parse the following good frame. + var malformed = new byte[] + { + 0x1B, 0x1B, 0x1B, 0x1B, 0x01, 0x01, 0x01, 0x01, + 0xAA, 0xBB, 0xCC, 0xDD, + 0x1B, 0x1B, 0x1B, 0x1B, 0x42, 0x00, 0x00, 0x00 + }; + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append(malformed); + detector.Append(good); + + received.Should().HaveCount(1); + received[0].IsCrcValid.Should().BeTrue(); + } + + [Fact] + public void Dispose_CompletesObservable() + { + var detector = new SmlMessageDetector(NullLogger.Instance); + var completed = false; + using var sub = detector.Messages.Subscribe(_ => { }, () => completed = true); + + ((IDisposable)detector).Dispose(); + + completed.Should().BeTrue(); + } + + [Fact] + public void Append_FrameContainingOddPadding_DeEscapesCorrectly() + { + // Payload whose length produces 2 bytes of padding (libSML aligns to 4). + var payload = new byte[] { 0x42, 0x42 }; + var frameBytes = FrameBuilder.BuildFrame(payload); + + using var detector = new SmlMessageDetector(NullLogger.Instance); + var received = new List(); + detector.MessageReceived += (_, e) => received.Add(e.Frame); + + detector.Append(frameBytes); + + received.Should().ContainSingle(); + received[0].IsCrcValid.Should().BeTrue(); + received[0].PayloadBytes.Should().Equal(0x42, 0x42); + received[0].PaddingBytes.Should().Be(2); + } } diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs index 259624c..72c400d 100644 --- a/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs @@ -1,6 +1,8 @@ using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Framing; using CreativeCoders.SmartMessageLanguage.Parsing; using CreativeCoders.SmartMessageLanguage.Tests.Fixtures; +using CreativeCoders.SmartMessageLanguage.Tlv; using CreativeCoders.SmartMessageLanguage.Units; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -9,13 +11,14 @@ namespace CreativeCoders.SmartMessageLanguage.Tests.Parsing; public class SmlParserTests { + private static SmlParser CreateSut() => new(NullLogger.Instance); + [Fact] public void Parse_GetListResponsePayload_ExtractsAllObisValues() { var payload = SampleSmlFile.BuildGetListResponsePayload(); - var parser = new SmlParser(NullLogger.Instance); - var result = parser.Parse(payload); + var result = CreateSut().Parse(payload); result.Warnings.Should().BeEmpty(); result.Values.Should().HaveCount(2); @@ -34,11 +37,465 @@ public void Parse_GetListResponsePayload_ExtractsAllObisValues() [Fact] public void Parse_EmptyPayload_ReturnsEmptyResult() { - var parser = new SmlParser(NullLogger.Instance); + var result = CreateSut().Parse([]); + + result.Values.Should().BeEmpty(); + result.Warnings.Should().BeEmpty(); + } + + [Fact] + public void Parse_Frame_DelegatesToPayloadOverload() + { + var payload = SampleSmlFile.BuildGetListResponsePayload(); + var frame = new SmlFrame([], payload, true, 0); + + var result = CreateSut().Parse(frame); + + result.Values.Should().HaveCount(2); + } + + [Fact] + public void Parse_TopLevelPrimitive_ReportsEnvelopeWarning() + { + // Top-level primitive (UInt8) instead of a List → warning + skipped element. + var payload = new TlvBuilder().UInt8(0x42).ToArray(); + + var result = CreateSut().Parse(payload); + + result.Values.Should().BeEmpty(); + result.Warnings.Should().ContainSingle() + .Which.Should().Contain("Unexpected top-level TLV type"); + } + + [Fact] + public void Parse_EndOfMessageAtTopLevel_IsIgnored() + { + // 0x00 end-of-message marker at top level must not produce warnings. + var payload = new TlvBuilder().EndOfMessage().ToArray(); - var result = parser.Parse([]); + var result = CreateSut().Parse(payload); result.Values.Should().BeEmpty(); result.Warnings.Should().BeEmpty(); } + + [Fact] + public void Parse_MessageWithWrongEntryCount_SkipsMessageSilently() + { + // Top-level List(3) instead of the expected List(6): parser just skips the entries. + var payload = new TlvBuilder() + .List(3) + .OctetString([0x01]) + .OctetString([0x02]) + .OctetString([0x03]) + .ToArray(); + + var result = CreateSut().Parse(payload); + + result.Values.Should().BeEmpty(); + } + + [Fact] + public void Parse_MessageBodyWithWrongWrapperArity_AddsWarning() + { + // messageBody (field 4) must be List(2); supply List(1) → "Malformed SML_Message body wrapper". + var payload = new TlvBuilder() + .List(6) + .OctetString([0xAA]) // transactionId + .UInt8(0) // groupNo + .UInt8(0) // abortOnError + .List(1) // malformed body wrapper + .UInt32(0x00000701) + .ToArray(); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().Contain(w => w.Contains("Malformed SML_Message body wrapper")); + } + + [Fact] + public void Parse_MessageBodyMissingTypeTag_AddsWarning() + { + // messageBody type tag must be Unsigned; supply OctetString instead. + var payload = new TlvBuilder() + .List(6) + .OctetString([0xAA]) + .UInt8(0) + .UInt8(0) + .List(2) + .OctetString([0x01]) // should be Unsigned type tag + .List(0) + .ToArray(); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().Contain(w => w.Contains("Missing messageBody type tag")); + } + + [Fact] + public void Parse_UnknownMessageBodyType_IsSkippedWithoutWarning() + { + // A message body type other than 0x701 is simply skipped; no warning expected. + var payload = BuildSingleMessage(messageBodyType: 0x12345678u, valListEntryBuilder: null); + + var result = CreateSut().Parse(payload); + + result.Values.Should().BeEmpty(); + result.Warnings.Should().BeEmpty(); + } + + [Fact] + public void Parse_GetListResponseWithWrongFieldCount_AddsWarning() + { + // GetListResponse must be List(7); inject List(3). + var payload = new TlvBuilder() + .List(6) + .OctetString([0xAA]) + .UInt8(0) + .UInt8(0) + .List(2) + .UInt32(0x00000701) + .List(3) + .OctetString([0x01]) + .OctetString([0x02]) + .OctetString([0x03]) + .UInt8(0) + .EndOfMessage() + .ToArray(); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().Contain(w => w.Contains("GetListResponse with unexpected field count")); + } + + [Fact] + public void Parse_ValListAbsent_ProducesNoValuesAndNoWarning() + { + // valList encoded as Null (absent optional) → no warning, no values. + var payload = BuildGetListResponseWithValList(b => b.Null()); + + var result = CreateSut().Parse(payload); + + result.Values.Should().BeEmpty(); + result.Warnings.Should().BeEmpty(); + } + + [Fact] + public void Parse_ValListWithUnexpectedType_AddsWarning() + { + // valList as Unsigned instead of List or OctetString → warning. + var payload = BuildGetListResponseWithValList(b => b.UInt8(0x42)); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().Contain(w => w.Contains("Unexpected valList type")); + } + + [Fact] + public void Parse_ValListEntryNotAList_AddsWarning() + { + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .UInt8(0x42)); // entry should itself be a List, not an Unsigned. + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().Contain("valList entry is not a list"); + } + + [Fact] + public void Parse_ValListEntryTooShort_AddsWarning() + { + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(3) + .OctetString(SampleSmlFile.ObisEnergy) + .Null() + .Null()); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().Contain(w => w.Contains("valList entry too short")); + } + + [Fact] + public void Parse_ValListEntryMissingObjName_AddsWarning() + { + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(6) + .UInt8(0x42) // objName must be OctetString + .Null() + .Null() + .UInt8(0) + .Int8(0) + .UInt8(0)); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().Contain("valList entry missing objName"); + } + + [Fact] + public void Parse_UnknownUnitCode_AddsWarningButKeepsValue() + { + // Unit code 99 is not defined in SmlUnit → warning, Unit=Unknown, value still parsed. + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(99) + .Int8(0) + .UInt8(42) + .Null()); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().Contain(w => w.Contains("Unknown unit code 99")); + result.Values.Should().ContainSingle().Which.Unit.Should().Be(SmlUnit.Unknown); + result.Values[0].Value.Should().Be(42m); + } + + [Fact] + public void Parse_UnitCodeZero_IsTreatedAsUnknownWithoutWarning() + { + // Unit code 0 is defined as SmlUnit.Unknown; no warning should be raised. + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(0) + .Int8(0) + .UInt8(1) + .Null()); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().BeEmpty(); + result.Values.Should().ContainSingle().Which.Unit.Should().Be(SmlUnit.Unknown); + } + + [Fact] + public void Parse_BooleanValue_IsMappedToOneOrZero() + { + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(0) + .Int8(0) + .Bool(true) + .Null()); + + var result = CreateSut().Parse(payload); + + var value = result.Values.Should().ContainSingle().Which; + value.Value.Should().Be(1m); + value.RawType.Should().Be(SmlMessageValueType.Boolean); + } + + [Fact] + public void Parse_OctetStringValue_KeepsRawButNullNumeric() + { + // Server ID-like value: OctetString → RawValue populated, Value null, no warning. + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(0) + .Int8(0) + .OctetString([0x01, 0x02, 0x03]) + .Null()); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().BeEmpty(); + var value = result.Values.Should().ContainSingle().Which; + value.Value.Should().BeNull(); + value.RawType.Should().Be(SmlMessageValueType.OctetString); + value.RawValue.Should().Equal(0x01, 0x02, 0x03); + } + + [Fact] + public void Parse_UnsupportedValueType_AddsWarningAndNullValue() + { + // A List as value is not supported by ComputeDecimalValue → warning. + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(0) + .Int8(0) + .List(0) + .Null()); + + var result = CreateSut().Parse(payload); + + result.Warnings.Should().Contain(w => w.Contains("Unsupported value type")); + result.Values.Should().ContainSingle().Which.Value.Should().BeNull(); + } + + [Theory] + [InlineData((sbyte)-2, 100, 1.00)] + [InlineData((sbyte)1, 42, 420.0)] + [InlineData((sbyte)0, 17, 17.0)] + public void Parse_ScaledUnsignedValue_AppliesPowerOfTen(sbyte scaler, byte raw, double expected) + { + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(SampleSmlFile.UnitWattHour) + .Int8(scaler) + .UInt8(raw) + .Null()); + + var result = CreateSut().Parse(payload); + + result.Values.Should().ContainSingle().Which.Value.Should().Be((decimal)expected); + } + + [Fact] + public void Parse_SignedValue_IsSignExtended() + { + // Signed -5 with scaler 0 → -5. + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString(SampleSmlFile.ObisPower) + .Null().Null() + .UInt8(SampleSmlFile.UnitWatt) + .Int8(0) + .Int8(-5) + .Null()); + + var result = CreateSut().Parse(payload); + + result.Values.Should().ContainSingle().Which.Value.Should().Be(-5m); + } + + [Fact] + public void Parse_MissingScaler_DefaultsToZero() + { + // Scaler is OctetString (unexpected) → parser keeps scaler = 0. + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString(SampleSmlFile.ObisPower) + .Null().Null() + .UInt8(SampleSmlFile.UnitWatt) + .OctetString([0x00]) // unexpected scaler type + .UInt8(7) + .Null()); + + var result = CreateSut().Parse(payload); + + var value = result.Values.Should().ContainSingle().Which; + value.Scaler.Should().Be((sbyte)0); + value.Value.Should().Be(7m); + } + + [Fact] + public void Parse_ShortObisBytes_FallsBackToHexRepresentation() + { + // 3-byte objName is below OBIS minimum length → formatted as uppercase hex. + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString([0xDE, 0xAD, 0xBE]) + .Null().Null() + .UInt8(0) + .Int8(0) + .UInt8(1) + .Null()); + + var result = CreateSut().Parse(payload); + + result.Values.Should().ContainSingle().Which.ObisCode.Should().Be("DEADBE"); + } + + [Fact] + public void Parse_FiveByteObis_DefaultsFTo255() + { + // 5-byte objName defaults the optional F to 255. + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(7) + .OctetString([1, 0, 1, 8, 0]) + .Null().Null() + .UInt8(0).Int8(0).UInt8(0) + .Null()); + + var result = CreateSut().Parse(payload); + + result.Values.Should().ContainSingle().Which.ObisCode.Should().Be("1-0:1.8.0*255"); + } + + [Fact] + public void Parse_ValListEntryWithExtraTrailingFields_StillParsesValue() + { + // Entry with 8 fields (valid: minimum is 6, the parser skips anything beyond field 6). + var payload = BuildGetListResponseWithValList(b => b + .List(1) + .List(8) + .OctetString(SampleSmlFile.ObisPower) + .Null().Null() + .UInt8(SampleSmlFile.UnitWatt) + .Int8(0) + .UInt8(42) + .Null() // valueSignature + .Null()); // extra field + + var result = CreateSut().Parse(payload); + + result.Values.Should().ContainSingle().Which.Value.Should().Be(42m); + } + + // Builds: List(6)[transactionId, groupNo, abortOnError, List(2)[bodyType, bodyChoice], crc, endOfMsg] + // with bodyChoice = Null when valListEntryBuilder is null. + private static byte[] BuildSingleMessage(uint messageBodyType, Action? valListEntryBuilder) + { + var body = new TlvBuilder().List(2).UInt32(messageBodyType); + + if (valListEntryBuilder is null) + { + body.Null(); + } + else + { + valListEntryBuilder(body); + } + + var message = new TlvBuilder() + .List(6) + .OctetString([0xAA]) + .UInt8(0) + .UInt8(0) + .Append(body) + .UInt8(0) + .EndOfMessage(); + + return message.ToArray(); + } + + // Wraps a valList (field 5 of GetListResponse) into a full single-message SML payload. + private static byte[] BuildGetListResponseWithValList(Action valListBuilder) + { + var inner = new TlvBuilder() + .List(7) + .OctetString([0x01]) + .OctetString([0x02]) + .Null() + .Null(); + valListBuilder(inner); + inner.Null().Null(); + + return BuildSingleMessage(0x00000701u, b => b.Append(inner)); + } } diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvElementTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvElementTests.cs new file mode 100644 index 0000000..87d42fb --- /dev/null +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvElementTests.cs @@ -0,0 +1,101 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Tlv; +using Xunit; + +namespace CreativeCoders.SmartMessageLanguage.Tests.Tlv; + +public class SmlTlvElementTests +{ + [Fact] + public void GetUInt64_WithEmptyPayload_ReturnsZero() + { + // 0x61 = Unsigned declared length 1 → 0 payload bytes. + var reader = new SmlTlvReader(new byte[] { 0x61 }); + reader.Read(); + + reader.Current.GetUInt64().Should().Be(0UL); + } + + [Theory] + [InlineData(new byte[] { 0x62, 0x2A }, 0x2AUL)] + [InlineData(new byte[] { 0x63, 0x01, 0x02 }, 0x0102UL)] + [InlineData(new byte[] { 0x65, 0x12, 0x34, 0x56, 0x78 }, 0x12345678UL)] + public void GetUInt64_WithVariousSizes_ReturnsBigEndianValue(byte[] data, ulong expected) + { + var reader = new SmlTlvReader(data); + reader.Read(); + + reader.Current.GetUInt64().Should().Be(expected); + } + + [Fact] + public void GetInt64_WithEmptyPayload_ReturnsZero() + { + // 0x51 = Integer declared length 1 → 0 payload bytes. + var reader = new SmlTlvReader(new byte[] { 0x51 }); + reader.Read(); + + reader.Current.GetInt64().Should().Be(0); + } + + [Theory] + [InlineData(new byte[] { 0x52, 0x7F }, 127L)] + [InlineData(new byte[] { 0x52, 0x80 }, -128L)] + [InlineData(new byte[] { 0x53, 0xFF, 0x00 }, -256L)] + [InlineData(new byte[] { 0x55, 0xFF, 0xFF, 0xFF, 0xFF }, -1L)] + public void GetInt64_SignExtendsCorrectly(byte[] data, long expected) + { + var reader = new SmlTlvReader(data); + reader.Read(); + + reader.Current.GetInt64().Should().Be(expected); + } + + [Theory] + [InlineData(new byte[] { 0x42, 0x01 }, true)] + [InlineData(new byte[] { 0x42, 0xFF }, true)] + [InlineData(new byte[] { 0x42, 0x00 }, false)] + [InlineData(new byte[] { 0x43, 0x00, 0x00 }, false)] + [InlineData(new byte[] { 0x43, 0x00, 0x01 }, true)] + public void GetBool_ReturnsTrueIfAnyNonZeroByte(byte[] data, bool expected) + { + var reader = new SmlTlvReader(data); + reader.Read(); + + reader.Current.GetBool().Should().Be(expected); + } + + [Fact] + public void GetOctetString_ReturnsCopyOfPayload() + { + var data = new byte[] { 0x04, 0xDE, 0xAD, 0xBE }; + var reader = new SmlTlvReader(data); + reader.Read(); + + var copy = reader.Current.GetOctetString(); + + copy.Should().Equal(0xDE, 0xAD, 0xBE); + copy.Should().NotBeSameAs(data); + } + + [Fact] + public void IsEndOfMessage_OnlyTrueForEndMarker() + { + var reader = new SmlTlvReader(new byte[] { 0x00, 0x62, 0x01 }); + + reader.Read(); + reader.Current.IsEndOfMessage.Should().BeTrue(); + + reader.Read(); + reader.Current.IsEndOfMessage.Should().BeFalse(); + } + + [Fact] + public void ListLength_IsZeroForPrimitives() + { + var reader = new SmlTlvReader(new byte[] { 0x62, 0x01 }); + reader.Read(); + + reader.Current.ListLength.Should().Be(0); + } +} diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs index 0f35c8b..f84db7e 100644 --- a/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs @@ -121,4 +121,104 @@ public void SkipCurrent_OnList_ConsumesAllChildrenRecursively() reader.Read().Should().BeTrue(); reader.Current.GetUInt64().Should().Be(9UL); } + + [Fact] + public void Read_OnEmptyData_ReturnsFalse() + { + var reader = new SmlTlvReader(ReadOnlySpan.Empty); + + reader.Read().Should().BeFalse(); + reader.EndOfData.Should().BeTrue(); + } + + [Theory] + [InlineData((byte)0x12)] + [InlineData((byte)0x22)] + [InlineData((byte)0x32)] + public void Read_UnknownTypeNibble_Throws(byte header) + { + var data = new byte[] { header, 0x00 }; + + var act = () => ReadFirst(data); + + act.Should().Throw() + .WithMessage("*TLV type nibble*"); + } + + [Fact] + public void Read_TruncatedLengthField_Throws() + { + // 0x80 declares "another length byte follows" but data ends. + var data = new byte[] { 0x80 }; + + var act = () => ReadFirst(data); + + act.Should().Throw() + .WithMessage("*Truncated TLV length field*"); + } + + [Fact] + public void Read_TruncatedPrimitivePayload_Throws() + { + // 0x05 declares OctetString of total length 5 → 4 payload bytes, but only 2 present. + var data = new byte[] { 0x05, 0xAA, 0xBB }; + + var act = () => ReadFirst(data); + + act.Should().Throw() + .WithMessage("*Truncated TLV element*"); + } + + [Fact] + public void SkipCurrent_OnPrimitive_IsNoOp() + { + var data = new byte[] { 0x62, 0x2A, 0x62, 0x2B }; + var reader = new SmlTlvReader(data); + reader.Read(); + + reader.SkipCurrent(); + + reader.Read().Should().BeTrue(); + reader.Current.GetUInt64().Should().Be(0x2BUL); + } + + [Fact] + public void SkipCurrent_OnListWithMissingChildren_Throws() + { + // List(2) but only one child present. + var data = new byte[] { 0x72, 0x62, 0x01 }; + + var act = () => ReadListThenSkip(data); + + act.Should().Throw() + .WithMessage("*end of data while skipping list*"); + } + + [Fact] + public void Position_AdvancesAcrossReads() + { + var data = new byte[] { 0x62, 0x01, 0x62, 0x02 }; + var reader = new SmlTlvReader(data); + + reader.Position.Should().Be(0); + reader.Read(); + reader.Position.Should().Be(2); + reader.Read(); + reader.Position.Should().Be(4); + reader.EndOfData.Should().BeTrue(); + } + + // Helpers that avoid capturing ref struct 'SmlTlvReader' inside lambdas. + private static void ReadFirst(byte[] data) + { + var reader = new SmlTlvReader(data); + reader.Read(); + } + + private static void ReadListThenSkip(byte[] data) + { + var reader = new SmlTlvReader(data); + reader.Read(); + reader.SkipCurrent(); + } } diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/CreativeCoders.SmartMeter.Core.Tests.csproj b/tests/CreativeCoders.SmartMeter.Core.Tests/CreativeCoders.SmartMeter.Core.Tests.csproj new file mode 100644 index 0000000..0727a8b --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/CreativeCoders.SmartMeter.Core.Tests.csproj @@ -0,0 +1,30 @@ + + + + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/Fixtures/FakeReactiveSerialPort.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/Fixtures/FakeReactiveSerialPort.cs new file mode 100644 index 0000000..bb81691 --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/Fixtures/FakeReactiveSerialPort.cs @@ -0,0 +1,60 @@ +using System.Reactive.Subjects; + +namespace CreativeCoders.SmartMeter.Core.Tests.Fixtures; + +/// +/// Hand-rolled test double for that records writes +/// and allows tests to push raw bytes to subscribed observers. Using a plain class +/// keeps observable semantics predictable and avoids the ref-struct limitations of +/// FakeItEasy when verifying observer interactions. +/// +internal sealed class FakeReactiveSerialPort : IReactiveSerialPort +{ + private readonly Subject _subject = new(); + + public List Writes { get; } = []; + + public int OpenCount { get; private set; } + + public int CloseCount { get; private set; } + + public int DisposeCount { get; private set; } + + public bool IsOpen { get; private set; } + + public Func? WriteBehavior { get; set; } + + public void Open() + { + OpenCount++; + IsOpen = true; + } + + public void Close() + { + CloseCount++; + IsOpen = false; + } + + public void Write(byte[] data) + { + var error = WriteBehavior?.Invoke(data); + + if (error is not null) + { + throw error; + } + + Writes.Add(data); + } + + public IDisposable Subscribe(IObserver observer) => _subject.Subscribe(observer); + + public void PushBytes(byte[] data) => _subject.OnNext(data); + + public void Dispose() + { + DisposeCount++; + _subject.Dispose(); + } +} diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs new file mode 100644 index 0000000..d7e40f4 --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs @@ -0,0 +1,134 @@ +using System.Reactive.Subjects; +using AwesomeAssertions; +using CreativeCoders.SmartMeter.Core.SmlData; +using CreativeCoders.SmartMeter.Core.Tests.Fixtures; +using CreativeCoders.SmartMeter.DataProcessing; +using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace CreativeCoders.SmartMeter.Core.Tests.SmlData; + +public class SmartMeterDataProducerTests +{ + private static (SmartMeterDataProducer Sut, FakeReactiveSerialPort Port, + ISmartMeterReactiveDataPipeline Pipeline) CreateSut() + { + var port = new FakeReactiveSerialPort(); + var factory = A.Fake(); + A.CallTo(() => factory.Create(A._)).Returns(port); + + var pipeline = A.Fake(); + // Make the pipeline observable behave like an empty subject — no values emitted. + var subject = new Subject(); + A.CallTo(() => pipeline.Subscribe(A>._)) + .ReturnsLazily(call => subject.Subscribe(call.GetArgument>(0)!)); + + var sut = new SmartMeterDataProducer( + pipeline, + NullLogger.Instance, + factory, + Options.Create(new SmartMeterOptions())); + + return (sut, port, pipeline); + } + + [Fact] + public async Task StartAsync_OpensPortAndSubscribesToPipeline() + { + // Arrange + var (sut, port, pipeline) = CreateSut(); + var observer = A.Fake>(); + + // Act + await sut.StartAsync(observer); + + // Assert + port.OpenCount.Should().Be(1); + // Note: SubscribeOn wraps the observer, so we cannot assert on the exact observer instance. + A.CallTo(() => pipeline.Subscribe(A>._)).MustHaveHappened(); + } + + [Fact] + public async Task StartAsync_ForwardsSerialPortBytesToPipeline() + { + // Arrange + var (sut, port, pipeline) = CreateSut(); + var observer = A.Fake>(); + await sut.StartAsync(observer); + + // Act + var payload = new byte[] { 1, 2, 3 }; + port.PushBytes(payload); + + // Assert + A.CallTo(() => pipeline.OnNext(payload)).MustHaveHappened(); + } + + [Fact] + public async Task StopAsync_ClosesPortAndDisposesSubscription() + { + // Arrange + var (sut, port, pipeline) = CreateSut(); + var observer = A.Fake>(); + await sut.StartAsync(observer); + + // Act + await sut.StopAsync(); + // After stop, pushing bytes should not propagate any more. + port.PushBytes([0xFF]); + + // Assert + port.CloseCount.Should().Be(1); + A.CallTo(() => pipeline.OnNext(A.That.Matches(b => b.Length == 1 && b[0] == 0xFF))) + .MustNotHaveHappened(); + } + + [Fact] + public void Dispose_WithoutStart_DisposesPortIdempotently() + { + // Arrange + var (sut, port, _) = CreateSut(); + + // Act + sut.Dispose(); + sut.Dispose(); + + // Assert - port disposed on both calls, but no throw on second call + port.DisposeCount.Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task Dispose_AfterStart_DisposesSubscriptionAndPort() + { + // Arrange + var (sut, port, _) = CreateSut(); + var observer = A.Fake>(); + await sut.StartAsync(observer); + + // Act + sut.Dispose(); + + // Assert + port.DisposeCount.Should().BeGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task StartAsync_CalledTwice_SubscribesToSerialPortOnlyOnce() + { + // Arrange + var (sut, port, pipeline) = CreateSut(); + var observer = A.Fake>(); + + // Act + await sut.StartAsync(observer); + await sut.StartAsync(observer); + + port.PushBytes([0x01]); + + // Assert - pipeline.OnNext should still only receive one forward per pushed batch + A.CallTo(() => pipeline.OnNext(A.That.Matches(b => b.Length == 1 && b[0] == 0x01))) + .MustHaveHappenedOnceExactly(); + } +} diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs new file mode 100644 index 0000000..2999ddc --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs @@ -0,0 +1,219 @@ +using System.Reactive.Subjects; +using System.Reactive.Linq; +using AwesomeAssertions; +using CreativeCoders.SmartMessageLanguage.Framing; +using CreativeCoders.SmartMessageLanguage.Parsing; +using CreativeCoders.SmartMessageLanguage.Tlv; +using CreativeCoders.SmartMessageLanguage.Units; +using CreativeCoders.SmartMeter.Core.SmlData; +using CreativeCoders.SmartMeter.DataProcessing; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace CreativeCoders.SmartMeter.Core.Tests.SmlData; + +public class SmartMeterReactiveDataPipelineTests +{ + private sealed class StubSmlMessageDetector : ISmlMessageDetector + { + public Subject MessagesSubject { get; } = new(); + + public event EventHandler? MessageReceived; + + public IObservable Messages => MessagesSubject; + + public List Appended { get; } = []; + + public int ResetCount { get; private set; } + + public int DisposeCount { get; private set; } + + public void Append(ReadOnlySpan data) => Appended.Add(data.ToArray()); + + public void Reset() => ResetCount++; + + public void Dispose() + { + DisposeCount++; + MessagesSubject.Dispose(); + } + + // Suppress unused-event warning + public void RaiseReceived(SmlFrame frame) => + MessageReceived?.Invoke(this, new SmlMessageEventArgs(frame)); + } + + private sealed class StubSmlParser : ISmlParser + { + public Func ParseBehavior { get; set; } = + _ => new SmlParseResult([], []); + + public SmlParseResult Parse(SmlFrame frame) => ParseBehavior(frame.PayloadBytes); + + public SmlParseResult Parse(ReadOnlySpan payload) => ParseBehavior(payload.ToArray()); + } + + private static SmlFrame MakeFrame(byte[] payload) => new(payload, payload, true, 0); + + private static ObisValue MakeObis(string code, decimal value) => + new(code, value, SmlUnit.WattHour, 0, [], SmlMessageValueType.Unsigned); + + private static (SmartMeterReactiveDataPipeline Sut, StubSmlParser Parser, + StubSmlMessageDetector Detector) CreateSut(SmartMeterOptions? opts = null) + { + var parser = new StubSmlParser(); + var detector = new StubSmlMessageDetector(); + + var sut = new SmartMeterReactiveDataPipeline( + parser, + detector, + Options.Create(opts ?? new SmartMeterOptions + { + PurchasedEnergyOffset = 0, + SoldEnergyOffset = 0 + }), + NullLogger.Instance); + + return (sut, parser, detector); + } + + [Fact] + public void OnNext_ForwardsBytesToDetector() + { + // Arrange + var (sut, _, detector) = CreateSut(); + var data = new byte[] { 0x01, 0x02, 0x03 }; + + // Act + sut.OnNext(data); + + // Assert + detector.Appended.Should().ContainSingle().Which.Should().Equal(data); + } + + [Fact] + public void Subscribe_WithPurchasedEnergyObis_EmitsPurchasedEnergyValueWithOffset() + { + // Arrange + var (sut, parser, detector) = CreateSut(new SmartMeterOptions + { + PurchasedEnergyOffset = 1000, + SoldEnergyOffset = 0 + }); + parser.ParseBehavior = _ => new SmlParseResult( + [MakeObis("1-0:1.8.0*255", 123m)], []); + + var received = new List(); + sut.Subscribe(new LambdaObserver(received.Add)); + + // Act + detector.MessagesSubject.OnNext(MakeFrame([0xAA])); + + // Assert + received.Should().ContainSingle(v => v.Type == SmartMeterValueType.TotalPurchasedEnergy + && v.Value == 1123m); + } + + [Fact] + public void Subscribe_WithSoldEnergyObis_EmitsSoldEnergyValueWithOffset() + { + // Arrange + var (sut, parser, detector) = CreateSut(new SmartMeterOptions + { + PurchasedEnergyOffset = 0, + SoldEnergyOffset = 50 + }); + parser.ParseBehavior = _ => new SmlParseResult( + [MakeObis("1-0:2.8.0*255", 10m)], []); + + var received = new List(); + sut.Subscribe(new LambdaObserver(received.Add)); + + // Act + detector.MessagesSubject.OnNext(MakeFrame([0xAA])); + + // Assert + received.Should().Contain(v => v.Type == SmartMeterValueType.TotalSoldEnergy && v.Value == 60m); + } + + [Fact] + public void Subscribe_WithUnrelatedObis_DoesNotEmitEnergyValues() + { + // Arrange + var (sut, parser, detector) = CreateSut(); + parser.ParseBehavior = _ => new SmlParseResult( + [MakeObis("1-0:99.9.0*255", 10m)], []); + + var received = new List(); + sut.Subscribe(new LambdaObserver(received.Add)); + + // Act + detector.MessagesSubject.OnNext(MakeFrame([0xAA])); + + // Assert + received.Should().NotContain(v => + v.Type == SmartMeterValueType.TotalPurchasedEnergy + || v.Type == SmartMeterValueType.TotalSoldEnergy); + } + + [Fact] + public void Subscribe_WhenObisValueIsNull_DoesNotEmit() + { + // Arrange + var (sut, parser, detector) = CreateSut(); + parser.ParseBehavior = _ => new SmlParseResult( + [new ObisValue("1-0:1.8.0*255", null, SmlUnit.Unknown, 0, [], + SmlMessageValueType.Unsigned)], []); + + var received = new List(); + sut.Subscribe(new LambdaObserver(received.Add)); + + // Act + detector.MessagesSubject.OnNext(MakeFrame([0xAA])); + + // Assert + received.Should().BeEmpty(); + } + + [Fact] + public void OnCompleted_DoesNotThrow() + { + // Arrange: OnCompleted propagates to the internal SmlValue subject but the downstream + // pipeline (SmlValueProcessor) does not forward completion to its observers. The method + // must still be callable without throwing. + var (sut, _, _) = CreateSut(); + sut.Subscribe(new LambdaObserver(_ => { })); + + // Act + var act = () => sut.OnCompleted(); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void OnError_DoesNotThrow() + { + // Arrange + var (sut, _, _) = CreateSut(); + + // Act + var act = () => sut.OnError(new InvalidOperationException("boom")); + + // Assert + act.Should().NotThrow(); + } + + private sealed class LambdaObserver( + Action onNext, + Action? onError = null, + Action? onCompleted = null) : IObserver + { + public void OnCompleted() => onCompleted?.Invoke(); + + public void OnError(Exception error) => onError?.Invoke(error); + + public void OnNext(T value) => onNext(value); + } +} diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs new file mode 100644 index 0000000..7b96694 --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs @@ -0,0 +1,137 @@ +using System.Text; +using AwesomeAssertions; +using CreativeCoders.SmartMeter.Core.Unlock; +using Xunit; + +namespace CreativeCoders.SmartMeter.Core.Tests.Unlock; + +public class ObisCodeScannerTests +{ + [Theory] + [InlineData("1-0:1.8.0*255", new byte[] { 1, 0, 1, 8, 0, 255 })] + [InlineData("1-0:16.7.0*255", new byte[] { 1, 0, 16, 7, 0, 255 })] + [InlineData("0-0:0.0.0*0", new byte[] { 0, 0, 0, 0, 0, 0 })] + public void ParseObis_WithValidString_ReturnsSixBytes(string input, byte[] expected) + { + // Act + var result = ObisCodeScanner.ParseObis(input); + + // Assert + result.Should().Equal(expected); + } + + [Theory] + [InlineData("1.0:1.8.0*255")] + [InlineData("1-0-1.8.0*255")] + [InlineData("1-0:1.8.0")] + [InlineData("1-0:1.8*255")] + [InlineData("1-0:1.8.0.0*255")] + public void ParseObis_WithMalformedString_ThrowsFormatException(string input) + { + // Act + var act = () => ObisCodeScanner.ParseObis(input); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ParseObis_WithEmptyString_Throws(string input) + { + // Act + var act = () => ObisCodeScanner.ParseObis(input); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ParseObis_WithByteOverflow_ThrowsOverflowException() + { + // Act + var act = () => ObisCodeScanner.ParseObis("300-0:1.8.0*255"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void FindMatches_WhenPatternIsContained_ReturnsMatchingCode() + { + // Arrange + var payload = new byte[] { 0xAA, 0xBB, 1, 0, 1, 8, 0, 255, 0xCC }; + var expected = new[] { "1-0:1.8.0*255", "1-0:2.8.0*255" }; + + // Act + var matches = ObisCodeScanner.FindMatches(payload, expected).ToArray(); + + // Assert + matches.Should().ContainSingle().Which.Should().Be("1-0:1.8.0*255"); + } + + [Fact] + public void FindMatches_WhenMultiplePatternsPresent_ReturnsAll() + { + // Arrange + var payload = new byte[] { 1, 0, 1, 8, 0, 255, 0, 1, 0, 2, 8, 0, 255 }; + var expected = new[] { "1-0:1.8.0*255", "1-0:2.8.0*255" }; + + // Act + var matches = ObisCodeScanner.FindMatches(payload, expected).ToArray(); + + // Assert + matches.Should().BeEquivalentTo(expected); + } + + [Fact] + public void FindMatches_WhenPayloadShorterThanPattern_ReturnsEmpty() + { + // Arrange + var payload = new byte[] { 1, 0, 1 }; + var expected = new[] { "1-0:1.8.0*255" }; + + // Act + var matches = ObisCodeScanner.FindMatches(payload, expected).ToArray(); + + // Assert + matches.Should().BeEmpty(); + } + + [Fact] + public void FindMatches_WithEmptyExpectedList_ReturnsEmpty() + { + // Arrange + var payload = Encoding.ASCII.GetBytes("whatever"); + + // Act + var matches = ObisCodeScanner.FindMatches(payload, []).ToArray(); + + // Assert + matches.Should().BeEmpty(); + } + + [Fact] + public void FindMatches_WithEmptyPayload_ReturnsEmpty() + { + // Arrange + var expected = new[] { "1-0:1.8.0*255" }; + + // Act + var matches = ObisCodeScanner.FindMatches([], expected).ToArray(); + + // Assert + matches.Should().BeEmpty(); + } + + [Fact] + public void FindMatches_WithNullPayload_Throws() + { + // Act + var act = () => ObisCodeScanner.FindMatches(null!, []).ToArray(); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs new file mode 100644 index 0000000..9207977 --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs @@ -0,0 +1,228 @@ +using System.Text; +using AwesomeAssertions; +using CreativeCoders.SmartMeter.Core.Tests.Fixtures; +using CreativeCoders.SmartMeter.Core.Unlock; +using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace CreativeCoders.SmartMeter.Core.Tests.Unlock; + +public class SmartMeterUnlockerTests +{ + private static (SmartMeterUnlocker Sut, FakeReactiveSerialPort Port) CreateSut( + SmartMeterOptions? options = null) + { + var port = new FakeReactiveSerialPort(); + var factory = A.Fake(); + A.CallTo(() => factory.Create(A._)).Returns(port); + + var sut = new SmartMeterUnlocker( + NullLogger.Instance, + factory, + Options.Create(options ?? new SmartMeterOptions())); + + return (sut, port); + } + + private static SmartMeterUnlockOptions FastOptions(SmartMeterUnlockOptions? baseOptions = null) => + (baseOptions ?? new SmartMeterUnlockOptions()) with + { + InitialDelay = TimeSpan.Zero, + VerificationTimeout = TimeSpan.FromMilliseconds(500), + DigitDelay = TimeSpan.Zero + }; + + [Fact] + public async Task UnlockAsync_WhenVerifySkipped_WritesPinAndReturnsSkipped() + { + // Arrange + var (sut, port) = CreateSut(); + var options = FastOptions() with { Verify = false, LineEnding = "\r\n" }; + + // Act + var result = await sut.UnlockAsync("00000000", options); + + // Assert + result.Success.Should().BeTrue(); + result.Outcome.Should().Be(SmartMeterUnlockOutcome.VerificationSkipped); + port.OpenCount.Should().Be(1); + port.Writes.Should().ContainSingle() + .Which.Should().Equal(Encoding.ASCII.GetBytes("00000000\r\n")); + } + + [Fact] + public async Task UnlockAsync_WhenExpectedObisCodeObserved_ReturnsPinAccepted() + { + // Arrange + var (sut, port) = CreateSut(); + var options = FastOptions() with + { + ExpectedObisCodes = ["1-0:1.8.0*255"] + }; + + // Act + var unlockTask = sut.UnlockAsync("12345678", options); + // Give the subscription a moment to register, then push the matching OBIS bytes + await Task.Delay(50); + port.PushBytes([0xFF, 1, 0, 1, 8, 0, 255, 0xAA]); + + var result = await unlockTask; + + // Assert + result.Success.Should().BeTrue(); + result.Outcome.Should().Be(SmartMeterUnlockOutcome.PinAccepted); + result.DetectedObisCodes.Should().ContainSingle().Which.Should().Be("1-0:1.8.0*255"); + } + + [Fact] + public async Task UnlockAsync_WhenNoEvidenceWithinTimeout_ReturnsVerificationTimeout() + { + // Arrange + var (sut, _) = CreateSut(); + var options = FastOptions() with + { + VerificationTimeout = TimeSpan.FromMilliseconds(100), + ExpectedObisCodes = ["1-0:1.8.0*255"] + }; + + // Act + var result = await sut.UnlockAsync("00000000", options); + + // Assert + result.Success.Should().BeFalse(); + result.Outcome.Should().Be(SmartMeterUnlockOutcome.VerificationTimeout); + result.DetectedObisCodes.Should().BeEmpty(); + } + + [Fact] + public async Task UnlockAsync_WithIskraStrategyAndAckByte_ReturnsPinAccepted() + { + // Arrange + var (sut, port) = CreateSut(); + var options = FastOptions() with { Strategy = SmartMeterPinStrategy.IskraAsciiBlock }; + + // Act + var unlockTask = sut.UnlockAsync("00000000", options); + await Task.Delay(50); + port.PushBytes([0x06]); + + var result = await unlockTask; + + // Assert + result.Success.Should().BeTrue(); + result.Outcome.Should().Be(SmartMeterUnlockOutcome.PinAccepted); + } + + [Fact] + public async Task UnlockAsync_WhenCancelledBeforeSend_ReturnsCancelled() + { + // Arrange + var (sut, _) = CreateSut(); + var options = FastOptions() with { InitialDelay = TimeSpan.FromSeconds(5) }; + using var cts = new CancellationTokenSource(); + + // Act + var task = sut.UnlockAsync("00000000", options, cts.Token); + cts.Cancel(); + + var result = await task; + + // Assert + result.Success.Should().BeFalse(); + result.Outcome.Should().Be(SmartMeterUnlockOutcome.Cancelled); + } + + [Fact] + public async Task UnlockAsync_WhenWriteThrows_ReturnsWriteFailed() + { + // Arrange + var (sut, port) = CreateSut(); + port.WriteBehavior = _ => new InvalidOperationException("boom"); + var options = FastOptions(); + + // Act + var result = await sut.UnlockAsync("00000000", options); + + // Assert + result.Success.Should().BeFalse(); + result.Outcome.Should().Be(SmartMeterUnlockOutcome.WriteFailed); + result.Message.Should().Contain("boom"); + } + + [Fact] + public async Task UnlockAsync_WithDigitByDigitStrategy_WritesEachDigitSeparately() + { + // Arrange + var (sut, port) = CreateSut(); + var options = FastOptions() with + { + Strategy = SmartMeterPinStrategy.EasymeterDigitByDigit, + Verify = false + }; + + // Act + var result = await sut.UnlockAsync("1234", options); + + // Assert + result.Outcome.Should().Be(SmartMeterUnlockOutcome.VerificationSkipped); + port.Writes.Should().HaveCount(4); + port.Writes.Select(w => w[0]).Should().Equal((byte)'1', (byte)'2', (byte)'3', (byte)'4'); + } + + [Fact] + public async Task UnlockAsync_WhenPortAlreadyOpen_DoesNotOpenAgain() + { + // Arrange + var (sut, port) = CreateSut(); + port.Open(); + var initialOpenCount = port.OpenCount; + + // Act + await sut.UnlockAsync("00000000", FastOptions() with { Verify = false }); + + // Assert + port.OpenCount.Should().Be(initialOpenCount); + } + + [Fact] + public async Task UnlockAsync_WithWhitespacePin_ThrowsArgumentException() + { + // Arrange + var (sut, _) = CreateSut(); + + // Act + var act = () => sut.UnlockAsync(" "); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UnlockAsync_WithLineEnding_AppendsCorrectly() + { + // Arrange + var (sut, port) = CreateSut(); + var options = FastOptions() with { Verify = false, LineEnding = "\n" }; + + // Act + await sut.UnlockAsync("ABCD", options); + + // Assert + port.Writes.Single().Should().Equal(Encoding.ASCII.GetBytes("ABCD\n")); + } + + [Fact] + public void Dispose_DisposesUnderlyingPort() + { + // Arrange + var (sut, port) = CreateSut(); + + // Act + sut.Dispose(); + + // Assert + port.DisposeCount.Should().Be(1); + } +} diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/History/ValueHistoryTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/History/ValueHistoryTests.cs new file mode 100644 index 0000000..466e4ff --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/History/ValueHistoryTests.cs @@ -0,0 +1,56 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMeter.DataProcessing.History; +using Xunit; + +namespace CreativeCoders.SmartMeter.DataProcessing.Tests.History; + +public class ValueHistoryTests +{ + [Fact] + public void GetHistoryData_FirstCallForType_ReturnsEmptyInstance() + { + var sut = new ValueHistory(); + + var data = sut.GetHistoryData(SmlValueType.PurchasedEnergy); + + data.Should().NotBeNull(); + data.DataSets.Should().BeEmpty(); + data.LastValue.Should().BeNull(); + data.LastValueTimeStamp.Should().BeNull(); + } + + [Fact] + public void GetHistoryData_CalledTwiceForSameType_ReturnsSameInstance() + { + var sut = new ValueHistory(); + + var first = sut.GetHistoryData(SmlValueType.PurchasedEnergy); + var second = sut.GetHistoryData(SmlValueType.PurchasedEnergy); + + second.Should().BeSameAs(first); + } + + [Fact] + public void GetHistoryData_DifferentTypes_ReturnsIndependentInstances() + { + var sut = new ValueHistory(); + + var purchased = sut.GetHistoryData(SmlValueType.PurchasedEnergy); + var sold = sut.GetHistoryData(SmlValueType.SoldEnergy); + + sold.Should().NotBeSameAs(purchased); + } + + [Fact] + public void GetHistoryData_PreservesMutationsAcrossCalls() + { + var sut = new ValueHistory(); + var first = sut.GetHistoryData(SmlValueType.SoldEnergy); + first.LastValue = new SmlValue(SmlValueType.SoldEnergy) { Value = 42m }; + + var second = sut.GetHistoryData(SmlValueType.SoldEnergy); + + second.LastValue.Should().NotBeNull(); + second.LastValue!.Value.Should().Be(42m); + } +} diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs new file mode 100644 index 0000000..4e939e1 --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs @@ -0,0 +1,228 @@ +using System.Globalization; +using System.Text; +using AwesomeAssertions; +using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; +using MQTTnet; +using Xunit; + +namespace CreativeCoders.SmartMeter.DataProcessing.Tests; + +public class MqttValuePublisherTests : IAsyncLifetime +{ + private readonly IMqttClient _client = A.Fake(); + private readonly MqttPublisherOptions _options = new() + { + Server = new Uri("tcp://localhost:1883"), + ClientName = "test-client", + TopicTemplate = "smartmeter/values/{0}" + }; + + private MqttValuePublisher? _sut; + + public Task InitializeAsync() => Task.CompletedTask; + + public Task DisposeAsync() + { + // Stop the worker thread by completing the queue if started. + // No public dispose — rely on process shutdown; _sut goes out of scope. + _sut = null; + return Task.CompletedTask; + } + + private static MqttClientConnectResult SuccessConnectResult() => + new() { ResultCode = MqttClientConnectResultCode.Success }; + + private static MqttClientPublishResult PublishOk() => + new(null, MqttClientPublishReasonCode.Success, null!, []); + + private MqttValuePublisher Create() + { + _sut = new MqttValuePublisher(_options, NullLogger.Instance, _client); + return _sut; + } + + [Fact] + public async Task InitAsync_WhenConnectSucceeds_DoesNotThrow() + { + // Arrange + A.CallTo(() => _client.ConnectAsync(A._, A._)) + .Returns(SuccessConnectResult()); + var sut = Create(); + + // Act + await sut.InitAsync(); + + // Assert + A.CallTo(() => _client.ConnectAsync(A._, A._)) + .MustHaveHappened(); + } + + [Fact] + public async Task InitAsync_WhenConnectFails_ThrowsInvalidOperationException() + { + // Arrange + var failed = new MqttClientConnectResult + { + ResultCode = MqttClientConnectResultCode.BadUserNameOrPassword, + ReasonString = "nope" + }; + A.CallTo(() => _client.ConnectAsync(A._, A._)) + .Returns(failed); + var sut = Create(); + + // Act + var act = () => sut.InitAsync(); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*BadUserNameOrPassword*"); + } + + [Fact] + public async Task OnNext_AfterInit_PublishesValueAsJsonByDefault() + { + // Arrange + A.CallTo(() => _client.ConnectAsync(A._, A._)) + .Returns(SuccessConnectResult()); + A.CallTo(() => _client.PublishAsync(A._, A._)) + .Returns(PublishOk()); + + var sut = Create(); + await sut.InitAsync(); + + // Act + sut.OnNext(new SmartMeterValue(SmartMeterValueType.TotalPurchasedEnergy) { Value = 42m }); + + // Assert - wait up to 2s for the worker thread to publish + await WaitForAsync(() => + Fake.GetCalls(_client).Any(c => c.Method.Name == nameof(IMqttClient.PublishAsync))); + + var published = Fake.GetCalls(_client) + .Where(c => c.Method.Name == nameof(IMqttClient.PublishAsync)) + .Select(c => (MqttApplicationMessage)c.Arguments[0]!) + .ToList(); + + published.Should().ContainSingle(); + published[0].Topic.Should().Be("smartmeter/values/TotalPurchasedEnergy"); + Encoding.UTF8.GetString(System.Buffers.BuffersExtensions.ToArray(published[0].Payload)).Should().Contain("\"Value\":42"); + } + + [Fact] + public async Task OnNext_WithWriteAsJsonFalse_PublishesRawInvariantDecimal() + { + // Arrange + A.CallTo(() => _client.ConnectAsync(A._, A._)) + .Returns(SuccessConnectResult()); + A.CallTo(() => _client.PublishAsync(A._, A._)) + .Returns(PublishOk()); + + var sut = Create(); + await sut.InitAsync(); + + // Act + sut.OnNext(new SmartMeterValue(SmartMeterValueType.GridPowerBalance) + { + Value = -12.5m, + WriteAsJson = false + }); + + // Assert + await WaitForAsync(() => + Fake.GetCalls(_client).Any(c => c.Method.Name == nameof(IMqttClient.PublishAsync))); + + var msg = Fake.GetCalls(_client) + .Where(c => c.Method.Name == nameof(IMqttClient.PublishAsync)) + .Select(c => (MqttApplicationMessage)c.Arguments[0]!) + .Single(); + + msg.Topic.Should().Be("smartmeter/values/GridPowerBalance"); + Encoding.UTF8.GetString(System.Buffers.BuffersExtensions.ToArray(msg.Payload)) + .Should().Be((-12.5m).ToString(CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task OnNext_WhenPublishThrows_WorkerContinuesAndDoesNotCrash() + { + // Arrange + A.CallTo(() => _client.ConnectAsync(A._, A._)) + .Returns(SuccessConnectResult()); + A.CallTo(() => _client.PublishAsync(A._, A._)) + .Throws().Once() + .Then.Returns(PublishOk()); + + var sut = Create(); + await sut.InitAsync(); + + // Act + sut.OnNext(new SmartMeterValue(SmartMeterValueType.TotalPurchasedEnergy) { Value = 1m }); + sut.OnNext(new SmartMeterValue(SmartMeterValueType.TotalSoldEnergy) { Value = 2m }); + + // Assert - second publish should still be attempted + await WaitForAsync(() => + Fake.GetCalls(_client).Count(c => c.Method.Name == nameof(IMqttClient.PublishAsync)) >= 2); + + Fake.GetCalls(_client).Count(c => c.Method.Name == nameof(IMqttClient.PublishAsync)) + .Should().BeGreaterThanOrEqualTo(2); + } + + [Fact] + public void Constructor_WithNullOptions_ThrowsArgumentNullException() + { + // Act + var act = () => new MqttValuePublisher(null!, NullLogger.Instance, _client); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act + var act = () => new MqttValuePublisher(_options, null!, _client); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullClient_ThrowsArgumentNullException() + { + // Act + var act = () => new MqttValuePublisher(_options, NullLogger.Instance, null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void OnError_AndOnCompleted_DoNotThrow() + { + // Arrange + var sut = Create(); + + // Act + var act1 = () => sut.OnError(new Exception("boom")); + var act2 = () => sut.OnCompleted(); + + // Assert + act1.Should().NotThrow(); + act2.Should().NotThrow(); + } + + private static async Task WaitForAsync(Func condition, int timeoutMs = 2000) + { + var start = Environment.TickCount; + + while (!condition()) + { + if (Environment.TickCount - start > timeoutMs) + { + return; + } + + await Task.Delay(20); + } + } +} diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs index e13ab9f..c3b5c2e 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs @@ -102,4 +102,124 @@ public void Subscribe_WithTwoPurchasedEnergyValues_ShouldReturnTotalAndCurrentAn .Should() .Be((smlValueValue2 - smlValueValue1) * 60); } + + [Fact] + public void Subscribe_WithSameValueEmittedTwiceQuickly_SuppressesDuplicateTotal() + { + // Arrange + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromSeconds(5)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + + // Assert + // Only the first value emits TotalPurchasedEnergy; the second is suppressed because + // value unchanged AND time diff < 30s. + results.Count(x => x.Type == SmartMeterValueType.TotalPurchasedEnergy).Should().Be(1); + } + + [Fact] + public void Subscribe_WithSameValueAfterFiveMinutes_EmitsTotalAgain() + { + // Arrange + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromMinutes(6)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + + // Assert + // After 5 minutes the time-based gate forces another total emission. + results.Count(x => x.Type == SmartMeterValueType.TotalPurchasedEnergy).Should().Be(2); + } + + [Fact] + public void Subscribe_WithNoSubsequentValueWithinTwentySeconds_DoesNotEmitCurrentPower() + { + // Arrange + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromSeconds(10)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 105m }); + + // Assert + results.Should().NotContain(x => x.Type == SmartMeterValueType.CurrentPurchasingPower); + } + + [Fact] + public void Subscribe_WithUnchangedValueAfterTwentyOneSeconds_EmitsZeroCurrentPowerButNoBalance() + { + // Arrange - value diff=0, time diff>20s triggers the current-power branch with value 0. + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromSeconds(21)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + + // Assert + results.Should().Contain(x => x.Type == SmartMeterValueType.CurrentPurchasingPower && x.Value == 0m); + // GridPowerBalance is only emitted when the current value is non-zero. + results.Should().NotContain(x => x.Type == SmartMeterValueType.GridPowerBalance); + } + + [Fact] + public void Subscribe_WithPurchasedEnergyGap_EmitsNegativeGridPowerBalance() + { + // Arrange + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromMinutes(1)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 200m }); + + // Assert + var balance = results.Single(x => x.Type == SmartMeterValueType.GridPowerBalance); + balance.Value.Should().BeLessThan(0m); + balance.WriteAsJson.Should().BeFalse(); + } + + [Fact] + public void Subscribe_AfterSourceCompletes_StopsEmittingButSubjectStaysAlive() + { + // Arrange + var results = new List(); + var input = new Subject(); + var sut = new SmlValueProcessor(input); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 50m }); + var emittedBefore = results.Count; + input.OnCompleted(); + + // Assert - new subscribers can still attach; no late values appear. + results.Count.Should().Be(emittedBefore); + } } diff --git a/tests/CreativeCoders.SmartMeter.Server.Core.Tests/CreativeCoders.SmartMeter.Server.Core.Tests.csproj b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/CreativeCoders.SmartMeter.Server.Core.Tests.csproj new file mode 100644 index 0000000..34cfdf9 --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/CreativeCoders.SmartMeter.Server.Core.Tests.csproj @@ -0,0 +1,30 @@ + + + + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs new file mode 100644 index 0000000..e00d9cb --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs @@ -0,0 +1,91 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMeter.Core.SmlData; +using CreativeCoders.SmartMeter.DataProcessing; +using CreativeCoders.SmartMeter.Server.Core; +using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CreativeCoders.SmartMeter.Server.Core.Tests; + +public class SmartMeterServerTests +{ + private readonly IMqttValuePublisher _publisher = A.Fake(); + private readonly ISmartMeterDataProducer _producer = A.Fake(); + + private SmartMeterServer CreateSut() => + new(NullLogger.Instance, _publisher, _producer); + + [Fact] + public async Task StartAsync_InitializesPublisherThenStartsProducerWithPublisher() + { + // Arrange + var sut = CreateSut(); + + // Act + await sut.StartAsync(); + + // Assert + A.CallTo(() => _publisher.InitAsync()).MustHaveHappened() + .Then(A.CallTo(() => _producer.StartAsync(_publisher)).MustHaveHappened()); + } + + [Fact] + public async Task StartAsync_WhenPublisherInitFails_DoesNotStartProducer() + { + // Arrange + A.CallTo(() => _publisher.InitAsync()).Throws(); + var sut = CreateSut(); + + // Act + var act = () => sut.StartAsync(); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(() => _producer.StartAsync(A>._)).MustNotHaveHappened(); + } + + [Fact] + public async Task StopAsync_StopsDataProducer() + { + // Arrange + var sut = CreateSut(); + + // Act + await sut.StopAsync(); + + // Assert + A.CallTo(() => _producer.StopAsync()).MustHaveHappened(); + } + + [Fact] + public void Constructor_WithNullLogger_Throws() + { + // Act + var act = () => new SmartMeterServer(null!, _publisher, _producer); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullPublisher_Throws() + { + // Act + var act = () => new SmartMeterServer(NullLogger.Instance, null!, _producer); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullProducer_Throws() + { + // Act + var act = () => new SmartMeterServer( + NullLogger.Instance, _publisher, null!); + + // Assert + act.Should().Throw(); + } +} From d8c77b65169c2211314238c35f6d2562fd69f142 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:13:26 +0200 Subject: [PATCH 21/35] Dispose resources properly in `MqttValuePublisher` and update related tests; suppress false-positive analyzer warnings in test fixtures and parsing logic; improve `SmartMeterServer` shutdown sequence. --- .../Parsing/SmlParser.cs | 3 ++ .../IMqttValuePublisher.cs | 2 +- .../MqttValuePublisher.cs | 50 ++++++++++++++++++- .../SmartMeterServer.cs | 2 + .../Fixtures/TlvBuilder.cs | 2 + .../SmartMeterReactiveDataPipelineTests.cs | 8 ++- .../Unlock/SmartMeterUnlockerTests.cs | 3 ++ .../MqttValuePublisherTests.cs | 11 ++-- .../SmartMeterServerTests.cs | 5 +- 9 files changed, 75 insertions(+), 11 deletions(-) diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs index c6a56f0..30363cb 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using CreativeCoders.Core; using CreativeCoders.SmartMessageLanguage.Framing; @@ -209,6 +210,8 @@ private void ProcessGetListResponse(ref SmlTlvReader reader, int entryCount, } } + [SuppressMessage("csharpsquid", "S3776", + Justification = "This method is long but straightforward; refactor if it grows more complex.")] private void ReadValListEntry(ref SmlTlvReader reader, List values, List warnings) { diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs b/source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs index 756ae10..58c9001 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs @@ -3,7 +3,7 @@ namespace CreativeCoders.SmartMeter.DataProcessing; /// /// Observer that publishes instances to an MQTT broker. /// -public interface IMqttValuePublisher : IObserver +public interface IMqttValuePublisher : IObserver, IAsyncDisposable { /// Connects to the broker and starts the background publishing loop. Task InitAsync(); diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs index e562d59..e9b867d 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs @@ -21,6 +21,8 @@ public class MqttValuePublisher : IMqttValuePublisher private readonly Thread _workerThread; + private bool _disposed; + /// Creates a publisher using a real MQTT client produced by . public MqttValuePublisher(MqttPublisherOptions options, ILogger logger) : this(options, logger, new MqttClientFactory().CreateMqttClient()) @@ -46,7 +48,11 @@ public MqttValuePublisher(MqttPublisherOptions options, ILogger _bytes = []; + [SuppressMessage("csharpsquid", "S2437", Justification = "This is a test fixture, so we can ignore the warning")] public TlvBuilder List(int count) { if (count > 0x0F) diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs index 2999ddc..038a6ee 100644 --- a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Reactive.Subjects; using System.Reactive.Linq; using AwesomeAssertions; @@ -40,6 +41,7 @@ public void Dispose() } // Suppress unused-event warning + [SuppressMessage("ReSharper", "UnusedMember.Local")] public void RaiseReceived(SmlFrame frame) => MessageReceived?.Invoke(this, new SmlMessageEventArgs(frame)); } @@ -163,8 +165,10 @@ public void Subscribe_WhenObisValueIsNull_DoesNotEmit() // Arrange var (sut, parser, detector) = CreateSut(); parser.ParseBehavior = _ => new SmlParseResult( - [new ObisValue("1-0:1.8.0*255", null, SmlUnit.Unknown, 0, [], - SmlMessageValueType.Unsigned)], []); + [ + new ObisValue("1-0:1.8.0*255", null, SmlUnit.Unknown, 0, [], + SmlMessageValueType.Unsigned) + ], []); var received = new List(); sut.Subscribe(new LambdaObserver(received.Add)); diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs index 9207977..16b8594 100644 --- a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text; using AwesomeAssertions; using CreativeCoders.SmartMeter.Core.Tests.Fixtures; @@ -116,6 +117,8 @@ public async Task UnlockAsync_WithIskraStrategyAndAckByte_ReturnsPinAccepted() } [Fact] + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966", Justification = "Sync method is ok in tests")] public async Task UnlockAsync_WhenCancelledBeforeSend_ReturnsCancelled() { // Arrange diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs index 4e939e1..1647582 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs @@ -22,12 +22,13 @@ public class MqttValuePublisherTests : IAsyncLifetime public Task InitializeAsync() => Task.CompletedTask; - public Task DisposeAsync() + public async Task DisposeAsync() { - // Stop the worker thread by completing the queue if started. - // No public dispose — rely on process shutdown; _sut goes out of scope. - _sut = null; - return Task.CompletedTask; + if (_sut is not null) + { + await _sut.DisposeAsync(); + _sut = null; + } } private static MqttClientConnectResult SuccessConnectResult() => diff --git a/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs index e00d9cb..adeee61 100644 --- a/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs +++ b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs @@ -46,7 +46,7 @@ public async Task StartAsync_WhenPublisherInitFails_DoesNotStartProducer() } [Fact] - public async Task StopAsync_StopsDataProducer() + public async Task StopAsync_StopsDataProducerThenDisposesPublisher() { // Arrange var sut = CreateSut(); @@ -55,7 +55,8 @@ public async Task StopAsync_StopsDataProducer() await sut.StopAsync(); // Assert - A.CallTo(() => _producer.StopAsync()).MustHaveHappened(); + A.CallTo(() => _producer.StopAsync()).MustHaveHappened() + .Then(A.CallTo(() => _publisher.DisposeAsync()).MustHaveHappened()); } [Fact] From 641c3e88a0fbaf43e35a1e41f8c4d399a967cbbf Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:26:48 +0200 Subject: [PATCH 22/35] Introduce Cake build system and GitHub Actions workflows; add `BuildContext` and related CI/CD scripts. --- .github/workflows/integration.yml | 80 +++++++++----- .github/workflows/main.yml | 78 ++++++++++---- .github/workflows/pull-request.yml | 70 ++++++++++++ .github/workflows/release.yml | 40 ++++--- Directory.Build.props | 1 + Directory.Packages.props | 55 +++++----- SmartMeter.sln | 100 +++--------------- build.ps1 | 35 +----- build.sh | 42 +------- build/Build.csproj | 12 +++ build/BuildContext.cs | 99 +++++++++++++++++ build/Program.cs | 19 ++++ ...CreativeCoders.SmartMessageLanguage.csproj | 11 +- 13 files changed, 390 insertions(+), 252 deletions(-) create mode 100644 .github/workflows/pull-request.yml create mode 100644 build/Build.csproj create mode 100644 build/BuildContext.cs create mode 100644 build/Program.cs diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 282afd9..7ca78e7 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -1,45 +1,77 @@ -# ------------------------------------------------------------------------------ -# -# -# This code was generated. -# -# - To turn off auto-generation set: -# -# [GitHubActions (AutoGenerate = false)] -# -# - To trigger manual generation invoke: -# -# nuke --generate-configuration GitHubActions_integration --host GitHubActions -# -# -# ------------------------------------------------------------------------------ - name: integration on: push: branches: - 'feature/**' - pull_request: - branches: - - main jobs: ubuntu-latest: name: ubuntu-latest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - name: 'Cache: .nuke/temp, ~/.nuget/packages' + - name: 'Cache: ~/.nuget/packages' uses: actions/cache@v4 with: path: | - .nuke/temp ~/.nuget/packages key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} - - name: 'Run: clean, restore, build, publish' - run: ./build.cmd clean restore build publish + - name: 'Build with target: NuGetPush' + run: ./build.cmd -t nugetpush -t publish -t createdistpackages + env: + NUGET_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-linux + path: .tests/coverage-report + - name: 'Publish: cli dist package' + uses: actions/upload-artifact@v4 + with: + name: HomeMatic.Cli + path: .artifacts/dist/HomeMatic.Cli.tar.gz + windows-latest: + name: windows-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: 'Cache: ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Build with target: Pack' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./build.cmd -t pack + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-windows + path: .tests/coverage-report + macos-latest: + name: macos-latest + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: 'Cache: ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Build with target: Pack' + run: ./build.cmd -t pack + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-macos + path: .tests/coverage-report diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7d9ea2d..ee8344a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,19 +1,3 @@ -# ------------------------------------------------------------------------------ -# -# -# This code was generated. -# -# - To turn off auto-generation set: -# -# [GitHubActions (AutoGenerate = false)] -# -# - To trigger manual generation invoke: -# -# nuke --generate-configuration GitHubActions_main --host GitHubActions -# -# -# ------------------------------------------------------------------------------ - name: main on: @@ -26,17 +10,69 @@ jobs: name: ubuntu-latest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - name: 'Cache: .nuke/temp, ~/.nuget/packages' + - name: 'Cache: ~/.nuget/packages' uses: actions/cache@v4 with: path: | - .nuke/temp ~/.nuget/packages key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} - - name: 'Run: clean, restore, build, publish' - run: ./build.cmd clean restore build publish + - name: 'Build with target: NuGetPush' + run: ./build.cmd -t nugetpush -t publish -t createdistpackages -t creategithubrelease env: + NUGET_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-linux + path: .tests/coverage-report + - name: 'Publish: cli dist package' + uses: actions/upload-artifact@v4 + with: + name: HomeMatic.Cli + path: .artifacts/dist/GitTool.Cli.tar.gz + windows-latest: + name: windows-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: 'Cache: ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Build with target: Pack' + run: ./build.cmd -t pack + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-windows + path: .tests/coverage-report + macos-latest: + name: macos-latest + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: 'Cache: ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Build with target: Pack' + run: ./build.cmd -t pack + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-macos + path: .tests/coverage-report diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..5075f28 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,70 @@ +name: pull-request + +on: + pull_request: + branches: + - main + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: 'Cache: ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Build with target: Pack' + run: ./build.cmd -t pack + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-linux + path: .tests/coverage-report + windows-latest: + name: windows-latest + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: 'Cache: ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Build with target: Pack' + run: ./build.cmd -t pack + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-windows + path: .tests/coverage-report + macos-latest: + name: macos-latest + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: 'Cache: ~/.nuget/packages' + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Build with target: Pack' + run: ./build.cmd -t pack + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-macos + path: .tests/coverage-report diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index beebb1f..b6c5980 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,9 @@ -# ------------------------------------------------------------------------------ -# -# -# This code was generated. -# -# - To turn off auto-generation set: -# -# [GitHubActions (AutoGenerate = false)] -# -# - To trigger manual generation invoke: -# -# nuke --generate-configuration GitHubActions_release --host GitHubActions -# -# -# ------------------------------------------------------------------------------ - name: release +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + on: push: tags: @@ -26,17 +14,27 @@ jobs: name: ubuntu-latest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - name: 'Cache: .nuke/temp, ~/.nuget/packages' + - name: 'Cache: ~/.nuget/packages' uses: actions/cache@v4 with: path: | - .nuke/temp ~/.nuget/packages key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} - - name: 'Run: clean, restore, build, publish, CreateDistPackages, CreateGithubRelease' - run: ./build.cmd clean restore build publish CreateDistPackages CreateGithubRelease + - name: 'Build with target: NuGetPush' + run: ./build.cmd -t pack -t nugetpush -t publish -t createdistpackages -t creategithubrelease env: + NUGET_TOKEN: ${{ secrets.NUGET_ORG_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: 'Publish: coverage_report' + uses: actions/upload-artifact@v4 + with: + name: coverage-report-linux + path: .tests/coverage-report + - name: 'Publish: cli dist package' + uses: actions/upload-artifact@v4 + with: + name: HomeMatic.Cli + path: .artifacts/dist/HomeMatic.Cli.tar.gz diff --git a/Directory.Build.props b/Directory.Build.props index 15e0d05..9b31f66 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,5 +6,6 @@ https://github.com/CreativeCodersTeam/SmartMeter enable enable + false diff --git a/Directory.Packages.props b/Directory.Packages.props index 8ca2e56..4f1ce55 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,29 +1,30 @@ - - true - - - - - - - - - - - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SmartMeter.sln b/SmartMeter.sln index 452be46..9f5040e 100644 --- a/SmartMeter.sln +++ b/SmartMeter.sln @@ -11,6 +11,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__global", "__global", "{EA LICENSE = LICENSE README.md = README.md Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_build", "_build", "{AEAE0BA3-481C-45C3-825C-71A5CE7F6A78}" @@ -37,136 +38,65 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.SmartMeter.Server.Core.Tests", "tests\CreativeCoders.SmartMeter.Server.Core.Tests\CreativeCoders.SmartMeter.Server.Core.Tests.csproj", "{3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "build\Build.csproj", "{ECD46CF5-A11F-46C9-B274-475F950F10E2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ci", "ci", "{65B0A31B-BCDB-4768-890F-ED46A4773182}" + ProjectSection(SolutionItems) = preProject + .github\workflows\integration.yml = .github\workflows\integration.yml + .github\workflows\main.yml = .github\workflows\main.yml + .github\workflows\pull-request.yml = .github\workflows\pull-request.yml + .github\workflows\release.yml = .github\workflows\release.yml + .github\workflows\sync-ai-config.yml = .github\workflows\sync-ai-config.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|x64.ActiveCfg = Debug|Any CPU - {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|x64.Build.0 = Debug|Any CPU - {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|x86.ActiveCfg = Debug|Any CPU - {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Debug|x86.Build.0 = Debug|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|Any CPU.ActiveCfg = Release|Any CPU {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|Any CPU.Build.0 = Release|Any CPU - {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|x64.ActiveCfg = Release|Any CPU - {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|x64.Build.0 = Release|Any CPU - {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|x86.ActiveCfg = Release|Any CPU - {9452116E-6A8B-42D2-BBDD-BF465097AEA1}.Release|x86.Build.0 = Release|Any CPU {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|x64.ActiveCfg = Debug|Any CPU - {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|x64.Build.0 = Debug|Any CPU - {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|x86.ActiveCfg = Debug|Any CPU - {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Debug|x86.Build.0 = Debug|Any CPU {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|Any CPU.ActiveCfg = Release|Any CPU {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|Any CPU.Build.0 = Release|Any CPU - {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|x64.ActiveCfg = Release|Any CPU - {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|x64.Build.0 = Release|Any CPU - {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|x86.ActiveCfg = Release|Any CPU - {7AD8C940-B783-4FE9-B437-CE7FB87A97CA}.Release|x86.Build.0 = Release|Any CPU {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|x64.ActiveCfg = Debug|Any CPU - {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|x64.Build.0 = Debug|Any CPU - {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|x86.ActiveCfg = Debug|Any CPU - {29638431-6971-4757-BE3B-A83D96300ED4}.Debug|x86.Build.0 = Debug|Any CPU {29638431-6971-4757-BE3B-A83D96300ED4}.Release|Any CPU.ActiveCfg = Release|Any CPU {29638431-6971-4757-BE3B-A83D96300ED4}.Release|Any CPU.Build.0 = Release|Any CPU - {29638431-6971-4757-BE3B-A83D96300ED4}.Release|x64.ActiveCfg = Release|Any CPU - {29638431-6971-4757-BE3B-A83D96300ED4}.Release|x64.Build.0 = Release|Any CPU - {29638431-6971-4757-BE3B-A83D96300ED4}.Release|x86.ActiveCfg = Release|Any CPU - {29638431-6971-4757-BE3B-A83D96300ED4}.Release|x86.Build.0 = Release|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|x64.ActiveCfg = Debug|Any CPU - {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|x64.Build.0 = Debug|Any CPU - {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|x86.ActiveCfg = Debug|Any CPU - {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Debug|x86.Build.0 = Debug|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|Any CPU.ActiveCfg = Release|Any CPU {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|Any CPU.Build.0 = Release|Any CPU - {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|x64.ActiveCfg = Release|Any CPU - {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|x64.Build.0 = Release|Any CPU - {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|x86.ActiveCfg = Release|Any CPU - {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB}.Release|x86.Build.0 = Release|Any CPU {344911B2-1104-457A-BD41-91EF270748A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {344911B2-1104-457A-BD41-91EF270748A9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {344911B2-1104-457A-BD41-91EF270748A9}.Debug|x64.ActiveCfg = Debug|Any CPU - {344911B2-1104-457A-BD41-91EF270748A9}.Debug|x64.Build.0 = Debug|Any CPU - {344911B2-1104-457A-BD41-91EF270748A9}.Debug|x86.ActiveCfg = Debug|Any CPU - {344911B2-1104-457A-BD41-91EF270748A9}.Debug|x86.Build.0 = Debug|Any CPU {344911B2-1104-457A-BD41-91EF270748A9}.Release|Any CPU.ActiveCfg = Release|Any CPU {344911B2-1104-457A-BD41-91EF270748A9}.Release|Any CPU.Build.0 = Release|Any CPU - {344911B2-1104-457A-BD41-91EF270748A9}.Release|x64.ActiveCfg = Release|Any CPU - {344911B2-1104-457A-BD41-91EF270748A9}.Release|x64.Build.0 = Release|Any CPU - {344911B2-1104-457A-BD41-91EF270748A9}.Release|x86.ActiveCfg = Release|Any CPU - {344911B2-1104-457A-BD41-91EF270748A9}.Release|x86.Build.0 = Release|Any CPU {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|x64.ActiveCfg = Debug|Any CPU - {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|x64.Build.0 = Debug|Any CPU - {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|x86.ActiveCfg = Debug|Any CPU - {B70158C0-A913-4877-9414-5CD5F39772F0}.Debug|x86.Build.0 = Debug|Any CPU {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|Any CPU.ActiveCfg = Release|Any CPU {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|Any CPU.Build.0 = Release|Any CPU - {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|x64.ActiveCfg = Release|Any CPU - {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|x64.Build.0 = Release|Any CPU - {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|x86.ActiveCfg = Release|Any CPU - {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|x86.Build.0 = Release|Any CPU {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|x64.ActiveCfg = Debug|Any CPU - {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|x64.Build.0 = Debug|Any CPU - {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|x86.ActiveCfg = Debug|Any CPU - {D956F76F-3826-4A51-8D05-8B35C062605F}.Debug|x86.Build.0 = Debug|Any CPU {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|Any CPU.ActiveCfg = Release|Any CPU {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|Any CPU.Build.0 = Release|Any CPU - {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x64.ActiveCfg = Release|Any CPU - {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x64.Build.0 = Release|Any CPU - {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x86.ActiveCfg = Release|Any CPU - {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|x86.Build.0 = Release|Any CPU {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|x64.ActiveCfg = Debug|Any CPU - {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|x64.Build.0 = Debug|Any CPU - {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|x86.ActiveCfg = Debug|Any CPU - {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Debug|x86.Build.0 = Debug|Any CPU {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|Any CPU.ActiveCfg = Release|Any CPU {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|Any CPU.Build.0 = Release|Any CPU - {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x64.ActiveCfg = Release|Any CPU - {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x64.Build.0 = Release|Any CPU - {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x86.ActiveCfg = Release|Any CPU - {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|x86.Build.0 = Release|Any CPU {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|x64.ActiveCfg = Debug|Any CPU - {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|x64.Build.0 = Debug|Any CPU - {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|x86.ActiveCfg = Debug|Any CPU - {63623865-60E4-428D-AFDA-5B309A69A69D}.Debug|x86.Build.0 = Debug|Any CPU {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|Any CPU.ActiveCfg = Release|Any CPU {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|Any CPU.Build.0 = Release|Any CPU - {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|x64.ActiveCfg = Release|Any CPU - {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|x64.Build.0 = Release|Any CPU - {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|x86.ActiveCfg = Release|Any CPU - {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|x86.Build.0 = Release|Any CPU {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|x64.ActiveCfg = Debug|Any CPU - {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|x64.Build.0 = Debug|Any CPU - {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|x86.ActiveCfg = Debug|Any CPU - {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Debug|x86.Build.0 = Debug|Any CPU {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|Any CPU.ActiveCfg = Release|Any CPU {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|Any CPU.Build.0 = Release|Any CPU - {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|x64.ActiveCfg = Release|Any CPU - {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|x64.Build.0 = Release|Any CPU - {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|x86.ActiveCfg = Release|Any CPU - {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|x86.Build.0 = Release|Any CPU + {ECD46CF5-A11F-46C9-B274-475F950F10E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECD46CF5-A11F-46C9-B274-475F950F10E2}.Release|Any CPU.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -182,5 +112,7 @@ Global {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3} = {B259CB14-56CC-45FA-9756-64A195F4F789} {63623865-60E4-428D-AFDA-5B309A69A69D} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} + {ECD46CF5-A11F-46C9-B274-475F950F10E2} = {AEAE0BA3-481C-45C3-825C-71A5CE7F6A78} + {65B0A31B-BCDB-4768-890F-ED46A4773182} = {AEAE0BA3-481C-45C3-825C-71A5CE7F6A78} EndGlobalSection EndGlobal diff --git a/build.ps1 b/build.ps1 index 4634dc0..1b91f63 100644 --- a/build.ps1 +++ b/build.ps1 @@ -13,12 +13,8 @@ $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent # CONFIGURATION ########################################################################### -$BuildProjectFile = "$PSScriptRoot\build\_build.csproj" -$TempDirectory = "$PSScriptRoot\\.nuke\temp" - +$BuildProjectFile = "$PSScriptRoot\build\Build.csproj" $DotNetGlobalFile = "$PSScriptRoot\\global.json" -$DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1" -$DotNetChannel = "STS" $env:DOTNET_CLI_TELEMETRY_OPTOUT = 1 $env:DOTNET_NOLOGO = 1 @@ -38,37 +34,12 @@ if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and ` $env:DOTNET_EXE = (Get-Command "dotnet").Path } else { - # Download install script - $DotNetInstallFile = "$TempDirectory\dotnet-install.ps1" - New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - (New-Object System.Net.WebClient).DownloadFile($DotNetInstallUrl, $DotNetInstallFile) - - # If global.json exists, load expected version - if (Test-Path $DotNetGlobalFile) { - $DotNetGlobal = $(Get-Content $DotNetGlobalFile | Out-String | ConvertFrom-Json) - if ($DotNetGlobal.PSObject.Properties["sdk"] -and $DotNetGlobal.sdk.PSObject.Properties["version"]) { - $DotNetVersion = $DotNetGlobal.sdk.version - } - } + Write-Host "No matching dotnet version found" - # Install by channel or version - $DotNetDirectory = "$TempDirectory\dotnet-win" - if (!(Test-Path variable:DotNetVersion)) { - ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Channel $DotNetChannel -NoPath } - } else { - ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath } - } - $env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe" - $env:PATH = "$DotNetDirectory;$env:PATH" + exit 1 } Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" -if (Test-Path env:NUKE_ENTERPRISE_TOKEN) { - & $env:DOTNET_EXE nuget remove source "nuke-enterprise" > $null - & $env:DOTNET_EXE nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password $env:NUKE_ENTERPRISE_TOKEN > $null -} - ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet } ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } diff --git a/build.sh b/build.sh index fdff0c6..35eaaba 100755 --- a/build.sh +++ b/build.sh @@ -2,19 +2,15 @@ bash --version 2>&1 | head -n 1 -set -eo pipefail +set -e SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) ########################################################################### # CONFIGURATION ########################################################################### -BUILD_PROJECT_FILE="$SCRIPT_DIR/build/_build.csproj" -TEMP_DIRECTORY="$SCRIPT_DIR//.nuke/temp" - +BUILD_PROJECT_FILE="$SCRIPT_DIR/build/Build.csproj" DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" -DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh" -DOTNET_CHANNEL="STS" export DOTNET_CLI_TELEMETRY_OPTOUT=1 export DOTNET_NOLOGO=1 @@ -23,45 +19,15 @@ export DOTNET_NOLOGO=1 # EXECUTION ########################################################################### -function FirstJsonValue { - perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" -} - # If dotnet CLI is installed globally and it matches requested version, use for execution if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then export DOTNET_EXE="$(command -v dotnet)" else - # Download install script - DOTNET_INSTALL_FILE="$TEMP_DIRECTORY/dotnet-install.sh" - mkdir -p "$TEMP_DIRECTORY" - curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL" - chmod +x "$DOTNET_INSTALL_FILE" - - # If global.json exists, load expected version - if [[ -f "$DOTNET_GLOBAL_FILE" ]]; then - DOTNET_VERSION=$(FirstJsonValue "version" "$(cat "$DOTNET_GLOBAL_FILE")") - if [[ "$DOTNET_VERSION" == "" ]]; then - unset DOTNET_VERSION - fi - fi - - # Install by channel or version - DOTNET_DIRECTORY="$TEMP_DIRECTORY/dotnet-unix" - if [[ -z ${DOTNET_VERSION+x} ]]; then - "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --channel "$DOTNET_CHANNEL" --no-path - else - "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path - fi - export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet" - export PATH="$DOTNET_DIRECTORY:$PATH" + echo "No matching dotnet version found" + exit 1 fi echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" -if [[ ! -z ${NUKE_ENTERPRISE_TOKEN+x} && "$NUKE_ENTERPRISE_TOKEN" != "" ]]; then - "$DOTNET_EXE" nuget remove source "nuke-enterprise" &>/dev/null || true - "$DOTNET_EXE" nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password "$NUKE_ENTERPRISE_TOKEN" --store-password-in-clear-text &>/dev/null || true -fi - "$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet "$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" diff --git a/build/Build.csproj b/build/Build.csproj new file mode 100644 index 0000000..3ef774f --- /dev/null +++ b/build/Build.csproj @@ -0,0 +1,12 @@ + + + + Exe + false + + + + + + + diff --git a/build/BuildContext.cs b/build/BuildContext.cs new file mode 100644 index 0000000..057ea20 --- /dev/null +++ b/build/BuildContext.cs @@ -0,0 +1,99 @@ +using Cake.Common.Build; +using Cake.Core; +using Cake.Core.IO; +using CreativeCoders.CakeBuild; +using CreativeCoders.CakeBuild.Tasks.Defaults; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using CreativeCoders.Core; +using CreativeCoders.Core.Collections; +using CreativeCoders.Core.IO; +using JetBrains.Annotations; + +namespace Build; + +[UsedImplicitly] +public class BuildContext(ICakeContext context) + : CakeBuildContext(context), IDefaultTaskSettings, ICreateDistPackagesTaskSettings, ICreateGitHubReleaseTaskSettings +{ + public IList DirectoriesToClean => this.CastAs() + .GetDefaultDirectoriesToClean().AddRange(RootDir.Combine(".tests")); + + + public string Copyright => $"{DateTime.Now.Year} CreativeCoders"; + + public string PackageProjectUrl => "https://github.com/CreativeCodersTeam/HomeMatic"; + + public string PackageLicenseExpression => PackageLicenseExpressions.ApacheLicense20; + + public string NuGetFeedUrl => this.GitHubActions().Environment.Workflow.Workflow == "release" + ? "nuget.org" + : "https://nuget.pkg.github.com/CreativeCodersTeam/index.json"; + + public bool SkipPush => this.BuildSystem().IsPullRequest || + this.BuildSystem().IsLocalBuild || + this.GitHubActions().Environment.Runner.OS != "Linux"; + + public DirectoryPath PublishOutputDir => ArtifactsDir.Combine("published"); + + private const string CliPath = "source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc"; + + private const string CliProjectFile = "CreativeCoders.HomeMatic.Tools.Cli.Hmc.csproj"; + + public IEnumerable PublishingItems => + [ + new PublishingItem( + RootDir + .Combine(CliPath) + .CombineWithFilePath(CliProjectFile), + PublishOutputDir.Combine("cli")), + new PublishingItem( + RootDir + .Combine(CliPath) + .CombineWithFilePath(CliProjectFile), + PublishOutputDir.Combine("cli-win64")) + { + Runtime = "win-x64", + SelfContained = true + }, + new PublishingItem( + RootDir + .Combine(CliPath) + .CombineWithFilePath(CliProjectFile), + PublishOutputDir.Combine("cli-win64-no-selfcontained")) + { + Runtime = "win-x64", + SelfContained = false + }, + new PublishingItem( + RootDir + .Combine(CliPath) + .CombineWithFilePath(CliProjectFile), + PublishOutputDir.Combine("cli-win-arm64")) + { + Runtime = "win-arm64", + SelfContained = true + } + ]; + + private const string DistPackageName = "HomeMatic.Cli"; + + public IEnumerable DistPackages => + [ + new DistPackage(DistPackageName, PublishOutputDir.Combine("cli")) + ]; + + public string ReleaseName => $"v{Version.FullSemVer}"; + + public string ReleaseVersion => $"v{Version.FullSemVer}"; + + public string ReleaseBody => "HomeMatic Release"; + + public bool IsPreRelease => !string.IsNullOrWhiteSpace(Version.PreReleaseTag); + + public IEnumerable ReleaseAssets => + [ + new GitHubReleaseFileAsset( + GetRequiredSettings().DistOutputPath + .CombineWithFilePath(DistPackageName + ".tar.gz").FullPath, null) + ]; +} diff --git a/build/Program.cs b/build/Program.cs new file mode 100644 index 0000000..34637f1 --- /dev/null +++ b/build/Program.cs @@ -0,0 +1,19 @@ +using CreativeCoders.CakeBuild; + +namespace Build; + +internal static class Program +{ + internal static int Main(string[] args) + { + return CakeHostBuilder.Create() + .UseBuildContext() + .AddDefaultTasks() + .AddBuildServerIntegration() + .InstallTools( + new DotNetToolInstallation("GitVersion.Tool", "6.5.1"), + new DotNetToolInstallation("dotnet-reportgenerator-globaltool", "5.5.1")) + .Build() + .Run(args); + } +} diff --git a/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj b/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj index 3e195a4..a5881b0 100644 --- a/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj +++ b/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj @@ -3,17 +3,18 @@ Streaming detector and parser for the Smart Message Language (SML) protocol CreativeCoders.SmartMessageLanguage + true - - - - + + + + - + From cb4e0efbb0f88afb74a5ceeec5f50660963d9977 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:32:12 +0200 Subject: [PATCH 23/35] Rename `HomeMatic` to `SmartMeter` across build scripts, workflows, and project files; remove redundant Windows-specific steps in CI pipelines. --- .github/workflows/integration.yml | 26 ++---------------- .github/workflows/pull-request.yml | 22 --------------- .github/workflows/release.yml | 4 +-- build/BuildContext.cs | 44 ++++++------------------------ 4 files changed, 12 insertions(+), 84 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7ca78e7..cc2fd5f 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -31,30 +31,8 @@ jobs: - name: 'Publish: cli dist package' uses: actions/upload-artifact@v4 with: - name: HomeMatic.Cli - path: .artifacts/dist/HomeMatic.Cli.tar.gz - windows-latest: - name: windows-latest - runs-on: windows-latest - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 - with: - path: | - ~/.nuget/packages - key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} - - name: 'Build with target: Pack' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./build.cmd -t pack - - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 - with: - name: coverage-report-windows - path: .tests/coverage-report + name: SmartMeter.Cli + path: .artifacts/dist/SmartMeter.Cli.tar.gz macos-latest: name: macos-latest runs-on: macos-latest diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5075f28..ad7e0b6 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -26,28 +26,6 @@ jobs: with: name: coverage-report-linux path: .tests/coverage-report - windows-latest: - name: windows-latest - runs-on: windows-latest - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 - with: - path: | - ~/.nuget/packages - key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} - - name: 'Build with target: Pack' - run: ./build.cmd -t pack - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 - with: - name: coverage-report-windows - path: .tests/coverage-report macos-latest: name: macos-latest runs-on: macos-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6c5980..e2861bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,5 +36,5 @@ jobs: - name: 'Publish: cli dist package' uses: actions/upload-artifact@v4 with: - name: HomeMatic.Cli - path: .artifacts/dist/HomeMatic.Cli.tar.gz + name: SmartMeter.Cli + path: .artifacts/dist/SmartMeter.Cli.tar.gz diff --git a/build/BuildContext.cs b/build/BuildContext.cs index 057ea20..3c3f9bc 100644 --- a/build/BuildContext.cs +++ b/build/BuildContext.cs @@ -6,7 +6,6 @@ using CreativeCoders.CakeBuild.Tasks.Templates.Settings; using CreativeCoders.Core; using CreativeCoders.Core.Collections; -using CreativeCoders.Core.IO; using JetBrains.Annotations; namespace Build; @@ -21,7 +20,7 @@ public class BuildContext(ICakeContext context) public string Copyright => $"{DateTime.Now.Year} CreativeCoders"; - public string PackageProjectUrl => "https://github.com/CreativeCodersTeam/HomeMatic"; + public string PackageProjectUrl => "https://github.com/CreativeCodersTeam/SmartMeter"; public string PackageLicenseExpression => PackageLicenseExpressions.ApacheLicense20; @@ -35,9 +34,9 @@ public class BuildContext(ICakeContext context) public DirectoryPath PublishOutputDir => ArtifactsDir.Combine("published"); - private const string CliPath = "source/Tools/Cli/CreativeCoders.HomeMatic.Tools.Cli.Hmc"; + private const string CliPath = "source/CreativeCoders.SmartMeter.Cli"; - private const string CliProjectFile = "CreativeCoders.HomeMatic.Tools.Cli.Hmc.csproj"; + private const string CliProjectFile = "CreativeCoders.SmartMeter.Cli.csproj"; public IEnumerable PublishingItems => [ @@ -45,48 +44,21 @@ public class BuildContext(ICakeContext context) RootDir .Combine(CliPath) .CombineWithFilePath(CliProjectFile), - PublishOutputDir.Combine("cli")), - new PublishingItem( - RootDir - .Combine(CliPath) - .CombineWithFilePath(CliProjectFile), - PublishOutputDir.Combine("cli-win64")) - { - Runtime = "win-x64", - SelfContained = true - }, - new PublishingItem( - RootDir - .Combine(CliPath) - .CombineWithFilePath(CliProjectFile), - PublishOutputDir.Combine("cli-win64-no-selfcontained")) - { - Runtime = "win-x64", - SelfContained = false - }, - new PublishingItem( - RootDir - .Combine(CliPath) - .CombineWithFilePath(CliProjectFile), - PublishOutputDir.Combine("cli-win-arm64")) - { - Runtime = "win-arm64", - SelfContained = true - } + PublishOutputDir.Combine("cli")) ]; - private const string DistPackageName = "HomeMatic.Cli"; + private const string CliDistPackageName = "SmartMeter.Cli"; public IEnumerable DistPackages => [ - new DistPackage(DistPackageName, PublishOutputDir.Combine("cli")) + new DistPackage(CliDistPackageName, PublishOutputDir.Combine("cli")) ]; public string ReleaseName => $"v{Version.FullSemVer}"; public string ReleaseVersion => $"v{Version.FullSemVer}"; - public string ReleaseBody => "HomeMatic Release"; + public string ReleaseBody => "SmartMeter Release"; public bool IsPreRelease => !string.IsNullOrWhiteSpace(Version.PreReleaseTag); @@ -94,6 +66,6 @@ public class BuildContext(ICakeContext context) [ new GitHubReleaseFileAsset( GetRequiredSettings().DistOutputPath - .CombineWithFilePath(DistPackageName + ".tar.gz").FullPath, null) + .CombineWithFilePath(CliDistPackageName + ".tar.gz").FullPath, null) ]; } From 7de9dbae8d0ce6651dac5681c23778fb59fc4f8f Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:35:36 +0200 Subject: [PATCH 24/35] Add .NET SDK detection to `build.sh` and include `scripts` folder in solution file --- SmartMeter.sln | 8 ++++++++ build.sh | 2 ++ 2 files changed, 10 insertions(+) diff --git a/SmartMeter.sln b/SmartMeter.sln index 9f5040e..a2ce898 100644 --- a/SmartMeter.sln +++ b/SmartMeter.sln @@ -49,6 +49,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ci", "ci", "{65B0A31B-BCDB- .github\workflows\sync-ai-config.yml = .github\workflows\sync-ai-config.yml EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{0313FF7A-1065-4C2C-8E8F-53014EB7D314}" + ProjectSection(SolutionItems) = preProject + build.cmd = build.cmd + build.ps1 = build.ps1 + build.sh = build.sh + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -114,5 +121,6 @@ Global {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A} = {675F198B-B173-421F-A53B-F7B98C8D0E4F} {ECD46CF5-A11F-46C9-B274-475F950F10E2} = {AEAE0BA3-481C-45C3-825C-71A5CE7F6A78} {65B0A31B-BCDB-4768-890F-ED46A4773182} = {AEAE0BA3-481C-45C3-825C-71A5CE7F6A78} + {0313FF7A-1065-4C2C-8E8F-53014EB7D314} = {AEAE0BA3-481C-45C3-825C-71A5CE7F6A78} EndGlobalSection EndGlobal diff --git a/build.sh b/build.sh index 35eaaba..d160bd9 100755 --- a/build.sh +++ b/build.sh @@ -19,6 +19,8 @@ export DOTNET_NOLOGO=1 # EXECUTION ########################################################################### +dotnet --list-sdks + # If dotnet CLI is installed globally and it matches requested version, use for execution if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then export DOTNET_EXE="$(command -v dotnet)" From c1336846bd7da99e9bdda299f6b9efcf20ebfdeb Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:36:28 +0200 Subject: [PATCH 25/35] Update .NET SDK version in `global.json` and remove redundant SDK listing in `build.sh` --- build.sh | 2 -- global.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/build.sh b/build.sh index d160bd9..35eaaba 100755 --- a/build.sh +++ b/build.sh @@ -19,8 +19,6 @@ export DOTNET_NOLOGO=1 # EXECUTION ########################################################################### -dotnet --list-sdks - # If dotnet CLI is installed globally and it matches requested version, use for execution if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then export DOTNET_EXE="$(command -v dotnet)" diff --git a/global.json b/global.json index 99b1511..998e2e4 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.202", + "version": "10.0.105", "rollForward": "latestFeature" } } From ccbcea89568854f533f929a9f2a8e36aa5188a2f Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:40:11 +0200 Subject: [PATCH 26/35] Refactor `GitVersion.yml` for consistent branch configuration and improved versioning flexibility. --- GitVersion.yml | 111 +++++++++++-------------------------------------- 1 file changed, 24 insertions(+), 87 deletions(-) diff --git a/GitVersion.yml b/GitVersion.yml index a7050c4..acef5c9 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,5 +1,7 @@ assembly-versioning-scheme: MajorMinorPatch assembly-file-versioning-scheme: MajorMinorPatchTag +tag-prefix: '^v?' +semantic-version-format: Loose major-version-bump-message: '\+semver:\s?(breaking|major)' minor-version-bump-message: '\+semver:\s?(feature|minor)' patch-version-bump-message: '\+semver:\s?(fix|patch)' @@ -7,112 +9,47 @@ no-bump-message: '\+semver:\s?(none|skip)' mode: ContinuousDeployment branches: feature: - mode: ContinuousDeployment - tag: alpha.{BranchName} + mode: ContinuousDelivery + label: feature.{BranchName} increment: Minor - prevent-increment-of-merged-branch-version: false track-merge-target: false - regex: ^features?[/-] + regex: ^features?[\/-](?.+) + prevent-increment: + when-current-commit-tagged: false source-branches: - - develop - - main - - release - - feature - - support - - hotfix + - main tracks-release-branches: false is-release-branch: false - is-mainline: false + is-main-branch: false pre-release-weight: 30000 main: - mode: ContinuousDeployment - tag: '' + mode: ContinuousDelivery + label: 'ci' increment: Patch - prevent-increment-of-merged-branch-version: true track-merge-target: false regex: ^master$|^main$ + prevent-increment: + when-current-commit-tagged: true source-branches: - - develop - - release + - develop + - release tracks-release-branches: false is-release-branch: false - is-mainline: true pre-release-weight: 55000 - develop: - mode: ContinuousDeployment - tag: alpha - increment: Minor - prevent-increment-of-merged-branch-version: false - track-merge-target: true - regex: ^dev(elop)?(ment)?$ - source-branches: [] - tracks-release-branches: true - is-release-branch: false - is-mainline: false - pre-release-weight: 0 - release: - mode: ContinuousDeployment - tag: beta - increment: None - prevent-increment-of-merged-branch-version: true - track-merge-target: false - regex: ^releases?[/-] - source-branches: - - develop - - main - - support - - release - tracks-release-branches: false - is-release-branch: true - is-mainline: false - pre-release-weight: 30000 pull-request: - mode: ContinuousDeployment - tag: PullRequest + mode: ContinuousDelivery + label: PR-{PullRequestName} increment: Inherit - prevent-increment-of-merged-branch-version: false - tag-number-pattern: '[/-](?\d+)' - track-merge-target: false - regex: ^(pull|pull\-requests|pr)[/-] - source-branches: - - develop - - main - - release - - feature - - support - - hotfix - tracks-release-branches: false - is-release-branch: false - is-mainline: false - pre-release-weight: 30000 - hotfix: - mode: ContinuousDeployment - tag: beta - increment: Patch - prevent-increment-of-merged-branch-version: false track-merge-target: false - regex: ^hotfix(es)?[/-] + regex: ^(pull|pull\-requests|pr)[/-](?.+) + prevent-increment: + when-current-commit-tagged: false source-branches: - - develop - - main - - support + - main + - feature tracks-release-branches: false is-release-branch: false - is-mainline: false pre-release-weight: 30000 - support: - mode: ContinuousDeployment - tag: '' - increment: Patch - prevent-increment-of-merged-branch-version: true - track-merge-target: false - regex: ^support[/-] - source-branches: - - main - tracks-release-branches: false - is-release-branch: false - is-mainline: true - pre-release-weight: 55000 ignore: - sha: [] -merge-message-formats: {} + sha: [ ] +merge-message-formats: { } From 7e2fee0bdbd9ee72f6261e467238776c36a62d1d Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:48:07 +0200 Subject: [PATCH 27/35] Fix flaky test in `SmartMeterDataProducerTests` by adding polling mechanism for asynchronous `Subscribe` assertions. --- .../SmlData/SmartMeterDataProducerTests.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs index d7e40f4..673600c 100644 --- a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs @@ -4,6 +4,7 @@ using CreativeCoders.SmartMeter.Core.Tests.Fixtures; using CreativeCoders.SmartMeter.DataProcessing; using FakeItEasy; +using FakeItEasy.Core; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; @@ -46,10 +47,30 @@ public async Task StartAsync_OpensPortAndSubscribesToPipeline() // Assert port.OpenCount.Should().Be(1); - // Note: SubscribeOn wraps the observer, so we cannot assert on the exact observer instance. + // Subscribe runs via SubscribeOn(TaskPoolScheduler), so the Subscribe call on + // the fake may not have happened synchronously when the assertion runs. Poll + // briefly to avoid CI flakiness. + await WaitForCallAsync(pipeline, + call => call.Method.Name == nameof(IObservable.Subscribe)); A.CallTo(() => pipeline.Subscribe(A>._)).MustHaveHappened(); } + private static async Task WaitForCallAsync(object fake, Func predicate, + int timeoutMs = 2000) + { + var start = Environment.TickCount; + + while (Environment.TickCount - start < timeoutMs) + { + if (Fake.GetCalls(fake).Any(predicate)) + { + return; + } + + await Task.Delay(20); + } + } + [Fact] public async Task StartAsync_ForwardsSerialPortBytesToPipeline() { From 909040aa4ade8e6f43d0ce11442b4468242385ec Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:57:08 +0200 Subject: [PATCH 28/35] Update GitHub Actions workflows to use newer versions of `checkout`, `cache`, and `upload-artifact` actions. --- .github/workflows/integration.yml | 14 +++++++------- .github/workflows/main.yml | 20 ++++++++++---------- .github/workflows/pull-request.yml | 12 ++++++------ .github/workflows/release.yml | 8 ++++---- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index cc2fd5f..8a74e51 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,11 +10,11 @@ jobs: name: ubuntu-latest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.nuget/packages @@ -24,12 +24,12 @@ jobs: env: NUGET_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-linux path: .tests/coverage-report - name: 'Publish: cli dist package' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: SmartMeter.Cli path: .artifacts/dist/SmartMeter.Cli.tar.gz @@ -37,11 +37,11 @@ jobs: name: macos-latest runs-on: macos-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.nuget/packages @@ -49,7 +49,7 @@ jobs: - name: 'Build with target: Pack' run: ./build.cmd -t pack - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-macos path: .tests/coverage-report diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee8344a..bf6ab59 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,11 +10,11 @@ jobs: name: ubuntu-latest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.nuget/packages @@ -25,12 +25,12 @@ jobs: NUGET_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-linux path: .tests/coverage-report - name: 'Publish: cli dist package' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: HomeMatic.Cli path: .artifacts/dist/GitTool.Cli.tar.gz @@ -38,11 +38,11 @@ jobs: name: windows-latest runs-on: windows-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.nuget/packages @@ -52,7 +52,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-windows path: .tests/coverage-report @@ -60,11 +60,11 @@ jobs: name: macos-latest runs-on: macos-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.nuget/packages @@ -72,7 +72,7 @@ jobs: - name: 'Build with target: Pack' run: ./build.cmd -t pack - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-macos path: .tests/coverage-report diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ad7e0b6..9869f1b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -10,11 +10,11 @@ jobs: name: ubuntu-latest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.nuget/packages @@ -22,7 +22,7 @@ jobs: - name: 'Build with target: Pack' run: ./build.cmd -t pack - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-linux path: .tests/coverage-report @@ -30,11 +30,11 @@ jobs: name: macos-latest runs-on: macos-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.nuget/packages @@ -42,7 +42,7 @@ jobs: - name: 'Build with target: Pack' run: ./build.cmd -t pack - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-macos path: .tests/coverage-report diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2861bc..4a54e98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,11 +14,11 @@ jobs: name: ubuntu-latest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.nuget/packages @@ -29,12 +29,12 @@ jobs: NUGET_TOKEN: ${{ secrets.NUGET_ORG_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report-linux path: .tests/coverage-report - name: 'Publish: cli dist package' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: SmartMeter.Cli path: .artifacts/dist/SmartMeter.Cli.tar.gz From fa84f63bc301bb18cab159d30e021c7d6e278236 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:40:22 +0200 Subject: [PATCH 29/35] Bump `CreativeCoders.*` package versions to `6.7.2`. --- Directory.Packages.props | 56 ++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4f1ce55..3467e08 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,30 +1,30 @@ - - true - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 3a0088cee8722b003463e2363d0cce969534537f Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:52:32 +0200 Subject: [PATCH 30/35] Rename `HomeMatic.Cli` to `SmartMeter.Cli` in GitHub Actions workflow and remove redundant Windows-specific steps. --- .github/workflows/main.yml | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bf6ab59..21c9a75 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,30 +32,8 @@ jobs: - name: 'Publish: cli dist package' uses: actions/upload-artifact@v7 with: - name: HomeMatic.Cli - path: .artifacts/dist/GitTool.Cli.tar.gz - windows-latest: - name: windows-latest - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - name: 'Cache: ~/.nuget/packages' - uses: actions/cache@v5 - with: - path: | - ~/.nuget/packages - key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} - - name: 'Build with target: Pack' - run: ./build.cmd -t pack - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: 'Publish: coverage_report' - uses: actions/upload-artifact@v7 - with: - name: coverage-report-windows - path: .tests/coverage-report + name: SmartMeter.Cli + path: .artifacts/dist/SmartMeter.Cli.tar.gz macos-latest: name: macos-latest runs-on: macos-latest From df61d88fa48c245402a063961999b981655cefc2 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:59:55 +0200 Subject: [PATCH 31/35] Add support for building and publishing `SmartMeter.Server.Linux` package in CI/CD pipelines. --- .github/workflows/integration.yml | 5 +++++ .github/workflows/main.yml | 5 +++++ .github/workflows/release.yml | 5 +++++ build/BuildContext.cs | 21 ++++++++++++++++++--- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 8a74e51..b520b9a 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -33,6 +33,11 @@ jobs: with: name: SmartMeter.Cli path: .artifacts/dist/SmartMeter.Cli.tar.gz + - name: 'Publish: server Linux dist package' + uses: actions/upload-artifact@v7 + with: + name: SmartMeter.Server.Linux + path: .artifacts/dist/SmartMeter.Server.Linux.tar.gz macos-latest: name: macos-latest runs-on: macos-latest diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 21c9a75..a40319c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,6 +34,11 @@ jobs: with: name: SmartMeter.Cli path: .artifacts/dist/SmartMeter.Cli.tar.gz + - name: 'Publish: server Linux dist package' + uses: actions/upload-artifact@v7 + with: + name: SmartMeter.Server.Linux + path: .artifacts/dist/SmartMeter.Server.Linux.tar.gz macos-latest: name: macos-latest runs-on: macos-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a54e98..033e81b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,3 +38,8 @@ jobs: with: name: SmartMeter.Cli path: .artifacts/dist/SmartMeter.Cli.tar.gz + - name: 'Publish: server Linux dist package' + uses: actions/upload-artifact@v7 + with: + name: SmartMeter.Server.Linux + path: .artifacts/dist/SmartMeter.Server.Linux.tar.gz diff --git a/build/BuildContext.cs b/build/BuildContext.cs index 3c3f9bc..9cdf996 100644 --- a/build/BuildContext.cs +++ b/build/BuildContext.cs @@ -38,20 +38,32 @@ public class BuildContext(ICakeContext context) private const string CliProjectFile = "CreativeCoders.SmartMeter.Cli.csproj"; + private const string ServerLinuxPath = "source/CreativeCoders.SmartMeter.Server.Linux"; + + private const string ServerLinuxProjectFile = "CreativeCoders.SmartMeter.Server.Linux.csproj"; + public IEnumerable PublishingItems => [ new PublishingItem( RootDir .Combine(CliPath) .CombineWithFilePath(CliProjectFile), - PublishOutputDir.Combine("cli")) + PublishOutputDir.Combine("cli")), + new PublishingItem( + RootDir + .Combine(ServerLinuxPath) + .CombineWithFilePath(ServerLinuxProjectFile), + PublishOutputDir.Combine("server-linux")) ]; private const string CliDistPackageName = "SmartMeter.Cli"; + private const string ServerLinuxDistPackageName = "SmartMeter.Server.Linux"; + public IEnumerable DistPackages => [ - new DistPackage(CliDistPackageName, PublishOutputDir.Combine("cli")) + new DistPackage(CliDistPackageName, PublishOutputDir.Combine("cli")), + new DistPackage(ServerLinuxDistPackageName, PublishOutputDir.Combine("server-linux")) ]; public string ReleaseName => $"v{Version.FullSemVer}"; @@ -66,6 +78,9 @@ public class BuildContext(ICakeContext context) [ new GitHubReleaseFileAsset( GetRequiredSettings().DistOutputPath - .CombineWithFilePath(CliDistPackageName + ".tar.gz").FullPath, null) + .CombineWithFilePath(CliDistPackageName + ".tar.gz").FullPath, null), + new GitHubReleaseFileAsset( + GetRequiredSettings().DistOutputPath + .CombineWithFilePath(ServerLinuxDistPackageName + ".tar.gz").FullPath, null) ]; } From cb4e4638aa045ea5a303e61683755c49c8261baa Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:31:21 +0200 Subject: [PATCH 32/35] Add `install-smartmeter.sh` script for automating SmartMeter server installation and include it in the solution file. --- SmartMeter.sln | 1 + install-smartmeter.sh | 291 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100755 install-smartmeter.sh diff --git a/SmartMeter.sln b/SmartMeter.sln index a2ce898..90c5a9e 100644 --- a/SmartMeter.sln +++ b/SmartMeter.sln @@ -12,6 +12,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__global", "__global", "{EA README.md = README.md Directory.Build.props = Directory.Build.props Directory.Packages.props = Directory.Packages.props + install-smartmeter.sh = install-smartmeter.sh EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_build", "_build", "{AEAE0BA3-481C-45C3-825C-71A5CE7F6A78}" diff --git a/install-smartmeter.sh b/install-smartmeter.sh new file mode 100755 index 0000000..f195481 --- /dev/null +++ b/install-smartmeter.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# install-smartmeter.sh +# +# Downloads the SmartMeter Linux server distribution package from either the +# latest stable GitHub release or the latest successful run of a specified +# GitHub Actions workflow, extracts it and runs the bundled install.sh. +# +# Usage: ./install-smartmeter.sh [--workflows ] [-y|--yes] [-h|--help] + +set -euo pipefail + +# Repository is intentionally hard-wired: this installer only targets the +# official SmartMeter repository. +readonly REPO="CreativeCodersTeam/SmartMeter" +readonly ARTIFACT_NAME="SmartMeter.Server.Linux" +readonly ARCHIVE_FILE="${ARTIFACT_NAME}.tar.gz" +readonly DEFAULT_BRANCH="main" + +# Exit codes +readonly EXIT_USER_ABORT=1 +readonly EXIT_MISSING_TOOL=2 +readonly EXIT_NOT_FOUND=3 +readonly EXIT_DOWNLOAD_FAILED=4 +readonly EXIT_INSTALL_FAILED=5 + +WORKFLOW_FILE="" +USE_WORKFLOW=0 +ASSUME_YES=0 +TMP_DIR="" + +usage() { + cat < Use the latest successful run of the given workflow + file (e.g. main.yml) on branch '${DEFAULT_BRANCH}' as + artifact source. When omitted, the latest stable + GitHub release is used. + -y, --yes Skip the interactive confirmation prompt. + -h, --help Show this help and exit. + +Requires: curl, tar, jq, sudo (and gh authenticated when --workflows is used). +EOF +} + +log() { printf '%s\n' "$*"; } +warn() { printf 'WARN: %s\n' "$*" >&2; } +err() { printf 'ERROR: %s\n' "$*" >&2; } + +cleanup() { + if [[ -n "${TMP_DIR}" && -d "${TMP_DIR}" ]]; then + rm -rf -- "${TMP_DIR}" + fi +} +trap cleanup EXIT + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --workflows) + if [[ $# -lt 2 || -z "${2:-}" || "${2:0:1}" == "-" ]]; then + err "--workflows requires a workflow file name (e.g. main.yml)" + exit "${EXIT_MISSING_TOOL}" + fi + USE_WORKFLOW=1 + WORKFLOW_FILE="$2" + shift 2 + ;; + -y|--yes) + ASSUME_YES=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + err "Unknown argument: $1" + usage >&2 + exit "${EXIT_MISSING_TOOL}" + ;; + esac + done +} + +check_dependencies() { + local missing=() + local tool + # Tools required in every mode: curl fetches the archive, tar extracts it, + # jq parses API JSON, sudo runs the privileged install.sh. + for tool in curl tar jq sudo; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing+=("$tool") + fi + done + # gh is only required for workflow-run access: listing runs and + # downloading workflow artifacts always requires authentication. + # Public release assets are fetched anonymously via curl. + if [[ "${USE_WORKFLOW}" -eq 1 ]] && ! command -v gh >/dev/null 2>&1; then + missing+=("gh") + fi + if [[ ${#missing[@]} -gt 0 ]]; then + err "Missing required tools: ${missing[*]}" + exit "${EXIT_MISSING_TOOL}" + fi + if [[ "${USE_WORKFLOW}" -eq 1 ]] && ! gh auth status >/dev/null 2>&1; then + err "gh is not authenticated. Run 'gh auth login' first." + exit "${EXIT_MISSING_TOOL}" + fi +} + +# Locates the latest stable (non-prerelease, non-draft) release via the +# public GitHub REST API (no authentication required for public repos) and +# validates that the expected archive asset is attached. +# Prints the asset's browser_download_url on stdout. +locate_release() { + local api_url="https://api.github.com/repos/${REPO}/releases/latest" + local response + if ! response=$(curl -fsSL \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}"); then + err "Failed to query latest release from ${REPO} (${api_url})." + exit "${EXIT_NOT_FOUND}" + fi + + local tag published html_url download_url + tag=$(printf '%s' "${response}" | jq -r '.tag_name // ""') + published=$(printf '%s' "${response}" | jq -r '.published_at // ""') + html_url=$(printf '%s' "${response}" | jq -r '.html_url // ""') + download_url=$(printf '%s' "${response}" \ + | jq -r --arg name "${ARCHIVE_FILE}" \ + '(.assets // [])[] | select(.name == $name) | .browser_download_url' \ + | head -n 1) + + if [[ -z "${tag}" ]]; then + err "No stable release found in ${REPO}." + exit "${EXIT_NOT_FOUND}" + fi + if [[ -z "${download_url}" ]]; then + err "Release ${tag} does not contain asset ${ARCHIVE_FILE}." + exit "${EXIT_NOT_FOUND}" + fi + + printf 'Found stable release:\n' >&2 + printf ' Tag: %s\n' "${tag}" >&2 + printf ' Published: %s\n' "${published}" >&2 + printf ' URL: %s\n' "${html_url}" >&2 + printf ' Asset: %s\n' "${ARCHIVE_FILE}" >&2 + + printf '%s\n' "${download_url}" +} + +# Locates the latest successful run of the specified workflow on the default +# branch. Prints the run's database id. +locate_workflow_run() { + local fields + if ! fields=$(gh run list \ + --repo "${REPO}" \ + --workflow "${WORKFLOW_FILE}" \ + --status success \ + --branch "${DEFAULT_BRANCH}" \ + --limit 1 \ + --json databaseId,headSha,displayTitle,createdAt,url \ + --jq '.[0] | [(.databaseId | tostring // ""), (.headSha // ""), (.displayTitle // ""), (.createdAt // ""), (.url // "")] | @tsv' \ + 2>/dev/null); then + err "Failed to query workflow runs for ${WORKFLOW_FILE} in ${REPO}." + exit "${EXIT_NOT_FOUND}" + fi + + local id sha title created url + IFS=$'\t' read -r id sha title created url <<<"${fields}" + + if [[ -z "${id}" ]]; then + err "No successful run found for workflow '${WORKFLOW_FILE}' on branch '${DEFAULT_BRANCH}'." + exit "${EXIT_NOT_FOUND}" + fi + + printf 'Found successful workflow run:\n' >&2 + printf ' Workflow: %s\n' "${WORKFLOW_FILE}" >&2 + printf ' Run ID: %s\n' "${id}" >&2 + printf ' Commit: %s\n' "${sha}" >&2 + printf ' Title: %s\n' "${title}" >&2 + printf ' Created: %s\n' "${created}" >&2 + printf ' URL: %s\n' "${url}" >&2 + printf ' Artifact: %s\n' "${ARTIFACT_NAME}" >&2 + + printf '%s\n' "${id}" +} + +# Helper removed: we now use `gh --jq ...` directly, which leverages gh's +# embedded jq and keeps the external dependency list minimal. + +confirm() { + if [[ "${ASSUME_YES}" -eq 1 ]]; then + return 0 + fi + local reply="" + printf 'Continue with download and installation? [y/N] ' >&2 + read -r reply || true + if [[ "${reply}" != "y" && "${reply}" != "Y" ]]; then + log "Aborted by user." + exit "${EXIT_USER_ABORT}" + fi +} + +download_release_asset() { + local url="$1" dest="$2" + local target="${dest}/${ARCHIVE_FILE}" + log "Downloading ${ARCHIVE_FILE} from ${url}..." + if ! curl -fL --progress-bar -o "${target}" "${url}"; then + err "Failed to download release asset." + exit "${EXIT_DOWNLOAD_FAILED}" + fi + printf '%s\n' "${target}" +} + +download_workflow_artifact() { + local run_id="$1" dest="$2" + log "Downloading workflow artifact ${ARTIFACT_NAME} from run ${run_id}..." + if ! gh run download "${run_id}" \ + --repo "${REPO}" \ + --name "${ARTIFACT_NAME}" \ + --dir "${dest}"; then + err "Failed to download workflow artifact." + exit "${EXIT_DOWNLOAD_FAILED}" + fi + local archive + archive=$(find "${dest}" -type f -name "${ARCHIVE_FILE}" -print -quit) + if [[ -z "${archive}" ]]; then + err "Artifact ${ARTIFACT_NAME} did not contain ${ARCHIVE_FILE}." + exit "${EXIT_DOWNLOAD_FAILED}" + fi + printf '%s\n' "${archive}" +} + +extract_archive() { + local archive="$1" extract_dir="$2" + mkdir -p "${extract_dir}" + log "Extracting $(basename "${archive}")..." + if ! tar -xzf "${archive}" -C "${extract_dir}"; then + err "Failed to extract ${archive}." + exit "${EXIT_DOWNLOAD_FAILED}" + fi +} + +run_installer() { + local extract_dir="$1" + local installer="${extract_dir}/install.sh" + if [[ ! -f "${installer}" ]]; then + err "install.sh not found in extracted package." + exit "${EXIT_INSTALL_FAILED}" + fi + chmod +x "${installer}" + log "Running installer with sudo..." + if ! ( cd "${extract_dir}" && sudo ./install.sh ); then + err "install.sh failed." + exit "${EXIT_INSTALL_FAILED}" + fi +} + +main() { + parse_args "$@" + check_dependencies + + local source_ref archive_path + TMP_DIR=$(mktemp -d -t smartmeter-install-XXXXXX) + local download_dir="${TMP_DIR}/download" + local extract_dir="${TMP_DIR}/extracted" + mkdir -p "${download_dir}" + + if [[ "${USE_WORKFLOW}" -eq 1 ]]; then + source_ref=$(locate_workflow_run) + confirm + archive_path=$(download_workflow_artifact "${source_ref}" "${download_dir}") + else + source_ref=$(locate_release) + confirm + archive_path=$(download_release_asset "${source_ref}" "${download_dir}") + fi + + extract_archive "${archive_path}" "${extract_dir}" + run_installer "${extract_dir}" + + log "Installation complete." +} + +main "$@" From ea5449ca507c70612961bf5c744c012df71da50e Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:43:45 +0200 Subject: [PATCH 33/35] Update `install.sh` to create a backup of the existing installation instead of deleting files --- source/CreativeCoders.SmartMeter.Server.Linux/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/CreativeCoders.SmartMeter.Server.Linux/install.sh b/source/CreativeCoders.SmartMeter.Server.Linux/install.sh index 19bc275..f7eb1fe 100644 --- a/source/CreativeCoders.SmartMeter.Server.Linux/install.sh +++ b/source/CreativeCoders.SmartMeter.Server.Linux/install.sh @@ -11,7 +11,7 @@ if systemctl status "$SERVICE_NAME" >/dev/null 2>&1; then fi if [ -d "$APP_DIR" ]; then - echo "Delete existing installation files" + echo "Create backup from latest installation" rm -rf "${APP_DIR:?}.bak/"* mv -f "$APP_DIR" "${APP_DIR:?}.bak" else From 8534aaf1e724c0dabe9dab6b2682492f417e1d4a Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:00:04 +0200 Subject: [PATCH 34/35] Refactor SML parser and related tests for consistent formatting and improved readability. Apply minor code cleanups across projects. --- SmartMeter.sln.DotSettings | 1 + .../Framing/SmlMessageDetector.cs | 10 +- .../Parsing/ISmlParser.cs | 2 + .../Parsing/SmlParser.cs | 10 +- .../SmlServiceCollectionExtensions.cs | 2 + .../Units/SmlUnit.cs | 3 + .../CreativeCoders.SmartMeter.Cli/Program.cs | 2 +- .../ISmartMeterReactiveDataPipeline.cs | 4 +- .../History/ValueHistory.cs | 1 - .../SmartMeterValue.cs | 2 +- .../SmlValueProcessor.cs | 1 + .../Parsing/SmlParserTests.cs | 242 +++++++++--------- .../Fixtures/FakeReactiveSerialPort.cs | 2 +- .../SmlData/SmartMeterDataProducerTests.cs | 2 +- .../SmartMeterReactiveDataPipelineTests.cs | 7 +- .../Unlock/ObisCodeScannerTests.cs | 2 + .../Unlock/SmartMeterUnlockerTests.cs | 1 + .../MqttValuePublisherTests.cs | 16 +- .../SmlValueProcessorTests.cs | 2 +- .../SmartMeterServerTests.cs | 5 +- 20 files changed, 162 insertions(+), 155 deletions(-) diff --git a/SmartMeter.sln.DotSettings b/SmartMeter.sln.DotSettings index 7270d8c..ef268ef 100644 --- a/SmartMeter.sln.DotSettings +++ b/SmartMeter.sln.DotSettings @@ -1,5 +1,6 @@  True + True True True diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetector.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetector.cs index 2d660ea..2c57ebf 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetector.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Framing/SmlMessageDetector.cs @@ -21,7 +21,7 @@ public sealed class SmlMessageDetector : ISmlMessageDetector // Defensive cap to prevent unbounded buffer growth if the peer never closes a frame. private const int MaxBufferSize = 64 * 1024; - private static readonly byte[] _startEscape = + private static readonly byte[] StartEscape = [0x1B, 0x1B, 0x1B, 0x1B, 0x01, 0x01, 0x01, 0x01]; private readonly Subject _subject = new Subject(); @@ -131,7 +131,7 @@ private bool TryExtractFrame(out SmlFrame frame) // Scan from just after the 8-byte start escape for the end escape, while // correctly stepping over doubled 0x1B escape runs inside the body. - var pos = _startEscape.Length; + var pos = StartEscape.Length; while (pos + 4 <= _length) { @@ -178,13 +178,13 @@ private bool TryExtractFrame(out SmlFrame frame) private bool TryAnchorOnStart() { - var idx = IndexOf(_buffer.AsSpan(0, _length), _startEscape); + var idx = IndexOf(_buffer.AsSpan(0, _length), StartEscape); if (idx < 0) { // Keep only the last (startEscape.Length - 1) bytes so a start escape // spanning two Append calls can still be detected. - var keep = Math.Min(_startEscape.Length - 1, _length); + var keep = Math.Min(StartEscape.Length - 1, _length); var drop = _length - keep; if (drop > 0) @@ -225,7 +225,7 @@ private static byte[] ExtractPayload(byte[] frame, int paddingBytes) { // Body lies between the 8-byte start escape and the 8-byte end escape, // minus any trailing 0x00 padding bytes used to align to a 4-byte boundary. - var bodyStart = _startEscape.Length; + var bodyStart = StartEscape.Length; var bodyEnd = frame.Length - 8 - paddingBytes; if (bodyEnd < bodyStart) diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/ISmlParser.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/ISmlParser.cs index d2995d2..3fba108 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Parsing/ISmlParser.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/ISmlParser.cs @@ -1,7 +1,9 @@ using CreativeCoders.SmartMessageLanguage.Framing; +using JetBrains.Annotations; namespace CreativeCoders.SmartMessageLanguage.Parsing; +[PublicAPI] public interface ISmlParser { /// Parses all OBIS values contained in the given frame. diff --git a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs index 30363cb..da745d7 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs @@ -317,6 +317,7 @@ private void ReadValListEntry(ref SmlTlvReader reader, List values, private decimal? ComputeDecimalValue(SmlTlvElement value, sbyte scaler, List warnings, string obisCode) { + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault switch (value.Type) { case SmlMessageValueType.Unsigned: @@ -363,12 +364,9 @@ private static string FormatObisCode(ReadOnlySpan raw) private static string ToHex(ReadOnlySpan data) { - if (data.IsEmpty) - { - return string.Empty; - } - - return Convert.ToHexString(data); + return data.IsEmpty + ? string.Empty + : Convert.ToHexString(data); } private static void SkipListEntries(ref SmlTlvReader reader, int count) diff --git a/source/CreativeCoders.SmartMessageLanguage/SmlServiceCollectionExtensions.cs b/source/CreativeCoders.SmartMessageLanguage/SmlServiceCollectionExtensions.cs index b4bff3f..5033066 100644 --- a/source/CreativeCoders.SmartMessageLanguage/SmlServiceCollectionExtensions.cs +++ b/source/CreativeCoders.SmartMessageLanguage/SmlServiceCollectionExtensions.cs @@ -1,10 +1,12 @@ using CreativeCoders.SmartMessageLanguage.Framing; using CreativeCoders.SmartMessageLanguage.Parsing; +using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; namespace CreativeCoders.SmartMessageLanguage; +[PublicAPI] public static class SmlServiceCollectionExtensions { public static IServiceCollection AddSml(this IServiceCollection services) diff --git a/source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs b/source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs index 21f50ae..dc00d10 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs @@ -1,9 +1,12 @@ +using JetBrains.Annotations; + namespace CreativeCoders.SmartMessageLanguage.Units; /// /// Subset of DLMS/IEC 62056-62 unit codes commonly used by electricity meters. /// The numeric values match the SML SML_Unit codes as received on the wire. /// +[PublicAPI] public enum SmlUnit : byte { /// Unknown or unassigned unit. diff --git a/source/CreativeCoders.SmartMeter.Cli/Program.cs b/source/CreativeCoders.SmartMeter.Cli/Program.cs index f90227e..23cc94e 100644 --- a/source/CreativeCoders.SmartMeter.Cli/Program.cs +++ b/source/CreativeCoders.SmartMeter.Cli/Program.cs @@ -10,7 +10,7 @@ namespace CreativeCoders.SmartMeter.Cli; internal static class Program { - static async Task Main(string[] args) + internal static async Task Main(string[] args) { AnsiConsole.WriteLine("Starting Smart Meter CLI..."); diff --git a/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs index e81f3eb..59f38f1 100644 --- a/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs +++ b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs @@ -2,6 +2,4 @@ namespace CreativeCoders.SmartMeter.Core.SmlData; -public interface ISmartMeterReactiveDataPipeline : IObserver, IObservable -{ -} +public interface ISmartMeterReactiveDataPipeline : IObserver, IObservable; diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs index efd41f9..c40ec0e 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs @@ -1,5 +1,4 @@ using System.Collections.Concurrent; -using CreativeCoders.Core; namespace CreativeCoders.SmartMeter.DataProcessing.History; diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs index ed3e272..6807aac 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs @@ -6,5 +6,5 @@ public class SmartMeterValue(SmartMeterValueType type) public decimal Value { get; init; } - public bool WriteAsJson { get; set; } = true; + public bool WriteAsJson { get; init; } = true; } diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs index 377b6f2..af245ab 100644 --- a/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs +++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs @@ -78,6 +78,7 @@ private void PushNewCurrentValue(SmartMeterValue value) return; } + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault switch (value.Type) { case SmartMeterValueType.CurrentPurchasingPower: diff --git a/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs index 72c400d..4f51406 100644 --- a/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs +++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs @@ -11,7 +11,7 @@ namespace CreativeCoders.SmartMessageLanguage.Tests.Parsing; public class SmlParserTests { - private static SmlParser CreateSut() => new(NullLogger.Instance); + private static SmlParser CreateSut() => new SmlParser(NullLogger.Instance); [Fact] public void Parse_GetListResponsePayload_ExtractsAllObisValues() @@ -85,9 +85,9 @@ public void Parse_MessageWithWrongEntryCount_SkipsMessageSilently() // Top-level List(3) instead of the expected List(6): parser just skips the entries. var payload = new TlvBuilder() .List(3) - .OctetString([0x01]) - .OctetString([0x02]) - .OctetString([0x03]) + .OctetString([0x01]) + .OctetString([0x02]) + .OctetString([0x03]) .ToArray(); var result = CreateSut().Parse(payload); @@ -101,11 +101,11 @@ public void Parse_MessageBodyWithWrongWrapperArity_AddsWarning() // messageBody (field 4) must be List(2); supply List(1) → "Malformed SML_Message body wrapper". var payload = new TlvBuilder() .List(6) - .OctetString([0xAA]) // transactionId - .UInt8(0) // groupNo - .UInt8(0) // abortOnError - .List(1) // malformed body wrapper - .UInt32(0x00000701) + .OctetString([0xAA]) // transactionId + .UInt8(0) // groupNo + .UInt8(0) // abortOnError + .List(1) // malformed body wrapper + .UInt32(0x00000701) .ToArray(); var result = CreateSut().Parse(payload); @@ -119,12 +119,12 @@ public void Parse_MessageBodyMissingTypeTag_AddsWarning() // messageBody type tag must be Unsigned; supply OctetString instead. var payload = new TlvBuilder() .List(6) - .OctetString([0xAA]) - .UInt8(0) - .UInt8(0) - .List(2) - .OctetString([0x01]) // should be Unsigned type tag - .List(0) + .OctetString([0xAA]) + .UInt8(0) + .UInt8(0) + .List(2) + .OctetString([0x01]) // should be Unsigned type tag + .List(0) .ToArray(); var result = CreateSut().Parse(payload); @@ -150,17 +150,17 @@ public void Parse_GetListResponseWithWrongFieldCount_AddsWarning() // GetListResponse must be List(7); inject List(3). var payload = new TlvBuilder() .List(6) - .OctetString([0xAA]) - .UInt8(0) - .UInt8(0) - .List(2) - .UInt32(0x00000701) - .List(3) - .OctetString([0x01]) - .OctetString([0x02]) - .OctetString([0x03]) - .UInt8(0) - .EndOfMessage() + .OctetString([0xAA]) + .UInt8(0) + .UInt8(0) + .List(2) + .UInt32(0x00000701) + .List(3) + .OctetString([0x01]) + .OctetString([0x02]) + .OctetString([0x03]) + .UInt8(0) + .EndOfMessage() .ToArray(); var result = CreateSut().Parse(payload); @@ -196,7 +196,7 @@ public void Parse_ValListEntryNotAList_AddsWarning() { var payload = BuildGetListResponseWithValList(b => b .List(1) - .UInt8(0x42)); // entry should itself be a List, not an Unsigned. + .UInt8(0x42)); // entry should itself be a List, not an Unsigned. var result = CreateSut().Parse(payload); @@ -208,10 +208,10 @@ public void Parse_ValListEntryTooShort_AddsWarning() { var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(3) - .OctetString(SampleSmlFile.ObisEnergy) - .Null() - .Null()); + .List(3) + .OctetString(SampleSmlFile.ObisEnergy) + .Null() + .Null()); var result = CreateSut().Parse(payload); @@ -223,13 +223,13 @@ public void Parse_ValListEntryMissingObjName_AddsWarning() { var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(6) - .UInt8(0x42) // objName must be OctetString - .Null() - .Null() - .UInt8(0) - .Int8(0) - .UInt8(0)); + .List(6) + .UInt8(0x42) // objName must be OctetString + .Null() + .Null() + .UInt8(0) + .Int8(0) + .UInt8(0)); var result = CreateSut().Parse(payload); @@ -242,13 +242,13 @@ public void Parse_UnknownUnitCode_AddsWarningButKeepsValue() // Unit code 99 is not defined in SmlUnit → warning, Unit=Unknown, value still parsed. var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString(SampleSmlFile.ObisEnergy) - .Null().Null() - .UInt8(99) - .Int8(0) - .UInt8(42) - .Null()); + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(99) + .Int8(0) + .UInt8(42) + .Null()); var result = CreateSut().Parse(payload); @@ -263,13 +263,13 @@ public void Parse_UnitCodeZero_IsTreatedAsUnknownWithoutWarning() // Unit code 0 is defined as SmlUnit.Unknown; no warning should be raised. var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString(SampleSmlFile.ObisEnergy) - .Null().Null() - .UInt8(0) - .Int8(0) - .UInt8(1) - .Null()); + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(0) + .Int8(0) + .UInt8(1) + .Null()); var result = CreateSut().Parse(payload); @@ -282,13 +282,13 @@ public void Parse_BooleanValue_IsMappedToOneOrZero() { var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString(SampleSmlFile.ObisEnergy) - .Null().Null() - .UInt8(0) - .Int8(0) - .Bool(true) - .Null()); + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(0) + .Int8(0) + .Bool(true) + .Null()); var result = CreateSut().Parse(payload); @@ -303,13 +303,13 @@ public void Parse_OctetStringValue_KeepsRawButNullNumeric() // Server ID-like value: OctetString → RawValue populated, Value null, no warning. var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString(SampleSmlFile.ObisEnergy) - .Null().Null() - .UInt8(0) - .Int8(0) - .OctetString([0x01, 0x02, 0x03]) - .Null()); + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(0) + .Int8(0) + .OctetString([0x01, 0x02, 0x03]) + .Null()); var result = CreateSut().Parse(payload); @@ -326,13 +326,13 @@ public void Parse_UnsupportedValueType_AddsWarningAndNullValue() // A List as value is not supported by ComputeDecimalValue → warning. var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString(SampleSmlFile.ObisEnergy) - .Null().Null() - .UInt8(0) - .Int8(0) - .List(0) - .Null()); + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(0) + .Int8(0) + .List(0) + .Null()); var result = CreateSut().Parse(payload); @@ -348,13 +348,13 @@ public void Parse_ScaledUnsignedValue_AppliesPowerOfTen(sbyte scaler, byte raw, { var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString(SampleSmlFile.ObisEnergy) - .Null().Null() - .UInt8(SampleSmlFile.UnitWattHour) - .Int8(scaler) - .UInt8(raw) - .Null()); + .List(7) + .OctetString(SampleSmlFile.ObisEnergy) + .Null().Null() + .UInt8(SampleSmlFile.UnitWattHour) + .Int8(scaler) + .UInt8(raw) + .Null()); var result = CreateSut().Parse(payload); @@ -367,13 +367,13 @@ public void Parse_SignedValue_IsSignExtended() // Signed -5 with scaler 0 → -5. var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString(SampleSmlFile.ObisPower) - .Null().Null() - .UInt8(SampleSmlFile.UnitWatt) - .Int8(0) - .Int8(-5) - .Null()); + .List(7) + .OctetString(SampleSmlFile.ObisPower) + .Null().Null() + .UInt8(SampleSmlFile.UnitWatt) + .Int8(0) + .Int8(-5) + .Null()); var result = CreateSut().Parse(payload); @@ -386,13 +386,13 @@ public void Parse_MissingScaler_DefaultsToZero() // Scaler is OctetString (unexpected) → parser keeps scaler = 0. var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString(SampleSmlFile.ObisPower) - .Null().Null() - .UInt8(SampleSmlFile.UnitWatt) - .OctetString([0x00]) // unexpected scaler type - .UInt8(7) - .Null()); + .List(7) + .OctetString(SampleSmlFile.ObisPower) + .Null().Null() + .UInt8(SampleSmlFile.UnitWatt) + .OctetString([0x00]) // unexpected scaler type + .UInt8(7) + .Null()); var result = CreateSut().Parse(payload); @@ -407,13 +407,13 @@ public void Parse_ShortObisBytes_FallsBackToHexRepresentation() // 3-byte objName is below OBIS minimum length → formatted as uppercase hex. var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString([0xDE, 0xAD, 0xBE]) - .Null().Null() - .UInt8(0) - .Int8(0) - .UInt8(1) - .Null()); + .List(7) + .OctetString([0xDE, 0xAD, 0xBE]) + .Null().Null() + .UInt8(0) + .Int8(0) + .UInt8(1) + .Null()); var result = CreateSut().Parse(payload); @@ -426,11 +426,11 @@ public void Parse_FiveByteObis_DefaultsFTo255() // 5-byte objName defaults the optional F to 255. var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(7) - .OctetString([1, 0, 1, 8, 0]) - .Null().Null() - .UInt8(0).Int8(0).UInt8(0) - .Null()); + .List(7) + .OctetString([1, 0, 1, 8, 0]) + .Null().Null() + .UInt8(0).Int8(0).UInt8(0) + .Null()); var result = CreateSut().Parse(payload); @@ -443,14 +443,14 @@ public void Parse_ValListEntryWithExtraTrailingFields_StillParsesValue() // Entry with 8 fields (valid: minimum is 6, the parser skips anything beyond field 6). var payload = BuildGetListResponseWithValList(b => b .List(1) - .List(8) - .OctetString(SampleSmlFile.ObisPower) - .Null().Null() - .UInt8(SampleSmlFile.UnitWatt) - .Int8(0) - .UInt8(42) - .Null() // valueSignature - .Null()); // extra field + .List(8) + .OctetString(SampleSmlFile.ObisPower) + .Null().Null() + .UInt8(SampleSmlFile.UnitWatt) + .Int8(0) + .UInt8(42) + .Null() // valueSignature + .Null()); // extra field var result = CreateSut().Parse(payload); @@ -474,9 +474,9 @@ private static byte[] BuildSingleMessage(uint messageBodyType, Action valList { var inner = new TlvBuilder() .List(7) - .OctetString([0x01]) - .OctetString([0x02]) - .Null() - .Null(); + .OctetString([0x01]) + .OctetString([0x02]) + .Null() + .Null(); valListBuilder(inner); inner.Null().Null(); diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/Fixtures/FakeReactiveSerialPort.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/Fixtures/FakeReactiveSerialPort.cs index bb81691..fa84a28 100644 --- a/tests/CreativeCoders.SmartMeter.Core.Tests/Fixtures/FakeReactiveSerialPort.cs +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/Fixtures/FakeReactiveSerialPort.cs @@ -10,7 +10,7 @@ namespace CreativeCoders.SmartMeter.Core.Tests.Fixtures; /// internal sealed class FakeReactiveSerialPort : IReactiveSerialPort { - private readonly Subject _subject = new(); + private readonly Subject _subject = new Subject(); public List Writes { get; } = []; diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs index 673600c..9f36f3f 100644 --- a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs @@ -51,7 +51,7 @@ public async Task StartAsync_OpensPortAndSubscribesToPipeline() // the fake may not have happened synchronously when the assertion runs. Poll // briefly to avoid CI flakiness. await WaitForCallAsync(pipeline, - call => call.Method.Name == nameof(IObservable.Subscribe)); + call => call.Method.Name == nameof(IObservable<>.Subscribe)); A.CallTo(() => pipeline.Subscribe(A>._)).MustHaveHappened(); } diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs index 038a6ee..4f4fcba 100644 --- a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs @@ -1,6 +1,5 @@ using System.Diagnostics.CodeAnalysis; using System.Reactive.Subjects; -using System.Reactive.Linq; using AwesomeAssertions; using CreativeCoders.SmartMessageLanguage.Framing; using CreativeCoders.SmartMessageLanguage.Parsing; @@ -18,7 +17,7 @@ public class SmartMeterReactiveDataPipelineTests { private sealed class StubSmlMessageDetector : ISmlMessageDetector { - public Subject MessagesSubject { get; } = new(); + public Subject MessagesSubject { get; } = new Subject(); public event EventHandler? MessageReceived; @@ -56,10 +55,10 @@ private sealed class StubSmlParser : ISmlParser public SmlParseResult Parse(ReadOnlySpan payload) => ParseBehavior(payload.ToArray()); } - private static SmlFrame MakeFrame(byte[] payload) => new(payload, payload, true, 0); + private static SmlFrame MakeFrame(byte[] payload) => new SmlFrame(payload, payload, true, 0); private static ObisValue MakeObis(string code, decimal value) => - new(code, value, SmlUnit.WattHour, 0, [], SmlMessageValueType.Unsigned); + new ObisValue(code, value, SmlUnit.WattHour, 0, [], SmlMessageValueType.Unsigned); private static (SmartMeterReactiveDataPipeline Sut, StubSmlParser Parser, StubSmlMessageDetector Detector) CreateSut(SmartMeterOptions? opts = null) diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs index 7b96694..fb0fcf5 100644 --- a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text; using AwesomeAssertions; using CreativeCoders.SmartMeter.Core.Unlock; @@ -5,6 +6,7 @@ namespace CreativeCoders.SmartMeter.Core.Tests.Unlock; +[SuppressMessage("ReSharper", "UseUtf8StringLiteral")] public class ObisCodeScannerTests { [Theory] diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs index 16b8594..a651069 100644 --- a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs +++ b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs @@ -10,6 +10,7 @@ namespace CreativeCoders.SmartMeter.Core.Tests.Unlock; +[SuppressMessage("ReSharper", "UseUtf8StringLiteral")] public class SmartMeterUnlockerTests { private static (SmartMeterUnlocker Sut, FakeReactiveSerialPort Port) CreateSut( diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs index 1647582..7f80d79 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs @@ -11,7 +11,8 @@ namespace CreativeCoders.SmartMeter.DataProcessing.Tests; public class MqttValuePublisherTests : IAsyncLifetime { private readonly IMqttClient _client = A.Fake(); - private readonly MqttPublisherOptions _options = new() + + private readonly MqttPublisherOptions _options = new MqttPublisherOptions { Server = new Uri("tcp://localhost:1883"), ClientName = "test-client", @@ -31,11 +32,11 @@ public async Task DisposeAsync() } } - private static MqttClientConnectResult SuccessConnectResult() => - new() { ResultCode = MqttClientConnectResultCode.Success }; + private static MqttClientConnectResult SuccessConnectResult() => new MqttClientConnectResult + { ResultCode = MqttClientConnectResultCode.Success }; private static MqttClientPublishResult PublishOk() => - new(null, MqttClientPublishReasonCode.Success, null!, []); + new MqttClientPublishResult(null, MqttClientPublishReasonCode.Success, null!, []); private MqttValuePublisher Create() { @@ -73,7 +74,7 @@ public async Task InitAsync_WhenConnectFails_ThrowsInvalidOperationException() var sut = Create(); // Act - var act = () => sut.InitAsync(); + var act = sut.InitAsync; // Assert await act.Should().ThrowAsync() @@ -106,7 +107,8 @@ await WaitForAsync(() => published.Should().ContainSingle(); published[0].Topic.Should().Be("smartmeter/values/TotalPurchasedEnergy"); - Encoding.UTF8.GetString(System.Buffers.BuffersExtensions.ToArray(published[0].Payload)).Should().Contain("\"Value\":42"); + Encoding.UTF8.GetString(System.Buffers.BuffersExtensions.ToArray(published[0].Payload)).Should() + .Contain("\"Value\":42"); } [Fact] @@ -205,7 +207,7 @@ public void OnError_AndOnCompleted_DoNotThrow() // Act var act1 = () => sut.OnError(new Exception("boom")); - var act2 = () => sut.OnCompleted(); + var act2 = sut.OnCompleted; // Assert act1.Should().NotThrow(); diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs index c3b5c2e..6dc893e 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs @@ -74,7 +74,7 @@ public void Subscribe_WithTwoPurchasedEnergyValues_ShouldReturnTotalAndCurrentAn var smlValueProcessor = new SmlValueProcessor(input, fakeTimeProvider); // Act - smlValueProcessor.Subscribe(x => resultValues.Add(x)); + smlValueProcessor.Subscribe(resultValues.Add); input.OnNext(smlValue1); diff --git a/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs index adeee61..50e077c 100644 --- a/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs +++ b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs @@ -1,7 +1,6 @@ using AwesomeAssertions; using CreativeCoders.SmartMeter.Core.SmlData; using CreativeCoders.SmartMeter.DataProcessing; -using CreativeCoders.SmartMeter.Server.Core; using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -14,7 +13,7 @@ public class SmartMeterServerTests private readonly ISmartMeterDataProducer _producer = A.Fake(); private SmartMeterServer CreateSut() => - new(NullLogger.Instance, _publisher, _producer); + new SmartMeterServer(NullLogger.Instance, _publisher, _producer); [Fact] public async Task StartAsync_InitializesPublisherThenStartsProducerWithPublisher() @@ -38,7 +37,7 @@ public async Task StartAsync_WhenPublisherInitFails_DoesNotStartProducer() var sut = CreateSut(); // Act - var act = () => sut.StartAsync(); + var act = sut.StartAsync; // Assert await act.Should().ThrowAsync(); From de5bcad19411557d2c863456e4c388f2a959819b Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:16:25 +0200 Subject: [PATCH 35/35] Refactor `Crc16X25` table usage for clarity --- README.md | 163 +++++++++++++++++- .../Framing/Crc16X25.cs | 4 +- 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bc67542..c4794ce 100644 --- a/README.md +++ b/README.md @@ -1 +1,162 @@ -# SmartMeter \ No newline at end of file +# SmartMeter + +A .NET-based server application that reads energy data from smart meters via the [Smart Message Language (SML)](https://de.wikipedia.org/wiki/Smart_Message_Language) protocol and publishes the values over MQTT. It runs as a systemd daemon on Linux and connects to the meter through a serial optical coupler (e.g. `/dev/ttyUSB0`). + +## Features + +- **SML Protocol Parser** -- streaming detector and parser for the Smart Message Language protocol, available as a standalone [NuGet package](https://www.nuget.org/packages/CreativeCoders.SmartMessageLanguage) +- **MQTT Publishing** -- forwards meter readings (purchased/sold energy, current power, grid balance) to an MQTT broker with configurable topics +- **Linux Daemon** -- runs as a systemd service under a dedicated user with automatic service registration +- **CLI Tool** -- command-line interface (`smc`) for diagnostics and manual data retrieval + +## Project Structure + +| Project | Description | +|---|---| +| `CreativeCoders.SmartMessageLanguage` | SML framing, TLV parsing, CRC validation, OBIS value extraction | +| `CreativeCoders.SmartMeter.Core` | Serial port abstraction and configuration options | +| `CreativeCoders.SmartMeter.DataProcessing` | Value processing pipeline and MQTT publisher | +| `CreativeCoders.SmartMeter.Server.Core` | Server logic and daemon host builder | +| `CreativeCoders.SmartMeter.Server.Linux` | Linux systemd daemon entry point | +| `CreativeCoders.SmartMeter.Cli` | CLI tool for manual interaction | + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) (for building from source) +- .NET 10 Runtime (on the target Linux machine) +- A smart meter with an SML-compatible infrared interface +- An optical reading head (IR coupler) connected via USB serial (e.g. `/dev/ttyUSB0`) +- An MQTT broker (e.g. [Mosquitto](https://mosquitto.org/)) + +## Building + +```bash +./build.sh +``` + +Or using the .NET CLI directly: + +```bash +dotnet build +dotnet test +``` + +## Linux Server Installation + +The recommended way to install or update the SmartMeter server on Linux is by using the provided `install-smartmeter.sh` script. It downloads the distribution package, extracts it, and runs the bundled installer. + +### Quick Install (Latest Release) + +Download and run the installer in one step: + +```bash +curl -fsSL https://raw.githubusercontent.com/CreativeCodersTeam/SmartMeter/main/install-smartmeter.sh -o install-smartmeter.sh +chmod +x install-smartmeter.sh +./install-smartmeter.sh +``` + +This will: + +1. Fetch the latest stable release from GitHub +2. Download the `SmartMeter.Server.Linux.tar.gz` asset +3. Extract and run the bundled `install.sh` with `sudo` + +### Install from CI Build + +To install from the latest successful CI build instead of a release (requires the [GitHub CLI](https://cli.github.com/) with authentication): + +```bash +./install-smartmeter.sh --workflows main.yml +``` + +### Non-Interactive Install + +Skip the confirmation prompt with `-y`: + +```bash +./install-smartmeter.sh -y +``` + +### Required Tools + +| Tool | Required for | +|---|---| +| `curl` | Downloading the archive | +| `tar` | Extracting the package | +| `jq` | Parsing GitHub API responses | +| `sudo` | Running the privileged installer | +| `gh` | Only when using `--workflows` | + +### What the Installer Does + +The bundled `install.sh` performs the following steps: + +1. Stops and disables the existing `smartmeter-server` systemd service (if running) +2. Backs up the current installation at `/opt/smartmetersrv` to `/opt/smartmetersrv.bak` +3. Copies the new files to `/opt/smartmetersrv` +4. Creates a dedicated `smartmeter-user` system user (if it doesn't exist) and adds it to the `dialout` group for serial port access +5. Sets file ownership to `smartmeter-user` +6. Registers and starts the systemd service via `dotnet smartmetersrv.dll --install` + +### Updating + +To update an existing installation, simply run `install-smartmeter.sh` again. The installer automatically backs up the previous installation before deploying the new version. + +```bash +./install-smartmeter.sh +``` + +> [!TIP] +> After an update, check that the service is running correctly: +> ```bash +> sudo systemctl status smartmeter-server +> sudo journalctl -u smartmeter-server -f +> ``` + +### Service Management + +Once installed, the server runs as a systemd service: + +```bash +# Check service status +sudo systemctl status smartmeter-server + +# Stop the service +sudo systemctl stop smartmeter-server + +# Start the service +sudo systemctl start smartmeter-server + +# View logs +sudo journalctl -u smartmeter-server -f +``` + +### Installation Paths + +| Path | Description | +|---|---| +| `/opt/smartmetersrv` | Application directory | +| `/opt/smartmetersrv.bak` | Backup of the previous installation | + +## Configuration + +The server reads its configuration at startup. Key options include: + +| Option | Default | Description | +|---|---|---| +| `PortName` | `/dev/ttyUSB0` | Serial port device path for the optical coupler | +| `MqttServer` | -- | URI of the MQTT broker | +| `MqttClientName` | `SmartMeterClient` | MQTT client identifier | +| `MqttTopicTemplate` | `smartmeter/values/{0}` | Topic template (`{0}` is replaced by the value type) | + +## Published MQTT Values + +The server publishes the following meter values: + +| Value | Description | +|---|---| +| `TotalPurchasedEnergy` | Cumulative energy purchased from the grid | +| `TotalSoldEnergy` | Cumulative energy sold to the grid | +| `CurrentPurchasingPower` | Current power being drawn | +| `CurrentSellingPower` | Current power being fed in | +| `GridPowerBalance` | Net power balance | diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs index cae1359..b62bcdf 100644 --- a/source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs +++ b/source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs @@ -11,7 +11,7 @@ namespace CreativeCoders.SmartMessageLanguage.Framing; /// internal static class Crc16X25 { - private static readonly ushort[] _table = BuildTable(); + private static readonly ushort[] Table = BuildTable(); /// Computes the CRC-16/X-25 over the given buffer. /// Bytes to compute the CRC over. @@ -22,7 +22,7 @@ public static ushort Compute(ReadOnlySpan data) foreach (var b in data) { - crc = (ushort)((crc >> 8) ^ _table[(crc ^ b) & 0xFF]); + crc = (ushort)((crc >> 8) ^ Table[(crc ^ b) & 0xFF]); } return (ushort)(crc ^ 0xFFFF);