AAComm Architecture
Client-side communication library for sending commands to hardware controllers via AACommServer.
Architecture Overview
AAComm is a client library that provides a high-level API for user applications to communicate with hardware controllers. It manages the connection to AACommServer, queues messages, handles responses, and maintains controller state information.
Communication Pipeline
The default wire protocol is binary: new CommAPI() constructs a BinaryCommHandler with
negotiation enabled, so every connect opens binary-first. The ASCII pipeline (CommHandler) is
deprecated and reached only as a connect-time fallback against a server that cannot speak the
typed-payload binary dialect. Both sit behind the same ICommHandler seam, so the queueing,
routing and callback machinery below is identical on either path; the protocol-layer differences
are called out where they exist. CommAPI.Protocol reports which one a live session negotiated.
Note: Send and SendReceive use independent FIFO pipelines sharing a single
underlying wire. Each path has its own queue (Channel<AACommMessage>), its own dispatcher
task, and its own in-flight slot. Ordinary messages in the two flows do not gate each
other — a long-running async Send callback does not block a concurrent SendReceive,
and a SendReceive parked on its safety-net wait does not block subsequent Sends.
Async Reset is the deliberate exception: later dispatch is parked until reset recovery
completes. Per-flow FIFO is preserved (consecutive Sends fire callbacks in order;
consecutive SRs return in order); cross-flow ordering is NOT guaranteed (an async Send
and a sync SendReceive issued back-to-back may dispatch in either order).
Wire writes from the two dispatchers are serialized by an internal mutex, and each dispatched
message is stamped with a session-scoped correlation id (copied onto IpcRequest by the
binary handler; the ASCII handler ignores it). On the binary protocol the server echoes the id on
the routed reply and the receiver routes by identity (Session.TryTakeInFlight); an id of 0 —
the string protocol or a legacy binary server — falls back to wire-arrival-order routing via the
_wireOrder queue (safe because AACommServer processes one message per channel at a time,
answers server-internal IA: queries through the same per-channel FIFO, and each client
socket has a single ordered writer — so replies arrive in send order). For
replies, the receiver thread hands the parsed args to the originating flow's
dispatcher via a per-iteration TaskCompletionSource, and the dispatcher runs the user
callback on its own thread — the receiver thread does not invoke OnReplyReceived. For
push messages, the receiver hands the parsed payload to the OnPushMessageReceived
event via Task.Run, so user code runs on the ThreadPool, not the receiver thread. The
one exception is OnCommsError: it is invoked synchronously on the receiver thread,
and long-running work in that handler will stall reply / push classification until it
returns (after a fatal the session is being torn down anyway, but handlers that need to
dispatch onto a UI thread should off-load with Task.Run). This receiver-never-blocks-on-
reply-callbacks discipline is what unblocks the bug where an async Send callback parked
on a UI dispatcher would deadlock a concurrent SendReceive from the UI thread.
SendReceive is implemented on top of the sync pipeline: it builds a tagged duplicate of
the user's message (IsSyncRequest = true), installs an internal wait-handle hook as
OnReplyReceived, and blocks until the reply arrives or the safety-net timeout expires.
The per-message IsSyncRequest marker drives event-suppression (OnCommsError and the
long-operation events OnLongOperationStarted/OnLongOperationEnded are not raised for sync
requests; the Reset recovery sleep is skipped).
graph TB
subgraph "User Application"
App[Application Code]
end
subgraph "AAComm Library"
subgraph "Public API Layer"
API[CommAPI<br/>- Connect/Disconnect<br/>- Send/SendReceive<br/>- Event Handlers<br/>- State Properties]
end
subgraph "Message Management Layer"
AsyncDispatcher[Async Dispatcher<br/>- Channel<T> FIFO (async)<br/>- Long-running dispatcher task<br/>- _asyncInFlight slot]
SyncDispatcher[Sync Dispatcher<br/>- Channel<T> FIFO (sync)<br/>- Long-running dispatcher task<br/>- _syncInFlight slot]
WireRouting[Reply Routing<br/>- _wireSendLock serializes wire writes + stamps correlation ids<br/>- TryTakeInFlight routes replies by id (id 0 = _wireOrder FIFO fallback)<br/>- _resetDispatchGate parks the other flow during async-Reset recovery]
Containers[State Containers<br/>- ControllerIdentityContainer<br/>- ControllerMessagesContainer<br/>- UserProgVarsAdapter]
end
subgraph "Protocol Layer"
Handler[ICommHandler<br/>- BinaryCommHandler builds IpcRequest/IpcReply protobuf (default)<br/>- CommHandler builds ASCII envelopes (deprecated fallback)<br/>- Payload build and reply classification<br/>- Bulk message handling]
end
subgraph "Transport Layer"
Socket[Socket Client<br/>- BinarySocketClient, length-prefixed frames<br/>- SocketClient, CR-delimited envelopes<br/>- TCP to the AACS loopback port<br/>- 100 000-byte receive buffer]
end
subgraph "Server Management"
Helper[AACommServerHelper<br/>- Process Launch<br/>- Health Check<br/>- Path Resolution]
end
end
subgraph "AACommServer Process"
Server[AACommServerCore<br/>See AACommServerCore/README.md]
end
%% Outbound Flow (App → Server)
App -->|1a. Async Send| API
App -->|1b. Sync SendReceive| API
API -->|2a. Enqueue (async flow)| AsyncDispatcher
API -->|2b. Enqueue tagged (sync flow)| SyncDispatcher
AsyncDispatcher -->|3a. Stamp id + enqueue _wireOrder + SendMessage| WireRouting
SyncDispatcher -->|3b. Stamp id + enqueue _wireOrder + SendMessage| WireRouting
WireRouting -->|4. Build typed payload, pack IpcRequest| Handler
Handler -->|5. Length-prefix the frame| Socket
Socket -->|6. TCP Send| Server
%% Inbound Flow (Server → App)
Server -->|7. IpcReply frame| Socket
Socket -->|8. Reassemble frames| Socket
Socket -->|9. FrameReceivedEvent| Handler
Handler -->|10. Parse IpcReply into ReceivedFrame| Handler
Handler -->|11. Update State| Containers
Handler -->|12. Route by correlation id<br/>(id 0 = _wireOrder head)| WireRouting
WireRouting -->|13a. Hand reply args via TCS| AsyncDispatcher
WireRouting -->|13b. Hand reply args via TCS| SyncDispatcher
AsyncDispatcher -->|14a. Run user callback on dispatcher thread<br/>(+ fire events)| API
SyncDispatcher -->|14b. Set wait-handle in SR wrapper| API
API -->|15a. Fire OnReplyReceived (async)| App
API -->|15b. Return AACommEventArgs (sync)| App
%% Server Management
App -.->|StartAACommServer| Helper
Helper -.->|Launch Process| Server
Helper -.->|Verify Running| Socket
%% Styling
classDef appClass fill:#e1f5ff,stroke:#01579b,stroke-width:2px
classDef apiClass fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
classDef queueClass fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px
classDef routingClass fill:#dcedc8,stroke:#33691e,stroke-width:2px
classDef containerClass fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef handlerClass fill:#fce4ec,stroke:#880e4f,stroke-width:2px
classDef socketClass fill:#fff9c4,stroke:#f57f17,stroke-width:2px
classDef helperClass fill:#f1f8e9,stroke:#33691e,stroke-width:2px
classDef serverClass fill:#efebe9,stroke:#3e2723,stroke-width:2px
class App appClass
class API apiClass
class AsyncDispatcher queueClass
class SyncDispatcher queueClass
class WireRouting routingClass
class Containers containerClass
class Handler handlerClass
class Socket socketClass
class Helper helperClass
class Server serverClass
Pipeline Flow Details
The numbered path below is the binary one — what a default CommAPI runs. Steps 1-4 and
10-14 are protocol-independent; steps 5-9 differ on the deprecated ASCII fallback, and the
difference is noted inline.
Outbound Path (Application → AACommServer)
- API Call: User calls
CommAPI.Send()(async flow) orCommAPI.SendReceive()(sync flow). - Enqueue Message: Message written to the FLOW-specific
Channel<AACommMessage>(_asyncChannelforSend;_syncChannelfor the SR-built tagged duplicate). Lock-free. - Dispatch: The flow's long-running dispatcher Task drains its channel FIFO. Per-iteration,
it publishes the message into its per-flow in-flight slot (
_asyncInFlight/_syncInFlight), then waits at_resetDispatchGateif an async Reset is still in its recovery window. - Stamp + atomic enqueue + Send: Under
_wireSendLock(serializes the two dispatchers), the message is checked againstUpBinDownloadGate, stamped with the next correlation id, enqueued into_wireOrder, ANDICommHandler.SendMessageis invoked — so wire arrival order matches_wireOrderorder and id order. - Build the payload:
PayloadBuilder.BuildTypedPayloadproduces apayload_kind+ rawpayload_bytespair;BinaryCommHandlerpacks it into anIpcRequesttogether withTimeoutMs, the serialized connection data, the correlation id, the AACS comm-log text, and (for a Reset) the controller-recovery wait the server should honor. Transport framing — type-header bytes, CAN chunking, the EA NUL — is the server communicator's job. ASCII fallback:PayloadBuilder.BuildPayloadproduces a command string, whichCommHandlerwraps in the legacy#@#-separated envelope (connection data, message type, timeout, body). - TCP Send:
BinarySocketClientprepends a 4-byte big-endian length header and writes the frame to the AACommServer loopback socket (127.0.0.1:399 on Windows, :1024 elsewhere). ASCII fallback:SocketClientwrites the>\r-terminated envelope.
Inbound Path (AACommServer → Application)
- Frame received:
BinarySocketClientreads into a 100 000-byte buffer and feedsBinaryFramingBuffer, which reassembles complete length-prefixed frames (10 MB inbound cap). ASCII fallback:SocketClientbuffers intoEnvelopeAccumulatoruntil a>\rdelimiter. - Deserialize: each frame is parsed as an
IpcReplyand lifted to aReceivedFrame. - Receiver entry:
Session.HandleBinaryReplyruns on the receiver thread. ASCII fallback:Session.HandleReplyclassifies the envelope string viaReplyClassifier. - Classify: Fatal / Push / ChannelEvent / Reply. Reply bodies get bulk-error mangling via
the per-message
ReplyPostProcessingStateattached toAACommMessage.BulkPostProcessingat send time, and contInt streams are decoded at the width the server threaded on the frame. - Route:
Fatals — and a
ChannelEvent, which carries the same terminal semantics — invokeOnCommsErrorsynchronously on the receiver (the suppression rule on that event is documented on the event itself). Pushes are parsed byAACommPushMsgBase.Parseand dispatched toOnPushMessageReceivedviaTask.Run. Replies resolve their in-flight item viaTryTakeInFlight(by correlation id; id 0 pops the_wireOrderhead) andTrySetResultitsReplyDone— the receiver thread does NOT invokeOnReplyReceivedfor replies. - Dispatcher resumes: The flow's dispatcher wakes from its
await item.ReplyDone.Taskwith the parsed args. State containers updated (e.g.,_userProg.Clear()on ProgErase ack). - User callback / sync return:
- Async (
Send):OnReplyReceivedinvoked on the async dispatcher thread.OnLongOperationStarted/OnLongOperationEndedfire for long-running operations (Reset, Save, Load, SaveUser, EventCorrect), gated onIsSyncRequest == false. For Reset: reply → recovery sleep → ended (viaResetCoordinator). For others: started (after the start-grace window) → reply wait → ended (before user callback). The ended edge carries the controller error code (0= success). - Sync (
SendReceive): The internal wait-handle setter (installed asOnReplyReceivedon the tagged duplicate) sets the SR's wait handle; SR returns the capturedAACommEventArgs. NoOnCommsError/OnLongOperationStarted/OnLongOperationEndedevent is fired for sync requests (suppressed by tag).
- Async (
- Loop: Each dispatcher loops to its next channel message. Per-flow FIFO holds; cross-flow ordering is NOT guaranteed.
Server Management (Dotted Lines)
- StartAACommServer: Static method; launches AACommServer if the loopback port does not
already answer, otherwise attaches to whatever is listening. Both outcomes return
Success. - Launch Process: Runs the resolved host directly (a
.dllgoes throughdotnet), always with--headlessso a spawned server is never interactive. The server outlives its starter, so it must not hold the starter's stdout or stderr. On Windows it starts through ShellExecute with a hidden window, which creates it without handle inheritance. Elsewhere all three standard streams are redirected, and the starter closes its ends of stdout and stderr. - Verify Running: A non-blocking connect +
Pollon the loopback port, run on the calling thread so ThreadPool pressure cannot fake a "no server" negative.
Key Features
Public Events
CommAPI raises five events. All but OnCommsError are dispatched via Task.Run.
| Event | Signature | Raised when |
|---|---|---|
OnCommsError |
Action<string> |
Fatal comms error — synchronously on the receiver thread; suppressed for sync requests |
OnPushMessageReceived |
Action<AACommPushMsgBase> |
Unsolicited controller push (AACommPushMsgError / AACommPushMsgPushEvents / AACommPushMsgUP) |
OnLongOperationStarted |
Action<LongOperationType> |
A long operation is confirmed long-running (see below) |
OnLongOperationEnded |
Action<LongOperationType, int> |
That operation finished; int is the controller error code (0 = success) |
OnUserUnitsEnabledChanged |
Action<char, bool> |
The controller acked a per-axis User Units enable/disable transition, whatever path sent it. The cache behind GetUserUnitsEnabled is written first, so handlers should re-read it |
Send and SendReceive — Two-Flow Pipeline
Send and SendReceive have independent FIFO queues and dispatchers. They cooperate
freely on the same CommAPI instance from any number of threads — neither rejects the
other, and ordinary messages on one flow do not gate the other.
Asynchronous (Send):
- Non-blocking; returns immediately after enqueue
- User callback (
OnReplyReceived) invoked after the reply arrives, on the async dispatcher thread OnLongOperationStarted/OnLongOperationEndedandOnCommsErrorevents fired as applicable- Suitable for multi-threaded applications and event-driven flows
- Flow:
Send()→ Queue → Dispatch → CommHandler → reply → callback (+ events)
Synchronous (SendReceive):
- Sync wrapper over the sync pipeline. Builds a tagged duplicate of the message
(
IsSyncRequest = true), installs an internal wait-handle hook asOnReplyReceived, registers aCancellationTokenRegistrationon the liveSession.SessionTokenthat wakes the same wait handle on session-death, enqueues to the sync dispatcher, then blocks on the handle. - Returns
AACommEventArgsto the caller. Five return shapes: the reply (the dominant path),SyncErr Timeouton either safety-net phase expiring,SyncErr Session terminatedwhen the session died (dispatcher crash, fatal comms error, concurrent Disconnect) before any reply arrived,SyncErr No replywhen the wait handle was set with nothing captured, andPC Suite: ERR 4025when the session closed before the request was sent. The re-entry rejection below is a sixth. OnCommsErrorand the long-operation events are suppressed for sync requests (the caller observes errors via the return value; reset recovery timing is the caller's problem). The exception is a timeout that closes the connection: it raisesOnCommsErroron the caller's thread beforeSendReceivereturns, so the connection loss reaches the event like a FATAL's.- Two-phase safety-net wait (pinned by
SendReceiveQueuedTimeoutTests): phase 1 bounds queue residency — expiry while the message is still QUEUED abandons it (the dispatcher skips abandoned messages, so it never reaches the wire) and returnsSyncErr Timeoutwithout disconnecting: the link is not implicated by another message's slowness. Phase 2 starts when the dispatcher attempts the send (the clock restarts — mirroring the server, which armsTimeoutMswhen it dequeues the request to the controller) and runs forTimeoutMs + 100 + SyncWireGraceMs: the server armsTimeoutMswhen it dequeues the request to the controller and FATALs on controller silence itself, so what phase 2 charges above the budget is never controller slowness — it is the reply's client-side ThreadPool completion hops (IOCP receive → TCS continuation → waiter wake), or server-side queue time behind another client's long operation. A reply landing inside the grace is returned normally; expiry with the grace also spent means the SERVER is unresponsive — the link is implicated and the API disconnects. - With a debugger attached both phases wait a flat
DebuggerAttachedWaitMs(60 s) instead of the message's budget: wide enough to step through the reply path, finite so a receiver the debugger froze — expression evaluation freezes every other thread — surfaces as Timeout rather than a hang. A breakpoint held longer than that tears the session down on resume. - Phase 1's budget depends on what the wire is doing, re-read each 50 ms slice.
Queued behind a live message, it is
TimeoutMs + 100— a fast cached read must stay responsive behind a slow write. Wire idle and still not dispatched, it isTimeoutMs + StarvationToleranceMs(5 000 ms): the only thing that can delay dispatch then isDispatchLoop, aTask.Runcontinuation, not getting a ThreadPool thread. That is the client host being busy and says nothing about the controller, so charging it toTimeoutMsis what made a loaded machine reportFailControllerTypeagainst a controller answering in 0-2 ms. - Flow:
SendReceive()→ tagged sync message → Queue → Dispatch → reply → wait handle set → return
Re-entry guard: calling SendReceive from inside an OnReplyReceived callback (i.e.
on that callback's dispatcher thread) returns an error rather than deadlocking the pipeline. Calling
Send from OnReplyReceived is fine and is the normal way to chain async work.
Concurrent-call timing: a deep queue × per-message reply latency can exceed an
individual SendReceive's TimeoutMs — such an SR returns SyncErr Timeout from its
queue-residency phase (without teardown, and without being sent). Callers issuing many
concurrent SRs should still pass a TimeoutMs generous enough for the expected queue depth.
Message Queue Management
- TWO
System.Threading.Channels.Channel<AACommMessage>(unbounded, single-reader) per session — one for the async flow (Send), one for the sync flow (SendReceive). Lock-free writes from any number of threads. - TWO long-running dispatcher tasks per session (one per flow). Each drains its own
channel FIFO, publishes the in-flight message into its per-flow slot, atomically (under
the wire-send lock) stamps the correlation id, enqueues the item into
_wireOrderand callsICommHandler.SendMessage, then awaits a per-iterationTaskCompletionSource<AACommEventArgs>set by the receiver when the reply arrives. After the await unblocks, the dispatcher runs the user callback on its own thread (NOT the receiver thread), then loops to the next message. That gate enforces per-flow FIFO-with-full-callback-completion WITHIN the flow. Stop and the final cancellation check share the wire-send lock: a dispatcher that outlives its bounded join cannot send through a handler reopened for a successor. - The receiver thread (single per session) routes replies via
TryTakeInFlight— by correlation id on the binary protocol,_wireOrderhead-pop for id 0 — and sets the item'sReplyDonewith the parsed args. The dispatcher whose item just got args-routed wakes up and runs the callback. Receiver never invokes user code. - Async Reset remains a global transport interlock: once an async Reset is sent, both dispatchers are prevented from sending later messages until the post-Reset recovery window completes. Sync Reset keeps the existing contract and skips that recovery wait; its caller is responsible for reset timing.
IsQueueEmptyis_asyncEnqueuedCount == 0 && _syncEnqueuedCount == 0 && _asyncInFlight == null && _syncInFlight == null. The counters are needed becauseChannelReader<T>.CountthrowsNotSupportedExceptionat runtime on the netstandard2.0 build ofSystem.Threading.Channelsresolved against net48 — it is NOT a safe primitive on this TFM.- Disconnect completes both channel writers, cancels the dispatchers' token, signals
the in-flight TCSes (with
nullargs = "session torn down") so dispatchers' awaits unblock, drains_wireOrderso late replies drop cleanly, and bounds the wait for both dispatchers to exit. - Async terminal delivery: every accepted
Sendresolves its callback exactly once, even across teardown — the callback is the async caller's only per-message result channel (native-SDK futures resolve inside it). On teardown the in-flight async message getsERR Sync: Session terminated(it reached the wire; controller execution state unknown) and queued messages getPC Suite: ERR 4025(never sent — same args as the enqueue-after-Stop race).AACommEventArgs.IsSessionClosedis true for both, so a consumer can leave them to the singleOnCommsErrorreport of a lost connection. Sync flows need neither: the SR waiter is woken by the session-death registration. TheOnCommsErrorevent remains the session-level surface (fired unless suppressed — see the suppression rule onCommAPI.OnCommsError). Terminal delivery is ordered behind the currently executing callback: the teardown drain runs on the dispatcher thread after the active callback returns (preserving FIFO-with-full-callback-completion — user callbacks never run concurrently within a flow), so a queued message's terminal callback fires only once the in-flight one's callback has exited. Corollary: an async callback must not block waiting for a later queued message's result — that self-deadlocks the flow, teardown included. Pinned bySessionTeardownAsyncDeliveryTests,Disconnect_ClearsAsyncQueue_EveryReplyCallbackFiresWithError, andDisconnect_FromInsideAsyncCallback_WithQueuedFollower_FollowerGetsExactlyOneTerminalCallback.
Buffer Management
- Socket receive buffer: 100 000 bytes on both socket clients
- Wire framing differs by protocol —
>\r-terminated#@#envelopes (string) vs length-prefixed protobuf frames (binary, 10 MB inbound cap). See IPC Framing & Wire Protocol below.
IPC Framing & Wire Protocol
The wire contract — framing (string envelope vs binary length-prefix), first-byte protocol
detection, the two-phase handshake, IpcRequest/IpcReply field semantics, correlation-id
routing rules, ordering invariants, error codes, and the version-compatibility matrix — is
documented once, with sequence diagrams, in AACommComponents/docs/ipc-contract.md. The
shared wire code lives in AAComm/Internal/Wire/ (namespace AAComm.Internal.Wire),
file-linked one-way into the server. This section covers only the client-side behavior.
Negotiation (client side). new CommAPI() opens binary-first; the consumer does not choose
the protocol — CommAPI.Protocol reports the outcome. BinaryCommHandler runs the two-phase
open: phase 1 waits a 2 000 ms window for the server's handshake ack; phase 2 waits
openTimeoutMs + 1000 for the real Open result, where openTimeoutMs is the channel's open
budget (10 000 ms Ethernet, 20 000 ms Simulator/Simulation, 1 000 ms otherwise). Phase 1
resolves to ConnectResult.ServerNotBinaryCapable — and so to the ASCII retry — on any of:
no frame inside the window, a socket error or close, bytes that do not deserialize to an
IpcReply, a first reply whose Kind is not Handshake, or a handshake that does not declare
ProtocolFeatures.TypedPayloads. ConnectionOrchestrator then retries with CommHandler
(ASCII) on a fresh socket — fresh because the legacy server already latched the first socket
to string framing on its first byte. Every connect re-negotiates from binary — public
Connect and silent reconnect alike (LowLevelConnect builds a fresh binary handler whenever
negotiation is enabled); within a live session the handler persists (post-Reset recovery doesn't
reconnect the IPC socket). Phase 2 never falls back: a socket death or an undecodable reply
there classifies as FailOpenCommHandler, a silent server as OpenChannelTimeout, and a typed
Fatal carries the real ConnectResult name as its content.
Attach classification. After a successful open the orchestrator classifies the server —
CommAPI.ServerCompat (pinned by ServerCompatTests): Current (binary, version ≥ the
MinCurrentServerVersion floor, 14.0.0 = binary IPC v3 — typed payloads), LegacyBinary (binary below the
floor / unparseable version), LegacyAscii (the ASCII fallback connected). The floor
classifies, it never rejects — a server started by an older SDK app owns the port at boot and
must keep serving a newer client in compat mode. Hosts surface Legacy visibly (PCSuite shows a
warning); the ASCII client pipeline is frozen (deprecated) and kept solely for this compat
mode. String-side envelope reassembly is EnvelopeAccumulator (drops its
buffer when fully drained so multi-MB RecUpload payloads don't pin their peak buffer);
binary-side frame reassembly is BinaryFramingBuffer.
The binary client lifts each IpcReply to a ReceivedFrame and dispatches in
Session.HandleBinaryReply (Fatal and ChannelEvent → OnCommsError, Push →
OnPushMessageReceived, Reply → the in-flight waiter). For a Reply it prefers ContentBytes
(the hot HW path) and falls back to Content when ContentBytes is empty/null, so
server-generated Content-only replies deliver their text rather than an empty string. This
fallback is contractual: without it on the steady-state path the IsSingleChannelUser reply read
empty and every binary-client FW/FPGA download failed its single-client exclusivity gate.
Continuation-segment (contInt) decoding. RecUpload and AllStat replies are streams of
fixed-width big-endian integers. Communicator_Ethernet knows the stride for every such reply and
threads it as IpcReply.SegmentWidth (0 = width unknown, 4 = Int32, 8 = Int64; on-wire stride is
SegmentWidth + 1), so the binary client decodes at the exact width. A threaded width is still
content-validated before the binary parser is used — SegmentWidth reports the data-TYPE width,
not "this reply is binary", and a short RecUpload can come back as ASCII. Only when
SegmentWidth == 0 does BinarySegmentParser.DetectSegmentSize infer the stride from content,
validating the full pattern (length an exact multiple of the stride and every inter-value
delimiter ,). The string and binary decoders are pinned byte-for-byte by
BinaryStringPathEquivalenceTests.
Known limitations.
- Fallback to ASCII happens only in the open handshake. A genuine open failure against a
binary-capable server (e.g. controller unreachable) does not fall back — it surfaces the
real
ConnectResult. - The content-based stride guess is reached only for a RecUpload-mnemonic reply the server threaded no width onto, and it tries stride 5 before stride 9 — a width the server does know is always preferred over guessing.
- Outbound frames are not size-checked:
BinaryFramingBuffer.MaxFrameSize(10 MB) is inbound-only headroom. All IPC payloads are bounded small — controller commands are small, and a RecUpload is bounded by the controller's recording buffer.
Connection Lifecycle
- StartAACommServer(): Launches (or attaches to) the AACommServer process. REQUIRED before
connecting —
Connect()never spawns the server; without a running AACS it fails withAACommServerNotRunning - TestConnection(): Verifies hardware connectivity without full connection
- Connect(): Establishes session, queries controller identity, initializes containers
- Send()/SendReceive(): Message exchange
- Disconnect(): Closes session, clears state
- CloseAACommServer(): Asks the AACommServer process to exit (optional)
Server Lifecycle
Resolution and launch. StartAACommServer returns Success whether it spawned the process
or attached to one already serving the port — the two outcomes are indistinguishable to the
caller. Resolution order: an explicit file argument wins; otherwise the app base directory then
the working directory, each probed in the aacommserver\ subfolder first and flat second; then,
for net48 clients on Windows only, the PCSuite install path from the 32-bit registry view. The
candidate name is TFM-matched: a .NET-Core client prefers AACommServerNative.exe over
AACommServer.exe (a stale net48 host left beside a net8 client would otherwise be spawned under
it), and off Windows only the extension-less NativeAOT host is a candidate. net48 clients prefer
AACommServer.exe. The helper then polls the port for up to 15 s, failing early if the child
exits without the port answering — except that a bind-race loser's exit is followed by one last
probe, so the surviving winner is attached to instead.
Shutdown. CloseAACommServer(isForceClose) disconnects this API's own session and then, if
the port answers, sends ShutDown (or ShutDownForce) to whichever AACommServer is
listening — the client keeps no record of what it spawned and applies no ownership check. The
server is what gates the request: a plain ShutDown is honoured only when no client holds an
open channel and is otherwise ignored; ShutDownForce is honoured unless a client is in
exclusive-comms mode.
Implicit last-client shutdown. The server also exits unasked. When the last client's socket
dies unexpectedly — or dies after its channel was fatal-evicted — a grace timer arms
(AACommServerModel.ImplicitShutdownGraceMs, 5 000 ms) and the process exits unless a client
opens a channel inside the window. The window exists because a firmware download drops the
controller link on the way into BOOT: the flasher is fatal-evicted and must be able to reconnect
to the boot image before its own server disappears. A graceful Close is deliberately NOT
counted — a politely departing client leaves the server up for reconnect flows.
State Management
ControllerIdentityContainer (CommAPI.IdentityContainer):
- Controller type, firmware version, serial number
- Feature flags (user units, variable names support, etc.)
- Number of axes
ControllerMessagesContainer (CommAPI.MessagesContainer):
- Controller-specific message definitions from XML
- Parameter info database for localization
- User units conversion (if enabled)
UserProgVarsAdapter (CommAPI.UserProgAdapter):
- Maps user program variable names to controller addresses
- Auto-refreshed on user program download, cleared on a successful
AProgErase
Error Handling
- OnCommsError event: Fatal errors (timeout, connection loss, AACommServer crash)
- ConnectResult enum: Detailed error codes for all operations
- Automatic cleanup: API auto-disconnects on fatal errors and sync safety-net timeouts — the originating session. The in-flight sync waiter is woken before that teardown runs, so a caller that expects the failure and reconnects at once (the EC firmware flasher after the password that drops the drive into BOOT) may already own a successor session; the late teardown leaves it alone and raises no
OnCommsError - Exception handling: TCP socket errors converted to events
Diagnostics
AAComm.Services.Diagnostics.AACommDebugLog is a process-wide, zero-config, always-on
diagnostic log — the client-side counterpart of AACommServer's debug log. Session-lifecycle
events only (construction, connect outcomes, sync phase-1 queue abandon vs phase-2 teardown,
server FATALs, disconnects with their reason), each line tagged HH:mm:ss.fff [api#N/conn#M],
accumulate in an in-memory ring with zero steady-state file IO; a severe event snapshots the
ring to %LocalAppData%\PCSuite\AAComm_DebugLogs. Public surface: AACommDebugLog.Sink
(host bridge) and Flush(). The full spec — line shapes, flush-on-severity semantics, the
server-side GlobalLogger counterpart over the same linked mechanics core, and the
local-port ↔ UserId procedure that joins the client and server log files — is
diagnostics-srs.md.
Protocol Support
- CAN: Binary protocol with CAN ID and data bytes (requires Kvaser CANlib; Windows or Linux, macOS unsupported)
- RS-232: Serial port text-based communication. A binary UP-download chunk (
UserProgramBin) is declined withPC Suite: ERR 4022unless a download is active (issueDownloadUPBin/DownloadUProgfirst) — its raw bytes (embedded CRs / the>\rpair) would otherwise fragment the controller's CR-delimited stream and desync the reply FIFO. Enforced on the client send path byUpBinDownloadGate, which gates RS-232 only: every other channel — Ethernet, CAN, Simulator, Simulation — is let through untouched, because there the payload is framed safely (length-prefixed binary / hex-sigil string). Reply adaptation encodes integer, error, boot and integer-continuation replies in their binary forms. Other text replies retain>\ras an explicit text marker through both IPC protocols; decimal text is preserved without interpreting its length as a binary frame. - Ethernet: TCP/IP socket-based communication with bulk message support (up to 50 messages per bulk)
- Simulation: managed firmware-sim — the server spawns
firmware_simlocally over loopback and bridges it (Communicator_FirmwareSim). The spawned sim is started bound to the server lifetime so an abnormal server exit can't orphan it (Windows Job Object / LinuxPR_SET_PDEATHSIG/ macOS/bin/shbabysitter — seeAACommServerCore/README.md). Thefirmware_simbinary is built per-platform in the firmware repo; Windows ships today, linux/osx pending. The legacy file-basedSimulatorchannel is removed; theSimulatorenum survives only as the in-process echo channel for the AAComm virtual-test harness.
Message Types
- Regular Messages: Text-based commands with parameters
- Bulk Messages: batch sending on Ethernet and Simulation channels (
CommAPI.MessagesInBulkreports 50 there, 1 on every other channel and while disconnected) - Binary Uploads: Firmware and user program downloads
- Special Messages: FPGA downloads, ASCII bulk, CNC-specific bulk messages
Long-Operation Handling
OnLongOperationStarted(LongOperationType) and OnLongOperationEnded(LongOperationType, int errorCode)
fire for operations that freeze the controller firmware for a significant duration. The ended edge's
errorCode is 0 on success or the controller error code (e.g. 22 = "motor on") on failure, so a
subscriber can suppress a "complete" message on a failed long operation. Both are suppressed for
SendReceive (sync path).
For the non-Reset types the started edge is gated by a short start-grace window
(Session.LongOpStartGraceMs, 100 ms): it fires only once the reply has failed to arrive within that
window. A reply that lands sooner means the op was not long-running — e.g. an immediate ERR 22 rejecting
a flash Save with the motor on — so neither edge fires and the reply surfaces its own result. This stops
a transient "started" notification from racing, and clobbering, an instant error reply. The
five operation types and their dispatch timing:
| Type | Mnemonic | Timeout | Event timing |
|---|---|---|---|
| Reset | AReset | 10 001 ms (10 002 on AGM800) | After reply → recovery sleep → end (ResetCoordinator) |
| Save | ASave | 120 000 ms | No reply within grace → start → reply wait → end |
| Load | ALoad | 120 000 ms | No reply within grace → start → reply wait → end |
| SaveUser | ASaveUser | 3 000 ms | No reply within grace → start → reply wait → end |
| EventCorrect | AEventCorrect | 10 000 ms | No reply within grace → start → reply wait → end |
Reset is the only type with a post-reply recovery sleep and dispatch gate (controller
reboots after the reply). The after-reset wait is 3 000 ms for standard controllers,
10 000 ms for MAS02 (AfterResetWait / AfterResetWaitMAS02). The async dispatcher
thread sleeps inside ResetCoordinator.RunPostResetWait; the sync flow is parked at
_resetDispatchGate for the same window. Skipped entirely for sync SendReceive of a
Reset (the caller manages reset timing).
- Reconnection: after a controller reboot the SERVER re-opens the hardware link —
ChannelHandler.ResetRecoveryWorkerretriesOpenon the Ethernet communicator within its retry budget, off the channel lock. NeitherSocketClientnorBinarySocketClientreconnects; the client's IPC socket is not touched by a Reset. - State preservation: Connection data maintained across reset
- Remote FPGA image transitions: the download engine checks preparation and reconnect
replies and reads fresh dual-configuration state before flash writes or success. Only an
exact
ERR 151at the final remote reboot trigger can proceed to that bounded verification; it remains uncertain completion in status output. Other errors terminate without repeating erase or transfer. The local FPGA path and genericCIConnectbehavior are unchanged; the image-state and identity contracts are infirmware-download.md. - Post-reset disconnect transparency: After a controller-rebooting step (FW DL EOF, FPGA
EOF, sync
AReset) the controller-side TCP socket goes away; AACommServer surfaces this as a disconnect-style reply.ResetReplyHelper.IsExpectedPostResetDisconnectmatchesERR 4021(AACS IPC connection lost),ERR 4020(API not connected),ERR 4025(session closed before the send),ERR 4011(Ethernet socket error), and any!@#FATAL:channel-fatal payload. These are not failures — they're proof the reset took effect.Session.SendReceivemasks the race for syncAResetso consumers always see the success shape regardless of which signal arrived first. The helper is only meaningful AFTER the reset-triggering step has been issued — before, the same markers indicate genuine connectivity failure. - User Units re-read: the controller can come back on different
*UnitFct/UserUnitsEnvalues, so a completed Reset re-reads the User Units cache (ControllerMessagesContainer.ReadUserUnitsSync) — the sync flow beforeSendReceivereturns, the async flow on the dispatcher thread after the recovery wait and before the reply callback. A Reset that returned an error leaves the cache alone. SeeAAComm/docs/floating-point.md.
Timeout Configuration
- Default timeout: 2000ms for standard messages
- Reset timeout: 10001ms (standard controller), 10002ms (AGM800)
- Firmware download: 6000ms; UP-bin chunk: 10000ms (
TimeoutMs.DownloadUPBin; text-mode UP lines use the default) - Open channel: 1000ms (RS-232/CAN), 10000ms (Ethernet), 20000ms (Simulator/Simulation)
- All timeouts configurable per-message via
TimeoutMsproperty; the full table is inAACommComponents/docs/ipc-contract.md
Exclusive Channel Access
- Request exclusive access: Prevents other clients from using the same hardware channel
- Managed server-side: AACommServer enforces exclusive mode per channel
- Use case: Critical operations like firmware updates, calibration
- API methods:
AACommExclusiveCommsManagerservice class - Error handling: Other clients receive
ERR 4010(CHANNEL_IS_EXCLUSIVE) during exclusive mode
Thread Safety
Connect/Disconnect/TestConnection/CloseAACommServermutually serialize via the orchestrator's connect lock (also held during the post-Reset recovery wait, so reset and connect/disconnect cannot race)SendandSendReceiveare thread-safe and may be interleaved freely from any number of threads. The two have INDEPENDENT pipelines: a long-running async callback does not block sync dispatch, and vice versa.- Each dispatcher is the single reader of its own message channel; per-flow FIFO is preserved by
the
Channel<T>writer order and the dispatcher's gated single-step loop - The receiver thread is single-threaded per
ICommHandlerinstance. For replies it hands parsed args to the originating flow's dispatcher via a per-iteration TCS (no user code on the receiver). For pushes it hands the payload toOnPushMessageReceivedviaTask.Run(no user code on the receiver). For fatals it invokesOnCommsErrorsynchronously on the receiver — long-running fatal handlers block reply / push classification until they return. SendReceivefrom inside anOnReplyReceivedcallback (on the dispatcher thread) returns an error to avoid same-flow pipeline deadlock;SendfromOnReplyReceivedis fine. The guard catches sync re-entry only — anasync voidcallback that issues SR from a continuation that resumed after anawaitis NOT rejected (out of scope: the post-await continuation no longer holds the dispatcher's frame).OnPushMessageReceived,OnUserUnitsEnabledChangedand the long-operation events are dispatched viaTask.Run(no blocking of the receiver thread), butResetCoordinator.RunPostResetWaitruns the recovery sleep synchronously on the async dispatcher thread — the async pipeline is blocked for the recovery window.OnCommsErroris the deliberate exception (sync on receiver) — see the suppression rule on the event for why mixed-mode fatals still fire it- Socket operations thread-safe via async callbacks
IsResettingis a volatile bool readable from any thread; flipped by the async dispatcher thread around the post-Reset recovery sleep
Typical Usage Patterns
Asynchronous Mode (Event-Driven)
using AAComm;
using AAComm.Services;
using AAComm.Shared;
var api = new CommAPI();
// 1. Start (or attach to) the server — required; Connect never spawns it
CommAPI.StartAACommServer();
// 2. Subscribe to events (REQUIRED for async mode)
api.OnCommsError += err => Console.WriteLine($"Error: {err}");
api.OnPushMessageReceived += push => Console.WriteLine($"Push: {push}");
api.OnLongOperationStarted += type => Console.WriteLine($"Long op started: {type}");
api.OnLongOperationEnded += (type, code) => Console.WriteLine($"Long op ended: {type}, errorCode: {code}");
api.OnUserUnitsEnabledChanged += (axis, on) => Console.WriteLine($"User Units on axis {axis}: {on}");
// 3. Connect to controller (ET_IP_Str is derived — set the four octets)
var connData = new ConnectionData {
CommChannelType = ChannelType.Ethernet,
ET_IP_1 = 192, ET_IP_2 = 168, ET_IP_3 = 1, ET_IP_4 = 100
};
var result = api.Connect(connData);
// 4. Send commands with callbacks (callback is supplied via AACommMessage)
api.Send(new AACommMessage((_, args) => Console.WriteLine($"Version: {args.MsgReceived}"), "VER"));
api.Send(new AACommMessage((_, args) => Console.WriteLine($"System: {args.MsgReceived}"), "SYSINFO"));
// Messages processed asynchronously in queue order
// ...
// 5. Disconnect
api.Disconnect();
Synchronous Mode (Blocking)
using AAComm;
using AAComm.Services;
using AAComm.Shared;
var api = new CommAPI();
// 1. Start server
CommAPI.StartAACommServer();
// 2. Events optional (not fired during SendReceive)
// api.OnCommsError += ...
// 3. Connect to controller
var connData = new ConnectionData {
CommChannelType = ChannelType.Ethernet,
ET_IP_1 = 192, ET_IP_2 = 168, ET_IP_3 = 1, ET_IP_4 = 100
};
var result = api.Connect(connData);
// 4. Send commands synchronously (blocks until reply)
var version = api.SendReceive(new AACommMessage("VER"));
Console.WriteLine($"Version: {version.MsgReceived}");
var sysInfo = api.SendReceive(new AACommMessage("SYSINFO"));
Console.WriteLine($"System: {sysInfo.MsgReceived}");
// Each call blocks until response received
// ...
// 5. Disconnect
api.Disconnect();
Relationship to AACommServer
AAComm is the client library, AACommServerCore is the server middleware.
- AAComm: Embedded in user applications, provides high-level API
- AACommServerCore: Standalone process, manages hardware connections
Multiple AAComm clients can connect to a single AACommServer instance, sharing hardware channels. See AACommServerCore/README.md for the server-side architecture.