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&lt;T&gt; FIFO (async)<br/>- Long-running dispatcher task<br/>- _asyncInFlight slot]
            SyncDispatcher[Sync Dispatcher<br/>- Channel&lt;T&gt; 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)

  1. API Call: User calls CommAPI.Send() (async flow) or CommAPI.SendReceive() (sync flow).
  2. Enqueue Message: Message written to the FLOW-specific Channel<AACommMessage> (_asyncChannel for Send; _syncChannel for the SR-built tagged duplicate). Lock-free.
  3. 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 _resetDispatchGate if an async Reset is still in its recovery window.
  4. Stamp + atomic enqueue + Send: Under _wireSendLock (serializes the two dispatchers), the message is checked against UpBinDownloadGate, stamped with the next correlation id, enqueued into _wireOrder, AND ICommHandler.SendMessage is invoked — so wire arrival order matches _wireOrder order and id order.
  5. Build the payload: PayloadBuilder.BuildTypedPayload produces a payload_kind + raw payload_bytes pair; BinaryCommHandler packs it into an IpcRequest together with TimeoutMs, 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.BuildPayload produces a command string, which CommHandler wraps in the legacy #@#-separated envelope (connection data, message type, timeout, body).
  6. TCP Send: BinarySocketClient prepends 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: SocketClient writes the >\r-terminated envelope.

Inbound Path (AACommServer → Application)

  1. Frame received: BinarySocketClient reads into a 100 000-byte buffer and feeds BinaryFramingBuffer, which reassembles complete length-prefixed frames (10 MB inbound cap). ASCII fallback: SocketClient buffers into EnvelopeAccumulator until a >\r delimiter.
  2. Deserialize: each frame is parsed as an IpcReply and lifted to a ReceivedFrame.
  3. Receiver entry: Session.HandleBinaryReply runs on the receiver thread. ASCII fallback: Session.HandleReply classifies the envelope string via ReplyClassifier.
  4. Classify: Fatal / Push / ChannelEvent / Reply. Reply bodies get bulk-error mangling via the per-message ReplyPostProcessingState attached to AACommMessage.BulkPostProcessing at send time, and contInt streams are decoded at the width the server threaded on the frame.
  5. Route: Fatals — and a ChannelEvent, which carries the same terminal semantics — invoke OnCommsError synchronously on the receiver (the suppression rule on that event is documented on the event itself). Pushes are parsed by AACommPushMsgBase.Parse and dispatched to OnPushMessageReceived via Task.Run. Replies resolve their in-flight item via TryTakeInFlight (by correlation id; id 0 pops the _wireOrder head) and TrySetResult its ReplyDone — the receiver thread does NOT invoke OnReplyReceived for replies.
  6. Dispatcher resumes: The flow's dispatcher wakes from its await item.ReplyDone.Task with the parsed args. State containers updated (e.g., _userProg.Clear() on ProgErase ack).
  7. User callback / sync return:
    • Async (Send): OnReplyReceived invoked on the async dispatcher thread. OnLongOperationStarted / OnLongOperationEnded fire for long-running operations (Reset, Save, Load, SaveUser, EventCorrect), gated on IsSyncRequest == false. For Reset: reply → recovery sleep → ended (via ResetCoordinator). 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 as OnReplyReceived on the tagged duplicate) sets the SR's wait handle; SR returns the captured AACommEventArgs. No OnCommsError / OnLongOperationStarted / OnLongOperationEnded event is fired for sync requests (suppressed by tag).
  8. 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 .dll goes through dotnet), always with --headless so 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 + Poll on 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 / OnLongOperationEnded and OnCommsError events 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 as OnReplyReceived, registers a CancellationTokenRegistration on the live Session.SessionToken that wakes the same wait handle on session-death, enqueues to the sync dispatcher, then blocks on the handle.
  • Returns AACommEventArgs to the caller. Five return shapes: the reply (the dominant path), SyncErr Timeout on either safety-net phase expiring, SyncErr Session terminated when the session died (dispatcher crash, fatal comms error, concurrent Disconnect) before any reply arrived, SyncErr No reply when the wait handle was set with nothing captured, and PC Suite: ERR 4025 when the session closed before the request was sent. The re-entry rejection below is a sixth.
  • OnCommsError and 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 raises OnCommsError on the caller's thread before SendReceive returns, 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 returns SyncErr Timeout without 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 arms TimeoutMs when it dequeues the request to the controller) and runs for TimeoutMs + 100 + SyncWireGraceMs: the server arms TimeoutMs when 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 is TimeoutMs + StarvationToleranceMs (5 000 ms): the only thing that can delay dispatch then is DispatchLoop, a Task.Run continuation, not getting a ThreadPool thread. That is the client host being busy and says nothing about the controller, so charging it to TimeoutMs is what made a loaded machine report FailControllerType against 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 _wireOrder and calls ICommHandler.SendMessage, then awaits a per-iteration TaskCompletionSource<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, _wireOrder head-pop for id 0 — and sets the item's ReplyDone with 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.
  • IsQueueEmpty is _asyncEnqueuedCount == 0 && _syncEnqueuedCount == 0 && _asyncInFlight == null && _syncInFlight == null. The counters are needed because ChannelReader<T>.Count throws NotSupportedException at runtime on the netstandard2.0 build of System.Threading.Channels resolved 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 null args = "session torn down") so dispatchers' awaits unblock, drains _wireOrder so late replies drop cleanly, and bounds the wait for both dispatchers to exit.
  • Async terminal delivery: every accepted Send resolves 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 gets ERR Sync: Session terminated (it reached the wire; controller execution state unknown) and queued messages get PC Suite: ERR 4025 (never sent — same args as the enqueue-after-Stop race). AACommEventArgs.IsSessionClosed is true for both, so a consumer can leave them to the single OnCommsError report of a lost connection. Sync flows need neither: the SR waiter is woken by the session-death registration. The OnCommsError event remains the session-level surface (fired unless suppressed — see the suppression rule on CommAPI.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 by SessionTeardownAsyncDeliveryTests, Disconnect_ClearsAsyncQueue_EveryReplyCallbackFiresWithError, and Disconnect_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

  1. StartAACommServer(): Launches (or attaches to) the AACommServer process. REQUIRED before connecting — Connect() never spawns the server; without a running AACS it fails with AACommServerNotRunning
  2. TestConnection(): Verifies hardware connectivity without full connection
  3. Connect(): Establishes session, queries controller identity, initializes containers
  4. Send()/SendReceive(): Message exchange
  5. Disconnect(): Closes session, clears state
  6. 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 with PC Suite: ERR 4022 unless a download is active (issue DownloadUPBin / DownloadUProg first) — its raw bytes (embedded CRs / the >\r pair) would otherwise fragment the controller's CR-delimited stream and desync the reply FIFO. Enforced on the client send path by UpBinDownloadGate, 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 >\r as 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_sim locally 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 / Linux PR_SET_PDEATHSIG / macOS /bin/sh babysitter — see AACommServerCore/README.md). The firmware_sim binary is built per-platform in the firmware repo; Windows ships today, linux/osx pending. The legacy file-based Simulator channel is removed; the Simulator enum 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.MessagesInBulk reports 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.ResetRecoveryWorker retries Open on the Ethernet communicator within its retry budget, off the channel lock. Neither SocketClient nor BinarySocketClient reconnects; 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 151 at 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 generic CIConnect behavior are unchanged; the image-state and identity contracts are in firmware-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.IsExpectedPostResetDisconnect matches ERR 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.SendReceive masks the race for sync AReset so 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 / UserUnitsEn values, so a completed Reset re-reads the User Units cache (ControllerMessagesContainer.ReadUserUnitsSync) — the sync flow before SendReceive returns, 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. See AAComm/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 TimeoutMs property; the full table is in AACommComponents/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: AACommExclusiveCommsManager service class
  • Error handling: Other clients receive ERR 4010 (CHANNEL_IS_EXCLUSIVE) during exclusive mode

Thread Safety

  • Connect / Disconnect / TestConnection / CloseAACommServer mutually serialize via the orchestrator's connect lock (also held during the post-Reset recovery wait, so reset and connect/disconnect cannot race)
  • Send and SendReceive are 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 ICommHandler instance. 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 to OnPushMessageReceived via Task.Run (no user code on the receiver). For fatals it invokes OnCommsError synchronously on the receiver — long-running fatal handlers block reply / push classification until they return.
  • SendReceive from inside an OnReplyReceived callback (on the dispatcher thread) returns an error to avoid same-flow pipeline deadlock; Send from OnReplyReceived is fine. The guard catches sync re-entry only — an async void callback that issues SR from a continuation that resumed after an await is NOT rejected (out of scope: the post-await continuation no longer holds the dispatcher's frame).
  • OnPushMessageReceived, OnUserUnitsEnabledChanged and the long-operation events are dispatched via Task.Run (no blocking of the receiver thread), but ResetCoordinator.RunPostResetWait runs the recovery sleep synchronously on the async dispatcher thread — the async pipeline is blocked for the recovery window. OnCommsError is 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
  • IsResetting is 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.