watlowlib — package design¶
A Python driver for Watlow temperature controllers, modeled on alicatlib
and sartoriuslib. Multi-protocol from day one: Standard Bus and Modbus
RTU, with room for Modbus TCP, F4T WatBus, and others.
1. Recommendation in one paragraph¶
watlowlib is sartoriuslib-shaped: a protocol-neutral Controller facade
backed by a Session that dispatches through one of N pluggable
ProtocolClients. The two initial protocols are Standard Bus (the
factory default on older controllers and the primary target — codec
ported from the watlowtesting/ RE workspace and unit-tested against
live PM3 captures) and Modbus RTU (thin adapter over the in-house
anymodbus package, which is also AnyIO + anyserial-based). The single
biggest win unique to Watlow is that the existing pm_parameters.json
already carries the cross-protocol mapping per parameter (Standard Bus
class/member/instance and Modbus relative_addr/absolute_addr
and RWES persistence flag), so read_parameter("setpoint") lowers to
either protocol from one shared registry. That is the seam that earns
the multi-protocol abstraction; without it, layering Modbus over
Standard Bus would be ceremony.
2. Repo layout¶
This repo at ~/Documents/git/watlowlib/ is a sibling to alicatlib /
sartoriuslib, separate from watlowtesting/ (which stays as the RE
/ capture workspace, like sartoriustesting → sartoriuslib).
Top-level files mirror sartoriuslib exactly: pyproject.toml
(hatchling + hatch-vcs, ruff, mypy strict, pyright, pytest+anyio, same
dep groups), README.md, CHANGELOG.md, LICENSE, CONTRIBUTING.md,
SECURITY.md, zensical.toml, docs/, tests/, src/watlowlib/.
3. Package tree¶
src/watlowlib/
__init__.py, py.typed, errors.py, firmware.py, version.py, _logging.py, config.py
transport/
base.py # Transport Protocol + SerialSettings (lifted from sartoriuslib)
serial.py # anyserial-backed
fake.py # scripted FakeTransport for tests / fixture replay
protocol/
base.py # ProtocolClient Protocol, ProtocolKind StrEnum {AUTO, STDBUS, MODBUS_RTU}
client.py # make_protocol_client(kind, transport, ...) factory
detect.py # conservative auto-detect: Modbus RTU probe → Std Bus probe → fail
stdbus/ # ported from watlowtesting/src/watlowlib/stdbus/
framing.py # current frame.py (BACnet MS/TP outer + CRCs)
payload.py # current payload.py (Watlow attribute service)
tlv.py # type-tag codec (split out of payload.py for clarity)
tables.py # ErrorCode, FrameType, type-tag table, address mapping
client.py # StdBusProtocolClient implementing ProtocolClient
types.py # StdBusFrame, StdBusReply, decoded value variants
modbus/ # NEW — thin adapter over anymodbus (no codec / no framing of our own)
client.py # ModbusProtocolClient: holds an anymodbus.Bus + slave address.
# execute(op: ModbusOp) -> tuple[int, ...] is the single
# entry point; it selects the matching anymodbus.Slave
# method from op.fn. Implements ProtocolClient (lock /
# dispose) so the Session treats it identically to the
# Std Bus client.
ops.py # ModbusFn StrEnum + ModbusOp dataclass (§5)
tables.py # data_type → register count + word/byte order defaults
# (float = 2 regs HIGH_LOW, u16 = 1 reg, s32 = 2 regs, ...)
# built on top of anymodbus.WordOrder / ByteOrder
errors.py # remap anymodbus exceptions into the WatlowError hierarchy
registry/
parameters.py # Parameter spec table loaded from data/pm_parameters.json,
# keyed by canonical name AND parameter_id; carries:
# - stdbus selector (cls, member, default instance)
# - modbus relative_addr, absolute_addr, register count
# - data_type → wire encoding per protocol
# - RWES → SafetyTier
# - range/default/enum metadata
families.py # ControllerFamily {PM, RM, ST, EZZONE_LIMIT, F4T, UNKNOWN}
# + classify_family(part_number)
enumerations.py# load data/enumerations.json (Heat Algo, Sensor Type, ...)
units.py # TemperatureUnit, OutputUnit (compact, Watlow doesn't have
# alicat's gas zoo)
aliases.py # fuzzy-string resolvers ("setpoint" → 7001)
data/
pm_parameters.json # already extracted
enumerations.json # already extracted
rm_parameters.json # later (RM family)
f4t_parameters.json # later
commands/
base.py # Command[Req,Resp], StdBusVariant, ModbusVariant, CommandContext
parameters.py # READ_PARAMETER / WRITE_PARAMETER (the workhorse)
identity.py # READ_PART_NUMBER, READ_HARDWARE_ID, READ_FIRMWARE
process.py # READ_PV, READ_SETPOINT, WRITE_SETPOINT, READ_OUTPUT
loop.py # PID, autotune, hi/lo limits
alarms.py
profile.py # ramp/soak (later — F4T-leaning)
raw.py # safe-list of read-only stdbus opcodes & modbus FCs
devices/
kind.py # ControllerFamily re-export
capability.py # Capability bitmap, SafetyTier, Availability
models.py # Reading, ProcessValue, LoopState, AlarmState,
# DeviceInfo, ParameterEntry
controller.py # the Controller facade
loop.py # ControllerLoop sub-facade returned by controller.loop(n)
session.py # I/O lock, gates, availability cache
factory.py # open_device / open_controller
discovery.py # sweep MS/TP MACs 0x10..0x1F or Modbus addresses 1..247
manager.py # WatlowManager — multi-controller, port-aware serializer
maintenance.py # baud / address / protocol-mode change helpers (DANGEROUS)
testing.py # FakeTransport, canned frames, fixture loaders
streaming/
sample.py, recorder.py # poll-only: record(controller, rate_hz=...)
# cadenced calls into read_pv() or a small group
# (Watlow has no autoprint/push-stream analog)
sinks/
base.py, _schema.py, memory.py, csv.py, jsonl.py, sqlite.py,
parquet.py, postgres.py # extras gated the same way
sync/
portal.py, controller.py, manager.py, recording.py, sinks.py
cli/
read.py # watlow-read
discover.py # watlow-discover
capture.py # watlow-capture
raw.py # watlow-raw (escape hatch per protocol)
decode.py # watlow-decode (offline frame decode)
configure.py # watlow-configure (baud / address / protocol-mode flips)
diagnostics/
snapshot.py, sweep.py, argfuzz.py, tap.py, stream.py
4. The protocol seam¶
class ProtocolKind(StrEnum):
AUTO = "auto"
STDBUS = "stdbus"
MODBUS_RTU = "modbus_rtu"
@runtime_checkable
class ProtocolClient[Request_contra, Reply_co](Protocol):
@property
def lock(self) -> anyio.Lock: ...
@property
def disposed(self) -> bool: ...
def dispose(self) -> None: ...
async def execute(self, request: Request_contra, *,
timeout: float | None = None,
command_name: str = "") -> Reply_co: ...
StdBusProtocolClient is ProtocolClient[bytes, StdBusReply] —
typed-bytes in, decoded reply out — because we own the codec.
ModbusProtocolClient is ProtocolClient[ModbusOp, tuple[int, ...]]
because anymodbus already owns the wire codec; the request shape is
a typed instruction (§5), not bytes. The Session holds an
active: ProtocolKind and dispatches; make_protocol_client is the
single factory. Adding a third protocol later (Modbus TCP, F4T's
WatBus, OPC UA) is one new subpackage + one new enum value + one new
factory branch — the Transport Protocol is wire-agnostic and a TCP
transport can sit alongside serial.py / fake.py without reshaping
the seam.
5. Command and variant shape¶
Commands are pure descriptors; the Session owns dispatch. Both
variants take a typed request and produce a typed response without
touching transport — this keeps the gate / log / availability machinery
in Session.execute (§5b) uniform across protocols and keeps variants
testable without a fake bus or a fake Slave.
@dataclass(frozen=True, slots=True)
class Command[Req, Resp]:
name: str
stdbus: StdBusVariant[Req, Resp] | None
modbus: ModbusVariant[Req, Resp] | None
family_hints: frozenset[ControllerFamily]
capability_hints: Capability
safety: SafetyTier # READ_ONLY / STATEFUL / PERSISTENT (§5b)
min_firmware: FirmwareVersion | None = None
Std Bus variants emit raw payload bytes — we own that codec, so the typed request goes in and bytes come out:
@dataclass(frozen=True, slots=True)
class StdBusVariant[Req, Resp]:
def encode(self, ctx: CommandContext, request: Req) -> bytes: ...
def decode(self, reply: StdBusFrame, ctx: CommandContext) -> Resp: ...
Modbus variants emit a small typed instruction (ModbusOp) instead of
bytes — anymodbus already owns the wire codec, so handing it bytes
would be a layer violation:
class ModbusFn(StrEnum):
READ_HOLDING = "read_holding"
READ_INPUT = "read_input"
WRITE_REGISTER = "write_register"
WRITE_REGISTERS = "write_registers"
# coil / discrete-input ops added if a registry parameter ever needs them
@dataclass(frozen=True, slots=True)
class ModbusOp:
fn: ModbusFn
address: int
count: int = 1
values: tuple[int, ...] | None = None # set on writes
@dataclass(frozen=True, slots=True)
class ModbusVariant[Req, Resp]:
def encode(self, ctx: CommandContext, request: Req) -> ModbusOp: ...
def decode(self, words: tuple[int, ...], ctx: CommandContext) -> Resp: ...
ModbusProtocolClient.execute(op) is the only code that touches an
anymodbus.Slave; it picks the matching Slave method from op.fn
and returns the raw register tuple. Both protocol clients share the
ProtocolClient Protocol (§4) for lifecycle (lock, dispose); the
request shape differs between protocols, but variants on either side
remain pure functions of (ctx, request).
The 80% case — read/write any registry parameter (§5a) — is one
ReadParameter / WriteParameter command pair whose variants both
pull selector + encoding from the spec. Specialized commands (autotune,
profile upload, identity) declare their own variants.
5a. Parameter registry¶
Each row of data/pm_parameters.json is loaded once into a
ParameterSpec, indexed by canonical name (with aliases) and by
parameter_id. The spec carries enough information to lower a
read_parameter("setpoint") call to either protocol with no
per-parameter bespoke code.
@dataclass(frozen=True, slots=True)
class ParameterSpec:
parameter_id: int # 4001, 7001, ...
name: str # canonical "process_value"
aliases: frozenset[str] # "pv", "process_val", ...
data_type: DataType # FLOAT / S32 / U16 / U32 / U8 / STRING / PACKED
rwes: RwesFlag # R / RW / RWE / RWES
safety: SafetyTier # derived from rwes (§5b)
# Std Bus selector
cls: int
member: int
default_instance: int
max_instance: int
# Modbus selector
relative_addr: int
absolute_addr: int
register_count: int
word_order: WordOrder | None = None # None → client default (HIGH_LOW)
# Decode metadata
enum: type[IntEnum] | None = None # bound from enumerations.json
range_min: float | None = None
range_max: float | None = None
default: object | None = None
# Family scope (advisory; empty = no prior, attempt anywhere)
family_hints: frozenset[ControllerFamily] = frozenset()
Forward extension points kept off the spec deliberately: per-parameter
min_firmware / max_firmware and capability_hints are not wired
in today because the JSON registry doesn't carry the data — adding
empty fields would mislead. Adding them later is non-breaking because
the spec is built once at load.
5b. Capability, safety, and availability¶
Three small enums set the contract between the registry, the command layer, and the session.
class SafetyTier(IntEnum):
READ_ONLY = 0 # R — no state change
STATEFUL = 1 # (none in registry) — runtime state change, no EEPROM
PERSISTENT = 2 # RW / RWE / RWES — EEPROM-backed; needs confirm=True
class Capability(Flag):
NONE = 0
# Initial vocabulary intentionally tiny — most Watlow gating today
# is by ControllerFamily and by registry parameter_id, not by per-
# feature hardware bits. Bits added when a captured family needs
# them; existing bit values stay stable across releases.
PROFILE = auto() # ramp / soak engine (F4T / RM)
LIMIT = auto() # over/under-temperature limit module
SECONDARY_OUT = auto() # second control output
class Availability(StrEnum):
UNKNOWN = "unknown" # never tried this session
SUPPORTED = "supported" # observed working this session
UNSUPPORTED = "unsupported" # device rejected with a "no such" code
SafetyTier derives from rwes: R → READ_ONLY,
RW / RWE / RWES → PERSISTENT. STATEFUL is reserved for
operations like "start autotune" that don't write EEPROM but do change
runtime state; today no parameter is classified STATEFUL, but the
tier exists so commands like loop.start_autotune() have a place to
live without inventing it later.
The session keeps a per-command Availability cache for the lifetime
of the session. Mapping from device responses to availability
transitions:
| Response | Transition |
|---|---|
| Success | → SUPPORTED |
Std Bus 0x81 (NO_SUCH_OBJECT) |
→ UNSUPPORTED |
Std Bus 0x83 (NO_SUCH_ATTRIBUTE) |
→ UNSUPPORTED |
Std Bus 0x84 (NO_SUCH_INSTANCE) |
unchanged — this instance is missing, not the parameter |
Modbus IllegalFunctionError |
→ UNSUPPORTED |
Modbus IllegalDataAddressError |
→ UNSUPPORTED |
Modbus IllegalDataValueError |
unchanged — bad argument, not absence |
Modbus SlaveDeviceFailureError / timeout / malformed |
unchanged — non-response is not a refusal |
UNSUPPORTED is sticky for the session and short-circuits with a
typed error pre-I/O. family_hints are advisory by default — attempt
and observe — and only refuse pre-I/O when the session was opened
with strict=True. A persistable, per-firmware probe report is left
open as future work; the in-memory Availability cache is the v1
mechanism.
6. Public API shape¶
import anyio
from watlowlib import open_device, ProtocolKind, Controller
async def main() -> None:
async with await open_device(
"/dev/ttyUSB0",
protocol=ProtocolKind.AUTO, # try Std Bus then Modbus RTU
address=1, # standard-bus address OR modbus slave
) as ctl:
print(await ctl.identify()) # DeviceInfo
pv = await ctl.read_pv() # Reading(value=72.4, unit=Unit.F)
await ctl.set_setpoint(75.0, confirm=True) # RWE → confirm gate
loop1 = ctl.loop(1)
print(await loop1.read_pid())
ProtocolKind.AUTO is conservative (Std Bus probe → Modbus probe →
fail clearly; rationale in §7), no opcode sweeps, no baud guessing.
Sync facade, manager, streaming, sinks, sync portal: aligned with the
family conventions — Watlow.open for sync, WatlowManager,
record(...), CsvSink, watlow-* CLIs. Async is canonical; sync
wraps async via anyio.from_thread.BlockingPortal.
Manager and ports. The same RS-485 segment can only carry one
wire protocol at a time (every device on a bus must speak the same
framing). WatlowManager keys devices by canonical port identity, and
add(name, port, ...) locks the port to the protocol of the first
device added to it; subsequent add calls on that port must use the
same protocol or raise WatlowConfigurationError. Devices on
different ports remain independent and run concurrently.
6a. Public dataclasses¶
All frozen, slots=True. py.typed ships.
@dataclass(frozen=True, slots=True)
class Reading:
value: float | None # None on overload / sensor-fail sentinel
unit: TemperatureUnit | None # None when the source is unitless or unknown
received_at: datetime
monotonic_ns: int
raw: bytes # original payload for debugging
protocol: ProtocolKind # which protocol decoded this
@dataclass(frozen=True, slots=True)
class LoopState:
loop: int # 1-indexed (§7 / §11)
pv: Reading
setpoint: Reading
output_pct: float | None
mode: ControlMode | None # OFF / AUTO / MANUAL / AUTOTUNE / UNKNOWN
alarm_bits: int | None # raw alarm word; AlarmState carries decoded view
received_at: datetime
raw: Mapping[str, bytes] # one entry per parameter that fed the snapshot
@dataclass(frozen=True, slots=True)
class ParameterEntry:
spec: ParameterSpec
instance: int
value: float | int | str | bool | None
raw: bytes
@dataclass(frozen=True, slots=True)
class PartNumber:
raw: str # "PM3R1CA-AAAAAAA"
family: ControllerFamily # parsed from leading characters
# Per-family digit decoding (control type, output type, sensor input,
# ...) is extensible — each family contributes its own decoder. Today
# only the family discriminator is parsed; remaining digits stay in
# `raw` until a family decoder lands.
@dataclass(frozen=True, slots=True)
class DeviceInfo:
part_number: PartNumber
hardware_id: int | None # parameter 1001
firmware_id: int | None # parameter 1002
serial_number: str | None
family: ControllerFamily
protocol: ProtocolKind # the protocol this session is speaking
address: int # bus address (Std Bus 1..16, Modbus 1..247)
capabilities: Capability # observed and/or seeded
serial_settings: SerialSettings
loops: int # discovered or family default
@dataclass(frozen=True, slots=True)
class AlarmState:
loop: int
high: bool | None
low: bool | None
silenced: bool | None
raw_bits: int
@dataclass(frozen=True, slots=True)
class DiscoveryResult:
port: str
serial_settings: SerialSettings
address: int
protocol: ProtocolKind | None
info: DeviceInfo | None
error: WatlowError | None
Forward extension: Reading.status_flags: Mapping[str, bool] is not
present today — the codec doesn't surface a per-value status word
distinct from the type tag. raw is sufficient for diagnostics in the
meantime; add status_flags when a captured parameter actually needs
it.
7. Auto-detection ordering¶
Older Watlow controllers (Series 96, F4, early EZ-ZONE) ship from the factory in Standard Bus; on those models Modbus RTU is the front-panel-opt-in mode. Newer EZ-ZONE firmware can ship in either mode depending on configuration. Probe order — Std Bus first, then Modbus — matches the older-fleet bias and aligns with the protocol we have the deepest RE coverage of:
- Drain → Standard Bus probe (read parameter 1001 / Hardware ID at MAC
0x10, no opcode sweeps). A valid55 FF-framed reply with correct header + data CRC = Std Bus. - Drain → Modbus RTU probe via
anymodbus: open aBus, callslave(N).read_holding_registers(addr_for_1001, count=2). A valid CRC-correct response = Modbus. - Fail with a clear
WatlowErrorlisting what we tried.
Auto-detect never tries multiple bauds; the user sets one. Both probes
are read-only by construction. The detector accepts an address: int
arg; if omitted it tries 1 first then sweeps 2..16 for Std Bus and
2..247 for Modbus only when explicitly asked (a separate
watlow-discover CLI, not the open-device path).
watlow-discover accepts repeatable --baud flags for environments
where the configured baud is not known; open_device never sweeps
baud. Watlow PM ships at 38400 8N1 on Std Bus per the manuals, and
Modbus RTU on the same controller can be configured at 9600 / 19200
/ 38400 / 57600 / 115200 — discovery defaults to a small
spec-supported set when --baud is not supplied.
8. Errors¶
WatlowError root with the same ErrorContext pattern. Layered
subclasses: WatlowTransportError/Timeout/Connection,
WatlowFrameError, WatlowProtocolError,
WatlowProtocolUnsupportedError,
WatlowCapabilityError/Warning,
WatlowConfirmationRequiredError, plus protocol-specific:
- Std Bus:
WatlowNoSuchObjectError(0x81),WatlowNoSuchAttributeError(0x83),WatlowNoSuchInstanceError(0x84) — already typed in the existing payload module, just split out. - Modbus: re-raise
anymodbusexceptions wrapped inWatlowErrorsubclasses (WatlowModbusIllegalFunctionError,WatlowModbusIllegalDataAddressError,WatlowModbusIllegalDataValueError,WatlowModbusSlaveFailureError,WatlowModbusTimeoutError) so callers see one error hierarchy regardless of protocol.__cause__preserves the originalanymodbusexception for callers that need it.
ErrorContext is the structured payload every WatlowError carries:
@dataclass(frozen=True, slots=True)
class ErrorContext:
command_name: str | None # registry parameter or command name
protocol: ProtocolKind | None
port: str | None
address: int | None # bus address
parameter_id: int | None # set when a parameter was the target
# Std Bus selector — set when failure is at the inner-payload layer
cls: int | None = None
member: int | None = None
instance: int | None = None
# Modbus selector — set when failure is at the Modbus layer
register_address: int | None = None
function_code: int | None = None
# Wire-level
request: bytes | None = None
response: bytes | None = None
elapsed_s: float | None = None
Selector fields are populated per-protocol: Std Bus failures fill
cls / member / instance; Modbus failures fill register_address
/ function_code. Logging policy: Session.execute emits one
structured event per call at DEBUG with command name + selector, and
one at WARNING on every error; raw bytes go to TRACE-equivalent
DEBUG-with-raw rather than the default DEBUG channel to keep
day-to-day log noise low.
9. What ports vs what's new¶
Port from watlowtesting/src/watlowlib/ (mostly mechanical — change
package paths, add docstrings, fold into Variant API):
_crc.py→protocol/stdbus/_crc.py(or fold into framing)stdbus/frame.py→protocol/stdbus/framing.pystdbus/payload.py→ split: codec intoprotocol/stdbus/payload.py, type-tags intoprotocol/stdbus/tlv.py, errors intoerrors.pystdbus/transport.py→protocol/stdbus/client.py(the read-loop becomes theProtocolClient.executebody)stdbus/client.py→ folded intocommands/parameters.py+devices/controller.pydata/pm_parameters.json,data/enumerations.json→data/(verbatim)tests/test_codec.py,tests/test_crc.py→tests/(verbatim, repath imports)
New work:
- Modbus adapter subpackage — thin wrapper over
anymodbus(no codec or framing of our own); just theModbusProtocolClient, the data-type ↔ register-block lookup, and the exception remap intoWatlowError. - Parameter registry that unifies the two protocols on top of
pm_parameters.json Controllerfacade +Session+ gates +factory.open_device+ multi-looploop(n)viewWatlowManager, streaming, sinks, sync facade, CLIs- Capability/family taxonomy & classification from part number
- Discovery (MS/TP MAC sweep, Modbus address sweep)
Dependencies (lean core, mirrors sartoriuslib):
Optional extras: parquet (pyarrow), postgres (asyncpg), trio
(secondary AnyIO backend in tests).
10. Open design questions¶
Resolved¶
- Repo location —
~/Documents/git/watlowlib/, sibling to alicatlib / sartoriuslib. ✓ - Modbus library — use the in-house
anymodbuspackage (pinned>=0.1,<0.2); revisit the upper bound when anymodbus tags 0.1.x. ✓ -
RE provenance docs — migrated. The literature survey lives at
docs/protocol-stdbus.md; the empirical findings live atdocs/protocol-stdbus-findings.md. The originals stay inwatlowtesting/as historical RE provenance. ✓ -
addressparameter onopen_device— surface a singleaddress: int. Each protocol client interprets and validates: the Std Bus client maps1..16to MS/TP MAC0x10..0x1Fand rejects anything out of range; the Modbus client passes1..247toanymodbus.Bus.slave()and lets it enforce the spec range. The Manager always knows which protocol is locked to a port (§6) so address validation happens atadd(...)time. ✓ - Loop indexing —
controller.loop(1)is 1-indexed throughout the public API. It matches Watlow's manuals and is the same number used for Std Businstanceand the Modbusnext_inst_offsetmath from the registry. ✓ - Word order on Modbus floats — bake
WordOrder.HIGH_LOW/ByteOrder.BIGinto theModbusProtocolClientas the default for the registry-driven path;ParameterSpec.word_orderisNonein day-one data and the client default applies. When RE turns up a parameter with a different layout, populate the spec'sword_orderfor that row only — no client change. ✓
Still open¶
- Cache strategy for EEPROM-backed parameters. Writes self-invalidate the in-session cache for the affected parameter (and any known dependents). Whether to add a polled config-counter parameter for bulk invalidation depends on whether any Watlow parameter reliably ticks on every persistent write — captures so far don't prove it. Default for v1: writes self-invalidate; everything else re-reads on demand.
- Raw Modbus escape hatch shape.
anymodbus.Slaveexposes typed-FC methods, not a raw FC dispatch, soraw_modbus(fc, ...)doesn't sit naturally on the public surface. Two shapes are plausible: (a) typed raw methods on the facade (raw_read_holding_registers,raw_write_register, …), or (b) oneraw_modbus(op: ModbusOp)that reuses the variant-layer instruction. (b) keeps the public surface small and reuses the already-tested dispatch. Lean (b); revisit if a real raw use case resists it. Std Bus already has a clean fit:raw_stdbus(service, cls, member, instance, value=None). - Family classification rules beyond PM. PM part numbers are
well-documented; RM, ST, F4T, and EZ-ZONE Limit decoders are not
in the design today. Each family lands a
PartNumberdecoder when its parameter table arrives, additive to theControllerFamilyenum without changing the data shape.