feat: bootstrap suitelink tag client codecs
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user