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 (). + 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() + .WithMessage("*BadUserNameOrPassword*"); + } + + [Fact] + public async Task OnNext_AfterInit_PublishesValueAsJsonByDefault() + { + // Arrange + A.CallTo(() => _client.ConnectAsync(A._, A._)) + .Returns(SuccessConnectResult()); + A.CallTo(() => _client.PublishAsync(A._, A._)) + .Returns(PublishOk()); + + var sut = Create(); + await sut.InitAsync(); + + // Act + sut.OnNext(new SmartMeterValue(SmartMeterValueType.TotalPurchasedEnergy) { Value = 42m }); + + // Assert - wait up to 2s for the worker thread to publish + await WaitForAsync(() => + Fake.GetCalls(_client).Any(c => c.Method.Name == nameof(IMqttClient.PublishAsync))); + + var published = Fake.GetCalls(_client) + .Where(c => c.Method.Name == nameof(IMqttClient.PublishAsync)) + .Select(c => (MqttApplicationMessage)c.Arguments[0]!) + .ToList(); + + published.Should().ContainSingle(); + published[0].Topic.Should().Be("smartmeter/values/TotalPurchasedEnergy"); + Encoding.UTF8.GetString(System.Buffers.BuffersExtensions.ToArray(published[0].Payload)).Should() + .Contain("\"Value\":42"); + } + + [Fact] + public async Task OnNext_WithWriteAsJsonFalse_PublishesRawInvariantDecimal() + { + // Arrange + A.CallTo(() => _client.ConnectAsync(A._, A._)) + .Returns(SuccessConnectResult()); + A.CallTo(() => _client.PublishAsync(A._, A._)) + .Returns(PublishOk()); + + var sut = Create(); + await sut.InitAsync(); + + // Act + sut.OnNext(new SmartMeterValue(SmartMeterValueType.GridPowerBalance) + { + Value = -12.5m, + WriteAsJson = false + }); + + // Assert + await WaitForAsync(() => + Fake.GetCalls(_client).Any(c => c.Method.Name == nameof(IMqttClient.PublishAsync))); + + var msg = Fake.GetCalls(_client) + .Where(c => c.Method.Name == nameof(IMqttClient.PublishAsync)) + .Select(c => (MqttApplicationMessage)c.Arguments[0]!) + .Single(); + + msg.Topic.Should().Be("smartmeter/values/GridPowerBalance"); + Encoding.UTF8.GetString(System.Buffers.BuffersExtensions.ToArray(msg.Payload)) + .Should().Be((-12.5m).ToString(CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task OnNext_WhenPublishThrows_WorkerContinuesAndDoesNotCrash() + { + // Arrange + A.CallTo(() => _client.ConnectAsync(A._, A._)) + .Returns(SuccessConnectResult()); + A.CallTo(() => _client.PublishAsync(A._, A._)) + .Throws().Once() + .Then.Returns(PublishOk()); + + var sut = Create(); + await sut.InitAsync(); + + // Act + sut.OnNext(new SmartMeterValue(SmartMeterValueType.TotalPurchasedEnergy) { Value = 1m }); + sut.OnNext(new SmartMeterValue(SmartMeterValueType.TotalSoldEnergy) { Value = 2m }); + + // Assert - second publish should still be attempted + await WaitForAsync(() => + Fake.GetCalls(_client).Count(c => c.Method.Name == nameof(IMqttClient.PublishAsync)) >= 2); + + Fake.GetCalls(_client).Count(c => c.Method.Name == nameof(IMqttClient.PublishAsync)) + .Should().BeGreaterThanOrEqualTo(2); + } + + [Fact] + public void Constructor_WithNullOptions_ThrowsArgumentNullException() + { + // Act + var act = () => new MqttValuePublisher(null!, NullLogger.Instance, _client); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act + var act = () => new MqttValuePublisher(_options, null!, _client); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullClient_ThrowsArgumentNullException() + { + // Act + var act = () => new MqttValuePublisher(_options, NullLogger.Instance, null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void OnError_AndOnCompleted_DoNotThrow() + { + // Arrange + var sut = Create(); + + // Act + var act1 = () => sut.OnError(new Exception("boom")); + var act2 = sut.OnCompleted; + + // Assert + act1.Should().NotThrow(); + act2.Should().NotThrow(); + } + + private static async Task WaitForAsync(Func condition, int timeoutMs = 2000) + { + var start = Environment.TickCount; + + while (!condition()) + { + if (Environment.TickCount - start > timeoutMs) + { + return; + } + + await Task.Delay(20); + } + } +} diff --git a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs index 45db059..6dc893e 100644 --- a/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs +++ b/tests/CreativeCoders.SmartMeter.DataProcessing.Tests/SmlValueProcessorTests.cs @@ -1,6 +1,5 @@ using System.Reactive.Subjects; -using CreativeCoders.SmartMeter.Sml; -using FluentAssertions; +using AwesomeAssertions; using Microsoft.Extensions.Time.Testing; using Xunit; @@ -75,7 +74,7 @@ public void Subscribe_WithTwoPurchasedEnergyValues_ShouldReturnTotalAndCurrentAn var smlValueProcessor = new SmlValueProcessor(input, fakeTimeProvider); // Act - smlValueProcessor.Subscribe(x => resultValues.Add(x)); + smlValueProcessor.Subscribe(resultValues.Add); input.OnNext(smlValue1); @@ -103,4 +102,124 @@ public void Subscribe_WithTwoPurchasedEnergyValues_ShouldReturnTotalAndCurrentAn .Should() .Be((smlValueValue2 - smlValueValue1) * 60); } + + [Fact] + public void Subscribe_WithSameValueEmittedTwiceQuickly_SuppressesDuplicateTotal() + { + // Arrange + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromSeconds(5)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + + // Assert + // Only the first value emits TotalPurchasedEnergy; the second is suppressed because + // value unchanged AND time diff < 30s. + results.Count(x => x.Type == SmartMeterValueType.TotalPurchasedEnergy).Should().Be(1); + } + + [Fact] + public void Subscribe_WithSameValueAfterFiveMinutes_EmitsTotalAgain() + { + // Arrange + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromMinutes(6)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + + // Assert + // After 5 minutes the time-based gate forces another total emission. + results.Count(x => x.Type == SmartMeterValueType.TotalPurchasedEnergy).Should().Be(2); + } + + [Fact] + public void Subscribe_WithNoSubsequentValueWithinTwentySeconds_DoesNotEmitCurrentPower() + { + // Arrange + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromSeconds(10)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 105m }); + + // Assert + results.Should().NotContain(x => x.Type == SmartMeterValueType.CurrentPurchasingPower); + } + + [Fact] + public void Subscribe_WithUnchangedValueAfterTwentyOneSeconds_EmitsZeroCurrentPowerButNoBalance() + { + // Arrange - value diff=0, time diff>20s triggers the current-power branch with value 0. + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromSeconds(21)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + + // Assert + results.Should().Contain(x => x.Type == SmartMeterValueType.CurrentPurchasingPower && x.Value == 0m); + // GridPowerBalance is only emitted when the current value is non-zero. + results.Should().NotContain(x => x.Type == SmartMeterValueType.GridPowerBalance); + } + + [Fact] + public void Subscribe_WithPurchasedEnergyGap_EmitsNegativeGridPowerBalance() + { + // Arrange + var results = new List(); + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var input = new Subject(); + var sut = new SmlValueProcessor(input, time); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 100m }); + time.Advance(TimeSpan.FromMinutes(1)); + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 200m }); + + // Assert + var balance = results.Single(x => x.Type == SmartMeterValueType.GridPowerBalance); + balance.Value.Should().BeLessThan(0m); + balance.WriteAsJson.Should().BeFalse(); + } + + [Fact] + public void Subscribe_AfterSourceCompletes_StopsEmittingButSubjectStaysAlive() + { + // Arrange + var results = new List(); + var input = new Subject(); + var sut = new SmlValueProcessor(input); + sut.Subscribe(results.Add); + + // Act + input.OnNext(new SmlValue(SmlValueType.PurchasedEnergy) { Value = 50m }); + var emittedBefore = results.Count; + input.OnCompleted(); + + // Assert - new subscribers can still attach; no late values appear. + results.Count.Should().Be(emittedBefore); + } } diff --git a/tests/CreativeCoders.SmartMeter.Server.Core.Tests/CreativeCoders.SmartMeter.Server.Core.Tests.csproj b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/CreativeCoders.SmartMeter.Server.Core.Tests.csproj new file mode 100644 index 0000000..34cfdf9 --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/CreativeCoders.SmartMeter.Server.Core.Tests.csproj @@ -0,0 +1,30 @@ + + + + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs new file mode 100644 index 0000000..50e077c --- /dev/null +++ b/tests/CreativeCoders.SmartMeter.Server.Core.Tests/SmartMeterServerTests.cs @@ -0,0 +1,91 @@ +using AwesomeAssertions; +using CreativeCoders.SmartMeter.Core.SmlData; +using CreativeCoders.SmartMeter.DataProcessing; +using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CreativeCoders.SmartMeter.Server.Core.Tests; + +public class SmartMeterServerTests +{ + private readonly IMqttValuePublisher _publisher = A.Fake(); + private readonly ISmartMeterDataProducer _producer = A.Fake(); + + private SmartMeterServer CreateSut() => + new SmartMeterServer(NullLogger.Instance, _publisher, _producer); + + [Fact] + public async Task StartAsync_InitializesPublisherThenStartsProducerWithPublisher() + { + // Arrange + var sut = CreateSut(); + + // Act + await sut.StartAsync(); + + // Assert + A.CallTo(() => _publisher.InitAsync()).MustHaveHappened() + .Then(A.CallTo(() => _producer.StartAsync(_publisher)).MustHaveHappened()); + } + + [Fact] + public async Task StartAsync_WhenPublisherInitFails_DoesNotStartProducer() + { + // Arrange + A.CallTo(() => _publisher.InitAsync()).Throws(); + var sut = CreateSut(); + + // Act + var act = sut.StartAsync; + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(() => _producer.StartAsync(A>._)).MustNotHaveHappened(); + } + + [Fact] + public async Task StopAsync_StopsDataProducerThenDisposesPublisher() + { + // Arrange + var sut = CreateSut(); + + // Act + await sut.StopAsync(); + + // Assert + A.CallTo(() => _producer.StopAsync()).MustHaveHappened() + .Then(A.CallTo(() => _publisher.DisposeAsync()).MustHaveHappened()); + } + + [Fact] + public void Constructor_WithNullLogger_Throws() + { + // Act + var act = () => new SmartMeterServer(null!, _publisher, _producer); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullPublisher_Throws() + { + // Act + var act = () => new SmartMeterServer(NullLogger.Instance, null!, _producer); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullProducer_Throws() + { + // Act + var act = () => new SmartMeterServer( + NullLogger.Instance, _publisher, null!); + + // Assert + act.Should().Throw(); + } +}