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 (m³).
+ 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