servomexlib — Architecture & Design (v2, from-scratch)¶
Async-first Python driver for Servomex SERVOPRO 4000-series gas analysers (developed against a bench 4100D on
COM11), presenting one protocol-neutral, channel-oriented API that decodes the analyser's three communication modes into a single set of typed models.This is a fresh, from-scratch design.
servomexlibis a member of the*libinstrument-driver family (anyserial,sartoriuslib,watlowlib,alicatlib,nidaqlib,dtollib). Family harmony is defined at the boundary, not the core: the public entry point, the frozenStrEnum/dataclass models, the error hierarchy, the streaming/sinks/sync/CLI conventions, and the entire tooling skeleton match the siblings exactly. The internal layering is shaped to this device — just asnidaqlib/dtollib(TaskSpec) diverge internally fromsartoriuslib(Command/Variant). This document is the reference the code points to as "design §N".
1. What kind of device this is (and why it drives the design)¶
Everything below follows from one observation: the 4100 is a multi-channel, read-mostly
sensor, not a richly-commanded instrument. Reconciling this against the family template
(sartoriuslib) is the central design act.
| Property of the 4100 | Consequence for the design |
|---|---|
| Reports a set of channels every cycle (I1–I4 measured, D1–D4 derived, E1–E2 external mA), each with id/name/value/unit/status | The core domain object is the channel, and the central artifact is a frame/snapshot = a timestamped set of channel Readings. One model serves all three modes. |
| Three wire modes, but only two semantics: an unsolicited read-only broadcast (Continuous ASCII) and a polled request/response (Modbus) — RTU vs ASCII differ only in framing | Two ProtocolClient implementations, not three. Modbus framing is a strategy (RTU=CRC-16, ASCII=LRC). |
| Continuous mode has no request channel at all — the analyser just emits frames every frame-frequency seconds | A symmetric Command[Req,Resp]-with-two-variants abstraction (sartorius's core) does not fit: the continuous "variant" would be decode-only. We drop that layer. |
| The entire control surface is tiny: read channels, read status, start/stop autocalibration | Semantic operations live directly on the per-protocol client interface. No command registry, no opcode tables. |
| Modes are mutually exclusive and set on the front panel | We never switch modes for the user. We are told the mode, or sniff it (AUTO). |
| RS485 multidrop, unique slave address per analyser | Per-call address; a ServomexManager over one bus (Modbus only — continuous is single-broadcaster). |
Design thesis: keep the family's outer shell verbatim; replace sartorius's
commands/ core with a channel-registry + thin per-protocol client core that matches the
4100's actual shape.
Goals¶
- One protocol-neutral, channel-oriented API.
poll(),read_channel("I1"),snapshot(),identify(),stream(),start_calibration(group)behave identically whether the analyser is in Continuous ASCII, Modbus ASCII, or Modbus RTU. - Async core on
anyio; a thin sync facade (Servomex.open(...)) for scripts/REPL. - No hardware needed to develop or test: a
FakeTransport+ recorded fixtures drive every protocol and the CLI in CI. This includes the Modbus path (see §5 — we own the bytes). - Family harmony at the boundary: identical entry point, models style, error hierarchy, tooling, tests, docs.
Non-goals¶
- We do not auto-switch the analyser's comm mode (front-panel only;
AUTOonly sniffs). - We do not model the front-panel menu, display, relays, or analogue outputs beyond what the serial/Modbus profile exposes.
- We do not depend on
pint.Unitis a localStrEnum;units.to_pint()returns pint-compatible strings for callers who want them. - We do not depend on
pydantic. All models are frozendataclass(slots=True).
2. Layered architecture¶
The same async onion as every sibling — raw bytes from anyserial (no framing, no Modbus;
we own all codecs) — but the core is channel-centric, not command-centric.
anyserial raw async serial bytes: open_serial_port, SerialPort,
SerialConfig, RS485Config, typed serial errors
│
▼
transport/ base.py Transport PEP-544 Protocol + SerialSettings frozen dataclass
serial.py SerialTransport over anyserial (read_exact / read_until /
read_available, anyio.fail_after timeouts, pushback buffer,
reopen() for re-config)
fake.py FakeTransport (scripted write→reply; the no-hardware seam —
drives BOTH continuous and Modbus paths)
│
▼
protocol/ base.py ProtocolKind StrEnum + ProtocolClient PEP-544 Protocol
(the small SEMANTIC surface — see §4.1) + Capability set
detect.py AUTO sniff ladder (§4.4)
continuous/ client.py passive receive loop + latest-frame cache + fan-out
parser.py pure bytes → ContinuousFrame (self-describing)
checksum.py 16-bit additive verify/compute (§3.1)
modbus/ client.py the ~5 semantic ops over anymodbus
registers.py channel↔register address math (consumes registry)
session.py anymodbus Slave bound to OUR Transport (§5)
errors.py anymodbus exception → ServomexModbusError mapping
│
▼
registry/ channels.py ChannelId / ChannelKind / ChannelSpec table ← the spine (§6)
units.py Unit / UnitKind enums + coercion + to_pint
status.py ChannelStatus + analyser-status flag decode (bitmap & ASCII)
charset.py Servomex display-ROM glyph → Unicode map (§3.3)
│
▼
devices/ session.py protocol-neutral dispatch + safety/capability gates +
background-task lifecycle (continuous receive loop) + caching
analyzer.py Analyzer — the public facade (async context manager)
factory.py async open_device(...) ← THE entry point
models.py Reading / Frame / Sample / DeviceInfo / ChannelInfo /
ChannelStatus / AnalyserStatus / CalibrationProgress
discovery.py find_devices / discover_port / DiscoveryResult
capability.py Capability / SafetyTier / Availability
│
▼
streaming/ Sample, record(), StreamMode (POLL | AUTOPRINT), AcquisitionSummary
sinks/ base, memory, csv, jsonl, sqlite (+ optional parquet, postgres)
sync/ Servomex.open(...) + SyncAnalyzer + SyncManager via anyio BlockingPortal
cli/ _common, read, stream, discover, decode, capture, diag/
manager.py ServomexManager (RS485 multidrop, Modbus)
testing.py FakeTransport, fixture loaders, FakeModbusSlave, canned frames
errors.py · config.py · units.py · version.py · _version.py · py.typed · __init__.py
What is deliberately absent vs sartoriuslib: the commands/ package and the
Command[Req,Resp] / XbpiVariant / SbiVariant machinery. The 4100's operation set is
small and asymmetric; semantic ops live on the ProtocolClient interface (§4.1) and the
registry carries the addressing knowledge a command table would otherwise hold.
3. The three communication modes¶
All three are mutually exclusive on the wire, selected on the analyser's front panel.
servomexlib is told which to speak via ProtocolKind; AUTO sniffs.
class ProtocolKind(StrEnum):
AUTO = "auto" # sniff: probe Modbus, else passive-listen continuous (§4.4)
CONTINUOUS_ASCII = "continuous" # unsolicited ;-delimited frames (§3.1)
MODBUS_RTU = "modbus_rtu" # binary Modbus, CRC-16 framed (§3.2)
MODBUS_ASCII = "modbus_ascii" # ':'…hex…LRC…CRLF Modbus (§3.2)
3.1 Continuous ASCII — unsolicited broadcast (§3.4 of the manual)¶
Validated against the bench 4100D (fixture
tests/fixtures/captures/continuous_4100_idle_5ch.bin
— 6 live frames, the reverse-engineering + parser-regression baseline):
| Parameter | Value |
|---|---|
| Baud / framing | 19200, 8 data, no parity, 1 stop, no flow control |
| Encoding | ASCII text |
| Cadence | one frame every frame-frequency seconds (1–9999 s; 0 = off). Observed ~2 s. |
Channels (N=05) |
I1 Oxygen %, I2 CO %, I3 CO2 %, E1 mA, E2 mA (this unit: 3 transducers + 2 external) |
| Idle values | O₂ ≈ 20.38 %, CO ≈ 0.08 %, CO₂ ≈ 0.25 %; E1/E2 = 0.0 mA |
One captured frame (;-delimited throughout; spaces significant):
06-10-20;02:54:12; ;S1S1S1S1;05;I1;Oxygen;20.376; % ; ; ; ; ;I2;CO ; 0.084; % ; …
… ;E1;||||||; 0.0; mA; ; ; ; ;E2;||||||; 0.0; mA; ; ; ; ;2A1D;<CR><LF>
Frame grammar (verified byte-for-byte against the fixture):
Header fields (5, ;-delimited):
| Field | Chars | Meaning | Format |
|---|---|---|---|
| date | 8 | analyser clock date | DD-MM-YY (may be unset/wrong) |
| time | 8 | analyser clock time | HH:MM:SS |
| FM status | 2 | analyser fault / maintenance | F/space, M/space |
| autocal flags | 8 | 4 cal-groups × 2 chars: S(sample)/C(cal) + 1/2 (which cal gas); S1S1S1S1 at idle |
|
| N | 2 | channel count | 03–07; last two are external inputs E1, E2 |
Per-channel block — exactly 8 ;-fields (verified):
| Sub | Chars | Meaning |
|---|---|---|
| id | 2 | channel id (I1, D1, E1, …) |
| name | 6 | display name (Oxygen; \|\|\|\|\|\| = unlabelled/idle) |
| value | 6 | measurement (right-justified) |
| unit | 3 | unit (%, mA, …) |
| alarms | 4 | one char each, alarms 1–4 (raised digit = active, space = OK) |
| FM | 2 | channel fault / maintenance — F/space, M/space |
| cal | 1 | C while calibrating, else space |
| warmup | 1 | W while warming up, else space |
Trailer: CKSUM = 4 uppercase hex digits, then a closing ;, then CR LF.
Checksum — 16-bit additive (CONFIRMED against all 6 fixture frames): sum every byte
after the leading start-space, up to and including the ; immediately preceding the
checksum field; take & 0xFFFF; emit as 4 uppercase hex digits. Excluded: the leading
0x20, the checksum's own 4 chars, the closing ;, and CR LF. The parser recomputes and
verifies; mismatch raises ServomexChecksumError carrying the raw bytes and both values.
Continuous mode is read-only — there is no command channel. start_calibration and any
write raise ServomexProtocolUnsupportedError in this mode (gated before any I/O, §4.3).
See §4.2 for how poll()/read_channel() behave against an unsolicited stream.
The parser is a pure function parse_frame(raw: bytes) -> ContinuousFrame: strip the
start char, split on ;, validate field count against N, decode header + N blocks, verify
checksum. |||||| names → None; blank status fields → cleared flags. Each block becomes a
Reading; the frame becomes a Frame (and fans out to list[Sample]).
3.2 Modbus (RTU + ASCII) — Appendix B of the manual¶
RTU and ASCII share one PDU/register map; they differ only in framing (RTU = binary +
CRC-16; ASCII = : + ASCII-hex + LRC + CRLF). One Modbus client; framing is a strategy.
Measurement data — input registers, FC04, IEEE-754 float32, 2 regs/value, high word first
(HW-confirmed: WordOrder.HIGH_LOW + ByteOrder.BIG; cross-checks the continuous capture
exactly — I1 20.378, I2 0.084, I3 0.250):
| Reg (1-based) | PDU addr | Datum |
|---|---|---|
| 30001–30002 | 0 | I1 value (float32) |
| 30003–30005 | 2 | I1 name (6-char string, 3 regs) |
| 30006–30007 | 5 | I1 unit (3-char string, 2 regs, trailing NUL) |
| … | I2 @30008 (PDU 7), I3 @30015, I4 @30022 — stride 7 regs | |
| 30029–30056 | 28 | D1–D4 derived (same 7-reg layout) |
| 30057–30070 | 56 | E1–E2 external mA (same layout) |
Status & alarms — discrete inputs, FC02, stride 8, bit offsets 0..7 =
Fault, Maintenance, Calibration, WarmingUp, Alarm1, Alarm2, Alarm3, Alarm4:
I1 @10001, I2 @10009, … E1 @10065, E2 @10073 (PDU addr = data-model − 10001). Analyser
status: 11001 Fault, 11002 Maintenance; 11009–11016 cal-group sample/cal & gas-1/gas-2
valve flags.
Two per-kind exceptions to that uniform bit layout (Appendix B notes), handled in
registry/status.py — not by re-reading the bitmap uniformly:
- EXTERNAL_INPUT (E1/E2): bit 0 is Invalid (not Fault) and bits 1–3 are reserved/0
(no Maintenance/Calibration/WarmingUp on an analogue input). The decoder maps E-channel bit 0
to a meaningful state so ChannelStatus.ok is correct for externals — it must not naively read
bit 0 as fault and bits 1–3 as cleared cal/warmup.
- DERIVED (D1–D4): bits 0–3 are copies of the parent transducer's flags, not independent
signals. The decoder/consumers treat them as derived, so a D-channel "fault" is not double-
counted as a second analyser fault.
Autocalibration control — coils, FC05/FC15 (read back FC01): 00001–00004 start cal
group 1–4; 00009 stop all. The action triggers on a 0→1 transition and the master must
return the coil to 0; the client models this as one start_calibration(group) pulse.
Diagnostics & exceptions: FC08 sub-function 00 = loopback (used by the AUTO probe and
servomex-diag). Modbus exception 01 → ServomexIllegalFunctionError; 02 →
ServomexIllegalDataAddressError. A CRC/LRC/frame error yields no response → surfaces as
ServomexTimeoutError. Modbus is question/answer only (no unsolicited traffic) → Modbus mode
uses StreamMode.POLL.
PDU addressing converts the 3xxxx/1xxxx/0xxxx data-model numbers to 0-based PDU addresses per
function code (registry holds the data-model numbers; protocol/modbus/registers.py does
the arithmetic).
Read grouping (2026-05-30): ModbusClient now plans block reads from the registry instead of
issuing per-field transactions. The bench unit answers FC04 0,count=70 and FC02 0,count=80
successfully (probe_out/probe_report.json), so a populated five-channel frame uses one broad
FC04 read, one broad FC02 channel-status read, and the existing analyser-status FC02 read. Static
name/unit metadata is cached internally and refreshed by identify(); poll/read-channel calls
populate missing metadata automatically. If a device rejects a broad span, the client caches a
strict gap-free policy and retries so later polls stay compatible.
3.3 Name/unit charset — the display-ROM gotcha (HW-confirmed)¶
Modbus name/unit registers use the analyser's display character ROM, not ASCII. CO₂'s
name comes back as bytes 43 4F 82 20 20 20 where 0x82 is the subscript-2 glyph (continuous
mode substitutes plain ASCII "CO2"). Decoding must be lenient and never raise:
registry/charset.py owns a Servomex-glyph→Unicode table (0x82→₂, plus ° and other
sub/superscripts as discovered) over a latin-1 / errors="replace" fallback. Both the
Modbus codec and the continuous parser route names/units through it, so a Reading.name is
always clean Unicode regardless of mode. Unit fields are 3 chars + trailing NUL — strip it.
4. Protocol layer — the device-fit core¶
4.1 ProtocolClient — a small semantic interface (not a command dispatcher)¶
Each mode implements one structural Protocol. The methods are the analyser's actual
capabilities; there is no generic execute(bytes)/opcode layer.
@runtime_checkable
class ProtocolClient(Protocol):
kind: ProtocolKind
capabilities: frozenset[Capability] # READ_CHANNELS, READ_STATUS, AUTOCAL, …
async def read_frame(self, *, timeout: float | None = None) -> Frame: ...
# continuous: most-recent cached frame (see §4.2); modbus: one FC04+FC02 sweep.
async def read_channel(self, channel: ChannelId, *, timeout=None) -> Reading: ...
# continuous: pulled from the latest frame; modbus: targeted 7-register read.
async def identify(self, *, timeout=None) -> DeviceInfo: ...
# continuous: derived from the first frame; modbus: name/unit strings per populated slot.
# control — present only when AUTOCAL ∈ capabilities (Modbus); else NotImplemented and
# the session's capability gate raises ServomexProtocolUnsupportedError *before* dispatch.
async def start_calibration(self, group: int, *, timeout=None) -> None: ...
async def stop_calibration(self, *, timeout=None) -> None: ...
async def calibration_status(self, *, timeout=None) -> CalibrationProgress: ...
async def aclose(self) -> None: ...
Capability is a small Flag (READ_CHANNELS | READ_STATUS | IDENTIFY | AUTOCAL | LOOPBACK).
The continuous client advertises read/identify only; the Modbus client advertises everything.
The session consults capabilities to gate, so the facade exposes one uniform method set
and unsupported operations fail cleanly per mode (§4.3).
4.2 Reading an unsolicited stream — the continuous-mode lifecycle¶
Continuous mode has no request, so read_frame()/read_channel() cannot "ask". The
ContinuousClient instead runs a background receive loop inside the Analyzer's async
context:
- On
Analyzer.__aenter__, the session starts a task in itsanyio.TaskGroupthat loops:read_until(b"\r\n")→parse_frame→ verify checksum → store aslatest→ fan-out to any livestream()subscribers (via ananyiomemory-object-stream broadcast). poll()/read_frame()return the most recent parsed frame immediately (wait_fresh=Trueforces a wait for the next frame instead). Before the first frame arrives, they wait up totimeoutand raiseServomexTimeoutErrorif none comes (e.g. frame-frequency set very high, or mode mismatch).- A bad frame (checksum/parse) increments a counter and is dropped from the cache but
surfaced to
stream()subscribers as an errorSample(resync, never crash the loop). __aexit__cancels the loop and closes the transport.
Modbus mode owns no background task: read_frame() performs a synchronous FC04+FC02
sweep; stream(mode=POLL) is a timed acquisition loop driven by record() (§7).
StreamMode therefore maps cleanly onto the modes: continuous → AUTOPRINT (passive
subscribe), Modbus → POLL (timed). The facade defaults the right mode for the active
protocol; passing the wrong one raises ServomexValidationError. (AUTOPRINT is the inherited
family StreamMode member — sartorius SBI vocabulary — reused verbatim for boundary harmony; for
the 4100 it denotes a passive unsolicited-broadcast subscribe. We keep the family name rather
than mint BROADCAST to avoid diverging the shared StreamMode enum.)
4.3 Session gates (pre-I/O, in order)¶
devices/session.py is the single dispatch point between facade and client. Every call walks:
- Safety-tier gate —
SafetyTier.STATEFULops (autocalibration) requireconfirm=True, elseServomexConfirmationRequiredErrorbefore any byte is sent. - Capability gate — the op's
Capabilitymust be in the active client's set, elseServomexProtocolUnsupportedError(e.g.start_calibrationin continuous mode). - Validation gate — argument checks (
group ∈ 1..4, knownChannelId, populated slot).
There is no opcode/availability cache to maintain (no command table); the registry already
knows which channel slots are populated, set at identify().
4.4 AUTO detection ladder¶
Because modes are mutually exclusive and a device in continuous mode is silent to Modbus
(and vice-versa), AUTO:
- Drain input — and ensure the Transport pushback buffer is empty, so the byte stream
handed to
anymodbus's framer starts clean (single-reader discipline, §5.1.1). - Probe Modbus at the configured
addresswith a cheap FC08 loopback (fallback: a 1-register FC04 read at PDU 0). Short per-try timeout, withRetryPolicyretries to absorb a dropped frame on the under-spaced bus (§13.5). A valid RTU response →MODBUS_RTU; retry the same probe with ASCII framing →MODBUS_ASCII. - Else passive-listen for a continuous frame:
read_until(CRLF)within a window ofmax(2 × expected_frame_frequency, listen_timeout); a checksum-valid parse →CONTINUOUS_ASCII. - Else
ServomexConnectionError("no recognised protocol"; context carries what was tried).
Probing Modbus first is cheap (fast req/resp, fails fast when silent); the slow path (waiting
out a possibly-multi-second continuous cadence) is last. All timeouts are configurable on
open_device.
5. Modbus engine — extend anymodbus, but keep our Transport¶
Decision (chosen): use the in-house anymodbus as the Modbus engine (a shared family
asset we own and can fix) rather than hand-rolling a codec — but resolve its two structural
mismatches so it fits this device and our hardware-free testing story.
Status — RESOLVED in anymodbus 0.2.0 (2026-05-30). All five §5.1 enhancements (and the
test-slave parity work) shipped in a single 0.2.0 minor; open item §13.4 is closed. The
[modbus-ascii] / AUTO extra pins anymodbus>=0.2,<0.3. The public surface we consume:
from anymodbus import Bus, Framing, RegisterSource, open_modbus_ascii
from anymodbus.lrc import lrc8, lrc8_bytes, verify_lrc # submodule, mirrors anymodbus.crc
bus = Bus(our_transport, framing=Framing.ASCII) # caller owns the port; bus.framing is introspectable
o2 = await bus.slave(30).read_float(0, source=RegisterSource.INPUT) # FC04 typed helper
alive = await bus.slave(30).diagnostic_loopback(b"\xAB\xCD") # FC08 sub-0 → echoes b"\xAB\xCD"
What 0.1.1 already gave us (unchanged): anymodbus.Bus accepts a caller-provided
stream: anyio.abc.ByteStream (bus.py) — only the open_modbus_rtu(path) convenience
wrapper opens its own port — so binding Modbus to OUR Transport/FakeTransport was already
possible, and "transport decoupling" turned out to be sugar only. FC04 raw reads
(read_input_registers) and the pure decode_float32/decode_string codec were already public;
0.2.0 adds the typed FC04 helpers on top via source=RegisterSource.INPUT. (Correction: the
design's earlier execute()/ModbusOp.fn phrasing was inaccurate — that op layer is
watlowlib's, not anymodbus's; FC04 is a first-class Slave.read_input_registers method.)
5.0 Binding strategy — and how it deliberately differs from watlowlib¶
watlowlib is the family's other anymodbus consumer, but we bind to it differently, on
purpose — this is an internal divergence (like nidaqlib/dtollib dropping commands/), not
a break with the boundary harmony of §1.
watlowlib |
servomexlib (this design) |
|
|---|---|---|
| Port ownership | anymodbus owns it: ModbusBusTransport.open() calls open_modbus_rtu(path) |
We own it: anymodbus.Bus(stream=our Transport) (§5.1.1) |
| Why | Watlow is Modbus-only — no need to share a port with another mode | The 4100 has three mutually-exclusive modes on one port; the AUTO ladder (§4.4) must sniff raw bytes before it knows the protocol, so a single uniform Transport must back all three |
| Modbus test seam | method-level FakeSlave/StubSlave (scripts (method, addr) → words, bypassing framing/CRC) |
byte-accurate via reused anymodbus.testing.MockSlave (§5.2) — emits real framed ADUs, because AUTO/ASCII/LRC tests exercise the framer |
Consequence for testing (now smaller than first budgeted): because we feed anymodbus's
framer from a byte stream, the hardware-free Modbus fake must be a genuine slave simulator —
real RTU ADUs (CRC-16), ASCII ADUs (LRC), FC08 sub-0 echo, exception frames — not a method-level
stub. As of anymodbus 0.2.0 we do not build this ourselves: MockSlave gained ASCII +
FC08 + bad-LRC-drop, so servomexlib.testing just preloads it with the 4100 banks (§5.2). We
may additionally keep a cheap watlow-style method-level fake for the pure semantic-op unit
tests; the MockSlave-backed byte-accurate path covers AUTO/framing/ASCII and is no longer
servomexlib's to maintain.
5.1 Upstream anymodbus enhancements — shipped in anymodbus 0.2.0¶
These are generically useful and belong in anymodbus, not buried in servomexlib. All five
landed in 0.2.0; each item below records the as-shipped interface (the strawman APIs in
the handoff were accepted with the spellings noted):
- Transport decoupling — shipped as a
framing=kwarg onBus(was largely already present).anymodbus.Busalready bound to a caller-provided byte stream (anyio.abc.ByteStream— which bothanyserial.SerialPortand ourFakeTransportsatisfy), so we use that constructor path directly instead ofopen_modbus_rtu(path).0.2.0adds the framing selector this needed once ASCII landed:Bus(stream, *, config=None, framing=Framing.RTU)is the load-bearing "I own the port" path, withbus.framingintrospectable. There's also a thinopen_modbus_ascii(path, *, baudrate, parity, data_bits=8)convenience opener (data_bits=7for classic 7E1) — but servomexlib uses the stream-boundBus(stream, framing=…)form, never the port-owning opener. There is noRtuBus/AsciiBussplit — oneBustype, one kwarg. (The pure-function fallback — PDU+CRC/LRC framed inprotocol/modbus/— is now moot; the framer lives inanymodbus.)
Two design constraints this imposes on our Transport seam (§2):
- Transport/FakeTransport MUST satisfy anyio.abc.ByteStream (receive/send/
aclose) in addition to our convenience helpers (read_exact/read_until/read_available).
anyserial.SerialPort already is a ByteStream; SerialTransport forwards/extends it and
FakeTransport implements it directly. The convenience helpers are a superset layered on top,
not an alternative interface — anymodbus only ever sees the ByteStream face.
- Single-reader discipline at protocol commit. The continuous path's read_until/pushback
buffer and anymodbus's framer are two readers of one stream; bytes parked in the pushback
buffer are invisible to the framer (and vice-versa). The AUTO ladder (§4.4) drains and
leaves the pushback buffer empty before handing the stream to anymodbus, and once a mode
is committed all reads go through exactly one path (Modbus → framer; continuous → pushback).
Never mix the two on a live stream.
2. FC04 input registers as first-class helpers — shipped as a source= kwarg. The high-
level read_float/read_int32/read_string helpers gained a source: RegisterSource
keyword (RegisterSource.HOLDING default = FC03, back-compatible; RegisterSource.INPUT =
FC04). Write helpers are unchanged (input registers are read-only). The 4100 exposes
measurements as input registers only, so our client always passes source=INPUT. (The
interim path — read_input_registers + decoders.decode_float32/decode_string directly —
still works and remains the fallback; the typed helper just removes the glue.)
3. Modbus-ASCII framing — shipped; full framer lives in anymodbus. :-prefixed,
ASCII-hex body, LRC checksum, CRLF terminator. The complete ASCII framer (not just the
LRC primitives) is in anymodbus and reuses pdu.py verbatim — we do not frame ASCII
ourselves. LRC is exposed as pure functions in the anymodbus.lrc submodule (mirroring
anymodbus.crc): lrc8, lrc8_bytes, verify_lrc (note: lrc8_bytes plural, vs the
handoff's strawman lrc8_byte). Selected via framing=Framing.ASCII (item 1).
4. FC08 sub-function 0 loopback — shipped, scoped to sub-0 only. await
slave.diagnostic_loopback(b"\xAB\xCD") == b"\xAB\xCD". Scoped to sub-function 0x0000 only
(fixed 6-byte RTU tail), exactly as proposed; other sub-functions stay unframable by
construction. Used by the AUTO probe (§4.4) and servomex-diag.
5. Inter-frame / retry timing knobs — shipped as TimingConfig + RetryPolicy. The knob
that actually matters for this device is inter_frame_idle: the 4100 needs ~50 ms of bus
silence between transactions or it drops ~25% of rapid sequential reads (§13.5 — not the
one-shot startup_settle cold-start settle originally theorised here, which had no measurable
effect). ModbusSession therefore overrides the spec-t3.5 "auto" default with a fixed
inter_frame_idle=0.05 and RetryPolicy(retries=2). reset_input_buffer_before_request
stays configurable; the shared-port caveat (a flush can discard bytes a continuous-mode reader
parked — §5.1.1) is documented in anymodbus's docs/timing.md.
servomexlib pins anymodbus>=0.2,<0.3 for the [modbus-ascii]/AUTO extra as an
optional dependency so continuous-only users stay lean (RTU + continuous core ships against
>=0.1.1 via the interim path — §11).
5.2 What stays in servomexlib¶
The Modbus engine is generic; the device knowledge is ours:
protocol/modbus/registers.py (channel↔register arithmetic from the registry, §6),
protocol/modbus/client.py (the ~5 semantic ops, charset routing, float/string decode policy),
and protocol/modbus/errors.py (anymodbus exception → ServomexModbusError mapping). The
Slave is bound to our Transport via the §5.1.1 path.
Byte-accurate fake — we reuse anymodbus.testing.MockSlave, not a hand-rolled simulator
(decided by anymodbus 0.2.0). 0.2.0 taught MockSlave both ASCII framing and FC08 sub-0
echo, backing one register bank with both an RTU and an ASCII bus:
client_slave_pair(framing=Framing.ASCII) / MockSlave(framing=…). It reuses the shared
read_ascii_frame / encode_ascii_adu, so the wire format is single-sourced between client and
mock, and bad-LRC requests are dropped (not crashed), mirroring the RTU bad-CRC drop. So
servomexlib.testing does not build its own byte-level ADU simulator: it preloads a
MockSlave with the 4100's register/coil banks (§6 / Appendix), and that one instance — driven
by both an RTU Bus and an ASCII Bus over an in-process ByteStream — answers FC01/02/04/05/08,
echoes FC08 sub-0, and emits exception frames (01/02). This is what lets the AUTO ladder and the
ASCII/LRC framing be tested without hardware. (A cheap method-level fake may still back the pure
semantic-op unit tests; the MockSlave-based byte-accurate path is required for AUTO/framing
coverage and is now upstream, not ours to maintain.)
6. The registry — one spine for both protocols¶
registry/channels.py is the heart of the device-fit core. A single declarative table maps
every channel to its addressing in both worlds:
@dataclass(frozen=True, slots=True)
class ChannelSpec:
channel: ChannelId # I1..I4, D1..D4, E1..E2
kind: ChannelKind # TRANSDUCER | DERIVED | EXTERNAL_INPUT
# Modbus addressing (data-model, 1-based; codec lowers to PDU):
value_register: int # e.g. I1 → 30001
name_register: int # e.g. I1 → 30003 (3 regs)
unit_register: int # e.g. I1 → 30006 (2 regs)
status_discrete: int # e.g. I1 → 10001 (stride-8 block base)
# Continuous mode is self-describing (id/name/value/unit inline), so the parser needs
# no per-channel address — only `kind` classification from this table.
The full table is generated from the stride pattern (I-block stride 7 regs from 30001,
discrete stride 8 from 10001; D-block from 30029/10033; E-block from 30057/10065) and
eagerly validated at import — a malformed/overlapping entry fails loud as
ServomexConfigurationError. The Modbus client reads addresses from here; the continuous
parser reads kind from here; identify() walks it to report populated slots. One source of
truth, consulted by both protocols — the role sartorius's command table plays, minus the
command machinery.
registry/units.py (Unit/UnitKind enums, %/vpm/mA/…, the 4000-series ×10000
percent↔vpm trace scale, to_pint), registry/status.py (flag decode from both the Modbus
discrete bitmap and the continuous ASCII status fields → one ChannelStatus, including the
per-kind bit-layout exceptions for EXTERNAL_INPUT Invalid and DERIVED copy-flags — §3.2), and
registry/charset.py (§3.3) complete the layer.
7. Public API¶
Entry point (free async function — family convention)¶
async def open_device(
port: str | Transport,
*,
protocol: ProtocolKind = ProtocolKind.AUTO,
address: int = 1, # Modbus slave addr (RS485 multidrop)
serial_settings: SerialSettings | None = None, # default 19200 8-N-1
timeout: float = 1.0,
identify: bool = True,
) -> Analyzer: ...
port is positional and accepts a str or a pre-built Transport (passing a
FakeTransport is the no-hardware path). Everything else is keyword-only. serial_settings
defaults to the observed 19200 / 8-N-1; valid baud 2400–19200 (§7.2.6). When identify=True,
the factory caches DeviceInfo (and, in continuous mode, waits for the first frame).
The Analyzer facade (async context manager)¶
async with await open_device("COM11", protocol=ProtocolKind.CONTINUOUS_ASCII) as anz:
info = await anz.identify() # DeviceInfo: populated channels, names, units, fw
o2 = await anz.read_channel("I1") # Reading(value, unit, status, protocol, raw, ts)
frame = await anz.poll() # Frame — all live channels + analyser status, one tick
snap = anz.snapshot() # no-I/O cached Frame
async for sample in anz.stream(): # AUTOPRINT in continuous; POLL in Modbus
print(sample.channel, sample.value, sample.unit)
# Modbus adds the control surface (gated by confirm + SafetyTier):
async with await open_device("COM11", protocol=ProtocolKind.MODBUS_RTU, address=30) as anz:
await anz.start_calibration(group=1, confirm=True)
prog = await anz.calibration_status()
await anz.stop_calibration(confirm=True)
Method naming (harmonised with the family):
- Reads:
poll() -> Frame(canonical one-shot, all channels),read_channel(id) -> Reading,read_all() -> dict[ChannelId, Reading],status(id) -> ChannelStatus,analyser_status() -> AnalyserStatus. - Identity/state:
identify() -> DeviceInfo,snapshot() -> Frame(no I/O, cached). - Control (Modbus only):
start_calibration(group, *, confirm=False),stop_calibration(*, confirm=False),calibration_status() -> CalibrationProgress. - Streaming:
stream(*, mode=None, rate_hz=None) -> StreamingSession(mode defaults per protocol). Drivesrecord()for drift-free batched acquisition intosinks/. - Per-call keyword-only
timeouton every I/O method (ruffASYNC109suppressed, family-wide). - Persistent/disruptive ops require
confirm=True(autocalibration isSafetyTier.STATEFUL).
Sync facade¶
from servomexlib.sync import Servomex
with Servomex.open("COM11", protocol="modbus_rtu", address=30) as anz:
print(anz.poll())
anz.start_calibration(1, confirm=True)
SyncAnalyzer wraps Analyzer through an anyio BlockingPortal; every async method has a
one-line sync twin. SyncManager wraps ServomexManager likewise.
Manager (RS485 multidrop — Modbus only)¶
ServomexManager registers named analysers across one or more ports and follows the
watlowlib concurrency contract:
- analysers sharing one port serialise through a shared transport/client lock;
- analysers on different ports poll concurrently;
- results use
success/failurewrappers (familyErrorPolicy), so one slave's timeout does not sink apoll_all(); - continuous-ASCII devices are single-broadcaster, not addressable peers — the manager refuses to group them as multidrop slaves.
8. Data models¶
All frozen dataclass(slots=True), StrEnum throughout, py.typed, clean under
mypy --strict + pyright strict. No pydantic. Every protocol decodes to the same models.
@dataclass(frozen=True, slots=True)
class Reading:
channel: ChannelId
kind: ChannelKind
name: str | None # "Oxygen"; None when unlabelled (||||||)
value: float | None # None on over-range / invalid
unit: Unit
status: ChannelStatus
protocol: ProtocolKind # which mode produced it
received_at: datetime # wall-clock UTC at acquisition
monotonic_ns: int # join key for streaming (family §C ts contract)
raw: bytes # the bytes this Reading was decoded from
@dataclass(frozen=True, slots=True)
class ChannelStatus:
fault: bool; maintenance: bool; calibrating: bool; warming_up: bool
alarms: tuple[bool, bool, bool, bool]
@property
def ok(self) -> bool: ...
@dataclass(frozen=True, slots=True)
class AnalyserStatus:
fault: bool; maintenance: bool
cal_groups: tuple[CalGroupState, ...] # per-group sample/cal + gas-1/gas-2
clock: datetime | None # analyser's own date/time (may be unset)
@dataclass(frozen=True, slots=True)
class Frame: # one continuous frame, or one Modbus sweep
readings: tuple[Reading, ...]
analyser: AnalyserStatus
protocol: ProtocolKind
received_at: datetime
monotonic_ns: int
raw: bytes
def channel(self, cid: ChannelId) -> Reading: ...
def as_samples(self) -> list[Sample]: ...
@dataclass(frozen=True, slots=True)
class DeviceInfo:
model: str # "4100D"
channels: tuple[ChannelInfo, ...] # id, name, unit, kind per populated slot
protocol: ProtocolKind
address: int
serial_settings: SerialSettings
@dataclass(frozen=True, slots=True)
class CalibrationProgress:
group: int
active: bool
phase: CalPhase # IDLE | SAMPLING | CAL_GAS_1 | CAL_GAS_2 | …
@dataclass(frozen=True, slots=True)
class Sample: # long-format: one row per channel, for streaming/sinks
device: str # device label (port / manager key)
channel: ChannelId
reading: Reading | None # None when the row carries only an error
protocol: ProtocolKind
monotonic_ns: int # join key (family §C ts contract)
received_at: datetime # wall-clock UTC at acquisition
requested_at: datetime | None = None # None in passive continuous mode (we didn't ask)
latency_s: float | None = None # None in passive mode (no request to measure against)
metadata: Mapping[str, object] = field(default_factory=dict)
error: BaseException | None = None # set when a frame was dropped/corrupt (resync; §4.2)
Enums: ChannelId, ChannelKind, Unit (PERCENT="%", VPM="vpm", MILLIAMP="mA", …),
UnitKind, ProtocolKind, StreamMode, SafetyTier, CalPhase, Capability (Flag).
Unit note (§1.7): the 4000-series prime measurement is percent; trace vpm carries a
default ×10 000 scale — modelled in registry/units.py.
9. Error hierarchy¶
One root ServomexError(Exception) carrying a frozen ErrorContext (port, protocol, address,
channel, register/function code, request/response bytes, elapsed) with .with_context(**) and
a context-rendering __str__ — identical pattern to the siblings (ErrorContext.merged +
slot-safe copy in with_context).
Caution on the multiple-inheritance hint below (ServomexModbusError … also subclass
ProtocolUnsupported where apt): keep any such cross-branch class single-rooted in MRO terms so
the inherited __init__(message, *, context) and the slot-safe with_context copy resolve to
one consistent path. Prefer a single base + a marker mixin (no competing __init__/__slots__)
over genuine diamond inheritance; cover it with a construction/with_context round-trip test.
ServomexError
├── ServomexConfigurationError → ServomexValidationError, ServomexConfirmationRequiredError
├── ServomexTransportError → ServomexTimeoutError, ServomexConnectionError
├── ServomexProtocolError → ServomexParseError, ServomexChecksumError,
│ ServomexFrameError, ServomexProtocolUnsupportedError
├── ServomexModbusError → ServomexIllegalFunctionError, ServomexIllegalDataAddressError
│ (also subclass ProtocolUnsupported where apt)
├── ServomexCapabilityError → ServomexUnsupportedCommandError
└── ServomexSinkError → schema / write / dependency
10. Testing strategy¶
- No hardware in CI.
FakeTransportimplementsTransportin-process from a scripted write→reply map; re-exported from the publicservomexlib.testingseam with fixture loaders. Because we own the bytes for all three modes (§5), a byte-accurate Modbus fake answers FC01/02/04/05/08 entirely in-process — the Modbus path, the framer, and theAUTOladder are fully testable without a device. As ofanymodbus 0.2.0this fake is a thinservomexlib.testingwrapper that preloadsanymodbus.testing.MockSlave(which now speaks both RTU CRC-16 and ASCII LRC ADUs, echoes FC08 sub-0, drops bad-LRC/bad-CRC frames, and emits exception frames 01/02) with the 4100's register/coil banks — we no longer hand-roll ADU building (§5.2). (A cheaper method-level fake may additionally back the pure semantic-op tests, aswatlowlibdoes; theMockSlave-backed byte-accurate path remains required for AUTO/framing coverage.) - Recorded fixtures: the real
COM11continuous capture (continuous_4100_idle_5ch.bin, the checksum/parse baseline) and synthesised Modbus register/coil tables; goldenReading/Frame/Sample/CSV outputs for regression. Fixtures use the human-readable> hex/< hexarrow format shared with the sibling.testingmodules. - Property tests (hypothesis): IEEE-754 float32 round-trip through the Modbus codec; continuous-frame build→parse→checksum round-trip; CRC-16 and LRC round-trips; charset decode never raises on arbitrary bytes.
conftest.pyparametrisesanyio_backendacross asyncio / asyncio+uvloop / trio (AnyIO pytest plugin, not pytest-asyncio).filterwarnings=["error"],xfail_strict,--strict-markers,--import-mode=importlib.- Hardware integration tests gated by markers (
hardware,hardware_stateful,hardware_destructive,slow), off by default, opt-in viaSERVOMEXLIB_ENABLE_*env vars. Autocalibration tests arehardware_stateful. (Note: on this dev machine, CrowdStrike blocks the agent harness from spawning PowerShell for serial probes; live validation runs via Bash→Python againstanyserial/anymodbus.)
11. Build / tooling¶
Inherited verbatim from the family skeleton (clone sartoriuslib, the newest, then rename):
- Packaging: hatchling + hatch-vcs dynamic version →
src/servomexlib/_version.py(fallback0.1.0.dev0); src-layout;py.typed; import name == distribution name. - Deps: core
anyio>=4.13,anyserial>=0.1.2,<0.2; optional[postgres]/[parquet]/[docs]. Modbus extras are staged to avoid hostage-to-upstream (§5.1, §13.4):servomexlib[modbus]→anymodbus>=0.1.1covers the RTU + continuous core (using the validated interimread_input_registers/decode_*path), so the first release does not block on an unreleasedanymodbus;servomexlib[modbus-ascii](theAUTOladder, ASCII/LRC framing, FC08 diag) pinsanymodbus>=0.2,<0.3— now a real cut:anymodbus 0.2.0shipped the §5.1 enhancements (2026-05-30). (Migration when the pin moves to0.2: it's additive — the interimread_input_registers/decode_*calls still work; the only change is swapping them forread_float(..., source=RegisterSource.INPUT)and adoptingFraming.ASCII/diagnostic_loopback/TimingConfig.startup_settle.) - Quality: PEP-735 dependency groups (lint/type/test/docs/dev); ruff (line 100, Google
docstrings, big
select,asyncio.wait_for→anyio.fail_afterbanned-API); mypy strict; pyright strict. - CI:
ci.yml(lint → typecheck → test matrix ubuntu/macos/windows × py3.13/3.14 → build),release.yml(PyPI Trusted Publishing via OIDC + attestations, manual tag cut, hatch-vcs version),docs.yml(zensical → GitHub Pages). Plusdependabot.yml, issue/PR templates,.pre-commit-config.yaml,.editorconfig,.gitattributes,.python-version(3.13),CONTRIBUTING/SECURITY/CHANGELOG(Keep-a-Changelog), MITLICENSE. Adopt dtollib'sfetch-depth: 0on lint/typecheck jobs (hatch-vcs needs full history). - Docs: zensical.toml (Material-style, mkdocstrings python over
src/) +docs/(this design doc + index/installation/quickstart-async/quickstart-sync +docs/api/per-module stubs). - Console scripts (each
--fixture-driveable for hardware-free CI):servomex-read,servomex-stream,servomex-discover,servomex-decode(decode a hex/raw continuous frame offline),servomex-capture(poll/subscribe → sink),servomex-diag(loopback, frame tap, jitter).
12. Implementation outline¶
Sequenced so each part is independently testable and the first three need no hardware
(FakeTransport + fixtures, §10):
- Scaffold + continuous parser. Clone the family tooling (§11), rename, wire CI green from
day one. Define
errors.py, the enums, and the frozen models (§8). Implementchecksum.py+ the pureparse_frame(§3.1) against the captured fixture. Shipservomex-decode. (Proves package shape with zero hardware.) - Transport + passive continuous client.
SerialTransportoveranyserial,FakeTransport, theContinuousClientbackground receive loop + latest-frame cache + fan-out (§4.2),Analyzer.poll()/read_channel()in continuous mode, passivestream(). - Modbus profile.
registry/channels.pyspine (§6),protocol/modbus/registers.pyaddress math, the ~5 semantic ops overanymodbusbound to ourTransport(§5), charset routing (§3.3), theMockSlave-backedFakeModbusSlave(§5.2). RTU first; ASCII framing behind the same client — now unblocked, since the §5.1 work shipped inanymodbus 0.2.0(Framing.ASCII,source=RegisterSource.INPUT,diagnostic_loopback). - Facade + sync + AUTO.
open_device, cachedidentify()/snapshot(), theAUTOsniff ladder (§4.4), theServomex.open(...)sync facade. Examples + quickstart docs. - Safety-gated control.
start_calibration/stop_calibration/calibration_statuswith theconfirm=True+SafetyTier.STATEFULgates (§4.3), fake coil-pulse tests, opt-inhardware_statefultests. - Manager, sinks, CLI, polish.
ServomexManager(§7), JSONL/CSV/SQLite sinks (+ optional parquet/postgres), the remaining CLIs, expanded docs.
13. Open items to resolve before/while coding¶
- ~~Continuous-mode checksum~~ — RESOLVED (§3.1): 16-bit additive over the body after
the start-space through the pre-checksum
;,& 0xFFFF, 4 hex digits. Confirmed vs 6 live frames. - ~~Frame field reconciliation~~ — RESOLVED (§3.1): 5-field header (
date;time;FM; autocal(8);N), each block exactly 8;-fields. - ~~Modbus RTU register map / word order~~ — RESOLVED (§3.2): FC04 input regs, IEEE-754 hi-word-first, stride-7 value/name/unit + stride-8 discrete status; validated live (addr 30) and cross-checked against the continuous capture.
- ~~
anymodbusenhancements (§5.1)~~ — RESOLVED (anymodbus 0.2.0, 2026-05-30): transport-decoupling (Bus(stream, framing=…)), FC04 typed helpers (source=RegisterSource.INPUT), ASCII/LRC framing (Framing.ASCII+anymodbus.lrc), FC08 sub-0 (diagnostic_loopback), and cold-start timing (TimingConfig.startup_settle) all shipped;[modbus-ascii]/AUTOpins>=0.2,<0.3(§5, §5.1, §11). The byte-accurate fake question is also settled: reuseanymodbus.testing.MockSlave(now RTU+ASCII+FC08+bad-LRC-drop) preloaded with the 4100 banks, plus an optional method-level companion for pure semantic-op tests (§5.2/§10). ~~Still open: validate the Modbus-ASCII path live.~~ RESOLVED (2026-05-30): all three modes validated live against the bench 4100D viatests/hardware/test_hardware_reads.py— Modbus RTU (17/17), Modbus ASCII (17/17,Framing.ASCII/LRC), continuous broadcast (6/6). - ~~Inter-frame timing~~ — RESOLVED (2026-05-30, supersedes the earlier "cold-start
turnaround" theory, which was a misattribution): the bench 4100/USB-RS485 link drops ~25%
of Modbus transactions hammered back-to-back at the RTU-spec t3.5 (~2 ms at 19200, which is
anymodbus'sinter_frame_idle="auto"default). Measured drop rate vs the idle gap between transactions: 26% @0 ms → 8% @10–20 ms → 0% @50 ms. It is not a cold-start/first-frame effect — isolated reads answer in ~20–40 ms even on a freshly-opened idle port; only rapid sequential reads desync. Fix:ModbusSessionsetsTimingConfig(inter_frame_idle=0.05) RetryPolicy(retries=2), and the client does not wrap a high-level op in an outerfail_after(a frame sweep is many transactions, so one outer deadline equal to a singlerequest_timeoutcancels legitimate mid-sweep retries — that was a real latent bug).- ~~
AUTOladder timeouts (§4.4)~~ — RESOLVED (2026-05-30): AUTO resolves all three modes live (FC08 Modbus probe → RTU/ASCII; else passive-listen → continuous). The one knob that matters: the continuous listen window must exceed the broadcast period, which is operator-configurable 1–999 s (front panel), so it cannot be a fixed default — size it from the expected frame frequency (the hardware suite takesSERVOMEXLIB_HARDWARE_BROADCAST_S). - Channel population across configs — confirm names/units cache at
identify()(Modbus strings / first continuous frame) and how a varyingN(3–7 channels, presence of D-block / I4) is reported. Observed here:N=05→ I1/I2/I3/E1/E2. Noteidentify()treats a||||||name (E1/E2 idle) as an unpopulated slot, so it is omitted fromDeviceInfo.channels. Modbus read coalescing implemented (2026-05-30):identify()refreshes the static name/unit cache and narrows full-frame sweeps to named populated slots;read_channel()can still read an omitted slot directly. A first poll withoutidentify()populates missing metadata from the coalesced input-register block so the default API remains transparent. - Control surface scope — v1 ships reads + status + autocalibration start/stop/status (the whole Modbus profile is read + autocal; richer config is front-panel only). Confirm no other coils/registers need exposing.
14. Source documents & references¶
The manuals are kept locally under docs/manuals/ but git-ignored (large, vendor-
proprietary — not redistributed). Clone them into place to follow the section references above.
docs/manuals/servomex_4100_manual.md— SERVOPRO 4000-series Installation Manual; the primary protocol authority. Cited: §3.3 serial/Modbus connection (PL6, RS232/RS485), §3.4 Continuous mode (Tables 3.6/3.7), §3.5 Modbus mode, §7.2.6 serial specs (2400–19200 baud), §1.4–1.7 transducer/output numbering & unit conversion, §4.5 Autocalibration, and Appendix B Modbus Profile (register/coil/discrete-input map).docs/manuals/servomex_4100_service_manual.md— Service Manual; §1.6–1.7 cover the front-panel UI (selecting comm mode, frame-frequency, Modbus slave address, serial params) and calibration/diagnostic procedures.tests/fixtures/captures/continuous_4100_idle_5ch.bin— 6 live continuous frames offCOM11(checksum + parser regression baseline; §3.1).scripts/probe_modbus.py,scripts/probe_name_bytes.py— read-only live Modbus-RTU probes (run viaanymodbus+anyserial) used to validate Appendix B (§3.2) and characterise the display-charset names (§3.3).
Sibling libraries (c:\Users\gbellamy\Documents\git\) — what each contributes as a reference:
sartoriuslib— newest multi-protocol template; source the outer shell + tooling skeleton verbatim (entry point, models style, error hierarchy, streaming/sinks/sync/CLI, CI). Itscommands/+Command/Variant core is deliberately not adopted (§1–§2).watlowlib— the Modbus sibling; reference for register-table organisation, per-calladdressmultidrop, and the manager concurrency contract (§7). Uses the sameanymodbus.alicatlib— serial-ASCII reference for transport injection, fake/captured-frame tests, and identify/probe-before-facade behaviour.nidaqlib/dtollib— confirm the family norm that the internal model is device-fit (both use aTaskSpec, not Command/Variant) — the precedent this design follows by droppingcommands/.anyserial— raw-bytes async transport foundation; framing/Modbus codecs stay inservomexlib/anymodbus, never pushed down into it.