diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 282afd9..b520b9a 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -1,45 +1,60 @@
-# ------------------------------------------------------------------------------
-#
-#
-# This code was generated.
-#
-# - To turn off auto-generation set:
-#
-# [GitHubActions (AutoGenerate = false)]
-#
-# - To trigger manual generation invoke:
-#
-# nuke --generate-configuration GitHubActions_integration --host GitHubActions
-#
-#
-# ------------------------------------------------------------------------------
-
name: integration
on:
push:
branches:
- 'feature/**'
- pull_request:
- branches:
- - main
jobs:
ubuntu-latest:
name: ubuntu-latest
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- - name: 'Cache: .nuke/temp, ~/.nuget/packages'
- uses: actions/cache@v4
+ - name: 'Cache: ~/.nuget/packages'
+ uses: actions/cache@v5
with:
path: |
- .nuke/temp
~/.nuget/packages
key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
- - name: 'Run: clean, restore, build, publish'
- run: ./build.cmd clean restore build publish
+ - name: 'Build with target: NuGetPush'
+ run: ./build.cmd -t nugetpush -t publish -t createdistpackages
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NUGET_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: 'Publish: coverage_report'
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report-linux
+ path: .tests/coverage-report
+ - name: 'Publish: cli dist package'
+ uses: actions/upload-artifact@v7
+ with:
+ name: SmartMeter.Cli
+ path: .artifacts/dist/SmartMeter.Cli.tar.gz
+ - name: 'Publish: server Linux dist package'
+ uses: actions/upload-artifact@v7
+ with:
+ name: SmartMeter.Server.Linux
+ path: .artifacts/dist/SmartMeter.Server.Linux.tar.gz
+ macos-latest:
+ name: macos-latest
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: 'Cache: ~/.nuget/packages'
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Build with target: Pack'
+ run: ./build.cmd -t pack
+ - name: 'Publish: coverage_report'
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report-macos
+ path: .tests/coverage-report
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 7d9ea2d..a40319c 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -1,19 +1,3 @@
-# ------------------------------------------------------------------------------
-#
-#
-# This code was generated.
-#
-# - To turn off auto-generation set:
-#
-# [GitHubActions (AutoGenerate = false)]
-#
-# - To trigger manual generation invoke:
-#
-# nuke --generate-configuration GitHubActions_main --host GitHubActions
-#
-#
-# ------------------------------------------------------------------------------
-
name: main
on:
@@ -26,17 +10,52 @@ jobs:
name: ubuntu-latest
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- - name: 'Cache: .nuke/temp, ~/.nuget/packages'
- uses: actions/cache@v4
+ - name: 'Cache: ~/.nuget/packages'
+ uses: actions/cache@v5
with:
path: |
- .nuke/temp
~/.nuget/packages
key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
- - name: 'Run: clean, restore, build, publish'
- run: ./build.cmd clean restore build publish
+ - name: 'Build with target: NuGetPush'
+ run: ./build.cmd -t nugetpush -t publish -t createdistpackages -t creategithubrelease
env:
+ NUGET_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: 'Publish: coverage_report'
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report-linux
+ path: .tests/coverage-report
+ - name: 'Publish: cli dist package'
+ uses: actions/upload-artifact@v7
+ with:
+ name: SmartMeter.Cli
+ path: .artifacts/dist/SmartMeter.Cli.tar.gz
+ - name: 'Publish: server Linux dist package'
+ uses: actions/upload-artifact@v7
+ with:
+ name: SmartMeter.Server.Linux
+ path: .artifacts/dist/SmartMeter.Server.Linux.tar.gz
+ macos-latest:
+ name: macos-latest
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: 'Cache: ~/.nuget/packages'
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Build with target: Pack'
+ run: ./build.cmd -t pack
+ - name: 'Publish: coverage_report'
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report-macos
+ path: .tests/coverage-report
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
new file mode 100644
index 0000000..9869f1b
--- /dev/null
+++ b/.github/workflows/pull-request.yml
@@ -0,0 +1,48 @@
+name: pull-request
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ ubuntu-latest:
+ name: ubuntu-latest
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: 'Cache: ~/.nuget/packages'
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Build with target: Pack'
+ run: ./build.cmd -t pack
+ - name: 'Publish: coverage_report'
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report-linux
+ path: .tests/coverage-report
+ macos-latest:
+ name: macos-latest
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: 'Cache: ~/.nuget/packages'
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Build with target: Pack'
+ run: ./build.cmd -t pack
+ - name: 'Publish: coverage_report'
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report-macos
+ path: .tests/coverage-report
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index beebb1f..033e81b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,21 +1,9 @@
-# ------------------------------------------------------------------------------
-#
-#
-# This code was generated.
-#
-# - To turn off auto-generation set:
-#
-# [GitHubActions (AutoGenerate = false)]
-#
-# - To trigger manual generation invoke:
-#
-# nuke --generate-configuration GitHubActions_release --host GitHubActions
-#
-#
-# ------------------------------------------------------------------------------
-
name: release
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
on:
push:
tags:
@@ -26,17 +14,32 @@ jobs:
name: ubuntu-latest
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- - name: 'Cache: .nuke/temp, ~/.nuget/packages'
- uses: actions/cache@v4
+ - name: 'Cache: ~/.nuget/packages'
+ uses: actions/cache@v5
with:
path: |
- .nuke/temp
~/.nuget/packages
key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
- - name: 'Run: clean, restore, build, publish, CreateDistPackages, CreateGithubRelease'
- run: ./build.cmd clean restore build publish CreateDistPackages CreateGithubRelease
+ - name: 'Build with target: NuGetPush'
+ run: ./build.cmd -t pack -t nugetpush -t publish -t createdistpackages -t creategithubrelease
env:
+ NUGET_TOKEN: ${{ secrets.NUGET_ORG_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: 'Publish: coverage_report'
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report-linux
+ path: .tests/coverage-report
+ - name: 'Publish: cli dist package'
+ uses: actions/upload-artifact@v7
+ with:
+ name: SmartMeter.Cli
+ path: .artifacts/dist/SmartMeter.Cli.tar.gz
+ - name: 'Publish: server Linux dist package'
+ uses: actions/upload-artifact@v7
+ with:
+ name: SmartMeter.Server.Linux
+ path: .artifacts/dist/SmartMeter.Server.Linux.tar.gz
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..9b31f66
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,11 @@
+
+
+ net10.0
+ CreativeCoders
+ $(NoWarn);IDE0079
+ https://github.com/CreativeCodersTeam/SmartMeter
+ enable
+ enable
+ false
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..3467e08
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,30 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/GitVersion.yml b/GitVersion.yml
index a7050c4..acef5c9 100644
--- a/GitVersion.yml
+++ b/GitVersion.yml
@@ -1,5 +1,7 @@
assembly-versioning-scheme: MajorMinorPatch
assembly-file-versioning-scheme: MajorMinorPatchTag
+tag-prefix: '^v?'
+semantic-version-format: Loose
major-version-bump-message: '\+semver:\s?(breaking|major)'
minor-version-bump-message: '\+semver:\s?(feature|minor)'
patch-version-bump-message: '\+semver:\s?(fix|patch)'
@@ -7,112 +9,47 @@ no-bump-message: '\+semver:\s?(none|skip)'
mode: ContinuousDeployment
branches:
feature:
- mode: ContinuousDeployment
- tag: alpha.{BranchName}
+ mode: ContinuousDelivery
+ label: feature.{BranchName}
increment: Minor
- prevent-increment-of-merged-branch-version: false
track-merge-target: false
- regex: ^features?[/-]
+ regex: ^features?[\/-](?.+)
+ prevent-increment:
+ when-current-commit-tagged: false
source-branches:
- - develop
- - main
- - release
- - feature
- - support
- - hotfix
+ - main
tracks-release-branches: false
is-release-branch: false
- is-mainline: false
+ is-main-branch: false
pre-release-weight: 30000
main:
- mode: ContinuousDeployment
- tag: ''
+ mode: ContinuousDelivery
+ label: 'ci'
increment: Patch
- prevent-increment-of-merged-branch-version: true
track-merge-target: false
regex: ^master$|^main$
+ prevent-increment:
+ when-current-commit-tagged: true
source-branches:
- - develop
- - release
+ - develop
+ - release
tracks-release-branches: false
is-release-branch: false
- is-mainline: true
pre-release-weight: 55000
- develop:
- mode: ContinuousDeployment
- tag: alpha
- increment: Minor
- prevent-increment-of-merged-branch-version: false
- track-merge-target: true
- regex: ^dev(elop)?(ment)?$
- source-branches: []
- tracks-release-branches: true
- is-release-branch: false
- is-mainline: false
- pre-release-weight: 0
- release:
- mode: ContinuousDeployment
- tag: beta
- increment: None
- prevent-increment-of-merged-branch-version: true
- track-merge-target: false
- regex: ^releases?[/-]
- source-branches:
- - develop
- - main
- - support
- - release
- tracks-release-branches: false
- is-release-branch: true
- is-mainline: false
- pre-release-weight: 30000
pull-request:
- mode: ContinuousDeployment
- tag: PullRequest
+ mode: ContinuousDelivery
+ label: PR-{PullRequestName}
increment: Inherit
- prevent-increment-of-merged-branch-version: false
- tag-number-pattern: '[/-](?\d+)'
- track-merge-target: false
- regex: ^(pull|pull\-requests|pr)[/-]
- source-branches:
- - develop
- - main
- - release
- - feature
- - support
- - hotfix
- tracks-release-branches: false
- is-release-branch: false
- is-mainline: false
- pre-release-weight: 30000
- hotfix:
- mode: ContinuousDeployment
- tag: beta
- increment: Patch
- prevent-increment-of-merged-branch-version: false
track-merge-target: false
- regex: ^hotfix(es)?[/-]
+ regex: ^(pull|pull\-requests|pr)[/-](?.+)
+ prevent-increment:
+ when-current-commit-tagged: false
source-branches:
- - develop
- - main
- - support
+ - main
+ - feature
tracks-release-branches: false
is-release-branch: false
- is-mainline: false
pre-release-weight: 30000
- support:
- mode: ContinuousDeployment
- tag: ''
- increment: Patch
- prevent-increment-of-merged-branch-version: true
- track-merge-target: false
- regex: ^support[/-]
- source-branches:
- - main
- tracks-release-branches: false
- is-release-branch: false
- is-mainline: true
- pre-release-weight: 55000
ignore:
- sha: []
-merge-message-formats: {}
+ sha: [ ]
+merge-message-formats: { }
diff --git a/README.md b/README.md
index bc67542..c4794ce 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,162 @@
-# SmartMeter
\ No newline at end of file
+# SmartMeter
+
+A .NET-based server application that reads energy data from smart meters via the [Smart Message Language (SML)](https://de.wikipedia.org/wiki/Smart_Message_Language) protocol and publishes the values over MQTT. It runs as a systemd daemon on Linux and connects to the meter through a serial optical coupler (e.g. `/dev/ttyUSB0`).
+
+## Features
+
+- **SML Protocol Parser** -- streaming detector and parser for the Smart Message Language protocol, available as a standalone [NuGet package](https://www.nuget.org/packages/CreativeCoders.SmartMessageLanguage)
+- **MQTT Publishing** -- forwards meter readings (purchased/sold energy, current power, grid balance) to an MQTT broker with configurable topics
+- **Linux Daemon** -- runs as a systemd service under a dedicated user with automatic service registration
+- **CLI Tool** -- command-line interface (`smc`) for diagnostics and manual data retrieval
+
+## Project Structure
+
+| Project | Description |
+|---|---|
+| `CreativeCoders.SmartMessageLanguage` | SML framing, TLV parsing, CRC validation, OBIS value extraction |
+| `CreativeCoders.SmartMeter.Core` | Serial port abstraction and configuration options |
+| `CreativeCoders.SmartMeter.DataProcessing` | Value processing pipeline and MQTT publisher |
+| `CreativeCoders.SmartMeter.Server.Core` | Server logic and daemon host builder |
+| `CreativeCoders.SmartMeter.Server.Linux` | Linux systemd daemon entry point |
+| `CreativeCoders.SmartMeter.Cli` | CLI tool for manual interaction |
+
+## Prerequisites
+
+- [.NET 10 SDK](https://dotnet.microsoft.com/download) (for building from source)
+- .NET 10 Runtime (on the target Linux machine)
+- A smart meter with an SML-compatible infrared interface
+- An optical reading head (IR coupler) connected via USB serial (e.g. `/dev/ttyUSB0`)
+- An MQTT broker (e.g. [Mosquitto](https://mosquitto.org/))
+
+## Building
+
+```bash
+./build.sh
+```
+
+Or using the .NET CLI directly:
+
+```bash
+dotnet build
+dotnet test
+```
+
+## Linux Server Installation
+
+The recommended way to install or update the SmartMeter server on Linux is by using the provided `install-smartmeter.sh` script. It downloads the distribution package, extracts it, and runs the bundled installer.
+
+### Quick Install (Latest Release)
+
+Download and run the installer in one step:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/CreativeCodersTeam/SmartMeter/main/install-smartmeter.sh -o install-smartmeter.sh
+chmod +x install-smartmeter.sh
+./install-smartmeter.sh
+```
+
+This will:
+
+1. Fetch the latest stable release from GitHub
+2. Download the `SmartMeter.Server.Linux.tar.gz` asset
+3. Extract and run the bundled `install.sh` with `sudo`
+
+### Install from CI Build
+
+To install from the latest successful CI build instead of a release (requires the [GitHub CLI](https://cli.github.com/) with authentication):
+
+```bash
+./install-smartmeter.sh --workflows main.yml
+```
+
+### Non-Interactive Install
+
+Skip the confirmation prompt with `-y`:
+
+```bash
+./install-smartmeter.sh -y
+```
+
+### Required Tools
+
+| Tool | Required for |
+|---|---|
+| `curl` | Downloading the archive |
+| `tar` | Extracting the package |
+| `jq` | Parsing GitHub API responses |
+| `sudo` | Running the privileged installer |
+| `gh` | Only when using `--workflows` |
+
+### What the Installer Does
+
+The bundled `install.sh` performs the following steps:
+
+1. Stops and disables the existing `smartmeter-server` systemd service (if running)
+2. Backs up the current installation at `/opt/smartmetersrv` to `/opt/smartmetersrv.bak`
+3. Copies the new files to `/opt/smartmetersrv`
+4. Creates a dedicated `smartmeter-user` system user (if it doesn't exist) and adds it to the `dialout` group for serial port access
+5. Sets file ownership to `smartmeter-user`
+6. Registers and starts the systemd service via `dotnet smartmetersrv.dll --install`
+
+### Updating
+
+To update an existing installation, simply run `install-smartmeter.sh` again. The installer automatically backs up the previous installation before deploying the new version.
+
+```bash
+./install-smartmeter.sh
+```
+
+> [!TIP]
+> After an update, check that the service is running correctly:
+> ```bash
+> sudo systemctl status smartmeter-server
+> sudo journalctl -u smartmeter-server -f
+> ```
+
+### Service Management
+
+Once installed, the server runs as a systemd service:
+
+```bash
+# Check service status
+sudo systemctl status smartmeter-server
+
+# Stop the service
+sudo systemctl stop smartmeter-server
+
+# Start the service
+sudo systemctl start smartmeter-server
+
+# View logs
+sudo journalctl -u smartmeter-server -f
+```
+
+### Installation Paths
+
+| Path | Description |
+|---|---|
+| `/opt/smartmetersrv` | Application directory |
+| `/opt/smartmetersrv.bak` | Backup of the previous installation |
+
+## Configuration
+
+The server reads its configuration at startup. Key options include:
+
+| Option | Default | Description |
+|---|---|---|
+| `PortName` | `/dev/ttyUSB0` | Serial port device path for the optical coupler |
+| `MqttServer` | -- | URI of the MQTT broker |
+| `MqttClientName` | `SmartMeterClient` | MQTT client identifier |
+| `MqttTopicTemplate` | `smartmeter/values/{0}` | Topic template (`{0}` is replaced by the value type) |
+
+## Published MQTT Values
+
+The server publishes the following meter values:
+
+| Value | Description |
+|---|---|
+| `TotalPurchasedEnergy` | Cumulative energy purchased from the grid |
+| `TotalSoldEnergy` | Cumulative energy sold to the grid |
+| `CurrentPurchasingPower` | Current power being drawn |
+| `CurrentSellingPower` | Current power being fed in |
+| `GridPowerBalance` | Net power balance |
diff --git a/SmartMeter.sln b/SmartMeter.sln
index baf4f94..90c5a9e 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
+ Directory.Packages.props = Directory.Packages.props
+ install-smartmeter.sh = install-smartmeter.sh
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}"
@@ -28,26 +27,42 @@ 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
+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
+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
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "build\Build.csproj", "{ECD46CF5-A11F-46C9-B274-475F950F10E2}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ci", "ci", "{65B0A31B-BCDB-4768-890F-ED46A4773182}"
+ ProjectSection(SolutionItems) = preProject
+ .github\workflows\integration.yml = .github\workflows\integration.yml
+ .github\workflows\main.yml = .github\workflows\main.yml
+ .github\workflows\pull-request.yml = .github\workflows\pull-request.yml
+ .github\workflows\release.yml = .github\workflows\release.yml
+ .github\workflows\sync-ai-config.yml = .github\workflows\sync-ai-config.yml
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{0313FF7A-1065-4C2C-8E8F-53014EB7D314}"
+ ProjectSection(SolutionItems) = preProject
+ build.cmd = build.cmd
+ build.ps1 = build.ps1
+ build.sh = build.sh
+ EndProjectSection
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
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}
- {29653CEF-A35C-4F4F-88EB-9336B7ABA9FB} = {675F198B-B173-421F-A53B-F7B98C8D0E4F}
- 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
- {3AE1B70E-C752-4E89-B0FC-D3FF85462C99}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -64,5 +79,49 @@ 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
+ {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}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B70158C0-A913-4877-9414-5CD5F39772F0}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D956F76F-3826-4A51-8D05-8B35C062605F}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {63623865-60E4-428D-AFDA-5B309A69A69D}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {ECD46CF5-A11F-46C9-B274-475F950F10E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ECD46CF5-A11F-46C9-B274-475F950F10E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {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}
+ {1B1D2F22-9BC1-4F69-82D8-1B3FB7C1BFB3} = {B259CB14-56CC-45FA-9756-64A195F4F789}
+ {63623865-60E4-428D-AFDA-5B309A69A69D} = {675F198B-B173-421F-A53B-F7B98C8D0E4F}
+ {3DA3A6E4-BC76-4465-AB2E-BE571D7A9B5A} = {675F198B-B173-421F-A53B-F7B98C8D0E4F}
+ {ECD46CF5-A11F-46C9-B274-475F950F10E2} = {AEAE0BA3-481C-45C3-825C-71A5CE7F6A78}
+ {65B0A31B-BCDB-4768-890F-ED46A4773182} = {AEAE0BA3-481C-45C3-825C-71A5CE7F6A78}
+ {0313FF7A-1065-4C2C-8E8F-53014EB7D314} = {AEAE0BA3-481C-45C3-825C-71A5CE7F6A78}
EndGlobalSection
EndGlobal
diff --git a/SmartMeter.sln.DotSettings b/SmartMeter.sln.DotSettings
index 7270d8c..ef268ef 100644
--- a/SmartMeter.sln.DotSettings
+++ b/SmartMeter.sln.DotSettings
@@ -1,5 +1,6 @@
True
+
True
True
True
diff --git a/build.ps1 b/build.ps1
index 4634dc0..1b91f63 100644
--- a/build.ps1
+++ b/build.ps1
@@ -13,12 +13,8 @@ $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent
# CONFIGURATION
###########################################################################
-$BuildProjectFile = "$PSScriptRoot\build\_build.csproj"
-$TempDirectory = "$PSScriptRoot\\.nuke\temp"
-
+$BuildProjectFile = "$PSScriptRoot\build\Build.csproj"
$DotNetGlobalFile = "$PSScriptRoot\\global.json"
-$DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1"
-$DotNetChannel = "STS"
$env:DOTNET_CLI_TELEMETRY_OPTOUT = 1
$env:DOTNET_NOLOGO = 1
@@ -38,37 +34,12 @@ if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and `
$env:DOTNET_EXE = (Get-Command "dotnet").Path
}
else {
- # Download install script
- $DotNetInstallFile = "$TempDirectory\dotnet-install.ps1"
- New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null
- [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
- (New-Object System.Net.WebClient).DownloadFile($DotNetInstallUrl, $DotNetInstallFile)
-
- # If global.json exists, load expected version
- if (Test-Path $DotNetGlobalFile) {
- $DotNetGlobal = $(Get-Content $DotNetGlobalFile | Out-String | ConvertFrom-Json)
- if ($DotNetGlobal.PSObject.Properties["sdk"] -and $DotNetGlobal.sdk.PSObject.Properties["version"]) {
- $DotNetVersion = $DotNetGlobal.sdk.version
- }
- }
+ Write-Host "No matching dotnet version found"
- # Install by channel or version
- $DotNetDirectory = "$TempDirectory\dotnet-win"
- if (!(Test-Path variable:DotNetVersion)) {
- ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Channel $DotNetChannel -NoPath }
- } else {
- ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath }
- }
- $env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe"
- $env:PATH = "$DotNetDirectory;$env:PATH"
+ exit 1
}
Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)"
-if (Test-Path env:NUKE_ENTERPRISE_TOKEN) {
- & $env:DOTNET_EXE nuget remove source "nuke-enterprise" > $null
- & $env:DOTNET_EXE nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password $env:NUKE_ENTERPRISE_TOKEN > $null
-}
-
ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet }
ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments }
diff --git a/build.sh b/build.sh
index fdff0c6..35eaaba 100755
--- a/build.sh
+++ b/build.sh
@@ -2,19 +2,15 @@
bash --version 2>&1 | head -n 1
-set -eo pipefail
+set -e
SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)
###########################################################################
# CONFIGURATION
###########################################################################
-BUILD_PROJECT_FILE="$SCRIPT_DIR/build/_build.csproj"
-TEMP_DIRECTORY="$SCRIPT_DIR//.nuke/temp"
-
+BUILD_PROJECT_FILE="$SCRIPT_DIR/build/Build.csproj"
DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json"
-DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh"
-DOTNET_CHANNEL="STS"
export DOTNET_CLI_TELEMETRY_OPTOUT=1
export DOTNET_NOLOGO=1
@@ -23,45 +19,15 @@ export DOTNET_NOLOGO=1
# EXECUTION
###########################################################################
-function FirstJsonValue {
- perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}"
-}
-
# If dotnet CLI is installed globally and it matches requested version, use for execution
if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then
export DOTNET_EXE="$(command -v dotnet)"
else
- # Download install script
- DOTNET_INSTALL_FILE="$TEMP_DIRECTORY/dotnet-install.sh"
- mkdir -p "$TEMP_DIRECTORY"
- curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL"
- chmod +x "$DOTNET_INSTALL_FILE"
-
- # If global.json exists, load expected version
- if [[ -f "$DOTNET_GLOBAL_FILE" ]]; then
- DOTNET_VERSION=$(FirstJsonValue "version" "$(cat "$DOTNET_GLOBAL_FILE")")
- if [[ "$DOTNET_VERSION" == "" ]]; then
- unset DOTNET_VERSION
- fi
- fi
-
- # Install by channel or version
- DOTNET_DIRECTORY="$TEMP_DIRECTORY/dotnet-unix"
- if [[ -z ${DOTNET_VERSION+x} ]]; then
- "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --channel "$DOTNET_CHANNEL" --no-path
- else
- "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path
- fi
- export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet"
- export PATH="$DOTNET_DIRECTORY:$PATH"
+ echo "No matching dotnet version found"
+ exit 1
fi
echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)"
-if [[ ! -z ${NUKE_ENTERPRISE_TOKEN+x} && "$NUKE_ENTERPRISE_TOKEN" != "" ]]; then
- "$DOTNET_EXE" nuget remove source "nuke-enterprise" &>/dev/null || true
- "$DOTNET_EXE" nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password "$NUKE_ENTERPRISE_TOKEN" --store-password-in-clear-text &>/dev/null || true
-fi
-
"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet
"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@"
diff --git a/build/.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/Build.csproj b/build/Build.csproj
new file mode 100644
index 0000000..3ef774f
--- /dev/null
+++ b/build/Build.csproj
@@ -0,0 +1,12 @@
+
+
+
+ Exe
+ false
+
+
+
+
+
+
+
diff --git a/build/BuildContext.cs b/build/BuildContext.cs
new file mode 100644
index 0000000..9cdf996
--- /dev/null
+++ b/build/BuildContext.cs
@@ -0,0 +1,86 @@
+using Cake.Common.Build;
+using Cake.Core;
+using Cake.Core.IO;
+using CreativeCoders.CakeBuild;
+using CreativeCoders.CakeBuild.Tasks.Defaults;
+using CreativeCoders.CakeBuild.Tasks.Templates.Settings;
+using CreativeCoders.Core;
+using CreativeCoders.Core.Collections;
+using JetBrains.Annotations;
+
+namespace Build;
+
+[UsedImplicitly]
+public class BuildContext(ICakeContext context)
+ : CakeBuildContext(context), IDefaultTaskSettings, ICreateDistPackagesTaskSettings, ICreateGitHubReleaseTaskSettings
+{
+ public IList DirectoriesToClean => this.CastAs()
+ .GetDefaultDirectoriesToClean().AddRange(RootDir.Combine(".tests"));
+
+
+ public string Copyright => $"{DateTime.Now.Year} CreativeCoders";
+
+ public string PackageProjectUrl => "https://github.com/CreativeCodersTeam/SmartMeter";
+
+ public string PackageLicenseExpression => PackageLicenseExpressions.ApacheLicense20;
+
+ public string NuGetFeedUrl => this.GitHubActions().Environment.Workflow.Workflow == "release"
+ ? "nuget.org"
+ : "https://nuget.pkg.github.com/CreativeCodersTeam/index.json";
+
+ public bool SkipPush => this.BuildSystem().IsPullRequest ||
+ this.BuildSystem().IsLocalBuild ||
+ this.GitHubActions().Environment.Runner.OS != "Linux";
+
+ public DirectoryPath PublishOutputDir => ArtifactsDir.Combine("published");
+
+ private const string CliPath = "source/CreativeCoders.SmartMeter.Cli";
+
+ private const string CliProjectFile = "CreativeCoders.SmartMeter.Cli.csproj";
+
+ private const string ServerLinuxPath = "source/CreativeCoders.SmartMeter.Server.Linux";
+
+ private const string ServerLinuxProjectFile = "CreativeCoders.SmartMeter.Server.Linux.csproj";
+
+ public IEnumerable PublishingItems =>
+ [
+ new PublishingItem(
+ RootDir
+ .Combine(CliPath)
+ .CombineWithFilePath(CliProjectFile),
+ PublishOutputDir.Combine("cli")),
+ new PublishingItem(
+ RootDir
+ .Combine(ServerLinuxPath)
+ .CombineWithFilePath(ServerLinuxProjectFile),
+ PublishOutputDir.Combine("server-linux"))
+ ];
+
+ private const string CliDistPackageName = "SmartMeter.Cli";
+
+ private const string ServerLinuxDistPackageName = "SmartMeter.Server.Linux";
+
+ public IEnumerable DistPackages =>
+ [
+ new DistPackage(CliDistPackageName, PublishOutputDir.Combine("cli")),
+ new DistPackage(ServerLinuxDistPackageName, PublishOutputDir.Combine("server-linux"))
+ ];
+
+ public string ReleaseName => $"v{Version.FullSemVer}";
+
+ public string ReleaseVersion => $"v{Version.FullSemVer}";
+
+ public string ReleaseBody => "SmartMeter Release";
+
+ public bool IsPreRelease => !string.IsNullOrWhiteSpace(Version.PreReleaseTag);
+
+ public IEnumerable ReleaseAssets =>
+ [
+ new GitHubReleaseFileAsset(
+ GetRequiredSettings().DistOutputPath
+ .CombineWithFilePath(CliDistPackageName + ".tar.gz").FullPath, null),
+ new GitHubReleaseFileAsset(
+ GetRequiredSettings().DistOutputPath
+ .CombineWithFilePath(ServerLinuxDistPackageName + ".tar.gz").FullPath, null)
+ ];
+}
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/Program.cs b/build/Program.cs
new file mode 100644
index 0000000..34637f1
--- /dev/null
+++ b/build/Program.cs
@@ -0,0 +1,19 @@
+using CreativeCoders.CakeBuild;
+
+namespace Build;
+
+internal static class Program
+{
+ internal static int Main(string[] args)
+ {
+ return CakeHostBuilder.Create()
+ .UseBuildContext()
+ .AddDefaultTasks()
+ .AddBuildServerIntegration()
+ .InstallTools(
+ new DotNetToolInstallation("GitVersion.Tool", "6.5.1"),
+ new DotNetToolInstallation("dotnet-reportgenerator-globaltool", "5.5.1"))
+ .Build()
+ .Run(args);
+ }
+}
diff --git a/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..998e2e4 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "8.0.404",
+ "version": "10.0.105",
"rollForward": "latestFeature"
}
}
diff --git a/install-smartmeter.sh b/install-smartmeter.sh
new file mode 100755
index 0000000..f195481
--- /dev/null
+++ b/install-smartmeter.sh
@@ -0,0 +1,291 @@
+#!/usr/bin/env bash
+# install-smartmeter.sh
+#
+# Downloads the SmartMeter Linux server distribution package from either the
+# latest stable GitHub release or the latest successful run of a specified
+# GitHub Actions workflow, extracts it and runs the bundled install.sh.
+#
+# Usage: ./install-smartmeter.sh [--workflows ] [-y|--yes] [-h|--help]
+
+set -euo pipefail
+
+# Repository is intentionally hard-wired: this installer only targets the
+# official SmartMeter repository.
+readonly REPO="CreativeCodersTeam/SmartMeter"
+readonly ARTIFACT_NAME="SmartMeter.Server.Linux"
+readonly ARCHIVE_FILE="${ARTIFACT_NAME}.tar.gz"
+readonly DEFAULT_BRANCH="main"
+
+# Exit codes
+readonly EXIT_USER_ABORT=1
+readonly EXIT_MISSING_TOOL=2
+readonly EXIT_NOT_FOUND=3
+readonly EXIT_DOWNLOAD_FAILED=4
+readonly EXIT_INSTALL_FAILED=5
+
+WORKFLOW_FILE=""
+USE_WORKFLOW=0
+ASSUME_YES=0
+TMP_DIR=""
+
+usage() {
+ cat < Use the latest successful run of the given workflow
+ file (e.g. main.yml) on branch '${DEFAULT_BRANCH}' as
+ artifact source. When omitted, the latest stable
+ GitHub release is used.
+ -y, --yes Skip the interactive confirmation prompt.
+ -h, --help Show this help and exit.
+
+Requires: curl, tar, jq, sudo (and gh authenticated when --workflows is used).
+EOF
+}
+
+log() { printf '%s\n' "$*"; }
+warn() { printf 'WARN: %s\n' "$*" >&2; }
+err() { printf 'ERROR: %s\n' "$*" >&2; }
+
+cleanup() {
+ if [[ -n "${TMP_DIR}" && -d "${TMP_DIR}" ]]; then
+ rm -rf -- "${TMP_DIR}"
+ fi
+}
+trap cleanup EXIT
+
+parse_args() {
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --workflows)
+ if [[ $# -lt 2 || -z "${2:-}" || "${2:0:1}" == "-" ]]; then
+ err "--workflows requires a workflow file name (e.g. main.yml)"
+ exit "${EXIT_MISSING_TOOL}"
+ fi
+ USE_WORKFLOW=1
+ WORKFLOW_FILE="$2"
+ shift 2
+ ;;
+ -y|--yes)
+ ASSUME_YES=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ err "Unknown argument: $1"
+ usage >&2
+ exit "${EXIT_MISSING_TOOL}"
+ ;;
+ esac
+ done
+}
+
+check_dependencies() {
+ local missing=()
+ local tool
+ # Tools required in every mode: curl fetches the archive, tar extracts it,
+ # jq parses API JSON, sudo runs the privileged install.sh.
+ for tool in curl tar jq sudo; do
+ if ! command -v "$tool" >/dev/null 2>&1; then
+ missing+=("$tool")
+ fi
+ done
+ # gh is only required for workflow-run access: listing runs and
+ # downloading workflow artifacts always requires authentication.
+ # Public release assets are fetched anonymously via curl.
+ if [[ "${USE_WORKFLOW}" -eq 1 ]] && ! command -v gh >/dev/null 2>&1; then
+ missing+=("gh")
+ fi
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ err "Missing required tools: ${missing[*]}"
+ exit "${EXIT_MISSING_TOOL}"
+ fi
+ if [[ "${USE_WORKFLOW}" -eq 1 ]] && ! gh auth status >/dev/null 2>&1; then
+ err "gh is not authenticated. Run 'gh auth login' first."
+ exit "${EXIT_MISSING_TOOL}"
+ fi
+}
+
+# Locates the latest stable (non-prerelease, non-draft) release via the
+# public GitHub REST API (no authentication required for public repos) and
+# validates that the expected archive asset is attached.
+# Prints the asset's browser_download_url on stdout.
+locate_release() {
+ local api_url="https://api.github.com/repos/${REPO}/releases/latest"
+ local response
+ if ! response=$(curl -fsSL \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ "${api_url}"); then
+ err "Failed to query latest release from ${REPO} (${api_url})."
+ exit "${EXIT_NOT_FOUND}"
+ fi
+
+ local tag published html_url download_url
+ tag=$(printf '%s' "${response}" | jq -r '.tag_name // ""')
+ published=$(printf '%s' "${response}" | jq -r '.published_at // ""')
+ html_url=$(printf '%s' "${response}" | jq -r '.html_url // ""')
+ download_url=$(printf '%s' "${response}" \
+ | jq -r --arg name "${ARCHIVE_FILE}" \
+ '(.assets // [])[] | select(.name == $name) | .browser_download_url' \
+ | head -n 1)
+
+ if [[ -z "${tag}" ]]; then
+ err "No stable release found in ${REPO}."
+ exit "${EXIT_NOT_FOUND}"
+ fi
+ if [[ -z "${download_url}" ]]; then
+ err "Release ${tag} does not contain asset ${ARCHIVE_FILE}."
+ exit "${EXIT_NOT_FOUND}"
+ fi
+
+ printf 'Found stable release:\n' >&2
+ printf ' Tag: %s\n' "${tag}" >&2
+ printf ' Published: %s\n' "${published}" >&2
+ printf ' URL: %s\n' "${html_url}" >&2
+ printf ' Asset: %s\n' "${ARCHIVE_FILE}" >&2
+
+ printf '%s\n' "${download_url}"
+}
+
+# Locates the latest successful run of the specified workflow on the default
+# branch. Prints the run's database id.
+locate_workflow_run() {
+ local fields
+ if ! fields=$(gh run list \
+ --repo "${REPO}" \
+ --workflow "${WORKFLOW_FILE}" \
+ --status success \
+ --branch "${DEFAULT_BRANCH}" \
+ --limit 1 \
+ --json databaseId,headSha,displayTitle,createdAt,url \
+ --jq '.[0] | [(.databaseId | tostring // ""), (.headSha // ""), (.displayTitle // ""), (.createdAt // ""), (.url // "")] | @tsv' \
+ 2>/dev/null); then
+ err "Failed to query workflow runs for ${WORKFLOW_FILE} in ${REPO}."
+ exit "${EXIT_NOT_FOUND}"
+ fi
+
+ local id sha title created url
+ IFS=$'\t' read -r id sha title created url <<<"${fields}"
+
+ if [[ -z "${id}" ]]; then
+ err "No successful run found for workflow '${WORKFLOW_FILE}' on branch '${DEFAULT_BRANCH}'."
+ exit "${EXIT_NOT_FOUND}"
+ fi
+
+ printf 'Found successful workflow run:\n' >&2
+ printf ' Workflow: %s\n' "${WORKFLOW_FILE}" >&2
+ printf ' Run ID: %s\n' "${id}" >&2
+ printf ' Commit: %s\n' "${sha}" >&2
+ printf ' Title: %s\n' "${title}" >&2
+ printf ' Created: %s\n' "${created}" >&2
+ printf ' URL: %s\n' "${url}" >&2
+ printf ' Artifact: %s\n' "${ARTIFACT_NAME}" >&2
+
+ printf '%s\n' "${id}"
+}
+
+# Helper removed: we now use `gh --jq ...` directly, which leverages gh's
+# embedded jq and keeps the external dependency list minimal.
+
+confirm() {
+ if [[ "${ASSUME_YES}" -eq 1 ]]; then
+ return 0
+ fi
+ local reply=""
+ printf 'Continue with download and installation? [y/N] ' >&2
+ read -r reply || true
+ if [[ "${reply}" != "y" && "${reply}" != "Y" ]]; then
+ log "Aborted by user."
+ exit "${EXIT_USER_ABORT}"
+ fi
+}
+
+download_release_asset() {
+ local url="$1" dest="$2"
+ local target="${dest}/${ARCHIVE_FILE}"
+ log "Downloading ${ARCHIVE_FILE} from ${url}..."
+ if ! curl -fL --progress-bar -o "${target}" "${url}"; then
+ err "Failed to download release asset."
+ exit "${EXIT_DOWNLOAD_FAILED}"
+ fi
+ printf '%s\n' "${target}"
+}
+
+download_workflow_artifact() {
+ local run_id="$1" dest="$2"
+ log "Downloading workflow artifact ${ARTIFACT_NAME} from run ${run_id}..."
+ if ! gh run download "${run_id}" \
+ --repo "${REPO}" \
+ --name "${ARTIFACT_NAME}" \
+ --dir "${dest}"; then
+ err "Failed to download workflow artifact."
+ exit "${EXIT_DOWNLOAD_FAILED}"
+ fi
+ local archive
+ archive=$(find "${dest}" -type f -name "${ARCHIVE_FILE}" -print -quit)
+ if [[ -z "${archive}" ]]; then
+ err "Artifact ${ARTIFACT_NAME} did not contain ${ARCHIVE_FILE}."
+ exit "${EXIT_DOWNLOAD_FAILED}"
+ fi
+ printf '%s\n' "${archive}"
+}
+
+extract_archive() {
+ local archive="$1" extract_dir="$2"
+ mkdir -p "${extract_dir}"
+ log "Extracting $(basename "${archive}")..."
+ if ! tar -xzf "${archive}" -C "${extract_dir}"; then
+ err "Failed to extract ${archive}."
+ exit "${EXIT_DOWNLOAD_FAILED}"
+ fi
+}
+
+run_installer() {
+ local extract_dir="$1"
+ local installer="${extract_dir}/install.sh"
+ if [[ ! -f "${installer}" ]]; then
+ err "install.sh not found in extracted package."
+ exit "${EXIT_INSTALL_FAILED}"
+ fi
+ chmod +x "${installer}"
+ log "Running installer with sudo..."
+ if ! ( cd "${extract_dir}" && sudo ./install.sh ); then
+ err "install.sh failed."
+ exit "${EXIT_INSTALL_FAILED}"
+ fi
+}
+
+main() {
+ parse_args "$@"
+ check_dependencies
+
+ local source_ref archive_path
+ TMP_DIR=$(mktemp -d -t smartmeter-install-XXXXXX)
+ local download_dir="${TMP_DIR}/download"
+ local extract_dir="${TMP_DIR}/extracted"
+ mkdir -p "${download_dir}"
+
+ if [[ "${USE_WORKFLOW}" -eq 1 ]]; then
+ source_ref=$(locate_workflow_run)
+ confirm
+ archive_path=$(download_workflow_artifact "${source_ref}" "${download_dir}")
+ else
+ source_ref=$(locate_release)
+ confirm
+ archive_path=$(download_release_asset "${source_ref}" "${download_dir}")
+ fi
+
+ extract_archive "${archive_path}" "${extract_dir}"
+ run_installer "${extract_dir}"
+
+ log "Installation complete."
+}
+
+main "$@"
diff --git a/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj b/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj
new file mode 100644
index 0000000..a5881b0
--- /dev/null
+++ b/source/CreativeCoders.SmartMessageLanguage/CreativeCoders.SmartMessageLanguage.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Streaming detector and parser for the Smart Message Language (SML) protocol
+ CreativeCoders.SmartMessageLanguage
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs b/source/CreativeCoders.SmartMessageLanguage/Framing/Crc16X25.cs
new file mode 100644
index 0000000..b62bcdf
--- /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..2c57ebf
--- /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..e957c45
--- /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.Debug,
+ 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..3fba108
--- /dev/null
+++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/ISmlParser.cs
@@ -0,0 +1,16 @@
+using CreativeCoders.SmartMessageLanguage.Framing;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.SmartMessageLanguage.Parsing;
+
+[PublicAPI]
+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..db0d8dd
--- /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,
+ SmlMessageValueType 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..da745d7
--- /dev/null
+++ b/source/CreativeCoders.SmartMessageLanguage/Parsing/SmlParser.cs
@@ -0,0 +1,384 @@
+using System.Diagnostics.CodeAnalysis;
+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 != SmlMessageValueType.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 != SmlMessageValueType.List || reader.Current.ListLength != 2)
+ {
+ warnings.Add("Malformed SML_Message body wrapper");
+ SkipListEntries(ref reader, 2);
+
+ return;
+ }
+
+ if (!reader.Read() || reader.Current.Type != SmlMessageValueType.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 == SmlMessageValueType.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 == SmlMessageValueType.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 != SmlMessageValueType.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();
+ }
+ }
+
+ [SuppressMessage("csharpsquid", "S3776",
+ Justification = "This method is long but straightforward; refactor if it grows more complex.")]
+ private void ReadValListEntry(ref SmlTlvReader reader, List values,
+ List warnings)
+ {
+ // SML_ListEntry = List of 7: objName (OctetString), status, valTime,
+ // unit (Unsigned8), scaler (Integer8), value, valueSignature.
+ if (!reader.Read() || reader.Current.Type != SmlMessageValueType.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 != SmlMessageValueType.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 == SmlMessageValueType.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 == SmlMessageValueType.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)
+ {
+ // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
+ switch (value.Type)
+ {
+ case SmlMessageValueType.Unsigned:
+ return ApplyScaler(value.GetUInt64(), scaler);
+ case SmlMessageValueType.Integer:
+ return ApplyScaler(value.GetInt64(), scaler);
+ case SmlMessageValueType.Boolean:
+ return value.GetBool() ? 1m : 0m;
+ case SmlMessageValueType.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)
+ {
+ return data.IsEmpty
+ ? string.Empty
+ : 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..34b9c8c
--- /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.Debug,
+ 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, 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, SmlMessageValueType 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..5033066
--- /dev/null
+++ b/source/CreativeCoders.SmartMessageLanguage/SmlServiceCollectionExtensions.cs
@@ -0,0 +1,19 @@
+using CreativeCoders.SmartMessageLanguage.Framing;
+using CreativeCoders.SmartMessageLanguage.Parsing;
+using JetBrains.Annotations;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace CreativeCoders.SmartMessageLanguage;
+
+[PublicAPI]
+public static class SmlServiceCollectionExtensions
+{
+ public static IServiceCollection AddSml(this IServiceCollection services)
+ {
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+
+ return services;
+ }
+}
diff --git a/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlMessageValueType.cs b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlMessageValueType.cs
new file mode 100644
index 0000000..793d3df
--- /dev/null
+++ b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlMessageValueType.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 SmlMessageValueType
+{
+ /// 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/Tlv/SmlTlvElement.cs b/source/CreativeCoders.SmartMessageLanguage/Tlv/SmlTlvElement.cs
new file mode 100644
index 0000000..4955df9
--- /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(SmlMessageValueType type, int listLength, ReadOnlySpan raw)
+ {
+ Type = type;
+ ListLength = listLength;
+ Raw = raw;
+ }
+
+ /// The TLV primitive or structural type.
+ public SmlMessageValueType 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 == SmlMessageValueType.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..40f694c
--- /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(SmlMessageValueType.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(SmlMessageValueType.List, length, ReadOnlySpan.Empty);
+
+ return true;
+ }
+ case 0x0:
+ case 0x4:
+ case 0x5:
+ case 0x6:
+ {
+ var resolvedType = typeNibble switch
+ {
+ 0x0 => SmlMessageValueType.OctetString,
+ 0x4 => SmlMessageValueType.Boolean,
+ 0x5 => SmlMessageValueType.Integer,
+ _ => SmlMessageValueType.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 != SmlMessageValueType.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/Units/SmlUnit.cs b/source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs
new file mode 100644
index 0000000..dc00d10
--- /dev/null
+++ b/source/CreativeCoders.SmartMessageLanguage/Units/SmlUnit.cs
@@ -0,0 +1,110 @@
+using JetBrains.Annotations;
+
+namespace CreativeCoders.SmartMessageLanguage.Units;
+
+///
+/// Subset of DLMS/IEC 62056-62 unit codes commonly used by electricity meters.
+/// The numeric values match the SML SML_Unit codes as received on the wire.
+///
+[PublicAPI]
+public enum SmlUnit : byte
+{
+ /// Unknown or unassigned unit.
+ 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/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..23cc94e
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Cli/Program.cs
@@ -0,0 +1,77 @@
+using CreativeCoders.SmartMeter.Core;
+using CreativeCoders.SmartMeter.Core.SmlData;
+using CreativeCoders.SmartMeter.Core.Unlock;
+using CreativeCoders.SmartMeter.DataProcessing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace CreativeCoders.SmartMeter.Cli;
+
+internal static class Program
+{
+ internal 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()
+ .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];
+ var unlocker = sp.GetRequiredService();
+
+ await SendPinAsync(unlocker, pin);
+
+ return;
+ }
+
+ 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");
+ }
+
+ private static async Task SendPinAsync(ISmartMeterUnlocker unlocker, string pin)
+ {
+ AnsiConsole.WriteLine($"Sending PIN: {pin}");
+ await unlocker.UnlockAsync(pin, new SmartMeterUnlockOptions
+ {
+ Strategy = SmartMeterPinStrategy.EmhAsciiBlock
+ });
+ }
+}
+
+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:N}");
+ }
+}
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..f77b33f
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/CreativeCoders.SmartMeter.Core.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Core library for the Smart Meter project.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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.Sml/Reactive/ReactiveSerialPort.cs b/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs
similarity index 85%
rename from source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs
rename to source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs
index 324c65b..0d9a12f 100644
--- a/source/CreativeCoders.SmartMeter.Sml/Reactive/ReactiveSerialPort.cs
+++ b/source/CreativeCoders.SmartMeter.Core/ReactiveSerialPort.cs
@@ -2,9 +2,9 @@
using System.Reactive.Linq;
using CreativeCoders.Core;
-namespace CreativeCoders.SmartMeter.Sml.Reactive;
+namespace CreativeCoders.SmartMeter.Core;
-public sealed class ReactiveSerialPort : IObservable, IDisposable
+public sealed class ReactiveSerialPort : IReactiveSerialPort
{
private readonly IObservable _dataObservable;
@@ -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/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
new file mode 100644
index 0000000..461836f
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/SmartMeterOptions.cs
@@ -0,0 +1,15 @@
+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
new file mode 100644
index 0000000..796c635
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/SmartMeterServerServiceCollectionExtensions.cs
@@ -0,0 +1,20 @@
+using CreativeCoders.SmartMessageLanguage;
+using CreativeCoders.SmartMeter.Core.SmlData;
+using CreativeCoders.SmartMeter.Core.Unlock;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace CreativeCoders.SmartMeter.Core;
+
+public static class SmartMeterServerServiceCollectionExtensions
+{
+ public static IServiceCollection AddSmartMeterServer(this IServiceCollection services)
+ {
+ services.AddSml();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+
+ return services;
+ }
+}
diff --git a/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterDataProducer.cs
new file mode 100644
index 0000000..c07b79d
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterDataProducer.cs
@@ -0,0 +1,10 @@
+using CreativeCoders.SmartMeter.DataProcessing;
+
+namespace CreativeCoders.SmartMeter.Core.SmlData;
+
+public interface ISmartMeterDataProducer : IDisposable
+{
+ Task StartAsync(IObserver observer);
+
+ Task StopAsync();
+}
diff --git a/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs
new file mode 100644
index 0000000..59f38f1
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/SmlData/ISmartMeterReactiveDataPipeline.cs
@@ -0,0 +1,5 @@
+using CreativeCoders.SmartMeter.DataProcessing;
+
+namespace CreativeCoders.SmartMeter.Core.SmlData;
+
+public interface ISmartMeterReactiveDataPipeline : IObserver, IObservable;
diff --git a/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs
new file mode 100644
index 0000000..82cefcd
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterDataProducer.cs
@@ -0,0 +1,108 @@
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using CreativeCoders.Core;
+using CreativeCoders.SmartMeter.DataProcessing;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace CreativeCoders.SmartMeter.Core.SmlData;
+
+public sealed class SmartMeterDataProducer : ISmartMeterDataProducer
+{
+ 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");
+
+ _reactiveDataPipeline
+ .SubscribeOn(new TaskPoolScheduler(new TaskFactory()))
+ .Subscribe(observer);
+
+ _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");
+ }
+
+ 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;
+ }
+
+ 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.Core/SmlData/SmartMeterReactiveDataPipeline.cs b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterReactiveDataPipeline.cs
new file mode 100644
index 0000000..e225283
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/SmlData/SmartMeterReactiveDataPipeline.cs
@@ -0,0 +1,97 @@
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+using CreativeCoders.Core;
+using CreativeCoders.SmartMessageLanguage.Framing;
+using CreativeCoders.SmartMessageLanguage.Parsing;
+using CreativeCoders.SmartMeter.DataProcessing;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace CreativeCoders.SmartMeter.Core.SmlData;
+
+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();
+
+ private readonly SmartMeterOptions _smartMeterOptions;
+
+ public SmartMeterReactiveDataPipeline(ISmlParser smlParser, ISmlMessageDetector smlMessageDetector,
+ 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 =>
+ {
+ _logger.LogDebug("SML message detected. Length: {Length}", message.PayloadBytes.Length);
+ });
+ }
+
+ public void OnCompleted()
+ {
+ _valueSubject.OnCompleted();
+ }
+
+ public void OnError(Exception error)
+ {
+ _logger.LogError(error, "Observer error in SmartMeterReactiveDataPipeline");
+ }
+
+ public void OnNext(byte[] value)
+ {
+ _smlMessageDetector.Append(value);
+ }
+
+ public IDisposable Subscribe(IObserver observer)
+ {
+ _smlMessageDetector.Messages.Select(message => _smlParser.Parse(message.PayloadBytes)).Subscribe(smlMessage =>
+ {
+ _logger.LogDebug("Parsed SML message. Values count: {Count}", smlMessage.Values.Count);
+
+ foreach (var value in smlMessage.Values.Where(v => v.Value.HasValue))
+ {
+ 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 _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/source/CreativeCoders.SmartMeter.Core/Unlock/ISmartMeterUnlocker.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/ISmartMeterUnlocker.cs
new file mode 100644
index 0000000..b22e239
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/Unlock/ISmartMeterUnlocker.cs
@@ -0,0 +1,19 @@
+namespace CreativeCoders.SmartMeter.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.Core/Unlock/ObisCodeScanner.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/ObisCodeScanner.cs
new file mode 100644
index 0000000..da8a973
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/Unlock/ObisCodeScanner.cs
@@ -0,0 +1,93 @@
+using CreativeCoders.Core;
+
+namespace CreativeCoders.SmartMeter.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.Core/Unlock/SmartMeterPinStrategy.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterPinStrategy.cs
new file mode 100644
index 0000000..31c709f
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterPinStrategy.cs
@@ -0,0 +1,26 @@
+namespace CreativeCoders.SmartMeter.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.Core/Unlock/SmartMeterUnlockOptions.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOptions.cs
new file mode 100644
index 0000000..2e19d54
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOptions.cs
@@ -0,0 +1,47 @@
+namespace CreativeCoders.SmartMeter.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.Core/Unlock/SmartMeterUnlockOutcome.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOutcome.cs
new file mode 100644
index 0000000..f1b2b85
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockOutcome.cs
@@ -0,0 +1,22 @@
+namespace CreativeCoders.SmartMeter.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.Core/Unlock/SmartMeterUnlockResult.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockResult.cs
new file mode 100644
index 0000000..9c41726
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlockResult.cs
@@ -0,0 +1,16 @@
+namespace CreativeCoders.SmartMeter.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.Core/Unlock/SmartMeterUnlocker.cs b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlocker.cs
new file mode 100644
index 0000000..06dfd39
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.Core/Unlock/SmartMeterUnlocker.cs
@@ -0,0 +1,264 @@
+using System.Diagnostics;
+using System.Text;
+using CreativeCoders.Core;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace CreativeCoders.SmartMeter.Core.Unlock;
+
+public sealed class SmartMeterUnlocker : ISmartMeterUnlocker
+{
+ 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 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,
+ 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();
+ }
+}
diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj b/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj
index 3d63d05..888029a 100644
--- a/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj
+++ b/source/CreativeCoders.SmartMeter.DataProcessing/CreativeCoders.SmartMeter.DataProcessing.csproj
@@ -1,19 +1,14 @@
- net8.0
- enable
- enable
+ Data processing library for SmartMeter
-
-
-
-
-
-
-
+
+
+
+
diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistory.cs b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs
similarity index 68%
rename from source/CreativeCoders.SmartMeter.DataProcessing/ValueHistory.cs
rename to source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs
index 1fe8f44..c40ec0e 100644
--- a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistory.cs
+++ b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistory.cs
@@ -1,15 +1,17 @@
using System.Collections.Concurrent;
-using CreativeCoders.SmartMeter.Sml;
-namespace CreativeCoders.SmartMeter.DataProcessing;
+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))
{
@@ -23,4 +25,4 @@ public ValueHistoryData GetHistoryData(SmlValueType valueType)
return historyData;
}
}
-}
\ No newline at end of file
+}
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/History/ValueHistoryDataSet.cs b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistoryDataSet.cs
new file mode 100644
index 0000000..4c8f944
--- /dev/null
+++ b/source/CreativeCoders.SmartMeter.DataProcessing/History/ValueHistoryDataSet.cs
@@ -0,0 +1,10 @@
+using CreativeCoders.Core;
+
+namespace CreativeCoders.SmartMeter.DataProcessing.History;
+
+public class ValueHistoryDataSet(SmlValue value)
+{
+ public DateTimeOffset TimeStamp { get; init; }
+
+ public SmlValue Value { get; } = Ensure.NotNull(value);
+}
diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs b/source/CreativeCoders.SmartMeter.DataProcessing/IMqttValuePublisher.cs
new file mode 100644
index 0000000..58c9001
--- /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, IAsyncDisposable
+{
+ /// Connects to the broker and starts the background publishing loop.
+ Task InitAsync();
+}
diff --git a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs
index 19a61bf..e9b867d 100644
--- a/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs
+++ b/source/CreativeCoders.SmartMeter.DataProcessing/MqttValuePublisher.cs
@@ -1,16 +1,15 @@
-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;
-using MQTTnet.Client;
namespace CreativeCoders.SmartMeter.DataProcessing;
-public class MqttValuePublisher : IObserver
+public class MqttValuePublisher : IMqttValuePublisher
{
private readonly IMqttClient _client;
@@ -22,12 +21,20 @@ public class MqttValuePublisher : IObserver
private readonly Thread _workerThread;
+ private bool _disposed;
+
+ /// Creates a publisher using a real MQTT client produced by .
public MqttValuePublisher(MqttPublisherOptions options, ILogger logger)
+ : this(options, logger, new MqttClientFactory().CreateMqttClient())
+ {
+ }
+
+ /// 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 MqttFactory().CreateMqttClient();
+ _client = Ensure.NotNull(client);
_publishingQueue = new BlockingCollection();
@@ -35,13 +42,17 @@ public MqttValuePublisher(MqttPublisherOptions options, ILogger(Encoding.UTF8.GetBytes(payload))
};
var publishResult = await SendMessageAsync(message);
@@ -124,6 +135,48 @@ public void OnError(Exception error)
public void OnNext(SmartMeterValue value)
{
+ if (_disposed || _publishingQueue.IsAddingCompleted)
+ {
+ return;
+ }
+
_publishingQueue.Add(value);
}
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ // Signal the worker to drain and exit.
+ _publishingQueue.CompleteAdding();
+
+ // Give the worker a brief window to exit; it consumes via GetConsumingEnumerable
+ // which completes once the queue is marked complete and empty.
+ if (_workerThread.IsAlive)
+ {
+ _workerThread.Join(TimeSpan.FromSeconds(2));
+ }
+
+ try
+ {
+ if (_client.IsConnected)
+ {
+ await _client.DisconnectAsync().ConfigureAwait(false);
+ }
+ }
+ catch (Exception e)
+ {
+ _logger.LogDebug(e, "Error while disconnecting MQTT client during dispose");
+ }
+
+ _client.Dispose();
+ _publishingQueue.Dispose();
+
+ GC.SuppressFinalize(this);
+ }
}
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/SmartMeterValue.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs
index 2182b65..6807aac 100644
--- a/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs
+++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmartMeterValue.cs
@@ -1,15 +1,10 @@
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; }
- public bool WriteAsJson { get; set; } = true;
+ public bool WriteAsJson { get; init; } = true;
}
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.DataProcessing/SmlValueProcessor.cs b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs
index 099cc49..af245ab 100644
--- a/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs
+++ b/source/CreativeCoders.SmartMeter.DataProcessing/SmlValueProcessor.cs
@@ -1,6 +1,6 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
-using CreativeCoders.SmartMeter.Sml;
+using CreativeCoders.SmartMeter.DataProcessing.History;
namespace CreativeCoders.SmartMeter.DataProcessing;
@@ -78,19 +78,20 @@ private void PushNewCurrentValue(SmartMeterValue value)
return;
}
+ // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (value.Type)
{
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.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.DataProcessing/ValueHistoryDataSet.cs b/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs
deleted file mode 100644
index d0de85a..0000000
--- a/source/CreativeCoders.SmartMeter.DataProcessing/ValueHistoryDataSet.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using CreativeCoders.Core;
-using CreativeCoders.SmartMeter.Sml;
-
-namespace CreativeCoders.SmartMeter.DataProcessing;
-
-public class ValueHistoryDataSet
-{
- public ValueHistoryDataSet(SmlValue value)
- {
- Value = Ensure.NotNull(value);
- }
-
- public DateTimeOffset TimeStamp { get; init; }
-
- public SmlValue Value { get; }
-}
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..f208107 100644
--- a/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj
+++ b/source/CreativeCoders.SmartMeter.Server.Core/CreativeCoders.SmartMeter.Server.Core.csproj
@@ -1,19 +1,19 @@
- net8.0
- enable
- enable
+ Core library for the SmartMeter server application
-
-
-
+
+
+
-
+
+
+
diff --git a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDaemonHostBuilder.cs b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDaemonHostBuilder.cs
index 247a9cc..6824da3 100644
--- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDaemonHostBuilder.cs
+++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterDaemonHostBuilder.cs
@@ -1,5 +1,10 @@
-using CreativeCoders.Daemon;
+using CreativeCoders.Daemon;
+using CreativeCoders.SmartMeter.Core;
+using CreativeCoders.SmartMeter.DataProcessing;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
using Serilog;
using Serilog.Events;
@@ -20,5 +25,10 @@ public static IDaemonHostBuilder CreateSmartMeterDaemonHostBuilder(string[] args
private static void ConfigureServices(IServiceCollection services)
{
services.AddOptions();
+ services.AddSmartMeterServer();
+
+ services.TryAddSingleton(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 1a151a6..631a37d 100644
--- a/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs
+++ b/source/CreativeCoders.SmartMeter.Server.Core/SmartMeterServer.cs
@@ -1,87 +1,40 @@
-using System.Reactive.Concurrency;
-using System.Reactive.Linq;
using CreativeCoders.Core;
using CreativeCoders.Daemon;
+using CreativeCoders.SmartMeter.Core.SmlData;
using CreativeCoders.SmartMeter.DataProcessing;
-using CreativeCoders.SmartMeter.Sml.Reactive;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
namespace CreativeCoders.SmartMeter.Server.Core;
[UsedImplicitly]
-public class SmartMeterServer : IDaemonService
+public class SmartMeterServer(
+ ILogger logger,
+ IMqttValuePublisher mqttValuePublisher,
+ ISmartMeterDataProducer smartMeterDataProducer)
+ : IDaemonService
{
- private readonly ILogger _logger;
-
- private readonly MqttPublisherOptions _mqttPublisherOptions;
- private readonly ILogger _publisherLogger;
-
- private readonly ReactiveSerialPort _serialPort;
-
- 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...");
- _serialPort.Close();
- _logger.LogInformation("Serial port closed");
- }
-
- private void DisposingSubscription()
- {
- if (_subscription != null)
- {
- _logger.LogInformation("Disposing subscription...");
-
- _subscription.Dispose();
-
- _logger.LogInformation("Subscription disposed");
-
- _subscription = null;
- }
- }
+ private readonly ISmartMeterDataProducer _smartMeterDataProducer = Ensure.NotNull(smartMeterDataProducer);
+ private readonly IMqttValuePublisher _mqttValuePublisher = Ensure.NotNull(mqttValuePublisher);
+ private readonly ILogger _logger = Ensure.NotNull(logger);
public async Task StartAsync()
{
_logger.LogInformation("Starting SmartMeter server");
- var mqttValuePublisher = new MqttValuePublisher(_mqttPublisherOptions, _publisherLogger);
-
- await mqttValuePublisher.InitAsync();
+ await _mqttValuePublisher.InitAsync().ConfigureAwait(false);
- _subscription ??= _serialPort
- .SelectSmlMessages()
- .SelectSmlValues()
- .SelectSmartMeterValues()
- .SubscribeOn(new TaskPoolScheduler(new TaskFactory()))
- .Subscribe(mqttValuePublisher);
-
- _serialPort.Open();
+ await _smartMeterDataProducer.StartAsync(_mqttValuePublisher).ConfigureAwait(false);
}
- public Task StopAsync()
+ public async Task StopAsync()
{
_logger.LogInformation("Stopping SmartMeter server");
- DisposingSubscription();
+ await _smartMeterDataProducer.StopAsync().ConfigureAwait(false);
- CloseSerialPort();
+ await _mqttValuePublisher.DisposeAsync().ConfigureAwait(false);
_logger.LogInformation("SmartMeter server stopped");
-
- return Task.CompletedTask;
}
}
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.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
diff --git a/source/CreativeCoders.SmartMeter.Server.Linux/install.sh b/source/CreativeCoders.SmartMeter.Server.Linux/install.sh
index 19bc275..f7eb1fe 100644
--- a/source/CreativeCoders.SmartMeter.Server.Linux/install.sh
+++ b/source/CreativeCoders.SmartMeter.Server.Linux/install.sh
@@ -11,7 +11,7 @@ if systemctl status "$SERVICE_NAME" >/dev/null 2>&1; then
fi
if [ -d "$APP_DIR" ]; then
- echo "Delete existing installation files"
+ echo "Create backup from latest installation"
rm -rf "${APP_DIR:?}.bak/"*
mv -f "$APP_DIR" "${APP_DIR:?}.bak"
else
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 ed3d84f..0000000
--- a/source/CreativeCoders.SmartMeter.Sml/CreativeCoders.SmartMeter.Sml.csproj
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
- net8.0
- enable
- enable
-
-
-
-
-
-
-
-
-
diff --git a/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs b/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs
deleted file mode 100644
index acaa98e..0000000
--- a/source/CreativeCoders.SmartMeter.Sml/ISmlValueReader.cs
+++ /dev/null
@@ -1,6 +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 c1df24b..0000000
--- a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlDataReader.cs
+++ /dev/null
@@ -1,110 +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 c2c6077..0000000
--- a/source/CreativeCoders.SmartMeter.Sml/Reactive/SmlReactiveExtensions.cs
+++ /dev/null
@@ -1,35 +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 92516fe..0000000
--- a/source/CreativeCoders.SmartMeter.Sml/SmlReadDataMode.cs
+++ /dev/null
@@ -1,8 +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 dc35bf1..0000000
--- a/source/CreativeCoders.SmartMeter.Sml/SmlValueReader.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using CreativeCoders.Core;
-
-namespace CreativeCoders.SmartMeter.Sml;
-
-public class SmlValueReader : ISmlValueReader
-{
- public IEnumerable Read(byte[] data)
- {
- Ensure.NotNull(data, nameof(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/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..d4603d6
--- /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..9b822b3
--- /dev/null
+++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Fixtures/TlvBuilder.cs
@@ -0,0 +1,129 @@
+using System.Buffers.Binary;
+using System.Diagnostics.CodeAnalysis;
+
+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 = [];
+
+ [SuppressMessage("csharpsquid", "S2437", Justification = "This is a test fixture, so we can ignore the warning")]
+ 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 TlvBuilder Append(TlvBuilder other)
+ {
+ _bytes.AddRange(other._bytes);
+
+ 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..f80e929
--- /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 (Debug, 1003) both appear once.
+ LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 1002).Should().Be(1);
+ LoggerCallAssertions.CountCalls(logger, LogLevel.Debug, 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..981f53f
--- /dev/null
+++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Framing/SmlMessageDetectorTests.cs
@@ -0,0 +1,255 @@
+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();
+ 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);
+ }
+
+ [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/SmlParserLoggingTests.cs b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserLoggingTests.cs
new file mode 100644
index 0000000..cf7a3a6
--- /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.Debug, 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..4f51406
--- /dev/null
+++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Parsing/SmlParserTests.cs
@@ -0,0 +1,501 @@
+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;
+
+namespace CreativeCoders.SmartMessageLanguage.Tests.Parsing;
+
+public class SmlParserTests
+{
+ private static SmlParser CreateSut() => new SmlParser(NullLogger.Instance);
+
+ [Fact]
+ public void Parse_GetListResponsePayload_ExtractsAllObisValues()
+ {
+ var payload = SampleSmlFile.BuildGetListResponsePayload();
+
+ var result = CreateSut().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 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 = 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/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/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
new file mode 100644
index 0000000..f84db7e
--- /dev/null
+++ b/tests/CreativeCoders.SmartMessageLanguage.Tests/Tlv/SmlTlvReaderTests.cs
@@ -0,0 +1,224 @@
+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(SmlMessageValueType.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(SmlMessageValueType.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(SmlMessageValueType.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(SmlMessageValueType.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(SmlMessageValueType.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(SmlMessageValueType.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(SmlMessageValueType.List);
+ reader.SkipCurrent();
+
+ 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..fa84a28
--- /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 Subject();
+
+ 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..9f36f3f
--- /dev/null
+++ b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterDataProducerTests.cs
@@ -0,0 +1,155 @@
+using System.Reactive.Subjects;
+using AwesomeAssertions;
+using CreativeCoders.SmartMeter.Core.SmlData;
+using CreativeCoders.SmartMeter.Core.Tests.Fixtures;
+using CreativeCoders.SmartMeter.DataProcessing;
+using FakeItEasy;
+using FakeItEasy.Core;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace CreativeCoders.SmartMeter.Core.Tests.SmlData;
+
+public class SmartMeterDataProducerTests
+{
+ private static (SmartMeterDataProducer Sut, FakeReactiveSerialPort Port,
+ ISmartMeterReactiveDataPipeline Pipeline) CreateSut()
+ {
+ var port = new FakeReactiveSerialPort();
+ var factory = A.Fake();
+ A.CallTo(() => factory.Create(A._)).Returns(port);
+
+ var pipeline = A.Fake();
+ // Make the pipeline observable behave like an empty subject — no values emitted.
+ var subject = new Subject();
+ A.CallTo(() => pipeline.Subscribe(A>._))
+ .ReturnsLazily(call => subject.Subscribe(call.GetArgument>(0)!));
+
+ var sut = new SmartMeterDataProducer(
+ pipeline,
+ NullLogger.Instance,
+ factory,
+ Options.Create(new SmartMeterOptions()));
+
+ return (sut, port, pipeline);
+ }
+
+ [Fact]
+ public async Task StartAsync_OpensPortAndSubscribesToPipeline()
+ {
+ // Arrange
+ var (sut, port, pipeline) = CreateSut();
+ var observer = A.Fake>();
+
+ // Act
+ await sut.StartAsync(observer);
+
+ // Assert
+ port.OpenCount.Should().Be(1);
+ // Subscribe runs via SubscribeOn(TaskPoolScheduler), so the Subscribe call on
+ // the fake may not have happened synchronously when the assertion runs. Poll
+ // briefly to avoid CI flakiness.
+ await WaitForCallAsync(pipeline,
+ call => call.Method.Name == nameof(IObservable<>.Subscribe));
+ A.CallTo(() => pipeline.Subscribe(A>._)).MustHaveHappened();
+ }
+
+ private static async Task WaitForCallAsync(object fake, Func predicate,
+ int timeoutMs = 2000)
+ {
+ var start = Environment.TickCount;
+
+ while (Environment.TickCount - start < timeoutMs)
+ {
+ if (Fake.GetCalls(fake).Any(predicate))
+ {
+ return;
+ }
+
+ await Task.Delay(20);
+ }
+ }
+
+ [Fact]
+ public async Task StartAsync_ForwardsSerialPortBytesToPipeline()
+ {
+ // Arrange
+ var (sut, port, pipeline) = CreateSut();
+ var observer = A.Fake>();
+ await sut.StartAsync(observer);
+
+ // Act
+ var payload = new byte[] { 1, 2, 3 };
+ port.PushBytes(payload);
+
+ // Assert
+ A.CallTo(() => pipeline.OnNext(payload)).MustHaveHappened();
+ }
+
+ [Fact]
+ public async Task StopAsync_ClosesPortAndDisposesSubscription()
+ {
+ // Arrange
+ var (sut, port, pipeline) = CreateSut();
+ var observer = A.Fake>();
+ await sut.StartAsync(observer);
+
+ // Act
+ await sut.StopAsync();
+ // After stop, pushing bytes should not propagate any more.
+ port.PushBytes([0xFF]);
+
+ // Assert
+ port.CloseCount.Should().Be(1);
+ A.CallTo(() => pipeline.OnNext(A.That.Matches(b => b.Length == 1 && b[0] == 0xFF)))
+ .MustNotHaveHappened();
+ }
+
+ [Fact]
+ public void Dispose_WithoutStart_DisposesPortIdempotently()
+ {
+ // Arrange
+ var (sut, port, _) = CreateSut();
+
+ // Act
+ sut.Dispose();
+ sut.Dispose();
+
+ // Assert - port disposed on both calls, but no throw on second call
+ port.DisposeCount.Should().BeGreaterThanOrEqualTo(1);
+ }
+
+ [Fact]
+ public async Task Dispose_AfterStart_DisposesSubscriptionAndPort()
+ {
+ // Arrange
+ var (sut, port, _) = CreateSut();
+ var observer = A.Fake>();
+ await sut.StartAsync(observer);
+
+ // Act
+ sut.Dispose();
+
+ // Assert
+ port.DisposeCount.Should().BeGreaterThanOrEqualTo(1);
+ }
+
+ [Fact]
+ public async Task StartAsync_CalledTwice_SubscribesToSerialPortOnlyOnce()
+ {
+ // Arrange
+ var (sut, port, pipeline) = CreateSut();
+ var observer = A.Fake>();
+
+ // Act
+ await sut.StartAsync(observer);
+ await sut.StartAsync(observer);
+
+ port.PushBytes([0x01]);
+
+ // Assert - pipeline.OnNext should still only receive one forward per pushed batch
+ A.CallTo(() => pipeline.OnNext(A.That.Matches(b => b.Length == 1 && b[0] == 0x01)))
+ .MustHaveHappenedOnceExactly();
+ }
+}
diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs
new file mode 100644
index 0000000..4f4fcba
--- /dev/null
+++ b/tests/CreativeCoders.SmartMeter.Core.Tests/SmlData/SmartMeterReactiveDataPipelineTests.cs
@@ -0,0 +1,222 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reactive.Subjects;
+using AwesomeAssertions;
+using CreativeCoders.SmartMessageLanguage.Framing;
+using CreativeCoders.SmartMessageLanguage.Parsing;
+using CreativeCoders.SmartMessageLanguage.Tlv;
+using CreativeCoders.SmartMessageLanguage.Units;
+using CreativeCoders.SmartMeter.Core.SmlData;
+using CreativeCoders.SmartMeter.DataProcessing;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace CreativeCoders.SmartMeter.Core.Tests.SmlData;
+
+public class SmartMeterReactiveDataPipelineTests
+{
+ private sealed class StubSmlMessageDetector : ISmlMessageDetector
+ {
+ public Subject MessagesSubject { get; } = new Subject();
+
+ public event EventHandler? MessageReceived;
+
+ public IObservable Messages => MessagesSubject;
+
+ public List Appended { get; } = [];
+
+ public int ResetCount { get; private set; }
+
+ public int DisposeCount { get; private set; }
+
+ public void Append(ReadOnlySpan data) => Appended.Add(data.ToArray());
+
+ public void Reset() => ResetCount++;
+
+ public void Dispose()
+ {
+ DisposeCount++;
+ MessagesSubject.Dispose();
+ }
+
+ // Suppress unused-event warning
+ [SuppressMessage("ReSharper", "UnusedMember.Local")]
+ public void RaiseReceived(SmlFrame frame) =>
+ MessageReceived?.Invoke(this, new SmlMessageEventArgs(frame));
+ }
+
+ private sealed class StubSmlParser : ISmlParser
+ {
+ public Func ParseBehavior { get; set; } =
+ _ => new SmlParseResult([], []);
+
+ public SmlParseResult Parse(SmlFrame frame) => ParseBehavior(frame.PayloadBytes);
+
+ public SmlParseResult Parse(ReadOnlySpan payload) => ParseBehavior(payload.ToArray());
+ }
+
+ private static SmlFrame MakeFrame(byte[] payload) => new SmlFrame(payload, payload, true, 0);
+
+ private static ObisValue MakeObis(string code, decimal value) =>
+ new ObisValue(code, value, SmlUnit.WattHour, 0, [], SmlMessageValueType.Unsigned);
+
+ private static (SmartMeterReactiveDataPipeline Sut, StubSmlParser Parser,
+ StubSmlMessageDetector Detector) CreateSut(SmartMeterOptions? opts = null)
+ {
+ var parser = new StubSmlParser();
+ var detector = new StubSmlMessageDetector();
+
+ var sut = new SmartMeterReactiveDataPipeline(
+ parser,
+ detector,
+ Options.Create(opts ?? new SmartMeterOptions
+ {
+ PurchasedEnergyOffset = 0,
+ SoldEnergyOffset = 0
+ }),
+ NullLogger.Instance);
+
+ return (sut, parser, detector);
+ }
+
+ [Fact]
+ public void OnNext_ForwardsBytesToDetector()
+ {
+ // Arrange
+ var (sut, _, detector) = CreateSut();
+ var data = new byte[] { 0x01, 0x02, 0x03 };
+
+ // Act
+ sut.OnNext(data);
+
+ // Assert
+ detector.Appended.Should().ContainSingle().Which.Should().Equal(data);
+ }
+
+ [Fact]
+ public void Subscribe_WithPurchasedEnergyObis_EmitsPurchasedEnergyValueWithOffset()
+ {
+ // Arrange
+ var (sut, parser, detector) = CreateSut(new SmartMeterOptions
+ {
+ PurchasedEnergyOffset = 1000,
+ SoldEnergyOffset = 0
+ });
+ parser.ParseBehavior = _ => new SmlParseResult(
+ [MakeObis("1-0:1.8.0*255", 123m)], []);
+
+ var received = new List();
+ sut.Subscribe(new LambdaObserver(received.Add));
+
+ // Act
+ detector.MessagesSubject.OnNext(MakeFrame([0xAA]));
+
+ // Assert
+ received.Should().ContainSingle(v => v.Type == SmartMeterValueType.TotalPurchasedEnergy
+ && v.Value == 1123m);
+ }
+
+ [Fact]
+ public void Subscribe_WithSoldEnergyObis_EmitsSoldEnergyValueWithOffset()
+ {
+ // Arrange
+ var (sut, parser, detector) = CreateSut(new SmartMeterOptions
+ {
+ PurchasedEnergyOffset = 0,
+ SoldEnergyOffset = 50
+ });
+ parser.ParseBehavior = _ => new SmlParseResult(
+ [MakeObis("1-0:2.8.0*255", 10m)], []);
+
+ var received = new List();
+ sut.Subscribe(new LambdaObserver(received.Add));
+
+ // Act
+ detector.MessagesSubject.OnNext(MakeFrame([0xAA]));
+
+ // Assert
+ received.Should().Contain(v => v.Type == SmartMeterValueType.TotalSoldEnergy && v.Value == 60m);
+ }
+
+ [Fact]
+ public void Subscribe_WithUnrelatedObis_DoesNotEmitEnergyValues()
+ {
+ // Arrange
+ var (sut, parser, detector) = CreateSut();
+ parser.ParseBehavior = _ => new SmlParseResult(
+ [MakeObis("1-0:99.9.0*255", 10m)], []);
+
+ var received = new List();
+ sut.Subscribe(new LambdaObserver(received.Add));
+
+ // Act
+ detector.MessagesSubject.OnNext(MakeFrame([0xAA]));
+
+ // Assert
+ received.Should().NotContain(v =>
+ v.Type == SmartMeterValueType.TotalPurchasedEnergy
+ || v.Type == SmartMeterValueType.TotalSoldEnergy);
+ }
+
+ [Fact]
+ public void Subscribe_WhenObisValueIsNull_DoesNotEmit()
+ {
+ // Arrange
+ var (sut, parser, detector) = CreateSut();
+ parser.ParseBehavior = _ => new SmlParseResult(
+ [
+ new ObisValue("1-0:1.8.0*255", null, SmlUnit.Unknown, 0, [],
+ SmlMessageValueType.Unsigned)
+ ], []);
+
+ var received = new List();
+ sut.Subscribe(new LambdaObserver(received.Add));
+
+ // Act
+ detector.MessagesSubject.OnNext(MakeFrame([0xAA]));
+
+ // Assert
+ received.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void OnCompleted_DoesNotThrow()
+ {
+ // Arrange: OnCompleted propagates to the internal SmlValue subject but the downstream
+ // pipeline (SmlValueProcessor) does not forward completion to its observers. The method
+ // must still be callable without throwing.
+ var (sut, _, _) = CreateSut();
+ sut.Subscribe(new LambdaObserver(_ => { }));
+
+ // Act
+ var act = () => sut.OnCompleted();
+
+ // Assert
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void OnError_DoesNotThrow()
+ {
+ // Arrange
+ var (sut, _, _) = CreateSut();
+
+ // Act
+ var act = () => sut.OnError(new InvalidOperationException("boom"));
+
+ // Assert
+ act.Should().NotThrow();
+ }
+
+ private sealed class LambdaObserver(
+ Action onNext,
+ Action? onError = null,
+ Action? onCompleted = null) : IObserver
+ {
+ public void OnCompleted() => onCompleted?.Invoke();
+
+ public void OnError(Exception error) => onError?.Invoke(error);
+
+ public void OnNext(T value) => onNext(value);
+ }
+}
diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs
new file mode 100644
index 0000000..fb0fcf5
--- /dev/null
+++ b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/ObisCodeScannerTests.cs
@@ -0,0 +1,139 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using AwesomeAssertions;
+using CreativeCoders.SmartMeter.Core.Unlock;
+using Xunit;
+
+namespace CreativeCoders.SmartMeter.Core.Tests.Unlock;
+
+[SuppressMessage("ReSharper", "UseUtf8StringLiteral")]
+public class ObisCodeScannerTests
+{
+ [Theory]
+ [InlineData("1-0:1.8.0*255", new byte[] { 1, 0, 1, 8, 0, 255 })]
+ [InlineData("1-0:16.7.0*255", new byte[] { 1, 0, 16, 7, 0, 255 })]
+ [InlineData("0-0:0.0.0*0", new byte[] { 0, 0, 0, 0, 0, 0 })]
+ public void ParseObis_WithValidString_ReturnsSixBytes(string input, byte[] expected)
+ {
+ // Act
+ var result = ObisCodeScanner.ParseObis(input);
+
+ // Assert
+ result.Should().Equal(expected);
+ }
+
+ [Theory]
+ [InlineData("1.0:1.8.0*255")]
+ [InlineData("1-0-1.8.0*255")]
+ [InlineData("1-0:1.8.0")]
+ [InlineData("1-0:1.8*255")]
+ [InlineData("1-0:1.8.0.0*255")]
+ public void ParseObis_WithMalformedString_ThrowsFormatException(string input)
+ {
+ // Act
+ var act = () => ObisCodeScanner.ParseObis(input);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ParseObis_WithEmptyString_Throws(string input)
+ {
+ // Act
+ var act = () => ObisCodeScanner.ParseObis(input);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ParseObis_WithByteOverflow_ThrowsOverflowException()
+ {
+ // Act
+ var act = () => ObisCodeScanner.ParseObis("300-0:1.8.0*255");
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void FindMatches_WhenPatternIsContained_ReturnsMatchingCode()
+ {
+ // Arrange
+ var payload = new byte[] { 0xAA, 0xBB, 1, 0, 1, 8, 0, 255, 0xCC };
+ var expected = new[] { "1-0:1.8.0*255", "1-0:2.8.0*255" };
+
+ // Act
+ var matches = ObisCodeScanner.FindMatches(payload, expected).ToArray();
+
+ // Assert
+ matches.Should().ContainSingle().Which.Should().Be("1-0:1.8.0*255");
+ }
+
+ [Fact]
+ public void FindMatches_WhenMultiplePatternsPresent_ReturnsAll()
+ {
+ // Arrange
+ var payload = new byte[] { 1, 0, 1, 8, 0, 255, 0, 1, 0, 2, 8, 0, 255 };
+ var expected = new[] { "1-0:1.8.0*255", "1-0:2.8.0*255" };
+
+ // Act
+ var matches = ObisCodeScanner.FindMatches(payload, expected).ToArray();
+
+ // Assert
+ matches.Should().BeEquivalentTo(expected);
+ }
+
+ [Fact]
+ public void FindMatches_WhenPayloadShorterThanPattern_ReturnsEmpty()
+ {
+ // Arrange
+ var payload = new byte[] { 1, 0, 1 };
+ var expected = new[] { "1-0:1.8.0*255" };
+
+ // Act
+ var matches = ObisCodeScanner.FindMatches(payload, expected).ToArray();
+
+ // Assert
+ matches.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void FindMatches_WithEmptyExpectedList_ReturnsEmpty()
+ {
+ // Arrange
+ var payload = Encoding.ASCII.GetBytes("whatever");
+
+ // Act
+ var matches = ObisCodeScanner.FindMatches(payload, []).ToArray();
+
+ // Assert
+ matches.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void FindMatches_WithEmptyPayload_ReturnsEmpty()
+ {
+ // Arrange
+ var expected = new[] { "1-0:1.8.0*255" };
+
+ // Act
+ var matches = ObisCodeScanner.FindMatches([], expected).ToArray();
+
+ // Assert
+ matches.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void FindMatches_WithNullPayload_Throws()
+ {
+ // Act
+ var act = () => ObisCodeScanner.FindMatches(null!, []).ToArray();
+
+ // Assert
+ act.Should().Throw();
+ }
+}
diff --git a/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs
new file mode 100644
index 0000000..a651069
--- /dev/null
+++ b/tests/CreativeCoders.SmartMeter.Core.Tests/Unlock/SmartMeterUnlockerTests.cs
@@ -0,0 +1,232 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using AwesomeAssertions;
+using CreativeCoders.SmartMeter.Core.Tests.Fixtures;
+using CreativeCoders.SmartMeter.Core.Unlock;
+using FakeItEasy;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace CreativeCoders.SmartMeter.Core.Tests.Unlock;
+
+[SuppressMessage("ReSharper", "UseUtf8StringLiteral")]
+public class SmartMeterUnlockerTests
+{
+ private static (SmartMeterUnlocker Sut, FakeReactiveSerialPort Port) CreateSut(
+ SmartMeterOptions? options = null)
+ {
+ var port = new FakeReactiveSerialPort();
+ var factory = A.Fake();
+ A.CallTo(() => factory.Create(A._)).Returns(port);
+
+ var sut = new SmartMeterUnlocker(
+ NullLogger.Instance,
+ factory,
+ Options.Create(options ?? new SmartMeterOptions()));
+
+ return (sut, port);
+ }
+
+ private static SmartMeterUnlockOptions FastOptions(SmartMeterUnlockOptions? baseOptions = null) =>
+ (baseOptions ?? new SmartMeterUnlockOptions()) with
+ {
+ InitialDelay = TimeSpan.Zero,
+ VerificationTimeout = TimeSpan.FromMilliseconds(500),
+ DigitDelay = TimeSpan.Zero
+ };
+
+ [Fact]
+ public async Task UnlockAsync_WhenVerifySkipped_WritesPinAndReturnsSkipped()
+ {
+ // Arrange
+ var (sut, port) = CreateSut();
+ var options = FastOptions() with { Verify = false, LineEnding = "\r\n" };
+
+ // Act
+ var result = await sut.UnlockAsync("00000000", options);
+
+ // Assert
+ result.Success.Should().BeTrue();
+ result.Outcome.Should().Be(SmartMeterUnlockOutcome.VerificationSkipped);
+ port.OpenCount.Should().Be(1);
+ port.Writes.Should().ContainSingle()
+ .Which.Should().Equal(Encoding.ASCII.GetBytes("00000000\r\n"));
+ }
+
+ [Fact]
+ public async Task UnlockAsync_WhenExpectedObisCodeObserved_ReturnsPinAccepted()
+ {
+ // Arrange
+ var (sut, port) = CreateSut();
+ var options = FastOptions() with
+ {
+ ExpectedObisCodes = ["1-0:1.8.0*255"]
+ };
+
+ // Act
+ var unlockTask = sut.UnlockAsync("12345678", options);
+ // Give the subscription a moment to register, then push the matching OBIS bytes
+ await Task.Delay(50);
+ port.PushBytes([0xFF, 1, 0, 1, 8, 0, 255, 0xAA]);
+
+ var result = await unlockTask;
+
+ // Assert
+ result.Success.Should().BeTrue();
+ result.Outcome.Should().Be(SmartMeterUnlockOutcome.PinAccepted);
+ result.DetectedObisCodes.Should().ContainSingle().Which.Should().Be("1-0:1.8.0*255");
+ }
+
+ [Fact]
+ public async Task UnlockAsync_WhenNoEvidenceWithinTimeout_ReturnsVerificationTimeout()
+ {
+ // Arrange
+ var (sut, _) = CreateSut();
+ var options = FastOptions() with
+ {
+ VerificationTimeout = TimeSpan.FromMilliseconds(100),
+ ExpectedObisCodes = ["1-0:1.8.0*255"]
+ };
+
+ // Act
+ var result = await sut.UnlockAsync("00000000", options);
+
+ // Assert
+ result.Success.Should().BeFalse();
+ result.Outcome.Should().Be(SmartMeterUnlockOutcome.VerificationTimeout);
+ result.DetectedObisCodes.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task UnlockAsync_WithIskraStrategyAndAckByte_ReturnsPinAccepted()
+ {
+ // Arrange
+ var (sut, port) = CreateSut();
+ var options = FastOptions() with { Strategy = SmartMeterPinStrategy.IskraAsciiBlock };
+
+ // Act
+ var unlockTask = sut.UnlockAsync("00000000", options);
+ await Task.Delay(50);
+ port.PushBytes([0x06]);
+
+ var result = await unlockTask;
+
+ // Assert
+ result.Success.Should().BeTrue();
+ result.Outcome.Should().Be(SmartMeterUnlockOutcome.PinAccepted);
+ }
+
+ [Fact]
+ [SuppressMessage("ReSharper", "MethodHasAsyncOverload")]
+ [SuppressMessage("csharpsquid", "S6966", Justification = "Sync method is ok in tests")]
+ public async Task UnlockAsync_WhenCancelledBeforeSend_ReturnsCancelled()
+ {
+ // Arrange
+ var (sut, _) = CreateSut();
+ var options = FastOptions() with { InitialDelay = TimeSpan.FromSeconds(5) };
+ using var cts = new CancellationTokenSource();
+
+ // Act
+ var task = sut.UnlockAsync("00000000", options, cts.Token);
+ cts.Cancel();
+
+ var result = await task;
+
+ // Assert
+ result.Success.Should().BeFalse();
+ result.Outcome.Should().Be(SmartMeterUnlockOutcome.Cancelled);
+ }
+
+ [Fact]
+ public async Task UnlockAsync_WhenWriteThrows_ReturnsWriteFailed()
+ {
+ // Arrange
+ var (sut, port) = CreateSut();
+ port.WriteBehavior = _ => new InvalidOperationException("boom");
+ var options = FastOptions();
+
+ // Act
+ var result = await sut.UnlockAsync("00000000", options);
+
+ // Assert
+ result.Success.Should().BeFalse();
+ result.Outcome.Should().Be(SmartMeterUnlockOutcome.WriteFailed);
+ result.Message.Should().Contain("boom");
+ }
+
+ [Fact]
+ public async Task UnlockAsync_WithDigitByDigitStrategy_WritesEachDigitSeparately()
+ {
+ // Arrange
+ var (sut, port) = CreateSut();
+ var options = FastOptions() with
+ {
+ Strategy = SmartMeterPinStrategy.EasymeterDigitByDigit,
+ Verify = false
+ };
+
+ // Act
+ var result = await sut.UnlockAsync("1234", options);
+
+ // Assert
+ result.Outcome.Should().Be(SmartMeterUnlockOutcome.VerificationSkipped);
+ port.Writes.Should().HaveCount(4);
+ port.Writes.Select(w => w[0]).Should().Equal((byte)'1', (byte)'2', (byte)'3', (byte)'4');
+ }
+
+ [Fact]
+ public async Task UnlockAsync_WhenPortAlreadyOpen_DoesNotOpenAgain()
+ {
+ // Arrange
+ var (sut, port) = CreateSut();
+ port.Open();
+ var initialOpenCount = port.OpenCount;
+
+ // Act
+ await sut.UnlockAsync("00000000", FastOptions() with { Verify = false });
+
+ // Assert
+ port.OpenCount.Should().Be(initialOpenCount);
+ }
+
+ [Fact]
+ public async Task UnlockAsync_WithWhitespacePin_ThrowsArgumentException()
+ {
+ // Arrange
+ var (sut, _) = CreateSut();
+
+ // Act
+ var act = () => sut.UnlockAsync(" ");
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task UnlockAsync_WithLineEnding_AppendsCorrectly()
+ {
+ // Arrange
+ var (sut, port) = CreateSut();
+ var options = FastOptions() with { Verify = false, LineEnding = "\n" };
+
+ // Act
+ await sut.UnlockAsync("ABCD", options);
+
+ // Assert
+ port.Writes.Single().Should().Equal(Encoding.ASCII.GetBytes("ABCD\n"));
+ }
+
+ [Fact]
+ public void Dispose_DisposesUnderlyingPort()
+ {
+ // Arrange
+ var (sut, port) = CreateSut();
+
+ // Act
+ sut.Dispose();
+
+ // Assert
+ port.DisposeCount.Should().Be(1);
+ }
+}
diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/CreativeCoders.SmartMeter.DataProcessing.Tests.csproj
index 75791c5..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,32 +1,29 @@
- net8.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/History/ValueHistoryTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/History/ValueHistoryTests.cs
new file mode 100644
index 0000000..466e4ff
--- /dev/null
+++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/History/ValueHistoryTests.cs
@@ -0,0 +1,56 @@
+using AwesomeAssertions;
+using CreativeCoders.SmartMeter.DataProcessing.History;
+using Xunit;
+
+namespace CreativeCoders.SmartMeter.DataProcessing.Tests.History;
+
+public class ValueHistoryTests
+{
+ [Fact]
+ public void GetHistoryData_FirstCallForType_ReturnsEmptyInstance()
+ {
+ var sut = new ValueHistory();
+
+ var data = sut.GetHistoryData(SmlValueType.PurchasedEnergy);
+
+ data.Should().NotBeNull();
+ data.DataSets.Should().BeEmpty();
+ data.LastValue.Should().BeNull();
+ data.LastValueTimeStamp.Should().BeNull();
+ }
+
+ [Fact]
+ public void GetHistoryData_CalledTwiceForSameType_ReturnsSameInstance()
+ {
+ var sut = new ValueHistory();
+
+ var first = sut.GetHistoryData(SmlValueType.PurchasedEnergy);
+ var second = sut.GetHistoryData(SmlValueType.PurchasedEnergy);
+
+ second.Should().BeSameAs(first);
+ }
+
+ [Fact]
+ public void GetHistoryData_DifferentTypes_ReturnsIndependentInstances()
+ {
+ var sut = new ValueHistory();
+
+ var purchased = sut.GetHistoryData(SmlValueType.PurchasedEnergy);
+ var sold = sut.GetHistoryData(SmlValueType.SoldEnergy);
+
+ sold.Should().NotBeSameAs(purchased);
+ }
+
+ [Fact]
+ public void GetHistoryData_PreservesMutationsAcrossCalls()
+ {
+ var sut = new ValueHistory();
+ var first = sut.GetHistoryData(SmlValueType.SoldEnergy);
+ first.LastValue = new SmlValue(SmlValueType.SoldEnergy) { Value = 42m };
+
+ var second = sut.GetHistoryData(SmlValueType.SoldEnergy);
+
+ second.LastValue.Should().NotBeNull();
+ second.LastValue!.Value.Should().Be(42m);
+ }
+}
diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs
new file mode 100644
index 0000000..7f80d79
--- /dev/null
+++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/MqttValuePublisherTests.cs
@@ -0,0 +1,231 @@
+using System.Globalization;
+using System.Text;
+using AwesomeAssertions;
+using FakeItEasy;
+using Microsoft.Extensions.Logging.Abstractions;
+using MQTTnet;
+using Xunit;
+
+namespace CreativeCoders.SmartMeter.DataProcessing.Tests;
+
+public class MqttValuePublisherTests : IAsyncLifetime
+{
+ private readonly IMqttClient _client = A.Fake();
+
+ private readonly MqttPublisherOptions _options = new MqttPublisherOptions
+ {
+ Server = new Uri("tcp://localhost:1883"),
+ ClientName = "test-client",
+ TopicTemplate = "smartmeter/values/{0}"
+ };
+
+ private MqttValuePublisher? _sut;
+
+ public Task InitializeAsync() => Task.CompletedTask;
+
+ public async Task DisposeAsync()
+ {
+ if (_sut is not null)
+ {
+ await _sut.DisposeAsync();
+ _sut = null;
+ }
+ }
+
+ private static MqttClientConnectResult SuccessConnectResult() => new MqttClientConnectResult
+ { ResultCode = MqttClientConnectResultCode.Success };
+
+ private static MqttClientPublishResult PublishOk() =>
+ new MqttClientPublishResult(null, MqttClientPublishReasonCode.Success, null!, []);
+
+ private MqttValuePublisher Create()
+ {
+ _sut = new MqttValuePublisher(_options, NullLogger.Instance, _client);
+ return _sut;
+ }
+
+ [Fact]
+ public async Task InitAsync_WhenConnectSucceeds_DoesNotThrow()
+ {
+ // Arrange
+ A.CallTo(() => _client.ConnectAsync(A._, A._))
+ .Returns(SuccessConnectResult());
+ var sut = Create();
+
+ // Act
+ await sut.InitAsync();
+
+ // Assert
+ A.CallTo(() => _client.ConnectAsync(A._, A._))
+ .MustHaveHappened();
+ }
+
+ [Fact]
+ public async Task InitAsync_WhenConnectFails_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var failed = new MqttClientConnectResult
+ {
+ ResultCode = MqttClientConnectResultCode.BadUserNameOrPassword,
+ ReasonString = "nope"
+ };
+ A.CallTo(() => _client.ConnectAsync(A._, A._))
+ .Returns(failed);
+ var sut = Create();
+
+ // Act
+ var act = sut.InitAsync;
+
+ // Assert
+ await act.Should().ThrowAsync