feat: bootstrap suitelink tag client codecs

This commit is contained in:
Joseph Doherty
2026-03-16 14:43:31 -04:00
commit 731bfe2237
30 changed files with 3429 additions and 0 deletions

484
.gitignore vendored Normal file
View File

@@ -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

56
SuiteLink.Client.sln Normal file
View File

@@ -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

8
SuiteLink.Client.slnx Normal file
View File

@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/SuiteLink.Client/SuiteLink.Client.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SuiteLink.Client.Tests/SuiteLink.Client.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -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<SubscriptionHandle> SubscribeAsync(
string itemName,
Action<SuiteLinkTagUpdate> onUpdate,
CancellationToken ct = default);
Task<SuiteLinkTagUpdate> 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

View File

@@ -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<byte>)`
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"
```

View File

@@ -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<byte>();
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<byte> writer, ReadOnlySpan<byte> bytes)
{
if (bytes.IsEmpty)
{
return;
}
var destination = writer.GetSpan(bytes.Length);
bytes.CopyTo(destination);
writer.Advance(bytes.Length);
}
}

View File

@@ -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<byte> 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<byte> bytes)
{
EnsureMinimumReadableLength(bytes, sizeof(ushort));
return BinaryPrimitives.ReadUInt16LittleEndian(bytes);
}
public static uint ReadUInt32LittleEndian(ReadOnlySpan<byte> bytes)
{
EnsureMinimumReadableLength(bytes, sizeof(uint));
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
}
public static int ReadInt32LittleEndian(ReadOnlySpan<byte> bytes)
{
EnsureMinimumReadableLength(bytes, sizeof(int));
return BinaryPrimitives.ReadInt32LittleEndian(bytes);
}
public static float ReadSingleLittleEndian(ReadOnlySpan<byte> bytes)
{
var intBits = ReadInt32LittleEndian(bytes);
return BitConverter.Int32BitsToSingle(intBits);
}
public static void WriteUInt16LittleEndian(Span<byte> destination, ushort value)
{
EnsureMinimumWritableLength(destination, sizeof(ushort));
BinaryPrimitives.WriteUInt16LittleEndian(destination, value);
}
public static void WriteUInt32LittleEndian(Span<byte> destination, uint value)
{
EnsureMinimumWritableLength(destination, sizeof(uint));
BinaryPrimitives.WriteUInt32LittleEndian(destination, value);
}
public static void WriteInt32LittleEndian(Span<byte> destination, int value)
{
EnsureMinimumWritableLength(destination, sizeof(int));
BinaryPrimitives.WriteInt32LittleEndian(destination, value);
}
public static void WriteSingleLittleEndian(Span<byte> 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<byte> bytes, int minimumLength)
{
if (bytes.Length < minimumLength)
{
throw new FormatException($"Buffer must contain at least {minimumLength} bytes.");
}
}
private static void EnsureMinimumWritableLength(Span<byte> bytes, int minimumLength)
{
if (bytes.Length < minimumLength)
{
throw new ArgumentException($"Buffer must contain at least {minimumLength} bytes.", nameof(bytes));
}
}
}

View File

@@ -0,0 +1,22 @@
namespace SuiteLink.Client.Protocol;
public readonly record struct SuiteLinkFrame
{
public SuiteLinkFrame(ushort messageType, ReadOnlySpan<byte> 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<byte> Payload { get; }
public ushort RemainingLength => (ushort)PayloadLengthWithTypeAndMarker(Payload.Length);
private static int PayloadLengthWithTypeAndMarker(int payloadLength) => payloadLength + 3;
}

View File

@@ -0,0 +1,60 @@
using System.Buffers.Binary;
namespace SuiteLink.Client.Protocol;
public static class SuiteLinkFrameReader
{
public static SuiteLinkFrame ParseFrame(ReadOnlySpan<byte> 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<byte> 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;
}
}

View File

@@ -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<byte> 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;
}
}

View File

@@ -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<byte> QueryMagic => QueryMagicBytes;
public static ReadOnlyMemory<byte> 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<byte> 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<byte> Data);

View File

@@ -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<byte> payload = stackalloc byte[sizeof(uint)];
SuiteLinkEncoding.WriteUInt32LittleEndian(payload, tagId);
return SuiteLinkFrameWriter.WriteFrame(UnadviseMessageType, payload);
}
public static IReadOnlyList<AdviseAck> DecodeAdviseAckMany(ReadOnlySpan<byte> 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<AdviseAck>(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<byte> 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);

View File

@@ -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<byte> 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<DecodedUpdate> DecodeMany(ReadOnlySpan<byte> 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<DecodedUpdate>();
var offset = 0;
while (offset < payload.Length)
{
updates.Add(DecodeSingleItem(payload, ref offset, messageEncoding));
}
return updates;
}
private static DecodedUpdate DecodeSingleItem(ReadOnlySpan<byte> 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<byte> payload, ref int offset)
{
EnsureRemaining(payload, offset, 1);
var value = payload[offset] != 0;
offset += 1;
return SuiteLinkValue.FromBoolean(value);
}
private static SuiteLinkValue DecodeInteger(ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> 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);

View File

@@ -0,0 +1,9 @@
namespace SuiteLink.Client.Protocol;
public enum SuiteLinkWireValueType : byte
{
Binary = 1,
Integer = 2,
Real = 3,
Message = 4
}

View File

@@ -0,0 +1,36 @@
using System.Threading;
namespace SuiteLink.Client;
public sealed class SubscriptionHandle : IAsyncDisposable
{
private readonly Func<ValueTask>? _disposeCallback;
private int _disposeState;
public SubscriptionHandle(string itemName, uint tagId, Func<ValueTask>? 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);
}
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,9 @@
namespace SuiteLink.Client;
public sealed record class SuiteLinkTagUpdate(
string ItemName,
uint TagId,
SuiteLinkValue Value,
ushort Quality,
ushort ElapsedMilliseconds,
DateTimeOffset ReceivedAtUtc);

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<byte> 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<FormatException>(() => SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out _));
}
[Fact]
public void DecodeNullTerminatedUtf16_WithoutNullTerminator_ThrowsFormatException()
{
var buffer = new byte[] { 0x41, 0x00, 0x42, 0x00 };
Assert.Throws<FormatException>(() => SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out _));
}
[Fact]
public void EncodeLengthPrefixedUtf16_WhenInputIsTooLong_ThrowsArgumentOutOfRangeException()
{
var value = new string('A', 256);
Assert.Throws<ArgumentOutOfRangeException>(() => 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<FormatException>(() => SuiteLinkEncoding.ReadUInt32LittleEndian(buffer));
}
[Fact]
public void WriteAndReadSingleLittleEndian_RoundTripsNonTrivialValue()
{
Span<byte> 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<byte> bytes = stackalloc byte[4];
var expected = float.NaN;
SuiteLinkEncoding.WriteSingleLittleEndian(bytes, expected);
var value = SuiteLinkEncoding.ReadSingleLittleEndian(bytes);
Assert.True(float.IsNaN(value));
}
}

View File

@@ -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<FormatException>(() => 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<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
}
[Fact]
public void ParseFrame_WithRemainingLengthBelowMinimum_ThrowsFormatException()
{
var bytes = new byte[]
{
0x02, 0x00,
0x40, 0x24
};
Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
}
[Fact]
public void ParseFrame_WithTruncatedInput_ThrowsFormatException()
{
var bytes = new byte[]
{
0x05, 0x00,
0x40, 0x24,
0x01,
0xA5
};
Assert.Throws<FormatException>(() => 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<FormatException>(() => 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);
}
}

View File

@@ -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]);
}
}

View File

@@ -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<ArgumentOutOfRangeException>(() =>
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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SuiteLink.Client\SuiteLink.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,123 @@
using SuiteLink.Client;
namespace SuiteLink.Client.Tests;
public sealed class SuiteLinkConnectionOptionsTests
{
public static TheoryData<string?> InvalidRequiredValues =>
new()
{
null,
"",
" ",
"\t"
};
[Theory]
[MemberData(nameof(InvalidRequiredValues))]
public void Constructor_InvalidHost_ThrowsArgumentException(string? host)
{
Assert.Throws<ArgumentException>(() => Create(host: host!));
}
[Theory]
[MemberData(nameof(InvalidRequiredValues))]
public void Constructor_InvalidApplication_ThrowsArgumentException(string? application)
{
Assert.Throws<ArgumentException>(() => Create(application: application!));
}
[Theory]
[MemberData(nameof(InvalidRequiredValues))]
public void Constructor_InvalidTopic_ThrowsArgumentException(string? topic)
{
Assert.Throws<ArgumentException>(() => Create(topic: topic!));
}
[Theory]
[MemberData(nameof(InvalidRequiredValues))]
public void Constructor_InvalidClientName_ThrowsArgumentException(string? clientName)
{
Assert.Throws<ArgumentException>(() => Create(clientName: clientName!));
}
[Theory]
[MemberData(nameof(InvalidRequiredValues))]
public void Constructor_InvalidClientNode_ThrowsArgumentException(string? clientNode)
{
Assert.Throws<ArgumentException>(() => Create(clientNode: clientNode!));
}
[Theory]
[MemberData(nameof(InvalidRequiredValues))]
public void Constructor_InvalidUserName_ThrowsArgumentException(string? userName)
{
Assert.Throws<ArgumentException>(() => Create(userName: userName!));
}
[Theory]
[MemberData(nameof(InvalidRequiredValues))]
public void Constructor_InvalidServerNode_ThrowsArgumentException(string? serverNode)
{
Assert.Throws<ArgumentException>(() => Create(serverNode: serverNode!));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(65536)]
public void Constructor_InvalidPort_ThrowsArgumentOutOfRangeException(int port)
{
Assert.Throws<ArgumentOutOfRangeException>(() => 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);
}
}

View File

@@ -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<ArgumentNullException>(() => SuiteLinkValue.FromString(null!));
}
}

View File

@@ -0,0 +1,10 @@
namespace SuiteLink.Client.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
Assert.True(true);
}
}