commit 731bfe223741ed3afbad1a20c080dbb37f5c6ab7 Author: Joseph Doherty Date: Mon Mar 16 14:43:31 2026 -0400 feat: bootstrap suitelink tag client codecs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4cd5d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +.DS_Store +.idea/ +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/SuiteLink.Client.sln b/SuiteLink.Client.sln new file mode 100644 index 0000000..5e76036 --- /dev/null +++ b/SuiteLink.Client.sln @@ -0,0 +1,56 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SuiteLink.Client.Tests", "tests\SuiteLink.Client.Tests\SuiteLink.Client.Tests.csproj", "{AA738D79-8A76-47DC-AB71-66AA6D9C24C6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SuiteLink.Client", "src\SuiteLink.Client\SuiteLink.Client.csproj", "{EACDBCBD-002A-410B-A180-20C4536984BA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Debug|x64.Build.0 = Debug|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Debug|x86.Build.0 = Debug|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Release|Any CPU.Build.0 = Release|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Release|x64.ActiveCfg = Release|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Release|x64.Build.0 = Release|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Release|x86.ActiveCfg = Release|Any CPU + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6}.Release|x86.Build.0 = Release|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Debug|x64.ActiveCfg = Debug|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Debug|x64.Build.0 = Debug|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Debug|x86.ActiveCfg = Debug|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Debug|x86.Build.0 = Debug|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Release|Any CPU.Build.0 = Release|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Release|x64.ActiveCfg = Release|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Release|x64.Build.0 = Release|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Release|x86.ActiveCfg = Release|Any CPU + {EACDBCBD-002A-410B-A180-20C4536984BA}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {AA738D79-8A76-47DC-AB71-66AA6D9C24C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {EACDBCBD-002A-410B-A180-20C4536984BA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/SuiteLink.Client.slnx b/SuiteLink.Client.slnx new file mode 100644 index 0000000..898a283 --- /dev/null +++ b/SuiteLink.Client.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/plans/2026-03-16-suitelink-client-design.md b/docs/plans/2026-03-16-suitelink-client-design.md new file mode 100644 index 0000000..c152f12 --- /dev/null +++ b/docs/plans/2026-03-16-suitelink-client-design.md @@ -0,0 +1,314 @@ +# SuiteLink Tag Client Design + +## Goal + +Build a cross-platform `.NET 10` C# client that communicates with AVEVA SuiteLink from macOS, Linux, and Windows for tag operations only. + +The v1 scope is limited to: + +- Connect to a SuiteLink endpoint +- Subscribe to tags +- Receive tag updates +- Write tag values +- Unsubscribe cleanly + +The v1 scope explicitly excludes: + +- AlarmMgr +- Alarm and event handling +- Secure SuiteLink V3 support +- Automatic reconnect and subscription rebuild + +## Constraints + +- The protocol is proprietary and the current design is based on reverse-engineered public evidence plus AVEVA product documentation. +- The initial target is non-encrypted SuiteLink V2 behavior, or servers configured for mixed mode that still permit legacy SuiteLink traffic. +- The first implementation only supports primitive value types: + - `bool` + - `int32` + - `float32` + - `string` +- The design should remain extensible for later support of `double`, `int64`, and `DateTime` if packet captures confirm their wire representation. + +## Protocol Target + +The target protocol surface is the normal SuiteLink tag exchange path, not AlarmMgr. + +Observed normal message structure: + +- `uint16 little-endian remaining_length` +- `uint16 little-endian message_type` +- payload +- trailing byte `0xA5` + +Observed message types to support: + +- `CONNECT` +- `ADVISE` +- `ADVISE ACK` +- `UPDATE` +- `UNADVISE` +- `UNADVISE ACK` +- `POKE` +- `POKE ACK` +- `TIME` +- Ping/pong keepalive messages + +Observed normal wire value types: + +- binary +- integer +- real +- message + +## Architecture + +The client is split into three layers. + +### Transport + +Responsible for: + +- Opening and closing TCP connections +- Reading complete SuiteLink frames +- Writing complete SuiteLink frames +- Cancellation and socket lifetime management + +### Protocol + +Responsible for: + +- Encoding the startup handshake +- Encoding `CONNECT` +- Encoding `ADVISE`, `UNADVISE`, and `POKE` +- Decoding handshake acknowledgements +- Decoding `ADVISE ACK`, `UPDATE`, and keepalive traffic +- Converting between wire values and typed client values + +### Client API + +Responsible for: + +- Exposing a minimal public API for connect, subscribe, read, write, and disconnect +- Hiding protocol details such as server-assigned tag ids +- Dispatching updates to user callbacks or future stream abstractions + +## Session Model + +The session uses one persistent TCP connection to one SuiteLink endpoint and one configured `application/topic` pair. + +State model: + +- `Disconnected` +- `TcpConnected` +- `HandshakeComplete` +- `SessionConnected` +- `Faulted` + +Startup flow: + +1. Open TCP connection. +2. Send SuiteLink handshake for the normal tag protocol. +3. Validate handshake acknowledgement. +4. Send `CONNECT` with application, topic, and client identity fields. +5. Transition to connected session state. +6. Send `ADVISE` for one or more items. +7. Capture `tag_id` mappings from server responses. +8. Receive `UPDATE` frames and dispatch typed values to subscribers. +9. Send `POKE` for writes. +10. Send `UNADVISE` when a subscription is disposed. + +Read behavior in v1 is implemented as a temporary subscription: + +1. Send `ADVISE` for the requested item. +2. Wait for the first matching `UPDATE`. +3. Return the decoded value. +4. Send `UNADVISE`. + +This is preferred over inventing a direct read request that has not yet been proven by packet captures. + +## Public API + +```csharp +public sealed class SuiteLinkClient : IAsyncDisposable +{ + Task ConnectAsync(SuiteLinkConnectionOptions options, CancellationToken ct = default); + Task DisconnectAsync(CancellationToken ct = default); + + Task SubscribeAsync( + string itemName, + Action onUpdate, + CancellationToken ct = default); + + Task ReadAsync( + string itemName, + TimeSpan timeout, + CancellationToken ct = default); + + Task WriteAsync( + string itemName, + SuiteLinkValue value, + CancellationToken ct = default); +} +``` + +Supporting models: + +- `SuiteLinkConnectionOptions` + - `Host` + - `Port` default `5413` + - `Application` + - `Topic` + - `ClientName` + - `ClientNode` + - `UserName` + - `ServerNode` +- `SuiteLinkValue` + - typed union for `bool`, `int`, `float`, and `string` +- `SuiteLinkTagUpdate` + - `ItemName` + - `TagId` + - `Value` + - `Quality` + - `ElapsedMilliseconds` + - `ReceivedAtUtc` +- `SubscriptionHandle` + - caller-facing subscription lifetime object + - disposes via `UNADVISE` + +## Internal Components + +### `SuiteLinkFrameReader` + +Responsibilities: + +- Read complete normal SuiteLink frames from a `NetworkStream` +- Parse the 2-byte little-endian remaining length +- Validate trailing `0xA5` +- Return frame payload slices to the codec + +### `SuiteLinkFrameWriter` + +Responsibilities: + +- Build frames in memory +- Write little-endian lengths and message types +- Encode UTF-16LE strings where required +- Append trailing `0xA5` + +### `SuiteLinkMessageCodec` + +Responsibilities: + +- Encode handshake and normal session messages +- Decode incoming acknowledgements and updates +- Map wire value types to `SuiteLinkValue` + +Expected methods: + +- `EncodeHandshake` +- `EncodeConnect` +- `EncodeAdvise` +- `EncodeUnadvise` +- `EncodePoke` +- `DecodeHandshakeAck` +- `DecodeAdviseAck` +- `DecodeUpdate` +- `DecodeKeepAlive` + +### `SuiteLinkSession` + +Responsibilities: + +- Own the send and receive loops +- Maintain session state +- Track `itemName <-> tagId` mappings +- Track active subscriptions +- Route decoded updates to the correct subscriber callbacks + +## Type Strategy + +The first release supports only: + +- `bool` +- `int32` +- `float32` +- `string` + +The public type wrapper must be extensible so later additions do not require replacing the whole API. The intended future-compatible additions are: + +- `double` +- `int64` +- `DateTime` + +The protocol layer should fail fast when an unsupported wire type or unsupported outgoing write type is encountered. + +## Error Handling + +The v1 client should be explicit and conservative. + +- Any malformed frame transitions the session to `Faulted` +- Any unexpected message type during startup fails the connection attempt +- Write attempts for unsupported types fail immediately +- Reconnect is not automatic in v1 +- Subscription rebuild after reconnect is deferred to a later version + +## Validation Strategy + +Testing is divided into three levels. + +### Unit Tests + +Validate: + +- frame length handling +- trailing marker validation +- UTF-16LE string encoding/decoding +- primitive value encoding/decoding + +### Golden Packet Tests + +Use known byte sequences and captures to verify: + +- handshake +- `CONNECT` +- `ADVISE` +- `ADVISE ACK` +- `UPDATE` +- `POKE` +- `UNADVISE` + +### Integration Tests + +Run against a real AVEVA/OI server configured to allow legacy or mixed-mode SuiteLink traffic. + +Success criteria: + +- connect successfully from macOS or Linux +- subscribe to one boolean tag +- subscribe to one integer tag +- subscribe to one real tag +- subscribe to one message tag +- receive live updates for each +- write to each supported tag type and verify the result +- disconnect cleanly + +## Non-Goals + +The following are intentionally deferred: + +- AlarmMgr support +- Secure SuiteLink V3 support +- automatic reconnect +- batched subscription optimization +- broad type support beyond the four proven primitive classes +- production hardening for all undocumented server variants + +## Recommended Next Step + +Create a detailed implementation plan that: + +- establishes the project structure +- defines the test-first workflow +- identifies capture-driven fixtures needed for codec tests +- breaks the implementation into transport, codec, session, and API slices diff --git a/docs/plans/2026-03-16-suitelink-client-implementation-plan.md b/docs/plans/2026-03-16-suitelink-client-implementation-plan.md new file mode 100644 index 0000000..fd8b04d --- /dev/null +++ b/docs/plans/2026-03-16-suitelink-client-implementation-plan.md @@ -0,0 +1,864 @@ +# SuiteLink Tag Client Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a cross-platform `.NET 10` C# SuiteLink V2 client for tag-only operations: connect, subscribe, receive updates, write values, and unsubscribe for `bool`, `int32`, `float32`, and `string`. + +**Architecture:** The implementation is split into transport, protocol codec, session state, and public client API layers. The first version targets normal non-encrypted SuiteLink tag traffic only and validates behavior with unit tests, golden packet tests, and optional live integration tests against an AVEVA/OI server in mixed or legacy mode. + +**Tech Stack:** .NET 10, C#, xUnit, `TcpClient`/`NetworkStream`, `System.Buffers`, `System.Buffers.Binary` + +--- + +### Task 1: Create Solution Skeleton + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLink.Client.csproj` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Class1.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLink.Client.Tests.csproj` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/UnitTest1.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln` + +**Step 1: Write the failing structure check** + +Create a test project with one placeholder test: + +```csharp +using Xunit; + +namespace SuiteLink.Client.Tests; + +public sealed class UnitTest1 +{ + [Fact] + public void Placeholder() + { + Assert.True(true); + } +} +``` + +**Step 2: Run test to verify the solution builds** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln` +Expected: PASS with one test executed + +**Step 3: Write minimal implementation** + +Create the library and solution structure only. Leave the default library type empty or minimal. + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln /Users/dohertj2/Desktop/suitelinkclient/src /Users/dohertj2/Desktop/suitelinkclient/tests +git commit -m "chore: scaffold suitelink client solution" +``` + +### Task 2: Define Public Value And Option Models + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkConnectionOptions.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkValue.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkTagUpdate.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SubscriptionHandle.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs` + +**Step 1: Write the failing test** + +```csharp +using Xunit; + +namespace SuiteLink.Client.Tests; + +public sealed class SuiteLinkValueTests +{ + [Fact] + public void BoolFactory_CreatesBoolValue() + { + var value = SuiteLinkValue.FromBoolean(true); + + Assert.True(value.TryGetBoolean(out var result)); + Assert.True(result); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter BoolFactory_CreatesBoolValue` +Expected: FAIL with missing type or method errors + +**Step 3: Write minimal implementation** + +Implement: + +- immutable `SuiteLinkConnectionOptions` +- `SuiteLinkValue` discriminated wrapper for `bool`, `int`, `float`, `string` +- `SuiteLinkTagUpdate` +- `SubscriptionHandle` placeholder with async disposal hook + +Minimal `SuiteLinkValue` pattern: + +```csharp +public enum SuiteLinkValueKind +{ + Boolean, + Int32, + Float32, + String +} +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkValueTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs +git commit -m "feat: add public suitelink value models" +``` + +### Task 3: Add Frame Reader And Writer + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkFrame.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkFrameWriter.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkFrameReader.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkFrameWriterTests.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkFrameReaderTests.cs` + +**Step 1: Write the failing test** + +```csharp +using Xunit; + +namespace SuiteLink.Client.Tests.Protocol; + +public sealed class SuiteLinkFrameWriterTests +{ + [Fact] + public void WriteFrame_AppendsLengthTypeAndMarker() + { + var bytes = SuiteLinkFrameWriter.WriteFrame(0x2440, []); + + Assert.Equal(5, bytes.Length); + Assert.Equal(0x03, bytes[0]); + Assert.Equal(0x00, bytes[1]); + Assert.Equal(0x40, bytes[2]); + Assert.Equal(0x24, bytes[3]); + Assert.Equal(0xA5, bytes[4]); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter WriteFrame_AppendsLengthTypeAndMarker` +Expected: FAIL with missing frame writer + +**Step 3: Write minimal implementation** + +Implement: + +- `SuiteLinkFrame` record holding message type and payload span or byte array +- frame writer for normal SuiteLink messages +- frame reader that: + - reads two-byte remaining length + - reads remaining bytes plus trailing marker + - validates final `0xA5` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkFrame` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol +git commit -m "feat: add suitelink frame reader and writer" +``` + +### Task 4: Add UTF-16LE And Primitive Wire Encoding Helpers + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkEncoding.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkEncodingTests.cs` + +**Step 1: Write the failing test** + +```csharp +using Xunit; + +namespace SuiteLink.Client.Tests.Protocol; + +public sealed class SuiteLinkEncodingTests +{ + [Fact] + public void EncodeLengthPrefixedUtf16_WritesCharacterCountThenUtf16Bytes() + { + var bytes = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("AB"); + + Assert.Equal(1 + 4, bytes.Length); + Assert.Equal(2, bytes[0]); + Assert.Equal((byte)'A', bytes[1]); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter EncodeLengthPrefixedUtf16_WritesCharacterCountThenUtf16Bytes` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement helper methods for: + +- one-byte-length-prefixed UTF-16LE strings +- null-terminated UTF-16LE strings +- little-endian primitive reads/writes +- FILETIME conversion helper if needed for future time messages + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkEncodingTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkEncoding.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkEncodingTests.cs +git commit -m "feat: add suitelink encoding helpers" +``` + +### Task 5: Encode Handshake And Connect Messages + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkHandshakeCodec.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkConnectCodec.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkHandshakeCodecTests.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkConnectCodecTests.cs` + +**Step 1: Write the failing test** + +Use known bytes captured from the reverse-engineered protocol and assert: + +```csharp +[Fact] +public void EncodeConnect_WritesConnectMessageType() +{ + var options = new SuiteLinkConnectionOptions( + host: "127.0.0.1", + application: "App", + topic: "Topic", + clientName: "Client", + clientNode: "Node", + userName: "User", + serverNode: "Server"); + + var bytes = SuiteLinkConnectCodec.Encode(options); + + Assert.Equal(0x80, bytes[2]); + Assert.Equal(0x01, bytes[3]); + Assert.Equal(0xA5, bytes[^1]); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter EncodeConnect_WritesConnectMessageType` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement: + +- normal SuiteLink handshake encoder +- handshake acknowledgement parser +- connect encoder using the observed field order +- isolate unknown fixed bytes in constants with comments pointing to capture evidence + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter CodecTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol +git commit -m "feat: encode suitelink handshake and connect" +``` + +### Task 6: Encode Advise And Unadvise Messages + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkSubscriptionCodec.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkSubscriptionCodecTests.cs` + +**Step 1: Write the failing test** + +```csharp +[Fact] +public void EncodeAdvise_WritesTagNameInUtf16() +{ + var bytes = SuiteLinkSubscriptionCodec.EncodeAdvise("Pump001.Run"); + + Assert.Equal(0x80, bytes[2]); + Assert.Equal(0x10, bytes[3]); + Assert.Equal(0xA5, bytes[^1]); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter EncodeAdvise_WritesTagNameInUtf16` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement: + +- `EncodeAdvise(string itemName)` +- `EncodeUnadvise(uint tagId)` +- `DecodeAdviseAck(ReadOnlySpan)` + +Add a small result model for advise acknowledgements that captures `tagId`. + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkSubscriptionCodecTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkSubscriptionCodecTests.cs +git commit -m "feat: add advise and unadvise codec support" +``` + +### Task 7: Decode Update Messages For Primitive Types + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkUpdateCodec.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkWireValueType.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkUpdateCodecTests.cs` + +**Step 1: Write the failing test** + +```csharp +[Fact] +public void DecodeUpdate_DecodesIntegerValue() +{ + var frame = new byte[] + { + 0x0D, 0x00, 0x00, 0x09, + 0x34, 0x12, 0x00, 0x00, + 0x01, 0x00, + 0xC0, 0x00, + 0x02, + 0x2A, 0x00, 0x00, 0x00, + 0xA5 + }; + + var update = SuiteLinkUpdateCodec.Decode(frame); + + Assert.Equal(0x1234u, update.TagId); + Assert.True(update.Value.TryGetInt32(out var value)); + Assert.Equal(42, value); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter DecodeUpdate_DecodesIntegerValue` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement decoding for: + +- binary to `bool` +- integer to `int` +- real to `float` +- message to `string` + +Return a parsed update model containing: + +- `TagId` +- `Quality` +- `ElapsedMilliseconds` +- `SuiteLinkValue` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkUpdateCodecTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkUpdateCodecTests.cs +git commit -m "feat: decode primitive suitelink update values" +``` + +### Task 8: Encode Poke Messages For Primitive Writes + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol/SuiteLinkWriteCodec.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkWriteCodecTests.cs` + +**Step 1: Write the failing test** + +```csharp +[Fact] +public void EncodeWrite_Int32Value_WritesPokeMessage() +{ + var bytes = SuiteLinkWriteCodec.Encode(0x1234, SuiteLinkValue.FromInt32(42)); + + Assert.Equal(0x08, bytes[2]); + Assert.Equal(0x0B, bytes[3]); + Assert.Equal(0xA5, bytes[^1]); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter EncodeWrite_Int32Value_WritesPokeMessage` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement primitive write encoding for: + +- `bool` +- `int32` +- `float32` +- `string` if confirmed by packet format used for wire message values + +Reject unsupported value kinds with a clear exception. + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkWriteCodecTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Protocol /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkWriteCodecTests.cs +git commit -m "feat: encode primitive suitelink writes" +``` + +### Task 9: Implement Session State And Tag Mapping + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkSessionState.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkSession.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Internal/SuiteLinkSessionTests.cs` + +**Step 1: Write the failing test** + +```csharp +[Fact] +public void RegisterSubscription_MapsItemNameToTagId() +{ + var session = new SuiteLinkSession(); + + session.RegisterSubscription("Pump001.Run", 0x1234); + + Assert.Equal(0x1234u, session.GetTagId("Pump001.Run")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter RegisterSubscription_MapsItemNameToTagId` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement: + +- session state enum +- tag map for `itemName -> tagId` +- reverse lookup for `tagId -> itemName` +- subscription callback registration +- update dispatch helper + +Keep transport mocking simple. Do not implement full socket I/O in this task. + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkSessionTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Internal/SuiteLinkSessionTests.cs +git commit -m "feat: add session state and tag mapping" +``` + +### Task 10: Implement Tcp Transport Loop + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Transport/SuiteLinkTcpTransport.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Transport/ISuiteLinkTransport.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Transport/SuiteLinkTcpTransportTests.cs` + +**Step 1: Write the failing test** + +```csharp +[Fact] +public async Task SendAsync_WritesFrameToUnderlyingStream() +{ + var stream = new MemoryStream(); + var transport = new SuiteLinkTcpTransport(stream); + + await transport.SendAsync(new byte[] { 0x01, 0x02 }, CancellationToken.None); + + Assert.Equal(new byte[] { 0x01, 0x02 }, stream.ToArray()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SendAsync_WritesFrameToUnderlyingStream` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement: + +- transport abstraction +- stream-backed send and receive methods +- real `TcpClient` constructor path +- test-friendly stream injection path + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkTcpTransportTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Transport /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Transport/SuiteLinkTcpTransportTests.cs +git commit -m "feat: add suitelink transport abstraction" +``` + +### Task 11: Implement Public Client Connect And Disconnect + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs` +- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SubscriptionHandle.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientConnectionTests.cs` + +**Step 1: Write the failing test** + +```csharp +[Fact] +public async Task ConnectAsync_TransitionsClientToConnectedState() +{ + var client = new SuiteLinkClient(new FakeTransport()); + + await client.ConnectAsync(TestOptions.Create()); + + Assert.True(client.IsConnected); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter ConnectAsync_TransitionsClientToConnectedState` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement: + +- `SuiteLinkClient` +- connect flow invoking handshake then connect codec +- disconnect path +- basic disposal + +Expose a minimal `IsConnected` property or equivalent state for tests. + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkClientConnectionTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SubscriptionHandle.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientConnectionTests.cs +git commit -m "feat: implement suitelink client connection flow" +``` + +### Task 12: Implement Subscribe And Read Flow + +**Files:** +- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs` +- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkSession.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientSubscriptionTests.cs` + +**Step 1: Write the failing test** + +```csharp +[Fact] +public async Task ReadAsync_ReturnsFirstUpdateForRequestedTag() +{ + var client = TestClientFactory.CreateConnectedClientWithUpdate("Pump001.Run", SuiteLinkValue.FromBoolean(true)); + + var update = await client.ReadAsync("Pump001.Run", TimeSpan.FromSeconds(1)); + + Assert.True(update.Value.TryGetBoolean(out var value)); + Assert.True(value); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter ReadAsync_ReturnsFirstUpdateForRequestedTag` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement: + +- `SubscribeAsync` +- `ReadAsync` via temporary subscription +- callback dispatch on incoming updates +- unadvise when subscription handle is disposed + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkClientSubscriptionTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientSubscriptionTests.cs +git commit -m "feat: implement suitelink subscribe and read flow" +``` + +### Task 13: Implement Primitive Write Flow + +**Files:** +- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs` +- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkSession.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientWriteTests.cs` + +**Step 1: Write the failing test** + +```csharp +[Fact] +public async Task WriteAsync_SendsPokeForSubscribedTag() +{ + var client = TestClientFactory.CreateConnectedSubscribedClient("Pump001.Speed", 0x1234); + + await client.WriteAsync("Pump001.Speed", SuiteLinkValue.FromInt32(42)); + + Assert.Contains(client.SentFrames, frame => frame[2] == 0x08 && frame[3] == 0x0B); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter WriteAsync_SendsPokeForSubscribedTag` +Expected: FAIL + +**Step 3: Write minimal implementation** + +Implement: + +- `WriteAsync` +- tag lookup by item name +- fail if item is unknown or not yet subscribed +- use primitive write codec + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter SuiteLinkClientWriteTests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientWriteTests.cs +git commit -m "feat: implement primitive suitelink writes" +``` + +### Task 14: Add Golden Packet Fixtures + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Fixtures/README.md` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Fixtures/*.bin` +- Modify: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol/*Tests.cs` + +**Step 1: Write the failing test** + +Add one fixture-backed assertion that compares encoded output to a stored handshake or connect frame. + +```csharp +[Fact] +public void EncodeConnect_MatchesGoldenFixture() +{ + var expected = File.ReadAllBytes("Fixtures/connect.bin"); + var actual = SuiteLinkConnectCodec.Encode(TestOptions.Create()).ToArray(); + + Assert.Equal(expected, actual); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter MatchesGoldenFixture` +Expected: FAIL until fixture and codec match + +**Step 3: Write minimal implementation** + +Add fixture files and normalize tests to read them from disk. Document where the fixture bytes came from and which captures or protocol references justify them. + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter Fixture` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Fixtures /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Protocol +git commit -m "test: add golden packet fixtures for suitelink codec" +``` + +### Task 15: Add Live Integration Test Harness + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/SuiteLink.Client.IntegrationTests.csproj` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/IntegrationSettings.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/TagRoundTripTests.cs` +- Create: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/README.md` +- Modify: `/Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln` + +**Step 1: Write the failing test** + +```csharp +[Fact(Skip = "Requires live AVEVA SuiteLink endpoint")] +public async Task CanSubscribeAndWriteBooleanTag() +{ + var client = new SuiteLinkClient(); + await client.ConnectAsync(IntegrationSettings.Load()); +} +``` + +**Step 2: Run test to verify the harness builds** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln --filter CanSubscribeAndWriteBooleanTag` +Expected: PASS or SKIP with the test discovered + +**Step 3: Write minimal implementation** + +Add: + +- integration project +- environment-based settings loader +- skipped or conditional tests for boolean, integer, float, and string tags + +**Step 4: Run test to verify it passes** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln` +Expected: PASS with integration tests skipped by default + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln +git commit -m "test: add suitelink integration test harness" +``` + +### Task 16: Add Package Documentation + +**Files:** +- Create: `/Users/dohertj2/Desktop/suitelinkclient/README.md` +- Modify: `/Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-16-suitelink-client-design.md` + +**Step 1: Write the failing documentation check** + +Define the required README sections: + +- project purpose +- supported protocol scope +- supported types +- unsupported features +- local build and test commands +- integration test setup + +**Step 2: Run documentation review** + +Run: `rg -n "Supported|Unsupported|Build|Test|Integration" /Users/dohertj2/Desktop/suitelinkclient/README.md` +Expected: FAIL until README exists + +**Step 3: Write minimal implementation** + +Create a README that states: + +- v1 supports normal SuiteLink V2 tag operations only +- v1 does not support AlarmMgr or secure V3 +- primitive types only +- exact `dotnet` commands for build and test + +**Step 4: Run documentation review** + +Run: `rg -n "Supported|Unsupported|Build|Test|Integration" /Users/dohertj2/Desktop/suitelinkclient/README.md` +Expected: PASS + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/README.md /Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-16-suitelink-client-design.md +git commit -m "docs: describe suitelink client scope and usage" +``` + +### Task 17: Full Verification Pass + +**Files:** +- Modify: `/Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-16-suitelink-client-implementation-plan.md` + +**Step 1: Run unit and integration-default test suite** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln` +Expected: PASS with integration tests skipped by default + +**Step 2: Run build verification** + +Run: `dotnet build /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln -c Release` +Expected: PASS + +**Step 3: Run formatting or analyzer checks if added** + +Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.sln -c Release` +Expected: PASS + +**Step 4: Update plan status notes if execution deviated** + +Add a short note to the plan if any task required deviation due to verified protocol differences. + +**Step 5: Commit** + +```bash +git add /Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-16-suitelink-client-implementation-plan.md +git commit -m "docs: finalize suitelink implementation verification" +``` diff --git a/src/SuiteLink.Client/Protocol/SuiteLinkConnectCodec.cs b/src/SuiteLink.Client/Protocol/SuiteLinkConnectCodec.cs new file mode 100644 index 0000000..4d78366 --- /dev/null +++ b/src/SuiteLink.Client/Protocol/SuiteLinkConnectCodec.cs @@ -0,0 +1,54 @@ +using System.Buffers; + +namespace SuiteLink.Client.Protocol; + +public static class SuiteLinkConnectCodec +{ + public const ushort ConnectMessageType = 0x0180; + + // Reverse-engineered 3-byte reserved segment between topic and client identity fields. + private static readonly byte[] UnknownSegment1Bytes = [0x00, 0x00, 0x00]; + + // Reverse-engineered 20-byte reserved segment before timezone1. + private static readonly byte[] UnknownSegment2Bytes = new byte[20]; + + // Reverse-engineered 38-byte reserved segment between timezone1 and timezone2. + private static readonly byte[] UnknownSegment3Bytes = new byte[38]; + + // Additional trailing bytes are observed in captures; v1 emits none. + private static readonly byte[] TrailingUnknownSegmentBytes = []; + + public static byte[] Encode(SuiteLinkConnectionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var payloadWriter = new ArrayBufferWriter(); + Append(payloadWriter, SuiteLinkEncoding.EncodeLengthPrefixedUtf16(options.Application)); + Append(payloadWriter, SuiteLinkEncoding.EncodeLengthPrefixedUtf16(options.Topic)); + Append(payloadWriter, UnknownSegment1Bytes); + Append(payloadWriter, SuiteLinkEncoding.EncodeLengthPrefixedUtf16(options.ClientName)); + Append(payloadWriter, SuiteLinkEncoding.EncodeLengthPrefixedUtf16(options.ClientNode)); + Append(payloadWriter, SuiteLinkEncoding.EncodeLengthPrefixedUtf16(options.UserName)); + Append(payloadWriter, SuiteLinkEncoding.EncodeLengthPrefixedUtf16(options.ServerNode)); + Append(payloadWriter, UnknownSegment2Bytes); + + Append(payloadWriter, SuiteLinkEncoding.EncodeNullTerminatedUtf16(options.Timezone)); + Append(payloadWriter, UnknownSegment3Bytes); + Append(payloadWriter, SuiteLinkEncoding.EncodeNullTerminatedUtf16(options.Timezone)); + Append(payloadWriter, TrailingUnknownSegmentBytes); + + return SuiteLinkFrameWriter.WriteFrame(ConnectMessageType, payloadWriter.WrittenSpan); + } + + private static void Append(ArrayBufferWriter writer, ReadOnlySpan bytes) + { + if (bytes.IsEmpty) + { + return; + } + + var destination = writer.GetSpan(bytes.Length); + bytes.CopyTo(destination); + writer.Advance(bytes.Length); + } +} diff --git a/src/SuiteLink.Client/Protocol/SuiteLinkEncoding.cs b/src/SuiteLink.Client/Protocol/SuiteLinkEncoding.cs new file mode 100644 index 0000000..279eb52 --- /dev/null +++ b/src/SuiteLink.Client/Protocol/SuiteLinkEncoding.cs @@ -0,0 +1,132 @@ +using System.Buffers.Binary; +using System.Text; + +namespace SuiteLink.Client.Protocol; + +public static class SuiteLinkEncoding +{ + private static readonly Encoding Utf16Le = Encoding.Unicode; + + public static byte[] EncodeLengthPrefixedUtf16(string value) + { + ArgumentNullException.ThrowIfNull(value); + + if (value.Length > byte.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(value), "String length must be <= 255 characters."); + } + + var textBytes = Utf16Le.GetBytes(value); + var output = new byte[1 + textBytes.Length]; + output[0] = (byte)value.Length; + textBytes.CopyTo(output, 1); + + return output; + } + + public static byte[] EncodeNullTerminatedUtf16(string value) + { + ArgumentNullException.ThrowIfNull(value); + + var textBytes = Utf16Le.GetBytes(value); + var output = new byte[textBytes.Length + 2]; + textBytes.CopyTo(output, 0); + output[^2] = 0x00; + output[^1] = 0x00; + + return output; + } + + public static string DecodeNullTerminatedUtf16(ReadOnlySpan bytes, out int consumed) + { + if ((bytes.Length & 1) != 0) + { + throw new FormatException("UTF-16LE data length must be even."); + } + + for (var i = 0; i <= bytes.Length - 2; i += 2) + { + if (bytes[i] == 0x00 && bytes[i + 1] == 0x00) + { + consumed = i + 2; + return Utf16Le.GetString(bytes[..i]); + } + } + + throw new FormatException("Null terminator not found in UTF-16LE sequence."); + } + + public static ushort ReadUInt16LittleEndian(ReadOnlySpan bytes) + { + EnsureMinimumReadableLength(bytes, sizeof(ushort)); + return BinaryPrimitives.ReadUInt16LittleEndian(bytes); + } + + public static uint ReadUInt32LittleEndian(ReadOnlySpan bytes) + { + EnsureMinimumReadableLength(bytes, sizeof(uint)); + return BinaryPrimitives.ReadUInt32LittleEndian(bytes); + } + + public static int ReadInt32LittleEndian(ReadOnlySpan bytes) + { + EnsureMinimumReadableLength(bytes, sizeof(int)); + return BinaryPrimitives.ReadInt32LittleEndian(bytes); + } + + public static float ReadSingleLittleEndian(ReadOnlySpan bytes) + { + var intBits = ReadInt32LittleEndian(bytes); + return BitConverter.Int32BitsToSingle(intBits); + } + + public static void WriteUInt16LittleEndian(Span destination, ushort value) + { + EnsureMinimumWritableLength(destination, sizeof(ushort)); + BinaryPrimitives.WriteUInt16LittleEndian(destination, value); + } + + public static void WriteUInt32LittleEndian(Span destination, uint value) + { + EnsureMinimumWritableLength(destination, sizeof(uint)); + BinaryPrimitives.WriteUInt32LittleEndian(destination, value); + } + + public static void WriteInt32LittleEndian(Span destination, int value) + { + EnsureMinimumWritableLength(destination, sizeof(int)); + BinaryPrimitives.WriteInt32LittleEndian(destination, value); + } + + public static void WriteSingleLittleEndian(Span destination, float value) + { + var intBits = BitConverter.SingleToInt32Bits(value); + WriteInt32LittleEndian(destination, intBits); + } + + public static DateTime FileTimeToUtcDateTime(long fileTime) + { + return DateTime.FromFileTimeUtc(fileTime); + } + + public static long UtcDateTimeToFileTime(DateTime value) + { + return value.Kind == DateTimeKind.Utc ? value.ToFileTimeUtc() : value.ToUniversalTime().ToFileTimeUtc(); + } + + private static void EnsureMinimumReadableLength(ReadOnlySpan bytes, int minimumLength) + { + if (bytes.Length < minimumLength) + { + throw new FormatException($"Buffer must contain at least {minimumLength} bytes."); + } + } + + private static void EnsureMinimumWritableLength(Span bytes, int minimumLength) + { + if (bytes.Length < minimumLength) + { + throw new ArgumentException($"Buffer must contain at least {minimumLength} bytes.", nameof(bytes)); + } + } +} diff --git a/src/SuiteLink.Client/Protocol/SuiteLinkFrame.cs b/src/SuiteLink.Client/Protocol/SuiteLinkFrame.cs new file mode 100644 index 0000000..bbb31a6 --- /dev/null +++ b/src/SuiteLink.Client/Protocol/SuiteLinkFrame.cs @@ -0,0 +1,22 @@ +namespace SuiteLink.Client.Protocol; + +public readonly record struct SuiteLinkFrame +{ + public SuiteLinkFrame(ushort messageType, ReadOnlySpan payload) + { + if (payload.Length > SuiteLinkFrameWriter.MaxPayloadLength) + { + throw new ArgumentOutOfRangeException(nameof(payload), payload.Length, + $"Payload exceeds maximum supported frame payload length of {SuiteLinkFrameWriter.MaxPayloadLength} bytes."); + } + + MessageType = messageType; + Payload = payload.ToArray(); + } + + public ushort MessageType { get; } + public ReadOnlyMemory Payload { get; } + public ushort RemainingLength => (ushort)PayloadLengthWithTypeAndMarker(Payload.Length); + + private static int PayloadLengthWithTypeAndMarker(int payloadLength) => payloadLength + 3; +} diff --git a/src/SuiteLink.Client/Protocol/SuiteLinkFrameReader.cs b/src/SuiteLink.Client/Protocol/SuiteLinkFrameReader.cs new file mode 100644 index 0000000..ecc3cbf --- /dev/null +++ b/src/SuiteLink.Client/Protocol/SuiteLinkFrameReader.cs @@ -0,0 +1,60 @@ +using System.Buffers.Binary; + +namespace SuiteLink.Client.Protocol; + +public static class SuiteLinkFrameReader +{ + public static SuiteLinkFrame ParseFrame(ReadOnlySpan bytes) + { + if (!TryParseFrame(bytes, out var frame, out var consumed)) + { + throw new FormatException("Frame is incomplete."); + } + + if (consumed != bytes.Length) + { + throw new FormatException( + $"Frame length mismatch. Parsed {consumed} bytes from a buffer containing {bytes.Length} bytes."); + } + + return frame; + } + + public static bool TryParseFrame(ReadOnlySpan bytes, out SuiteLinkFrame frame, out int consumed) + { + frame = default; + consumed = 0; + + if (bytes.Length < 2) + { + return false; + } + + var remainingLength = BinaryPrimitives.ReadUInt16LittleEndian(bytes[..2]); + if (remainingLength < 3) + { + throw new FormatException("Remaining length must include message type and end marker."); + } + + var frameLength = remainingLength + 2; + if (bytes.Length < frameLength) + { + return false; + } + + if (bytes[frameLength - 1] != SuiteLinkFrameWriter.EndMarker) + { + throw new FormatException("Frame end marker is invalid."); + } + + var frameBytes = bytes[..frameLength]; + var messageType = BinaryPrimitives.ReadUInt16LittleEndian(frameBytes.Slice(2, 2)); + var payloadLength = remainingLength - 3; + var payload = frameBytes.Slice(4, payloadLength); + + frame = new SuiteLinkFrame(messageType, payload); + consumed = frameLength; + + return true; + } +} diff --git a/src/SuiteLink.Client/Protocol/SuiteLinkFrameWriter.cs b/src/SuiteLink.Client/Protocol/SuiteLinkFrameWriter.cs new file mode 100644 index 0000000..0eafd1a --- /dev/null +++ b/src/SuiteLink.Client/Protocol/SuiteLinkFrameWriter.cs @@ -0,0 +1,29 @@ +using System.Buffers.Binary; + +namespace SuiteLink.Client.Protocol; + +public static class SuiteLinkFrameWriter +{ + public const int HeaderLength = 4; + public const byte EndMarker = 0xA5; + public const int MaxPayloadLength = ushort.MaxValue - 3; + + public static byte[] WriteFrame(ushort messageType, ReadOnlySpan payload) + { + if (payload.Length > MaxPayloadLength) + { + throw new ArgumentOutOfRangeException(nameof(payload), payload.Length, + $"Payload exceeds maximum supported frame payload length of {MaxPayloadLength} bytes."); + } + + var remainingLength = payload.Length + 3; + var frame = new byte[2 + remainingLength]; + + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(0, 2), (ushort)remainingLength); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(2, 2), messageType); + payload.CopyTo(frame.AsSpan(HeaderLength, payload.Length)); + frame[^1] = EndMarker; + + return frame; + } +} diff --git a/src/SuiteLink.Client/Protocol/SuiteLinkHandshakeCodec.cs b/src/SuiteLink.Client/Protocol/SuiteLinkHandshakeCodec.cs new file mode 100644 index 0000000..3b2f60d --- /dev/null +++ b/src/SuiteLink.Client/Protocol/SuiteLinkHandshakeCodec.cs @@ -0,0 +1,80 @@ +using System.Buffers.Binary; + +namespace SuiteLink.Client.Protocol; + +public static class SuiteLinkHandshakeCodec +{ + private static readonly byte[] QueryMagicBytes = Convert.FromHexString("CAFE8BBAFE8BD311AA0500A0C9ECFD9F"); + private static readonly byte[] UnknownQueryMagicBytes = Convert.FromHexString("FF9855C83D25D411AA2700A0C9ECFD9F"); + + public static ReadOnlyMemory QueryMagic => QueryMagicBytes; + public static ReadOnlyMemory UnknownQueryMagic => UnknownQueryMagicBytes; + + // Observed as 0x00000001 in reverse-engineered normal/query handshakes. + public const uint DirectConnectionType = 1; + public const ushort NormalHandshakeAckType = 0x0001; + + public static byte[] EncodeNormalQueryHandshake(string targetApplication, string sourceNode, string sourceUser) + { + ValidateRequired(targetApplication, nameof(targetApplication)); + ValidateRequired(sourceNode, nameof(sourceNode)); + ValidateRequired(sourceUser, nameof(sourceUser)); + + var applicationBytes = SuiteLinkEncoding.EncodeNullTerminatedUtf16(targetApplication); + var sourceNodeBytes = SuiteLinkEncoding.EncodeNullTerminatedUtf16(sourceNode); + var sourceUserBytes = SuiteLinkEncoding.EncodeNullTerminatedUtf16(sourceUser); + + var payloadLength = QueryMagicBytes.Length + + UnknownQueryMagicBytes.Length + + sizeof(uint) + + applicationBytes.Length + + sourceNodeBytes.Length + + sourceUserBytes.Length; + + if (payloadLength > byte.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(payloadLength), payloadLength, + "Total handshake payload exceeds one-byte length field (255 bytes)."); + } + + var output = new byte[payloadLength + 1]; + output[0] = (byte)payloadLength; + + var span = output.AsSpan(1); + QueryMagicBytes.CopyTo(span); + span = span[QueryMagicBytes.Length..]; + UnknownQueryMagicBytes.CopyTo(span); + span = span[UnknownQueryMagicBytes.Length..]; + BinaryPrimitives.WriteUInt32LittleEndian(span[..sizeof(uint)], DirectConnectionType); + span = span[sizeof(uint)..]; + applicationBytes.CopyTo(span); + span = span[applicationBytes.Length..]; + sourceNodeBytes.CopyTo(span); + span = span[sourceNodeBytes.Length..]; + sourceUserBytes.CopyTo(span); + + return output; + } + + public static NormalHandshakeAck ParseNormalHandshakeAck(ReadOnlySpan bytes) + { + var frame = SuiteLinkFrameReader.ParseFrame(bytes); + if (frame.MessageType != NormalHandshakeAckType) + { + throw new FormatException( + $"Unexpected handshake ack message type 0x{frame.MessageType:x4}; expected 0x{NormalHandshakeAckType:x4}."); + } + + return new NormalHandshakeAck(frame.MessageType, frame.Payload); + } + + private static void ValidateRequired(string value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value cannot be null or whitespace.", paramName); + } + } +} + +public readonly record struct NormalHandshakeAck(ushort MessageType, ReadOnlyMemory Data); diff --git a/src/SuiteLink.Client/Protocol/SuiteLinkSubscriptionCodec.cs b/src/SuiteLink.Client/Protocol/SuiteLinkSubscriptionCodec.cs new file mode 100644 index 0000000..d051968 --- /dev/null +++ b/src/SuiteLink.Client/Protocol/SuiteLinkSubscriptionCodec.cs @@ -0,0 +1,84 @@ +namespace SuiteLink.Client.Protocol; + +public static class SuiteLinkSubscriptionCodec +{ + public const ushort AdviseMessageType = 0x8010; + public const ushort AdviseAckMessageType = 0x0003; + public const ushort UnadviseMessageType = 0x8004; + + public static byte[] EncodeAdvise(string itemName) + { + // Convenience overload for initial bring-up scenarios. + return EncodeAdvise(0, itemName); + } + + public static byte[] EncodeAdvise(uint tagId, string itemName) + { + if (string.IsNullOrWhiteSpace(itemName)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(itemName)); + } + + var itemNameBytes = SuiteLinkEncoding.EncodeLengthPrefixedUtf16(itemName); + var payload = new byte[sizeof(uint) + itemNameBytes.Length]; + SuiteLinkEncoding.WriteUInt32LittleEndian(payload.AsSpan(0, sizeof(uint)), tagId); + itemNameBytes.CopyTo(payload, sizeof(uint)); + + return SuiteLinkFrameWriter.WriteFrame(AdviseMessageType, payload); + } + + public static byte[] EncodeUnadvise(uint tagId) + { + Span payload = stackalloc byte[sizeof(uint)]; + SuiteLinkEncoding.WriteUInt32LittleEndian(payload, tagId); + return SuiteLinkFrameWriter.WriteFrame(UnadviseMessageType, payload); + } + + public static IReadOnlyList DecodeAdviseAckMany(ReadOnlySpan bytes) + { + var frame = SuiteLinkFrameReader.ParseFrame(bytes); + if (frame.MessageType != AdviseAckMessageType) + { + throw new FormatException( + $"Unexpected advise ack message type 0x{frame.MessageType:x4}; expected 0x{AdviseAckMessageType:x4}."); + } + + var payload = frame.Payload.Span; + if (payload.Length == 0) + { + return []; + } + + if (payload.Length % 5 != 0) + { + throw new FormatException( + $"Unexpected advise ack payload length {payload.Length}; payload must be a multiple of 5 bytes."); + } + + var itemCount = payload.Length / 5; + var items = new List(itemCount); + var offset = 0; + while (offset < payload.Length) + { + var tagId = SuiteLinkEncoding.ReadUInt32LittleEndian(payload.Slice(offset, 4)); + items.Add(new AdviseAck(tagId)); + offset += 5; + } + + return items; + } + + public static AdviseAck DecodeAdviseAck(ReadOnlySpan bytes) + { + var items = DecodeAdviseAckMany(bytes); + if (items.Count != 1) + { + throw new FormatException( + $"Expected a single advise ack item but decoded {items.Count} items."); + } + + return items[0]; + } +} + +public readonly record struct AdviseAck(uint TagId); diff --git a/src/SuiteLink.Client/Protocol/SuiteLinkUpdateCodec.cs b/src/SuiteLink.Client/Protocol/SuiteLinkUpdateCodec.cs new file mode 100644 index 0000000..ee2c9b4 --- /dev/null +++ b/src/SuiteLink.Client/Protocol/SuiteLinkUpdateCodec.cs @@ -0,0 +1,121 @@ +using System.Text; + +namespace SuiteLink.Client.Protocol; + +public static class SuiteLinkUpdateCodec +{ + public const ushort UpdateMessageType = 0x0009; + + public static DecodedUpdate Decode(ReadOnlySpan bytes, Encoding? messageEncoding = null) + { + var updates = DecodeMany(bytes, messageEncoding); + if (updates.Count != 1) + { + throw new FormatException( + $"Expected a single update item but decoded {updates.Count} items."); + } + + return updates[0]; + } + + public static IReadOnlyList DecodeMany(ReadOnlySpan bytes, Encoding? messageEncoding = null) + { + var frame = SuiteLinkFrameReader.ParseFrame(bytes); + if (frame.MessageType != UpdateMessageType) + { + throw new FormatException( + $"Unexpected update message type 0x{frame.MessageType:x4}; expected 0x{UpdateMessageType:x4}."); + } + + messageEncoding ??= Encoding.Latin1; + var payload = frame.Payload.Span; + if (payload.IsEmpty) + { + return []; + } + + var updates = new List(); + var offset = 0; + while (offset < payload.Length) + { + updates.Add(DecodeSingleItem(payload, ref offset, messageEncoding)); + } + + return updates; + } + + private static DecodedUpdate DecodeSingleItem(ReadOnlySpan payload, ref int offset, Encoding messageEncoding) + { + EnsureRemaining(payload, offset, 9); + var tagId = SuiteLinkEncoding.ReadUInt32LittleEndian(payload[offset..]); + offset += 4; + var elapsedMilliseconds = SuiteLinkEncoding.ReadUInt16LittleEndian(payload[offset..]); + offset += 2; + var quality = SuiteLinkEncoding.ReadUInt16LittleEndian(payload[offset..]); + offset += 2; + var valueType = (SuiteLinkWireValueType)payload[offset]; + offset += 1; + + var value = valueType switch + { + SuiteLinkWireValueType.Binary => DecodeBinary(payload, ref offset), + SuiteLinkWireValueType.Integer => DecodeInteger(payload, ref offset), + SuiteLinkWireValueType.Real => DecodeReal(payload, ref offset), + SuiteLinkWireValueType.Message => DecodeMessage(payload, ref offset, messageEncoding), + _ => throw new FormatException($"Unsupported update value type: 0x{(byte)valueType:x2}.") + }; + + return new DecodedUpdate(tagId, quality, elapsedMilliseconds, value); + } + + private static SuiteLinkValue DecodeBinary(ReadOnlySpan payload, ref int offset) + { + EnsureRemaining(payload, offset, 1); + var value = payload[offset] != 0; + offset += 1; + return SuiteLinkValue.FromBoolean(value); + } + + private static SuiteLinkValue DecodeInteger(ReadOnlySpan payload, ref int offset) + { + EnsureRemaining(payload, offset, 4); + var value = SuiteLinkEncoding.ReadInt32LittleEndian(payload[offset..]); + offset += 4; + return SuiteLinkValue.FromInt32(value); + } + + private static SuiteLinkValue DecodeReal(ReadOnlySpan payload, ref int offset) + { + EnsureRemaining(payload, offset, 4); + var value = SuiteLinkEncoding.ReadSingleLittleEndian(payload[offset..]); + offset += 4; + return SuiteLinkValue.FromFloat32(value); + } + + private static SuiteLinkValue DecodeMessage(ReadOnlySpan payload, ref int offset, Encoding messageEncoding) + { + EnsureRemaining(payload, offset, 2); + var messageLength = SuiteLinkEncoding.ReadUInt16LittleEndian(payload[offset..]); + offset += 2; + + EnsureRemaining(payload, offset, messageLength); + var value = messageEncoding.GetString(payload.Slice(offset, messageLength)); + offset += messageLength; + return SuiteLinkValue.FromString(value); + } + + private static void EnsureRemaining(ReadOnlySpan payload, int offset, int neededBytes) + { + if (payload.Length - offset < neededBytes) + { + throw new FormatException( + $"Update payload is truncated. Needed {neededBytes} bytes at offset {offset}, but only {payload.Length - offset} remain."); + } + } +} + +public readonly record struct DecodedUpdate( + uint TagId, + ushort Quality, + ushort ElapsedMilliseconds, + SuiteLinkValue Value); diff --git a/src/SuiteLink.Client/Protocol/SuiteLinkWireValueType.cs b/src/SuiteLink.Client/Protocol/SuiteLinkWireValueType.cs new file mode 100644 index 0000000..cbc3a70 --- /dev/null +++ b/src/SuiteLink.Client/Protocol/SuiteLinkWireValueType.cs @@ -0,0 +1,9 @@ +namespace SuiteLink.Client.Protocol; + +public enum SuiteLinkWireValueType : byte +{ + Binary = 1, + Integer = 2, + Real = 3, + Message = 4 +} diff --git a/src/SuiteLink.Client/SubscriptionHandle.cs b/src/SuiteLink.Client/SubscriptionHandle.cs new file mode 100644 index 0000000..78fff56 --- /dev/null +++ b/src/SuiteLink.Client/SubscriptionHandle.cs @@ -0,0 +1,36 @@ +using System.Threading; + +namespace SuiteLink.Client; + +public sealed class SubscriptionHandle : IAsyncDisposable +{ + private readonly Func? _disposeCallback; + private int _disposeState; + + public SubscriptionHandle(string itemName, uint tagId, Func? disposeCallback = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(itemName); + ItemName = itemName; + TagId = tagId; + _disposeCallback = disposeCallback; + } + + public string ItemName { get; } + + public uint TagId { get; } + + public bool IsDisposed => _disposeState == 1; + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeState, 1) == 1) + { + return; + } + + if (_disposeCallback is not null) + { + await _disposeCallback().ConfigureAwait(false); + } + } +} diff --git a/src/SuiteLink.Client/SuiteLink.Client.csproj b/src/SuiteLink.Client/SuiteLink.Client.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/SuiteLink.Client/SuiteLink.Client.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/SuiteLink.Client/SuiteLinkConnectionOptions.cs b/src/SuiteLink.Client/SuiteLinkConnectionOptions.cs new file mode 100644 index 0000000..980450e --- /dev/null +++ b/src/SuiteLink.Client/SuiteLinkConnectionOptions.cs @@ -0,0 +1,57 @@ +namespace SuiteLink.Client; + +public sealed record class SuiteLinkConnectionOptions +{ + public SuiteLinkConnectionOptions( + string host, + string application, + string topic, + string clientName, + string clientNode, + string userName, + string serverNode, + string? timezone = null, + int port = 5413) + { + ValidateRequired(host, nameof(host)); + ValidateRequired(application, nameof(application)); + ValidateRequired(topic, nameof(topic)); + ValidateRequired(clientName, nameof(clientName)); + ValidateRequired(clientNode, nameof(clientNode)); + ValidateRequired(userName, nameof(userName)); + ValidateRequired(serverNode, nameof(serverNode)); + + if (port is < 1 or > 65535) + { + throw new ArgumentOutOfRangeException(nameof(port), port, "Port must be between 1 and 65535."); + } + + Host = host; + Application = application; + Topic = topic; + ClientName = clientName; + ClientNode = clientNode; + UserName = userName; + ServerNode = serverNode; + Timezone = string.IsNullOrWhiteSpace(timezone) ? "UTC" : timezone; + Port = port; + } + + public string Host { get; } + public string Application { get; } + public string Topic { get; } + public string ClientName { get; } + public string ClientNode { get; } + public string UserName { get; } + public string ServerNode { get; } + public string Timezone { get; } + public int Port { get; } + + private static void ValidateRequired(string value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value cannot be null or whitespace.", paramName); + } + } +} diff --git a/src/SuiteLink.Client/SuiteLinkTagUpdate.cs b/src/SuiteLink.Client/SuiteLinkTagUpdate.cs new file mode 100644 index 0000000..a662631 --- /dev/null +++ b/src/SuiteLink.Client/SuiteLinkTagUpdate.cs @@ -0,0 +1,9 @@ +namespace SuiteLink.Client; + +public sealed record class SuiteLinkTagUpdate( + string ItemName, + uint TagId, + SuiteLinkValue Value, + ushort Quality, + ushort ElapsedMilliseconds, + DateTimeOffset ReceivedAtUtc); diff --git a/src/SuiteLink.Client/SuiteLinkValue.cs b/src/SuiteLink.Client/SuiteLinkValue.cs new file mode 100644 index 0000000..b4c4cb1 --- /dev/null +++ b/src/SuiteLink.Client/SuiteLinkValue.cs @@ -0,0 +1,103 @@ +namespace SuiteLink.Client; + +public enum SuiteLinkValueKind +{ + None = 0, + Boolean = 1, + Int32 = 2, + Float32 = 3, + String = 4 +} + +public readonly struct SuiteLinkValue +{ + private readonly bool _booleanValue; + private readonly int _int32Value; + private readonly float _float32Value; + private readonly string? _stringValue; + + private SuiteLinkValue( + SuiteLinkValueKind kind, + bool booleanValue = default, + int int32Value = default, + float float32Value = default, + string? stringValue = default) + { + Kind = kind; + _booleanValue = booleanValue; + _int32Value = int32Value; + _float32Value = float32Value; + _stringValue = stringValue; + } + + public SuiteLinkValueKind Kind { get; } + + public static SuiteLinkValue FromBoolean(bool value) + { + return new SuiteLinkValue(SuiteLinkValueKind.Boolean, booleanValue: value); + } + + public static SuiteLinkValue FromInt32(int value) + { + return new SuiteLinkValue(SuiteLinkValueKind.Int32, int32Value: value); + } + + public static SuiteLinkValue FromFloat32(float value) + { + return new SuiteLinkValue(SuiteLinkValueKind.Float32, float32Value: value); + } + + public static SuiteLinkValue FromString(string value) + { + ArgumentNullException.ThrowIfNull(value); + return new SuiteLinkValue(SuiteLinkValueKind.String, stringValue: value); + } + + public bool TryGetBoolean(out bool value) + { + if (Kind == SuiteLinkValueKind.Boolean) + { + value = _booleanValue; + return true; + } + + value = default; + return false; + } + + public bool TryGetInt32(out int value) + { + if (Kind == SuiteLinkValueKind.Int32) + { + value = _int32Value; + return true; + } + + value = default; + return false; + } + + public bool TryGetFloat32(out float value) + { + if (Kind == SuiteLinkValueKind.Float32) + { + value = _float32Value; + return true; + } + + value = default; + return false; + } + + public bool TryGetString(out string? value) + { + if (Kind == SuiteLinkValueKind.String) + { + value = _stringValue; + return true; + } + + value = default; + return false; + } +} diff --git a/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkConnectCodecTests.cs b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkConnectCodecTests.cs new file mode 100644 index 0000000..245a90c --- /dev/null +++ b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkConnectCodecTests.cs @@ -0,0 +1,122 @@ +using SuiteLink.Client.Protocol; + +namespace SuiteLink.Client.Tests.Protocol; + +public sealed class SuiteLinkConnectCodecTests +{ + [Fact] + public void EncodeConnect_WritesConnectMessageTypeAndEndMarker() + { + var options = new SuiteLinkConnectionOptions( + host: "127.0.0.1", + application: "App", + topic: "Topic", + clientName: "Client", + clientNode: "Node", + userName: "User", + serverNode: "Server", + timezone: "UTC"); + + var bytes = SuiteLinkConnectCodec.Encode(options); + + Assert.Equal(0x80, bytes[2]); + Assert.Equal(0x01, bytes[3]); + Assert.Equal(0xA5, bytes[^1]); + } + + [Fact] + public void EncodeConnect_WritesExpectedFieldOrderReservedSegmentsAndTimezoneStrings() + { + var options = new SuiteLinkConnectionOptions( + host: "127.0.0.1", + application: "App", + topic: "Topic", + clientName: "Client", + clientNode: "Node", + userName: "User", + serverNode: "Server", + timezone: "UTC"); + + var bytes = SuiteLinkConnectCodec.Encode(options); + var frame = SuiteLinkFrameReader.ParseFrame(bytes); + var payload = frame.Payload.Span; + var index = 0; + + var app = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("App"); + Assert.True(payload[index..].StartsWith(app)); + index += app.Length; + + var topic = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Topic"); + Assert.True(payload[index..].StartsWith(topic)); + index += topic.Length; + + Assert.True(payload[index..(index + 3)].ToArray().All(static b => b == 0x00)); + index += 3; + + var client = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Client"); + Assert.True(payload[index..].StartsWith(client)); + index += client.Length; + + var clientNode = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Node"); + Assert.True(payload[index..].StartsWith(clientNode)); + index += clientNode.Length; + + var user = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("User"); + Assert.True(payload[index..].StartsWith(user)); + index += user.Length; + + var serverNode = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Server"); + Assert.True(payload[index..].StartsWith(serverNode)); + index += serverNode.Length; + + Assert.True(payload[index..(index + 20)].ToArray().All(static b => b == 0x00)); + index += 20; + + var timezone1 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out var timezone1ConsumedBytes); + index += timezone1ConsumedBytes; + Assert.Equal("UTC", timezone1); + + Assert.True(payload[index..(index + 38)].ToArray().All(static b => b == 0x00)); + index += 38; + + var timezone2 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out var timezone2ConsumedBytes); + index += timezone2ConsumedBytes; + Assert.Equal("UTC", timezone2); + Assert.Equal(timezone1, timezone2); + + Assert.Equal(payload.Length, index); + } + + [Fact] + public void EncodeConnect_WhenTimezoneNotProvided_UsesUtcDefault() + { + var options = new SuiteLinkConnectionOptions( + host: "127.0.0.1", + application: "App", + topic: "Topic", + clientName: "Client", + clientNode: "Node", + userName: "User", + serverNode: "Server"); + + var frame = SuiteLinkFrameReader.ParseFrame(SuiteLinkConnectCodec.Encode(options)); + var payload = frame.Payload.Span; + var index = 0; + + index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("App").Length; + index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Topic").Length; + index += 3; + index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Client").Length; + index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Node").Length; + index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("User").Length; + index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Server").Length; + index += 20; + + var timezone1 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out var timezone1ConsumedBytes); + index += timezone1ConsumedBytes + 38; + var timezone2 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out _); + + Assert.Equal("UTC", timezone1); + Assert.Equal("UTC", timezone2); + } +} diff --git a/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkEncodingTests.cs b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkEncodingTests.cs new file mode 100644 index 0000000..a4189d2 --- /dev/null +++ b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkEncodingTests.cs @@ -0,0 +1,111 @@ +using SuiteLink.Client.Protocol; + +namespace SuiteLink.Client.Tests.Protocol; + +public sealed class SuiteLinkEncodingTests +{ + [Fact] + public void EncodeLengthPrefixedUtf16_WithAsciiText_WritesCharacterCountAndUtf16Bytes() + { + var bytes = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("AB"); + + Assert.Equal(new byte[] { 0x02, 0x41, 0x00, 0x42, 0x00 }, bytes); + } + + [Fact] + public void DecodeNullTerminatedUtf16_ReadsStringAndConsumedBytes() + { + var buffer = new byte[] { 0x48, 0x00, 0x69, 0x00, 0x00, 0x00, 0x20, 0x00 }; + + var text = SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out var consumed); + + Assert.Equal("Hi", text); + Assert.Equal(6, consumed); + } + + [Fact] + public void WriteAndReadUInt32LittleEndian_RoundTripsValue() + { + Span bytes = stackalloc byte[4]; + + SuiteLinkEncoding.WriteUInt32LittleEndian(bytes, 0x11223344); + var value = SuiteLinkEncoding.ReadUInt32LittleEndian(bytes); + + Assert.Equal((uint)0x11223344, value); + Assert.Equal(new byte[] { 0x44, 0x33, 0x22, 0x11 }, bytes.ToArray()); + } + + [Fact] + public void FileTimeToUtcDateTime_ConvertsKnownEpochValue() + { + const long unixEpochFileTime = 116444736000000000L; + + var value = SuiteLinkEncoding.FileTimeToUtcDateTime(unixEpochFileTime); + + Assert.Equal(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), value); + } + + [Fact] + public void DecodeNullTerminatedUtf16_WithOddLength_ThrowsFormatException() + { + var buffer = new byte[] { 0x41, 0x00, 0x00 }; + + Assert.Throws(() => SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out _)); + } + + [Fact] + public void DecodeNullTerminatedUtf16_WithoutNullTerminator_ThrowsFormatException() + { + var buffer = new byte[] { 0x41, 0x00, 0x42, 0x00 }; + + Assert.Throws(() => SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out _)); + } + + [Fact] + public void EncodeLengthPrefixedUtf16_WhenInputIsTooLong_ThrowsArgumentOutOfRangeException() + { + var value = new string('A', 256); + + Assert.Throws(() => SuiteLinkEncoding.EncodeLengthPrefixedUtf16(value)); + } + + [Fact] + public void EncodeLengthPrefixedUtf16_WithSurrogatePair_PrefixUsesUtf16CodeUnits() + { + var bytes = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("A\U0001F600"); + + Assert.Equal(3, bytes[0]); + } + + [Fact] + public void ReadUInt32LittleEndian_WhenInputIsTooShort_ThrowsFormatException() + { + var buffer = new byte[] { 0x01, 0x02, 0x03 }; + + Assert.Throws(() => SuiteLinkEncoding.ReadUInt32LittleEndian(buffer)); + } + + [Fact] + public void WriteAndReadSingleLittleEndian_RoundTripsNonTrivialValue() + { + Span bytes = stackalloc byte[4]; + const float expected = 123.4567f; + + SuiteLinkEncoding.WriteSingleLittleEndian(bytes, expected); + var value = SuiteLinkEncoding.ReadSingleLittleEndian(bytes); + + Assert.Equal(expected, value); + } + + [Fact] + public void WriteAndReadSingleLittleEndian_RoundTripsNaN() + { + Span bytes = stackalloc byte[4]; + var expected = float.NaN; + + SuiteLinkEncoding.WriteSingleLittleEndian(bytes, expected); + var value = SuiteLinkEncoding.ReadSingleLittleEndian(bytes); + + Assert.True(float.IsNaN(value)); + } +} diff --git a/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkFrameReaderTests.cs b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkFrameReaderTests.cs new file mode 100644 index 0000000..8ff3f7b --- /dev/null +++ b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkFrameReaderTests.cs @@ -0,0 +1,122 @@ +using SuiteLink.Client.Protocol; + +namespace SuiteLink.Client.Tests.Protocol; + +public sealed class SuiteLinkFrameReaderTests +{ + [Fact] + public void ParseFrame_WithValidFrame_ParsesMessageTypeAndPayload() + { + var bytes = new byte[] + { + 0x05, 0x00, + 0x00, 0x09, + 0x01, 0x02, + 0xA5 + }; + + var frame = SuiteLinkFrameReader.ParseFrame(bytes); + + Assert.Equal((ushort)0x0900, frame.MessageType); + Assert.Equal(new byte[] { 0x01, 0x02 }, frame.Payload.ToArray()); + Assert.Equal((ushort)5, frame.RemainingLength); + } + + [Fact] + public void ParseFrame_WithInvalidMarker_ThrowsFormatException() + { + var bytes = new byte[] + { + 0x03, 0x00, + 0x40, 0x24, + 0x00 + }; + + var exception = Assert.Throws(() => SuiteLinkFrameReader.ParseFrame(bytes)); + + Assert.Contains("marker", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ParseFrame_WithTooShortInput_ThrowsFormatException() + { + var bytes = new byte[] { 0x03, 0x00, 0x40, 0x24 }; + + Assert.Throws(() => SuiteLinkFrameReader.ParseFrame(bytes)); + } + + [Fact] + public void ParseFrame_WithRemainingLengthBelowMinimum_ThrowsFormatException() + { + var bytes = new byte[] + { + 0x02, 0x00, + 0x40, 0x24 + }; + + Assert.Throws(() => SuiteLinkFrameReader.ParseFrame(bytes)); + } + + [Fact] + public void ParseFrame_WithTruncatedInput_ThrowsFormatException() + { + var bytes = new byte[] + { + 0x05, 0x00, + 0x40, 0x24, + 0x01, + 0xA5 + }; + + Assert.Throws(() => SuiteLinkFrameReader.ParseFrame(bytes)); + } + + [Fact] + public void TryParseFrame_WithExtraBytes_ReturnsFrameAndConsumedLength() + { + var bytes = new byte[] + { + 0x03, 0x00, + 0x40, 0x24, + 0xA5, + 0xFF, 0xEE + }; + + var parsed = SuiteLinkFrameReader.TryParseFrame(bytes, out var frame, out var consumed); + + Assert.True(parsed); + Assert.Equal(5, consumed); + Assert.Equal((ushort)0x2440, frame.MessageType); + Assert.True(frame.Payload.IsEmpty); + } + + [Fact] + public void ParseFrame_WithExtraBytes_ThrowsFormatException() + { + var bytes = new byte[] + { + 0x03, 0x00, + 0x40, 0x24, + 0xA5, + 0xFF + }; + + Assert.Throws(() => SuiteLinkFrameReader.ParseFrame(bytes)); + } + + [Fact] + public void TryParseFrame_WithIncompleteBuffer_ReturnsFalse() + { + var bytes = new byte[] + { + 0x05, 0x00, + 0x00, 0x09, + 0x01 + }; + + var parsed = SuiteLinkFrameReader.TryParseFrame(bytes, out _, out var consumed); + + Assert.False(parsed); + Assert.Equal(0, consumed); + } +} diff --git a/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkFrameWriterTests.cs b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkFrameWriterTests.cs new file mode 100644 index 0000000..bbfc0dc --- /dev/null +++ b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkFrameWriterTests.cs @@ -0,0 +1,19 @@ +using SuiteLink.Client.Protocol; + +namespace SuiteLink.Client.Tests.Protocol; + +public sealed class SuiteLinkFrameWriterTests +{ + [Fact] + public void WriteFrame_WithEmptyPayload_WritesHeaderAndMarker() + { + var bytes = SuiteLinkFrameWriter.WriteFrame(0x2440, []); + + Assert.Equal(5, bytes.Length); + Assert.Equal(0x03, bytes[0]); + Assert.Equal(0x00, bytes[1]); + Assert.Equal(0x40, bytes[2]); + Assert.Equal(0x24, bytes[3]); + Assert.Equal(0xA5, bytes[4]); + } +} diff --git a/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkHandshakeCodecTests.cs b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkHandshakeCodecTests.cs new file mode 100644 index 0000000..d72b1d5 --- /dev/null +++ b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkHandshakeCodecTests.cs @@ -0,0 +1,58 @@ +using System.Buffers.Binary; +using SuiteLink.Client.Protocol; + +namespace SuiteLink.Client.Tests.Protocol; + +public sealed class SuiteLinkHandshakeCodecTests +{ + [Fact] + public void EncodeNormalQueryHandshake_WritesLengthMagicsAndIdentityStrings() + { + var bytes = SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake( + targetApplication: "Intouch", + sourceNode: "NodeA", + sourceUser: "UserA"); + + Assert.Equal(bytes.Length - 1, bytes[0]); + Assert.Equal(SuiteLinkHandshakeCodec.QueryMagic.ToArray(), bytes.AsSpan(1, 16).ToArray()); + Assert.Equal(SuiteLinkHandshakeCodec.UnknownQueryMagic.ToArray(), bytes.AsSpan(17, 16).ToArray()); + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(33, 4))); + + var expectedApp = SuiteLinkEncoding.EncodeNullTerminatedUtf16("Intouch"); + var expectedNode = SuiteLinkEncoding.EncodeNullTerminatedUtf16("NodeA"); + var expectedUser = SuiteLinkEncoding.EncodeNullTerminatedUtf16("UserA"); + var payload = bytes.AsSpan(37); + + Assert.True(payload.StartsWith(expectedApp)); + payload = payload[expectedApp.Length..]; + Assert.True(payload.StartsWith(expectedNode)); + payload = payload[expectedNode.Length..]; + Assert.True(payload.StartsWith(expectedUser)); + } + + [Fact] + public void ParseNormalHandshakeAck_WithNormalAckFrame_ReturnsAckData() + { + // Fixed vector for normal ACK assumption: + // remaining=0x0006, type=0x0001, payload=0xA1B2C3, marker=0xA5. + byte[] frame = [0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5]; + + var ack = SuiteLinkHandshakeCodec.ParseNormalHandshakeAck(frame); + + Assert.Equal(0x0001, ack.MessageType); + Assert.Equal(new byte[] { 0xA1, 0xB2, 0xC3 }, ack.Data.ToArray()); + } + + [Fact] + public void EncodeNormalQueryHandshake_WhenPayloadExceedsOneByteLength_ThrowsWithPayloadContext() + { + var ex = Assert.Throws(() => + SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake( + targetApplication: "App", + sourceNode: new string('N', 80), + sourceUser: new string('U', 80))); + + Assert.Equal("payloadLength", ex.ParamName); + Assert.Contains("Total handshake payload", ex.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkSubscriptionCodecTests.cs b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkSubscriptionCodecTests.cs new file mode 100644 index 0000000..e7f588e --- /dev/null +++ b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkSubscriptionCodecTests.cs @@ -0,0 +1,73 @@ +using SuiteLink.Client.Protocol; + +namespace SuiteLink.Client.Tests.Protocol; + +public sealed class SuiteLinkSubscriptionCodecTests +{ + [Fact] + public void EncodeAdvise_WritesExpectedGoldenVectorWithCallerTagId() + { + var bytes = SuiteLinkSubscriptionCodec.EncodeAdvise(0x11223344, "A"); + var frame = SuiteLinkFrameReader.ParseFrame(bytes); + byte[] expected = [0x0A, 0x00, 0x10, 0x80, 0x44, 0x33, 0x22, 0x11, 0x01, 0x41, 0x00, 0xA5]; + + Assert.Equal(expected, bytes); + Assert.Equal(0x10, bytes[2]); + Assert.Equal(0x80, bytes[3]); + Assert.Equal(SuiteLinkSubscriptionCodec.AdviseMessageType, frame.MessageType); + Assert.Equal(0x11223344u, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span[..4])); + } + + [Fact] + public void EncodeAdviseWithoutTagId_UsesExplicitDefaultTagIdOfZero() + { + var bytes = SuiteLinkSubscriptionCodec.EncodeAdvise("Pump001.Run"); + var frame = SuiteLinkFrameReader.ParseFrame(bytes); + Assert.Equal(0u, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span[..4])); + } + + [Fact] + public void EncodeUnadvise_WritesExpectedGoldenVector() + { + var bytes = SuiteLinkSubscriptionCodec.EncodeUnadvise(0x78563412); + var frame = SuiteLinkFrameReader.ParseFrame(bytes); + byte[] expected = [0x07, 0x00, 0x04, 0x80, 0x12, 0x34, 0x56, 0x78, 0xA5]; + + Assert.Equal(expected, bytes); + Assert.Equal(0x04, bytes[2]); + Assert.Equal(0x80, bytes[3]); + Assert.Equal(SuiteLinkSubscriptionCodec.UnadviseMessageType, frame.MessageType); + Assert.Equal(4, frame.Payload.Length); + Assert.Equal(0x78563412u, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span)); + } + + [Fact] + public void DecodeAdviseAck_ParsesTagIdFromFixedVector() + { + // remaining=0x0008, type=0x0003, payload={tag_id=0x78563412, unknown=0x00}, marker=0xA5 + byte[] frame = [0x08, 0x00, 0x03, 0x00, 0x12, 0x34, 0x56, 0x78, 0x00, 0xA5]; + + var ack = SuiteLinkSubscriptionCodec.DecodeAdviseAck(frame); + + Assert.Equal(0x78563412u, ack.TagId); + } + + [Fact] + public void DecodeAdviseAckMany_ParsesTwoAckItems() + { + // remaining=0x000D, type=0x0003, payload={item1,item2}, marker=0xA5 + byte[] frame = + [ + 0x0D, 0x00, 0x03, 0x00, + 0x12, 0x34, 0x56, 0x78, 0x00, + 0xAA, 0xBB, 0xCC, 0xDD, 0x01, + 0xA5 + ]; + + var acks = SuiteLinkSubscriptionCodec.DecodeAdviseAckMany(frame); + + Assert.Equal(2, acks.Count); + Assert.Equal(0x78563412u, acks[0].TagId); + Assert.Equal(0xDDCCBBAAu, acks[1].TagId); + } +} diff --git a/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkUpdateCodecTests.cs b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkUpdateCodecTests.cs new file mode 100644 index 0000000..ee8d6fd --- /dev/null +++ b/tests/SuiteLink.Client.Tests/Protocol/SuiteLinkUpdateCodecTests.cs @@ -0,0 +1,159 @@ +using System.Text; +using SuiteLink.Client.Protocol; + +namespace SuiteLink.Client.Tests.Protocol; + +public sealed class SuiteLinkUpdateCodecTests +{ + [Fact] + public void DecodeUpdate_DecodesBinaryValue() + { + byte[] frame = + [ + 0x0D, 0x00, 0x09, 0x00, + 0x34, 0x12, 0x00, 0x00, + 0x0A, 0x00, + 0xC0, 0x00, + 0x01, + 0x01, + 0xA5 + ]; + Assert.Equal(0x09, frame[2]); + Assert.Equal(0x00, frame[3]); + + var update = SuiteLinkUpdateCodec.Decode(frame); + + Assert.Equal(0x1234u, update.TagId); + Assert.Equal(0x00C0, update.Quality); + Assert.Equal(10, update.ElapsedMilliseconds); + Assert.True(update.Value.TryGetBoolean(out var value)); + Assert.True(value); + } + + [Fact] + public void DecodeUpdate_DecodesIntegerValue() + { + byte[] frame = + [ + 0x10, 0x00, 0x09, 0x00, + 0x78, 0x56, 0x34, 0x12, + 0x01, 0x00, + 0xC0, 0x00, + 0x02, + 0x2A, 0x00, 0x00, 0x00, + 0xA5 + ]; + + var update = SuiteLinkUpdateCodec.Decode(frame); + + Assert.Equal(0x12345678u, update.TagId); + Assert.True(update.Value.TryGetInt32(out var value)); + Assert.Equal(42, value); + } + + [Fact] + public void DecodeUpdate_DecodesRealValue() + { + byte[] frame = + [ + 0x10, 0x00, 0x09, 0x00, + 0x34, 0x12, 0x00, 0x00, + 0x01, 0x00, + 0xC0, 0x00, + 0x03, + 0x00, 0x00, 0x48, 0x41, + 0xA5 + ]; + + var update = SuiteLinkUpdateCodec.Decode(frame); + + Assert.True(update.Value.TryGetFloat32(out var value)); + Assert.Equal(12.5f, value); + } + + [Fact] + public void DecodeUpdate_DecodesMessageValue() + { + byte[] frame = + [ + 0x10, 0x00, 0x09, 0x00, + 0x22, 0x22, 0x00, 0x00, + 0x02, 0x00, + 0xC0, 0x00, + 0x04, + 0x02, 0x00, + 0x4F, 0x4B, + 0xA5 + ]; + + var update = SuiteLinkUpdateCodec.Decode(frame); + + Assert.True(update.Value.TryGetString(out var value)); + Assert.Equal("OK", value); + } + + [Fact] + public void DecodeUpdateMany_ParsesTwoItemsFromSingleFrame() + { + byte[] frame = + [ + 0x1A, 0x00, 0x09, 0x00, + 0x11, 0x11, 0x00, 0x00, 0x01, 0x00, 0xC0, 0x00, 0x01, 0x01, + 0x22, 0x22, 0x00, 0x00, 0x02, 0x00, 0xC0, 0x00, 0x02, 0x2A, 0x00, 0x00, 0x00, + 0xA5 + ]; + + var updates = SuiteLinkUpdateCodec.DecodeMany(frame); + + Assert.Equal(2, updates.Count); + Assert.Equal(0x1111u, updates[0].TagId); + Assert.True(updates[0].Value.TryGetBoolean(out var boolValue)); + Assert.True(boolValue); + + Assert.Equal(0x2222u, updates[1].TagId); + Assert.True(updates[1].Value.TryGetInt32(out var intValue)); + Assert.Equal(42, intValue); + } + + [Fact] + public void DecodeUpdate_WithDefaultMessageEncoding_UsesLatin1LosslessMapping() + { + byte[] frame = + [ + 0x0F, 0x00, 0x09, 0x00, + 0x33, 0x33, 0x00, 0x00, + 0x01, 0x00, + 0xC0, 0x00, + 0x04, + 0x01, 0x00, + 0xE9, + 0xA5 + ]; + + var update = SuiteLinkUpdateCodec.Decode(frame); + + Assert.True(update.Value.TryGetString(out var value)); + Assert.Equal("\u00E9", value); + } + + [Fact] + public void DecodeUpdate_WithExplicitUtf8MessageEncoding_UsesProvidedEncoding() + { + byte[] frame = + [ + 0x10, 0x00, 0x09, 0x00, + 0x44, 0x44, 0x00, 0x00, + 0x01, 0x00, + 0xC0, 0x00, + 0x04, + 0x02, 0x00, + 0xC3, 0xA9, + 0xA5 + ]; + + var update = SuiteLinkUpdateCodec.Decode(frame, Encoding.UTF8); + + Assert.True(update.Value.TryGetString(out var value)); + Assert.Equal("\u00E9", value); + } +} diff --git a/tests/SuiteLink.Client.Tests/SuiteLink.Client.Tests.csproj b/tests/SuiteLink.Client.Tests/SuiteLink.Client.Tests.csproj new file mode 100644 index 0000000..fd96ef7 --- /dev/null +++ b/tests/SuiteLink.Client.Tests/SuiteLink.Client.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/SuiteLink.Client.Tests/SuiteLinkConnectionOptionsTests.cs b/tests/SuiteLink.Client.Tests/SuiteLinkConnectionOptionsTests.cs new file mode 100644 index 0000000..ff7a793 --- /dev/null +++ b/tests/SuiteLink.Client.Tests/SuiteLinkConnectionOptionsTests.cs @@ -0,0 +1,123 @@ +using SuiteLink.Client; + +namespace SuiteLink.Client.Tests; + +public sealed class SuiteLinkConnectionOptionsTests +{ + public static TheoryData InvalidRequiredValues => + new() + { + null, + "", + " ", + "\t" + }; + + [Theory] + [MemberData(nameof(InvalidRequiredValues))] + public void Constructor_InvalidHost_ThrowsArgumentException(string? host) + { + Assert.Throws(() => Create(host: host!)); + } + + [Theory] + [MemberData(nameof(InvalidRequiredValues))] + public void Constructor_InvalidApplication_ThrowsArgumentException(string? application) + { + Assert.Throws(() => Create(application: application!)); + } + + [Theory] + [MemberData(nameof(InvalidRequiredValues))] + public void Constructor_InvalidTopic_ThrowsArgumentException(string? topic) + { + Assert.Throws(() => Create(topic: topic!)); + } + + [Theory] + [MemberData(nameof(InvalidRequiredValues))] + public void Constructor_InvalidClientName_ThrowsArgumentException(string? clientName) + { + Assert.Throws(() => Create(clientName: clientName!)); + } + + [Theory] + [MemberData(nameof(InvalidRequiredValues))] + public void Constructor_InvalidClientNode_ThrowsArgumentException(string? clientNode) + { + Assert.Throws(() => Create(clientNode: clientNode!)); + } + + [Theory] + [MemberData(nameof(InvalidRequiredValues))] + public void Constructor_InvalidUserName_ThrowsArgumentException(string? userName) + { + Assert.Throws(() => Create(userName: userName!)); + } + + [Theory] + [MemberData(nameof(InvalidRequiredValues))] + public void Constructor_InvalidServerNode_ThrowsArgumentException(string? serverNode) + { + Assert.Throws(() => Create(serverNode: serverNode!)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(65536)] + public void Constructor_InvalidPort_ThrowsArgumentOutOfRangeException(int port) + { + Assert.Throws(() => Create(port: port)); + } + + [Fact] + public void Constructor_NoTimezone_UsesUtcByDefault() + { + var options = Create(timezone: null); + + Assert.Equal("UTC", options.Timezone); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Constructor_WhitespaceTimezone_UsesUtcByDefault(string timezone) + { + var options = Create(timezone: timezone); + + Assert.Equal("UTC", options.Timezone); + } + + [Fact] + public void Constructor_ExplicitTimezone_PreservesProvidedValue() + { + var options = Create(timezone: "America/Indiana/Indianapolis"); + + Assert.Equal("America/Indiana/Indianapolis", options.Timezone); + } + + private static SuiteLinkConnectionOptions Create( + string host = "127.0.0.1", + string application = "TestApp", + string topic = "TestTopic", + string clientName = "Client", + string clientNode = "Node", + string userName = "User", + string serverNode = "Server", + string? timezone = null, + int port = 5413) + { + return new SuiteLinkConnectionOptions( + host, + application, + topic, + clientName, + clientNode, + userName, + serverNode, + timezone, + port); + } +} diff --git a/tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs b/tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs new file mode 100644 index 0000000..68a1397 --- /dev/null +++ b/tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs @@ -0,0 +1,76 @@ +using SuiteLink.Client; + +namespace SuiteLink.Client.Tests; + +public sealed class SuiteLinkValueTests +{ + [Fact] + public void Default_ValueIsNone_AndTryGetMethodsReturnFalse() + { + var value = default(SuiteLinkValue); + + Assert.Equal(SuiteLinkValueKind.None, value.Kind); + Assert.False(value.TryGetBoolean(out _)); + Assert.False(value.TryGetInt32(out _)); + Assert.False(value.TryGetFloat32(out _)); + Assert.False(value.TryGetString(out _)); + } + + [Fact] + public void FromBoolean_CreatesBooleanValue_AndTryGetBooleanSucceeds() + { + var value = SuiteLinkValue.FromBoolean(true); + + Assert.Equal(SuiteLinkValueKind.Boolean, value.Kind); + Assert.True(value.TryGetBoolean(out var boolValue)); + Assert.True(boolValue); + Assert.False(value.TryGetInt32(out _)); + Assert.False(value.TryGetFloat32(out _)); + Assert.False(value.TryGetString(out _)); + } + + [Fact] + public void FromInt32_CreatesInt32Value_AndTryGetInt32Succeeds() + { + var value = SuiteLinkValue.FromInt32(42); + + Assert.Equal(SuiteLinkValueKind.Int32, value.Kind); + Assert.True(value.TryGetInt32(out var intValue)); + Assert.Equal(42, intValue); + Assert.False(value.TryGetBoolean(out _)); + Assert.False(value.TryGetFloat32(out _)); + Assert.False(value.TryGetString(out _)); + } + + [Fact] + public void FromFloat32_CreatesFloat32Value_AndTryGetFloat32Succeeds() + { + var value = SuiteLinkValue.FromFloat32(12.5f); + + Assert.Equal(SuiteLinkValueKind.Float32, value.Kind); + Assert.True(value.TryGetFloat32(out var floatValue)); + Assert.Equal(12.5f, floatValue); + Assert.False(value.TryGetBoolean(out _)); + Assert.False(value.TryGetInt32(out _)); + Assert.False(value.TryGetString(out _)); + } + + [Fact] + public void FromString_CreatesStringValue_AndTryGetStringSucceeds() + { + var value = SuiteLinkValue.FromString("tag-value"); + + Assert.Equal(SuiteLinkValueKind.String, value.Kind); + Assert.True(value.TryGetString(out var stringValue)); + Assert.Equal("tag-value", stringValue); + Assert.False(value.TryGetBoolean(out _)); + Assert.False(value.TryGetInt32(out _)); + Assert.False(value.TryGetFloat32(out _)); + } + + [Fact] + public void FromString_NullValue_ThrowsArgumentNullException() + { + Assert.Throws(() => SuiteLinkValue.FromString(null!)); + } +} diff --git a/tests/SuiteLink.Client.Tests/UnitTest1.cs b/tests/SuiteLink.Client.Tests/UnitTest1.cs new file mode 100644 index 0000000..a3f2760 --- /dev/null +++ b/tests/SuiteLink.Client.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace SuiteLink.Client.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + Assert.True(true); + } +}