A self-contained, migration-ready communication layer for industrial equipment (PLCs, barcode scanners,
instruments, vision controllers, scales, printers). It provides two drop-in classes with identical calling
conventions — TcpCommunicator and SerialPortCommunicator — plus a minimal WinForms harness used to
exercise read/write and reconnection behavior.
The library has no dependency on WinForms or WPF; events are marshalled back to the UI thread through
SynchronizationContext, so the same classes can be reused verbatim in WPF applications, Windows services,
console tools or background jobs.
- Screenshots
- Key Features
- Requirements
- Repository Layout
- Class Reference
- Quick Start
- Configuration Reference
- Reading Data: The Dual-Channel Model
- Framing: Solving Coalescing and Fragmentation
- Disconnection Detection and Auto-Reconnect
- Heartbeat and Watchdog
- Thread Safety and Resource Management
- Design Decisions
- Safety Rules Implemented
- WinForms Test Harness
- Known Limitations and Extension Points
- Build and Run
- Documentation Maintenance Policy
Screenshot files live next to this README and are committed together with the source code.
| Capability | Description |
|---|---|
| Asynchronous API | OpenAsync / CloseAsync / WriteAsync / ReadAsync are all genuinely asynchronous (async/await + CancellationToken) and never block the UI thread. |
| Disconnection detection | Never trusts the cached TcpClient.Connected value. Disconnection is detected via read failure, a zero-byte read, or the heartbeat watchdog. |
| Auto-reconnect | Exponential backoff with jitter, capped at a configurable ceiling, unlimited or bounded by attempt count. |
| Dual-channel read | Push mode (DataReceived event) and pull mode (ReadAsync) coexist; inbound frames are delivered to whichever consumer is active. |
| Framing | Delimiter-based, 2-byte little-endian length prefix, or raw pass-through — solves packet coalescing (AA + BB arriving as AABB) and fragmentation (AABB arriving as AA then BB). |
| Heartbeat watchdog | Optional periodic keep-alive plus silence-based link-death detection. |
| Write protection | SemaphoreSlim write lock, minimum inter-write spacing, and configurable retry with interval — prevents packet coalescing on the wire. |
| Selectable close mode | TcpCloseMode.Graceful (FIN handshake) or TcpCloseMode.Abortive (RST, frees the peer's connection slot immediately) via TcpCommunicatorConfig.CloseMode. |
| Resource safety | All unmanaged resources (TcpClient, NetworkStream, SerialPort, CancellationTokenSource, Timer) are disposed deterministically; close paths cannot hang indefinitely. |
| UI-framework agnostic | No WinForms/WPF reference; SynchronizationContext support for thread-safe UI updates. |
| Defensive by design | Atomic open-claim (no double-connect race), volatile cross-thread flags, bounded receive buffer, null-safe conversions. |
| Item | Value |
|---|---|
| Target framework | net8.0-windows (the communication classes themselves require only net8.0; the WinForms harness needs -windows) |
| Language version | latest |
| NuGet package | System.IO.Ports 8.0.0 (required for the serial class; already included by the Windows Desktop runtime, referenced explicitly for reliable restore) |
| Platform | Windows (serial port support); TCP class is platform-agnostic |
TcpSerialComm/
├─ TcpSerialComm.csproj Project file (net8.0-windows, WinForms)
├─ Program.cs Entry point, single-instance Mutex guard
├─ MainForm.cs WinForms test harness (UI text fully in English)
├─ README.md This document
├─ TCPClientAndComTool.png Screenshot of the test harness (referenced by this README)
├─ Communicators/ ← the reusable library
│ ├─ ICommunicator.cs Unified interface for both classes
│ ├─ TcpCommunicator.cs TCP read/write class (core)
│ ├─ SerialPortCommunicator.cs Serial read/write class (mirrors the TCP design)
│ ├─ CommunicatorConfig.cs Shared + per-transport configuration, FramingMode / TcpCloseMode enums
│ ├─ ConnectionState.cs State machine enum
│ ├─ CommunicatorEventArgs.cs Event payload types
│ └─ FrameBuilder.cs Inbound frame assembler (internal)
└─ Common/ ← shared helpers, reusable in any project
├─ ByteArrayConverter.cs byte[] ⇄ text ⇄ hex conversion
├─ Guard.cs Defensive argument validation
└─ RetryHelper.cs Generic cancellable retry helper
To migrate into a new project, copy Communicators/ and Common/ — nothing else is required.
The unified contract implemented by both transports, so calling code does not care which one is in use.
public interface ICommunicator : IDisposable
{
ConnectionState State { get; }
bool IsConnected { get; }
event EventHandler<ConnectionStateChangedEventArgs> StateChanged;
event EventHandler<DataReceivedEventArgs> DataReceived; // push mode
event EventHandler<CommunicatorErrorEventArgs> Error;
Task OpenAsync(CancellationToken ct = default);
Task CloseAsync(CancellationToken ct = default);
Task<bool> WriteAsync(byte[] data, CancellationToken ct = default);
Task<byte[]> ReadAsync(CancellationToken ct = default); // pull mode
}| Member | Description |
|---|---|
TcpCommunicator(TcpCommunicatorConfig config) |
Constructor. Host is validated as non-empty. |
Config |
The live TcpCommunicatorConfig instance (mutate before OpenAsync). |
SyncContext |
Assign SynchronizationContext.Current in a UI app so all events arrive on the UI thread. |
State / IsConnected |
Current ConnectionState; IsConnected is true only when fully connected. |
OpenAsync |
Opens the connection. Rejects concurrent/repeated opens. On failure with AutoReconnect enabled, starts the reconnect loop instead of throwing. |
CloseAsync |
Stops the heartbeat, cancels the read/reconnect loops, releases the stream, and wakes pending readers. Idempotent. |
WriteAsync |
Thread-safe write with framing, minimum spacing, and retry. Returns false on failure (never throws for transport errors). |
ReadAsync |
Awaits the next complete frame (request-response pattern). Returns null on cancellation or when not connected. |
DataReceived |
Raised for every complete frame when no ReadAsync caller is waiting. |
StateChanged |
Raised on every state transition, carrying the human-readable reason in Message. |
Error |
Raised with the failed Operation name (Connect, Read, Write, Heartbeat, KeepAlive, …). |
Dispose |
Closes the connection (max 2 s) and releases every managed resource. Idempotent. |
Same public surface as TcpCommunicator, backed by SerialPortCommunicatorConfig. Two behavioral
differences worth knowing:
- It reads through
SerialPort.BaseStream.ReadAsyncrather than theDataReceivedevent, because that event is unreliable on several .NET versions (it can drop or interleave data). PortNameis intentionally not validated at construction time, so the port can be chosen at runtime from a combo box just before opening.
Disconnected ──► Connecting ──► Connected
▲ │ │
│ ▼ ▼
│ Reconnecting ◄── (link lost, AutoReconnect on)
│ │
└──── Disconnecting ──────────┘ Error (terminal failure state)
| Type | Payload |
|---|---|
DataReceivedEventArgs |
Data (byte[], never null), Timestamp (DateTime.Now) |
ConnectionStateChangedEventArgs |
OldState, NewState, Message |
CommunicatorErrorEventArgs |
Exception, Operation (never null) |
| Class | Methods |
|---|---|
ByteArrayConverter |
FromText(string, Encoding = UTF8), ToText(byte[], Encoding = UTF8), FromHex(string), ToHex(byte[], string separator = " ") |
Guard |
ArgumentNotNull, ArgumentNotNullOrEmpty, InRange(int/long, min, max, paramName) |
RetryHelper |
RetryAsync<T>(Func<Task<T>>, int retryCount, int intervalMs, CancellationToken) and a non-generic overload |
ByteArrayConverter.FromHex accepts "AA BB CC", "AABBCC", "AA-BB", "AA,BB" and "AA:BB"
(separators optional) and throws FormatException on invalid characters or an odd number of digits.
using TcpSerialComm.Communicators;
using TcpSerialComm.Common;
using System.Text;
var cfg = new TcpCommunicatorConfig
{
Host = "192.168.1.10",
Port = 502,
Framing = FramingMode.Delimiter, // one message ends with CRLF (default)
AutoReconnect = true,
MaxReconnectAttempts = 0, // 0 = unlimited
};
using var comm = new TcpCommunicator(cfg);
comm.SyncContext = SynchronizationContext.Current; // omit in a console/service app
// Push mode: unsolicited data from the device.
comm.DataReceived += (s, e) => Console.WriteLine("RX: " + ByteArrayConverter.ToText(e.Data));
// Observe state transitions and errors.
comm.StateChanged += (s, e) => Console.WriteLine($"{e.OldState} -> {e.NewState} {e.Message}");
comm.Error += (s, e) => Console.WriteLine($"ERR [{e.Operation}] {e.Exception.Message}");
await comm.OpenAsync();
// Writes are framed automatically: "HELLO" goes out as "HELLO\r\n".
await comm.WriteAsync(Encoding.ASCII.GetBytes("HELLO"));
// Pull mode: send a command and wait for exactly one reply frame.
await comm.WriteAsync(Encoding.ASCII.GetBytes("READ?"));
byte[] reply = await comm.ReadAsync(); // null if disconnected / cancelled
if (reply != null) Console.WriteLine("Reply: " + ByteArrayConverter.ToText(reply));
await comm.CloseAsync();using TcpSerialComm.Communicators;
var cfg = new SerialPortCommunicatorConfig
{
PortName = "COM3", // pick at runtime, e.g. SerialPort.GetPortNames()
BaudRate = 115200,
Parity = System.IO.Ports.Parity.None,
DataBits = 8,
StopBits = System.IO.Ports.StopBits.One,
Framing = FramingMode.Delimiter,
};
using var comm = new SerialPortCommunicator(cfg);
comm.SyncContext = SynchronizationContext.Current;
comm.DataReceived += (s, e) => Console.WriteLine("RX: " + ByteArrayConverter.ToHex(e.Data));
await comm.OpenAsync(); // pulling the cable triggers auto-reconnect
await comm.WriteAsync(Encoding.ASCII.GetBytes("AT\r\n")); // AppendDelimiterOnWrite adds the terminator
await comm.CloseAsync();ICommunicator comm = useSerial ? serialComm : tcpComm; // caller decides the transport
await comm.OpenAsync();
await comm.WriteAsync(payload);
await comm.CloseAsync();var cfg = new TcpCommunicatorConfig
{
Host = "10.0.0.5",
Port = 9000,
Framing = FramingMode.LengthPrefix, // a 2-byte little-endian length header is added automatically
};
// Payload {0x01,0x02,0x03} is transmitted as: 03 00 01 02 03All properties live on CommunicatorConfig (shared) and its two subclasses. Every option has a default
value, so a valid configuration can be built by setting only the endpoint.
| Property | Type | Default | Unit | Description |
|---|---|---|---|---|
AutoReconnect |
bool |
true |
— | Reconnect automatically after a disconnection. |
MaxReconnectAttempts |
int |
0 |
attempts | 0 = unlimited reconnection. A positive value stops the loop once reached. |
ReconnectBaseDelayMs |
int |
1000 |
ms | Base backoff delay; also the jitter range. |
ReconnectMaxDelayMs |
int |
30000 |
ms | Ceiling for the backoff delay. |
Framing |
FramingMode |
Delimiter |
— | Raw, Delimiter or LengthPrefix. |
FrameDelimiter |
byte[] |
{ 0x0D, 0x0A } (CRLF) |
— | Frame terminator used when Framing = Delimiter. |
AppendDelimiterOnWrite |
bool |
true |
— | Append FrameDelimiter to outgoing payloads. Set false for raw/hex pass-through. |
ReceiveBufferSize |
int |
4096 |
bytes | Size of the read buffer used by the read loop. |
MaxFrameBufferBytes |
int |
1048576 |
bytes | Upper bound for the inbound framing buffer; 0 = unlimited. Protects against memory exhaustion when the peer never sends a terminator. |
HeartbeatEnabled |
bool |
false |
— | Enable the heartbeat sender and the silence watchdog. |
HeartbeatIntervalMs |
int |
30000 |
ms | How often the heartbeat request is sent. |
HeartbeatSilenceTimeoutMs |
int |
15000 |
ms | No data received for longer than this is treated as a dead link. |
HeartbeatRequest |
byte[] |
ASCII "PING" |
— | Heartbeat payload; framed automatically on send. |
WriteRetryCount |
int |
3 |
attempts | Total write attempts before giving up. |
WriteRetryIntervalMs |
int |
30 |
ms | Delay between write attempts. |
WriteMinIntervalMs |
int |
20 |
ms | Minimum spacing between two writes (industrial convention: 20–50 ms) to prevent packet coalescing. |
| Property | Type | Default | Description |
|---|---|---|---|
Host |
string |
"127.0.0.1" |
Remote host or IP address. Must be non-empty (validated in the constructor). |
Port |
int |
502 |
Remote TCP port. |
ConnectTimeoutMs |
int |
5000 |
Connect timeout; implemented with a linked CancellationTokenSource. |
NoDelay |
bool |
true |
Disables Nagle's algorithm for lower latency. |
KeepAlive |
bool |
true |
Enables the OS-level TCP keep-alive probe. |
KeepAliveTimeSec |
int |
30 |
Idle time before the first probe (the OS default of 2 hours is impractical). |
KeepAliveIntervalSec |
int |
10 |
Interval between probes. |
KeepAliveRetryCount |
int |
3 |
Failed probes before the connection is dropped. |
SendBufferSize |
int |
8192 |
Socket send buffer size. |
CloseMode |
TcpCloseMode |
Abortive |
How the socket is torn down. Graceful = normal Close() (FIN handshake, pending data flushed); Abortive = RST (LingerOption(true,0) + Close) that frees the peer slot immediately. See §11. |
| Property | Type | Default | Description |
|---|---|---|---|
PortName |
string |
"" |
e.g. "COM3". Must be set before OpenAsync; validated at open time, not at construction. |
BaudRate |
int |
9600 |
Baud rate. |
Parity |
Parity |
None |
Parity mode. |
DataBits |
int |
8 |
Data bits. |
StopBits |
StopBits |
One |
Stop bits. |
RtsEnable |
bool |
false |
Request-to-send line. |
DtrEnable |
bool |
false |
Data-terminal-ready line. |
ReadTimeout |
int |
0 |
Kept for compatibility only — see the note below. |
WriteTimeout |
int |
0 |
Synchronous write timeout (0 = infinite). |
Note on serial timeouts.
SerialPort.ReadTimeout/WriteTimeoutapply only to the synchronousRead/Writecalls. This class reads viaBaseStream.ReadAsync, so the read timeout has no effect on it. Disconnection is detected through stream failure and the watchdog instead, which is more reliable anyway.
Inbound data can be consumed in two ways, and both are available simultaneously.
comm.DataReceived += (s, e) => HandleFrame(e.Data);Data is broadcast to every subscriber as soon as a complete frame arrives. Use this for devices that report spontaneously (sensors, indicators, scales, vision controllers).
await comm.WriteAsync(command);
byte[] reply = await comm.ReadAsync(ct);The caller awaits exactly one frame. Use this for command/response protocols.
Each assembled frame is offered to the pull-mode waiters first:
if (TryDispatchToWaiter(f)) continue; // a ReadAsync caller is waiting -> hand it over
Post(() => DataReceived?.Invoke(this, new DataReceivedEventArgs(data))); // otherwise -> push event_pendingReadersis a FIFO queue ofTaskCompletionSource<byte[]>guarded by its own lock, so the first caller to await is the first to be served.- If no
ReadAsynccaller is waiting, the frame is broadcast throughDataReceivedinstead — the two modes never duplicate a frame. - Cancelling a
ReadAsynccall removes its entry from the queue, so nothing leaks. - When the state leaves
Connected(disconnection, close, dispose), every pending waiter is woken and receivesnull. A request/response caller therefore cannot hang forever on a dead link.
Byte streams have no message boundaries. Two commands written back-to-back may arrive merged
("AA" + "BB" → "AABB"), or one message may arrive split ("AABB" → "AA", then "BB"). Framing
defines where a message ends. FrameBuilder (inbound) and CommunicatorConfig.BuildSendPayload
(outbound) are exact mirrors of each other, so the send and receive sides always agree.
FramingMode |
Wire format | Inbound rule | Outbound rule |
|---|---|---|---|
Delimiter |
HELLO\r\n |
Emit everything up to and excluding the delimiter | Append FrameDelimiter when AppendDelimiterOnWrite is true |
LengthPrefix |
06 00 48 45 4C 4C 4F 21 |
Read the 2-byte little-endian length, then that many bytes | Prepend a 2-byte little-endian length header |
Raw |
HELLO |
Emit whatever was read, immediately | Send as-is |
A note on line protocols. With the defaults (Delimiter + CRLF), writing "HELLO" puts "HELLO\r\n"
on the wire, and the inbound frame handed to your code contains "HELLO" — the terminator has already
been stripped. Switching the combo box to LF or None changes this behavior at runtime; None maps
to Raw.
Malformed-stream protection. In Delimiter and LengthPrefix mode, a peer that never terminates a
frame would grow _buffer without limit. Once the buffer exceeds MaxFrameBufferBytes (default 1 MB) it
is cleared and reset, trading one lost frame for protection against an out-of-memory condition.
Three independent signals, none of which relies on the cached TcpClient.Connected property (which stays
true long after the peer has gone away):
- Read failure —
ReadAsyncon the socket throws (connection reset, cable pulled, adapter disabled). - Zero-byte read — the peer performed a graceful shutdown; the loop logs a
Readerror and exits. - Heartbeat watchdog — no bytes received within
HeartbeatSilenceTimeoutMswhileHeartbeatEnabledis true. This catches "half-open" links where the peer is silent but the socket still looks alive.
As soon as any of these fires, the read loop ends, SetState wakes all pending ReadAsync waiters, and
the reconnect loop is started (when AutoReconnect is true).
delay = min(ReconnectBaseDelayMs * 2^attempt, ReconnectMaxDelayMs) + jitter
jitter = random(0, ReconnectBaseDelayMs)
With the defaults (base 1000 ms, max 30000 ms) the delay grows 1s → 2s → 4s → 8s → 16s → 30s → 30s …,
with jitter added to avoid a thundering-herd effect when several clients reconnect at once.
MaxReconnectAttempts = 0 means the loop retries indefinitely; the loop is cancelled immediately by
CloseAsync/Dispose, so it never blocks shutdown.
Every transition is published through StateChanged and mirrored in Message, for example:
Connecting -> Connected
Connected -> Disconnected (Connection lost (peer closed or read error).) # read loop detected a dead link
Disconnected -> Reconnecting (Starting automatic reconnect (unlimited retries).)
Reconnecting -> Reconnecting (Reconnect failed (attempt 3, retrying in 4000 ms).)
Reconnecting -> Connected (Reconnected successfully.) # peer came back, link restored
Connected -> Disconnected (Read loop ended.) # AutoReconnect off
Design note: a lost link first moves to
Disconnected(which releases any caller blocked inReadAsync) and then toReconnecting. The reconnect entry point therefore does not reject a pending state ofConnected— the read/write loops flag the link down viaSetStateimmediately before starting the reconnect loop, so an automatic reconnect is never suppressed.
Heartbeat is disabled by default (HeartbeatEnabled = false) and enabled when a periodic keep-alive
is required.
- Every
HeartbeatIntervalMsthe timer raisesHeartbeatTick, which first checks the watchdog and then sendsHeartbeatRequestthrough the normalWriteAsyncpath — so the heartbeat respects the framing mode, the write lock, the minimum spacing and the retry policy like any other write. - If no data has arrived for longer than
HeartbeatSilenceTimeoutMs, aTimeoutExceptionis reported throughError(operationHeartbeat) and the underlying stream is released, forcing the read loop to fail so the reconnect loop takes over. - This is a one-way silence watchdog: it detects a peer that has stopped sending. If your protocol
requires proof of life in the other direction (send
PING, requirePONG), see Known Limitations.
| Concern | Mechanism |
|---|---|
| Concurrent writes | SemaphoreSlim(1, 1) serialises WriteAsync; released in a finally. |
| Write spacing | _lastWriteTime compared against WriteMinIntervalMs, then Task.Delay. |
| Concurrent opens | Interlocked.CompareExchange(ref _openInProgress, 1, 0) claims the open atomically; released in finally. Defeats the check-then-act (TOCTOU) race that could otherwise create two connections. |
| Duplicate connection | IsActive check rejects an open while already connecting/connected/reconnecting. |
| Reconnect loop duplication | Interlocked.CompareExchange(ref _reconnecting, 1, 0) guarantees a single reconnect loop. |
| Cross-thread flag visibility | _closing and _disposed are volatile — they are written by the close/dispose thread and read by the heartbeat timer thread. |
| State reads/writes | Guarded by _stateLock. |
| Pending-reader queue | Guarded by _pendingLock. |
| Inbound frame buffer | Guarded by FrameBuilder._lock. |
| Event delivery to the UI | SyncContext.Post, so handlers run on the UI thread without manual Invoke. |
| Background tasks | Read and reconnect loops run as Task.Run with a CancellationToken; they exit when cancelled, so the process can always shut down. |
Disposal. Dispose reuses CloseAsync (capped at 2 s) before setting _disposed, then wakes any
remaining readers, cleans up connection objects, and disposes the write lock, heartbeat timer and both
CancellationTokenSource instances. Every field that holds an unmanaged resource is released on a single
deterministic path.
Hang-free close. Cancellation tokens cannot interrupt a socket read that is already blocked on some
.NET versions, so CloseAsync additionally releases the stream first (ForceDisconnectForReconnect) and
then waits with Task.WhenAny(task, Task.Delay(2000)). Closing therefore cannot hang indefinitely.
Abortive close (RST) for TCP. The close behavior is controlled by TcpCommunicatorConfig.CloseMode
(enum TcpCloseMode, default Abortive):
-
Graceful— a normalTcpClient.Close(). The OS completes the four-way FIN handshake and flushes any pending data. This is the safe default for general-purpose servers, but the peer keeps its connection slot occupied until it processes the EOF. -
Abortive— the socket is closed with an abortive reset:- A polite FIN is sent first (
Socket.Shutdown(SocketShutdown.Send)) so peers that handle EOF cleanly can wrap up normally. LingerOption(true, 0)is then set andTcpClient.Close()is called, which makes the OS emit an RST on close. The RST forces the peer kernel to drop the connection slot immediately — without depending on whether the firmware actually processes the EOF. This is critical for printers / code-jet devices that hold a fixed number of connection slots and would otherwise leave a slot occupied until the (ignored) FIN times out.
The abortive path runs on
CloseAsync,Dispose, and every reconnect teardown, so a dropped link always releases its slot at once. In the test tool, the Abortive close (RST) checkbox in the TCP group selects between the two modes before connecting. - A polite FIN is sent first (
- Never trust
TcpClient.Connected. It reflects the state at the last I/O operation, not the actual link state. Read failure plus the watchdog is the reliable signal. - TCP keep-alive as an OS-level assist. Configured for 30 s idle / 10 s interval / 3 retries instead of the 2-hour default, so dead sockets are reaped by the OS as well.
- All-async, cancellation-driven. No blocking waits on the UI thread; every loop observes a
CancellationTokenand exits cleanly. - Write lock plus minimum spacing. Industrial devices frequently cannot handle two frames arriving in the same TCP segment or back-to-back on the serial line, so writes are serialised and spaced.
- Exponential backoff with jitter. Keeps reconnection responsive when the device returns quickly without spinning the CPU, and avoids synchronised reconnect storms.
- Framing on both ends by construction.
BuildSendPayloadandFrameBuilderare mirror images, so an outbound payload is always parsed correctly by the peer using the same configuration. - Events via
SynchronizationContext. Keeps the library free of UI-framework references while still making UI updates safe, which is what makes it drop-in migratable to WPF, services or console apps. - Read via
BaseStream.ReadAsyncon serial. TheSerialPort.DataReceivedevent is unreliable across .NET versions (dropped and interleaved data), so the async stream is the deterministic choice.
The library and harness implement the following industrial development conventions end to end.
Code-level guarantees (inside the reusable classes)
| Rule | Implementation |
|---|---|
| No duplicate connection | OpenAsync rejects a second open via the atomic _openInProgress claim plus the IsActive check. |
| Verify connectivity before writing | WriteAsync checks the state (and IsOpen for serial) before acquiring the write lock; a failed write releases the stream and triggers reconnection. |
| Heartbeat during continuous reading | The watchdog timer runs alongside the read loop and detects a silent peer. |
| Locking and spacing (PV-style) | SemaphoreSlim write lock + WriteMinIntervalMs spacing (default 20 ms, convention 20–50 ms). |
| Bounded write retries | WriteRetryCount / WriteRetryIntervalMs, configurable. |
| Background threads stop with the app | Read/reconnect loops are cancellation-driven and terminate on CloseAsync/Dispose. |
| Loop pacing | The reconnect loop sleeps with exponential backoff instead of spinning. |
| Unmanaged resource release | Streams, clients, ports, timers and CTS instances are all disposed on a single deterministic path; each scope releases what it created. |
| Defensive validation | Guard validates arguments; types are checked before use; conversions are null-safe. |
| No SQL, no XSS surface | The library performs no database access and builds no markup; there is nothing to inject. |
Application-level conventions (inside MainForm / Program)
| Rule | Implementation |
|---|---|
| Single instance | Named global Mutex in Program.Main; a second launch shows a notice and exits. |
| No double-click on save/send | The button is disabled before the await and re-enabled afterwards; every handler also returns early if already disabled. |
| No duplicate device connect | The connect button is disabled while connecting, and OpenAsync enforces the rule independently. |
| Terminal state is authoritative | Button enablement is corrected by the StateChanged event rather than by the click handler. |
| Full teardown on exit | FormClosing closes any connected device and calls Dispose on both communicators. |
| Confirmation for destructive actions | Disconnect and "Clear log" ask for confirmation before acting. |
| Empty data never crashes | The port list and log handle empty collections and nulls safely. |
| Parameter panel locking | The configuration panel is disabled while either device is active, so settings cannot change mid-session. |
MainForm is a deliberately minimal shell for exercising the two classes. It is not the deliverable —
the classes are.
| Region | Contents |
|---|---|
| TCP Client | IP, Port, Connect, Disconnect, state label, Abortive close (RST) selector, send box with Text/Hex selector, Send |
| Serial Port | port list, baud rate, Connect, Disconnect, state label, send box with Text/Hex selector, Send |
| Connection parameters | auto-reconnect, max retries (0 = unlimited), backoff base and max, frame terminator (CRLF / LF / None), minimum write gap, write retries and interval, heartbeat and interval, silence timeout |
| Receive / Log | Timestamped log of every state change, frame and error, plus Send/Receive entries ([TCP RX], [TCP TX], [SERIAL RX], …) |
The parameter panel is applied when you click Connect, so common settings can be changed without editing code, and it locks automatically once a device is active.
Start a listener on the same machine, for example with Python:
python -c "import socket;s=socket.socket();s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind(('0.0.0.0',502));s.listen(1);c,_=s.accept();print('accepted');print(c.recv(1024))"Then in the harness: keep IP = 127.0.0.1, Port = 502, click Connect, type HELLO and click
Send. The peer receives HELLO\r\n (the CRLF is appended automatically), and the log shows
[TCP TX] HELLO.
Suggested checks:
- Basic I/O — send
HELLO, confirm the peer seesHELLO\r\n. - Framing — have the peer send
A\r\nB\r\nin one write; the log should show two separate frames. - Disconnection — kill the listener; the state goes
Connected → Reconnectingand keeps retrying. - Reconnection — restart the listener; the state returns to
Connectedautomatically. - Heartbeat — enable heartbeat, set the interval to 5000 ms and the silence timeout to 3000 ms, then
stop the peer from sending; a watchdog
TimeoutExceptionappears and reconnection starts. - Close mode — with Abortive close (RST) checked, the peer sees an RST on disconnect (its slot is freed immediately); unchecked, the peer sees a normal FIN close.
- Exit — close the window while connected; no exception, no lingering process.
Use a USB-to-serial adapter with TX looped back to RX (or two adapters cross-connected: TX↔RX, RX↔TX, GND↔GND). With a loopback, whatever you send comes back as a received frame.
If you have no physical port, a virtual null-modem pair (such as com0com) works equally well.
14:32:05.118 [TCP] State: Disconnected -> Connecting
14:32:05.141 [TCP] State: Connecting -> Connected
14:32:07.002 [TCP TX] HELLO
14:32:07.310 [TCP RX] WORLD
14:32:11.900 [TCP ERROR/Read] IOException: Unable to read data from the transport connection
14:32:11.903 [TCP] State: Connected -> Reconnecting (Starting automatic reconnect (unlimited retries).)
14:32:14.918 [TCP] State: Reconnecting -> Connected (Reconnected successfully.)
| Item | Notes |
|---|---|
| Heartbeat is one-way | The watchdog detects a silent peer. Strict two-way validation (send PING, require PONG before declaring the link healthy) is not implemented — add it if your protocol demands proof of life from the peer. |
| Connectivity check is implicit | "Verify before write" is implemented as a state check plus "a failed write triggers reconnection". An explicit active probe before every write is possible but costs an extra round trip. |
Throw on MaxFrameBufferBytes overflow |
The buffer is silently cleared and the oversized data discarded. If you need to surface this as an error, raise Error inside FrameBuilder.Push. |
| No built-in emulator | Testing requires an external TCP peer or a loopback serial connection, as described above. |
Serial ReadTimeout is inert |
Documented behavior of BaseStream.ReadAsync; rely on stream failure and the watchdog instead. |
Jitter uses a fresh Random per calculation |
Adequate for reconnect jitter. A shared thread-safe RNG would be marginally better if you call it at high frequency. |
cd TcpSerialComm
dotnet build -c Release
dotnet run -c ReleaseThe build produces a WinForms executable. The library folders (Communicators/, Common/) can also be
dropped into any net8.0 class library, WPF project or Windows service without modification — remove
MainForm.cs, Program.cs and <UseWindowsForms> in that case.
Rule: no change ships without a matching README update. Every code change, feature upgrade or bug fix must be reflected in this document in the same commit. A change that is not documented here is considered incomplete. This document records the final implementation only — it contains no dated change history or process narrative; when behavior changes, update the affected sections in place.
When you modify the project, update the matching location:
| What you changed | Where to update in this README |
|---|---|
| Public API (new method, renamed parameter, changed signature) | Section 4 — Class Reference (interface signatures and per-member tables) |
| Any configuration property added, changed or removed | Section 6 — Configuration Reference (all three tables, incl. default values and units) |
| Behavior of reconnection, heartbeat, framing, timeouts, close mode, state machine | The corresponding section (7/8/9/10/11) plus Section 12 — Design Decisions if the rationale changed |
| A known limitation that has now been fixed | Section 15 — Known Limitations — remove it or mark it as fixed; never leave a stale entry |
| UI strings, log prefixes or harness behavior | Section 14 — WinForms Test Harness (including the sample log output) |
| Safety rules or project conventions | Section 13 — Safety Rules Implemented |
Reuse rule. Communicators/ and Common/ are self-contained and are meant to be copied as-is into
other projects; keep the two folders and this document in sync when a change is made here, so downstream
copies never inherit a stale behaviour.
Language rule. Everything in this project is English — source comments, XML documentation, exception
messages, log text, UI strings and this document. Do not introduce Chinese text into any .cs file.
# echo_server.py — replies to every CRLF-terminated line with "WORLD\r\n"
import socket
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 502))
srv.listen(1)
print("listening on 502")
conn, addr = srv.accept()
print("accepted", addr)
buf = b""
while True:
chunk = conn.recv(1024)
if not chunk:
break
buf += chunk
while b"\r\n" in buf:
line, buf = buf.split(b"\r\n", 1)
print("RX:", line)
conn.sendall(b"WORLD\r\n")Point the harness at 127.0.0.1:502, connect, send HELLO, and WORLD appears in the log.
- Wire TX to RX on the same adapter (or cross-connect two adapters, including GND).
- Select the port and a baud rate in the harness and click Connect.
- Type
TESTin Send and click Send. - The log shows
[SERIAL TX] TESTimmediately followed by[SERIAL RX] TEST. - Unplug the adapter: the state moves to
Reconnecting; plug it back in and it returns toConnected.
