ASTM Channels
Clinical analyzers — Bio-Rad, Beckman, Roche, and many others — commonly speak ASTM E1381 (the
link layer) carrying ASTM E1394 (the record format). The Agent handles these with a
byte stream channel, configured entirely through query parameters on the channel's
Endpoint.address.
This page covers the configuration those analyzers need, and the behaviors that most often surprise people integrating one for the first time.
:::info Transport: raw TCP only
Everything here describes ASTM carried over a raw TCP stream. A byte stream channel listens on a TCP port, so an analyzer that presents a serial (RS-232) interface — still the majority — has to reach the Agent through a serial-to-TCP/IP adapter, sometimes sold as a serial device server or terminal server. The adapter passes bytes through untouched, so the ASTM framing and the configuration below are unaffected by it.
Connecting to a serial port directly from the Agent is not yet supported, but is planned. Until then an adapter is required for serial-only analyzers.
:::
Recommended configuration
tcp://0.0.0.0:9004
?startChar=%05&endChar=%04
&autoRespond=%05:%06,%03:%06,%17:%06
&stripControlChars=true&keepControlChars=%0D%0A
&bodyEncoding=utf-8
&ignoreResponse=true
(Shown wrapped for readability; the real value is a single line.)
| Parameter | Why an ASTM channel needs it |
|---|---|
startChar=%05&endChar=%04 | Frames on ENQ/EOT, so one ASTM session becomes one message. See Framing. |
autoRespond=%05:%06,%03:%06,%17:%06 | Answers ENQ, ETX, and ETB with ACK, which is the full set of bytes E1381 acknowledges. See Acknowledgement. |
stripControlChars=true | Removes the ENQ, STX, ETX/ETB and EOT framing, leaving readable records. |
keepControlChars=%0D%0A | Exempts CR and LF from that sweep. ASTM records are CR-terminated; without this the whole message is one run-on line. |
bodyEncoding=utf-8 | Delivers the body to the Bot as text. The default, hex, would require the Bot to decode it first. |
ignoreResponse=true | Makes the channel one-way, for analyzers that never read a reply. See One-way channels. |
Framing: one session per message
An ASTM session looks like this on the wire:
--> ENQ
<-- ACK
--> STX 1 H|\^&|||... CR ETX C1C2 CR LF
<-- ACK
--> STX 2 P|1|... CR ETX C1C2 CR LF
<-- ACK
--> EOT
Framing on ENQ/EOT (startChar=%05&endChar=%04) makes the Agent treat everything between them
as a single message, so the Bot receives the complete result set — H through L — in one
invocation.
:::danger Do not frame on STX/ETX
startChar=%02&endChar=%03 looks natural, since STX and ETX delimit each frame. It will deliver
one message per frame, so a Bot that needs the header, order, and result records together sees
them split across several invocations with no way to correlate them.
:::
Link-level acknowledgement
E1381 expects exactly one ACK per frame. autoRespond answers these at the byte level, before
framing and without involving the Bot, which is what lets an analyzer proceed without waiting on a
round trip to the server.
| Byte | Rule | Acknowledged? | Notes |
|---|---|---|---|
ENQ (0x05) | %05:%06 | Yes | Establishment. |
ETX (0x03) | %03:%06 | Yes | Terminates the final frame of a message. |
ETB (0x17) | %17:%06 | Yes | Terminates an intermediate frame of a split message. |
EOT (0x04) | — | No | Never acknowledged; the sender has already dropped to idle. |
LF (0x0A) | — | No | Not an ASTM acknowledgement point. |
Two mistakes are easy to make here:
- Forgetting
%17:%06. Records longer than a frame are split, with every frame but the last terminated byETBrather thanETX. Without a rule forETBthose frames go unanswered and the analyzer stalls, retransmits, and eventually aborts the session. Short messages work fine, so this often surfaces only once a larger result set arrives. - Mapping
%0A:%06or%04:%06. Every frame endsCR LF, so a rule onLFproduces a secondACKfor each frame. A sender expecting one reply per frame then runs permanently one ahead, reading frame N'sACKas the answer to frame N+1 — which means a frame that was actually rejected can be read as accepted. A rule onEOTsends an unsolicitedACKinto the idle state, which stricter analyzers log as a protocol violation.
:::caution autoRespond cannot validate frames
It matches bytes; it does not parse frames or verify checksums, so it ACKs a corrupt frame exactly
as readily as a good one and can never send a NAK. Checksum-driven retransmission is not available
through this mechanism. If an analyzer depends on it, validate in the Bot and reconcile
out of band.
:::
What the Bot receives
Filtering operates on bytes, not frames, so everything printable in the analyzer's framing survives. A session arrives looking like this:
1H|\^&|||Bio-Rad CDM System SN 12345^...|||||LIS||P|1|20260806041500<CR>
P|1||||Doe^John||19700101|M<CR>
O|1|511979^002^01^0002||^^^4||...<CR>
R|9|^^^A1c^AREA|5.2|NGSP|...<CR>
L|1|N<CR>
D5<CR><LF>
Two artifacts to plan for:
- Frame sequence numbers. The
1ahead ofHis the frame number. It is an ASCII digit, sostripControlCharscannot remove it. - Checksums. The trailing
D5is the frame checksum. Splitting onCRleaves it on a line of its own, where most parsers harmlessly ignore it.
Frame packing changes which lines carry a sequence number
Only the first record in each frame is prefixed with the frame number. An analyzer that packs a
whole message into one frame produces exactly one prefix, on the H record — so startsWith('O|1|')
works. The same analyzer splitting that message across frames prefixes the first record of every
frame, and the same check silently stops matching.
Do not rely on the packing staying constant; it changes with result-set size, retransmissions, and firmware. Normalize instead:
const lines = input
.split(/\r\n?/)
// Strip a frame sequence number only where one precedes a record type letter.
.map((line) => line.replace(/^[0-7](?=[A-Z]\|)/, ''));
One-way channels
Most analyzers send results and never read a reply — the link-level ACKs already told them the
message was accepted. Set ignoreResponse=true so the Bot's response is discarded rather than
written back to the socket.
Without it, every message writes something to the analyzer, and it is often not what you would expect: when a Bot returns no value, the server substitutes the Bot's execution logs as the response body. An analyzer sitting idle then receives kilobytes of unframed log text.
An empty string is a falsy return value, so returning '' still produces the execution-log
fallback. ignoreResponse=true is the reliable way to send nothing.
Sessions with no data
An analyzer with nothing to report may still open a session — ENQ, take the ACK, then EOT. Both
framing bytes are removed by stripControlChars, so what remains is a zero-length body, and the
Agent dispatches it to the Bot like any other message.
Guard against it at the top of the Bot rather than paying for a full execution per poll:
if (!input?.trim()) {
return;
}
Verifying a new channel
When bringing up an analyzer, confirm each layer separately — a failure at any one looks like "nothing happened" from the outside:
- Link layer. Watch the
ACKs. Exactly one per frame, and none afterEOT. Extra or missingACKs point at theautoRespondrules. - Framing. Log the body's length and whether it contains
CR. If there is noCR, checkkeepControlChars. If the body starts with a control byte instead of a record type letter, checkstripControlChars. - Parsing. Log each line and which branch matched it. A
(no match)on a record you expected to match is usually a frame sequence number, per Frame packing.
A Bot that logs these and writes nothing is worth keeping around; channel logs and
the Bot's AuditEvent history together cover all three layers.