TCP Request-Reply Protocol¶
Use when: Device communicates over TCP/IP with a binary or text protocol where you send a command and wait for a response.
Examples: intrusion panels with an ASCII command protocol, access controllers with a binary or AES-encrypted protocol, and most request/response field devices.
Framework provides: TCP socket, PipeReader buffering, request-reply orchestration, retry, timeout, disconnect handling.
You implement: Frame parsing, response matching, protocol logic.
Architecture¶
┌─────────────────────────────────────────────────────────────────┐
│ Your Code │
├─────────────────────────────────────────────────────────────────┤
│ MyFrame : FrameBase ← frame structure + matching │
│ MyFrameParser : IFrameParser ← byte stream → frames │
│ Protocol : TcpProtocolBase ← business logic + Send() │
│ Things (Panel, Door, etc.) ← device tree │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Framework (automatic) │
├─────────────────────────────────────────────────────────────────┤
│ TcpTransportBase ← connection management │
│ ProtocolChannel ← request-reply orchestration │
│ TcpConnection ← socket + PipeReader │
└─────────────────────────────────────────────────────────────────┘
Step-by-Step Implementation¶
1. Configure transport in adapter-registration.yaml¶
transport:
type: tcp
protocol:
address_type: byte # device address type (byte, ushort, uint, string)
frame_type: "MyAdapter.Communication.MyFrame"
event_type: "MyAdapter.Communication.MyEvent"
2. Define your Frame type¶
// Communication/MyFrame.cs
using Pq.Adapters.Framework.Communication.Tcp;
public sealed class MyFrame : FrameBase
{
public required byte Type { get; init; }
public required byte[] Payload { get; init; }
// Pre-built bytes for outgoing requests (null for parsed incoming)
internal byte[]? RawPacket { get; init; }
// Serialize for transmission
public override byte[] Serialize() =>
RawPacket ?? throw new InvalidOperationException("Not a request frame");
// Match response to request
public override bool IsResponseTo(FrameBase request) =>
request is MyFrame req && Type == req.Type;
// Factory for outgoing requests
internal static MyFrame CreateSend(byte type, byte[] packet) => new()
{
Type = type,
Payload = [],
RawPacket = packet,
};
}
For protocols with explicit busy response codes, override MatchResponse and return MatchResult.Busy for those codes. ProtocolChannel then retries using SendOptions.RetryPolicy.
public override MatchResult MatchResponse(FrameBase request)
{
if (!IsResponseTo(request))
return MatchResult.NoMatch;
return ResponseCode == 0xDD
? MatchResult.Busy
: MatchResult.Match;
}
3. Implement frame parser¶
// Communication/MyFrameParser.cs
using System.Buffers;
using Pq.Adapters.Framework.Communication.Tcp;
public sealed class MyFrameParser : IFrameParser<MyFrame>
{
public bool TryParse(ref ReadOnlySequence<byte> buffer, out MyFrame frame)
{
frame = null!;
// Need at least header
if (buffer.Length < 3)
return false;
// Read length from header
var reader = new SequenceReader<byte>(buffer);
reader.TryReadBigEndian(out short length);
// Need full frame
if (buffer.Length < length)
return false;
// Parse frame
reader.TryRead(out var type);
var payload = new byte[length - 3];
reader.TryCopyTo(payload);
reader.Advance(payload.Length);
frame = new MyFrame { Type = type, Payload = payload };
buffer = buffer.Slice(length); // consume bytes
return true;
}
}
4. Write Protocol using TcpProtocolBase¶
// Communication/Protocol.cs
using Pq.Adapters.Framework.Communication;
using Pq.Adapters.Framework.Communication.Protocol;
using Pq.Adapters.Framework.Events;
public partial class Protocol : TcpProtocolBase<MyEvent, MyFrame>
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
public Protocol(Transport transport, ILogger<Protocol> logger) : base(logger)
{
Transport = transport;
}
internal Transport Transport { get; }
// --- Simple command ---
public async Task<bool> Arm(int partition, CancellationToken ct)
{
var request = MyFrame.CreateSend(0x01, BuildPacket(0x01, [(byte)partition]));
var response = await Send(request, CommandTimeout, ct);
return response.Payload[0] == 0x00;
}
// --- With retry ---
public async Task<byte[]?> ReadMemory(ushort address, CancellationToken ct)
{
var request = MyFrame.CreateSend(0x10, BuildPacket(0x10, BitConverter.GetBytes(address)));
try
{
var response = await Send(request, new SendOptions
{
Timeout = TimeSpan.FromSeconds(2),
RetryPolicy = RetryPolicy.Exponential(3, TimeSpan.FromMilliseconds(100))
}, ct);
return response.Payload;
}
catch (TimeoutException)
{
Logger.LogWarning("ReadMemory timeout");
return null;
}
}
// --- Bulk upload ---
public async Task<int> UploadCredentials(IReadOnlyList<Credential> creds, CancellationToken ct)
{
var frames = creds.Select(c => MyFrame.CreateSend(0x20, EncodeCredential(c))).ToList();
var result = await SendBulk(frames, new BulkOptions
{
RetryPolicy = RetryPolicy.Fixed(2, TimeSpan.FromMilliseconds(50))
}, ct);
return result.SuccessCount;
}
// --- Multi-packet logical operation ---
internal Task<TResult> RunSession<TResult>(Func<CancellationToken, Task<TResult>> operation, CancellationToken ct)
=> ExclusiveSession(operation, ct);
// --- Unsolicited events (alarms, status) ---
protected override IEnumerable<MyEvent> TransformToEvents(MyFrame frame)
{
if (frame.Type == 0xE0)
yield return MyEvent.Parse(frame.Payload);
}
// --- Connect/Disconnect ---
internal async Task<bool> Connect(Panel panel, IEventRouter<MyEvent> router, CancellationToken ct)
{
var channel = await Transport.ConnectChannel(
panel.Address,
panel.IpAddress,
panel.Port,
router,
ct);
AttachChannel(channel, router);
// Handshake, login, etc.
return await Authenticate(panel.Password, ct);
}
internal async Task Disconnect(byte address)
{
DetachChannel();
await Transport.Disconnect(address);
}
private byte[] BuildPacket(byte type, byte[] payload)
{
var length = (short)(3 + payload.Length);
return [
(byte)(length >> 8), (byte)(length & 0xFF),
type,
..payload
];
}
}
5. Implement Thing with IDeviceConnection¶
// Things/Panel.cs
public partial class Panel : IDeviceConnection
{
public Task<bool> Connect(ConnectionContext context, CancellationToken ct)
=> Protocol.Connect(this, EventRouter, ct);
public async Task Disconnect(CancellationToken ct)
{
await Protocol.Disconnect(Address);
}
}
Retry Design Checklist¶
Before finishing protocol implementation, fill this checklist for every Send/SendBulk path:
- Operation map - Split operations into
read/pollandmutating(write/enroll/delete/action). - Busy semantics - Document what vendor busy codes mean (
not processed,processing,unknown) per operation class. - Policy declaration -
RetryPolicy.Noneis the compliant one-attempt default. Every enabled or vendor-required retry has an operation-specificRetryPolicy.*, rationale, and evidence. - Bounds and cancellation - Retries are bounded (
maxAttemptsand optional cap) and every delay/send honorsct. - Starvation guard - On shared channels, retry timing does not block history/status polling cadence.
Use this lightweight table in PR notes or adapter docs:
| Operation | Mutating | RetryPolicy | Why safe | Evidence | Notes |
|---|---|---|---|---|---|
ReadMemory |
No | Exponential(4,100ms,500ms) |
read-only | vendor SDK sec. 4.2 | busy tolerated |
UnlockDoor |
Yes | None |
duplicate unsafe | vendor SDK sec. 7.1 | manual retry only |
Key APIs¶
| What you need | How to do it |
|---|---|
| Simple send | await Send(request, timeout, ct) |
| Send with retry | await Send(request, new SendOptions { RetryPolicy = ... }, ct) |
| Fire-and-forget | await Send(request, new SendOptions { NoReply = true }, ct) |
| Bulk upload | await SendBulk(frames, options, ct) |
| Multi-packet logical operation | await ExclusiveSession(operation, ct) or an adapter-local wrapper |
| High priority | new SendOptions { Priority = Priority.High } |
| Handle events | Override TransformToEvents(frame) |
| Connect | Transport.ConnectChannel() then AttachChannel() |
| Disconnect | DetachChannel() then Transport.Disconnect() |
When to use this vs other archetypes¶
| Archetype | Use when |
|---|---|
| tcp-request-reply (this) | Binary/text TCP protocol, request-response pattern |
| native-sdk-shared-transport | Vendor SDK with callbacks (C#/.NET wrapper) |
| gateway-grpc | gRPC streaming API |
| http-streaming | REST API with polling or webhooks |
| generated-only | Pure cloud integration, no device protocol |
Full reference¶
See concepts/tcp-transport.md for complete API reference, matching strategies, exclusive sessions, and common mistakes.