feat: bootstrap suitelink tag client codecs
This commit is contained in:
484
.gitignore
vendored
Normal file
484
.gitignore
vendored
Normal 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
56
SuiteLink.Client.sln
Normal 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
8
SuiteLink.Client.slnx
Normal 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>
|
||||
314
docs/plans/2026-03-16-suitelink-client-design.md
Normal file
314
docs/plans/2026-03-16-suitelink-client-design.md
Normal 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
|
||||
864
docs/plans/2026-03-16-suitelink-client-implementation-plan.md
Normal file
864
docs/plans/2026-03-16-suitelink-client-implementation-plan.md
Normal 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"
|
||||
```
|
||||
54
src/SuiteLink.Client/Protocol/SuiteLinkConnectCodec.cs
Normal file
54
src/SuiteLink.Client/Protocol/SuiteLinkConnectCodec.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
132
src/SuiteLink.Client/Protocol/SuiteLinkEncoding.cs
Normal file
132
src/SuiteLink.Client/Protocol/SuiteLinkEncoding.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/SuiteLink.Client/Protocol/SuiteLinkFrame.cs
Normal file
22
src/SuiteLink.Client/Protocol/SuiteLinkFrame.cs
Normal 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;
|
||||
}
|
||||
60
src/SuiteLink.Client/Protocol/SuiteLinkFrameReader.cs
Normal file
60
src/SuiteLink.Client/Protocol/SuiteLinkFrameReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/SuiteLink.Client/Protocol/SuiteLinkFrameWriter.cs
Normal file
29
src/SuiteLink.Client/Protocol/SuiteLinkFrameWriter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
80
src/SuiteLink.Client/Protocol/SuiteLinkHandshakeCodec.cs
Normal file
80
src/SuiteLink.Client/Protocol/SuiteLinkHandshakeCodec.cs
Normal 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);
|
||||
84
src/SuiteLink.Client/Protocol/SuiteLinkSubscriptionCodec.cs
Normal file
84
src/SuiteLink.Client/Protocol/SuiteLinkSubscriptionCodec.cs
Normal 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);
|
||||
121
src/SuiteLink.Client/Protocol/SuiteLinkUpdateCodec.cs
Normal file
121
src/SuiteLink.Client/Protocol/SuiteLinkUpdateCodec.cs
Normal 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);
|
||||
9
src/SuiteLink.Client/Protocol/SuiteLinkWireValueType.cs
Normal file
9
src/SuiteLink.Client/Protocol/SuiteLinkWireValueType.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace SuiteLink.Client.Protocol;
|
||||
|
||||
public enum SuiteLinkWireValueType : byte
|
||||
{
|
||||
Binary = 1,
|
||||
Integer = 2,
|
||||
Real = 3,
|
||||
Message = 4
|
||||
}
|
||||
36
src/SuiteLink.Client/SubscriptionHandle.cs
Normal file
36
src/SuiteLink.Client/SubscriptionHandle.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/SuiteLink.Client/SuiteLink.Client.csproj
Normal file
9
src/SuiteLink.Client/SuiteLink.Client.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
57
src/SuiteLink.Client/SuiteLinkConnectionOptions.cs
Normal file
57
src/SuiteLink.Client/SuiteLinkConnectionOptions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/SuiteLink.Client/SuiteLinkTagUpdate.cs
Normal file
9
src/SuiteLink.Client/SuiteLinkTagUpdate.cs
Normal 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);
|
||||
103
src/SuiteLink.Client/SuiteLinkValue.cs
Normal file
103
src/SuiteLink.Client/SuiteLinkValue.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
111
tests/SuiteLink.Client.Tests/Protocol/SuiteLinkEncodingTests.cs
Normal file
111
tests/SuiteLink.Client.Tests/Protocol/SuiteLinkEncodingTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
25
tests/SuiteLink.Client.Tests/SuiteLink.Client.Tests.csproj
Normal file
25
tests/SuiteLink.Client.Tests/SuiteLink.Client.Tests.csproj
Normal 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>
|
||||
123
tests/SuiteLink.Client.Tests/SuiteLinkConnectionOptionsTests.cs
Normal file
123
tests/SuiteLink.Client.Tests/SuiteLinkConnectionOptionsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
76
tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs
Normal file
76
tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs
Normal 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!));
|
||||
}
|
||||
}
|
||||
10
tests/SuiteLink.Client.Tests/UnitTest1.cs
Normal file
10
tests/SuiteLink.Client.Tests/UnitTest1.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SuiteLink.Client.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
Assert.True(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user