watlowlib.protocol¶
ProtocolKind, the ProtocolClient runtime-checkable Protocol, and
the Standard Bus / Modbus RTU client implementations. See
Design §4–5.
Public surface¶
watlowlib.protocol ¶
Protocol layer — framing, parsing, and protocol-client adapters.
Standard Bus and Modbus RTU each have a full subpackage under here. The
shared :class:ProtocolClient Protocol and :class:ProtocolKind enum
live at this level. See docs/design.md §4.
ProtocolClient ¶
Bases: Protocol
Per-device protocol client.
Implementations own the wire codec and the per-port lock. The
:class:watlowlib.devices.session.Session is the only caller; it
holds lock for the duration of a single command (request +
reply).
lock
property
¶
Per-client lock acquired by :meth:Session.execute.
One lock per port — a single :class:Session serializes its
own traffic, and :class:watlowlib.manager.WatlowManager
enforces one protocol per port across sessions.
dispose ¶
Mark the client unusable. Subsequent execute calls raise.
Synchronous because dispose is called from teardown paths that
don't always have an event loop. The client is responsible for
closing its transport (or signalling the owning :class:Session
to do so) — this method just trips the flag.
Source code in src/watlowlib/protocol/base.py
execute
async
¶
Send request to address, return the typed reply.
address travels with every call so one client can serve
multiple devices on a multi-drop RS-485 segment without
re-construction. Std Bus accepts 1..16, Modbus RTU accepts
1..247.
timeout overrides :attr:watlowlib.config.DEFAULTS.io_timeout_s
for this call only. command_name is threaded into log
events and error contexts; it is informational, not load-bearing
for dispatch.
Source code in src/watlowlib/protocol/base.py
ProtocolKind ¶
Bases: StrEnum
Wire protocol selected for a session.
AUTO triggers the conservative Std Bus → Modbus probe.
make_protocol_client ¶
Build an address-agnostic :class:ProtocolClient for kind over transport.
The returned client takes a destination address per
:meth:ProtocolClient.execute call, so one client can serve every
device on a multi-drop RS-485 segment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
kind
|
ProtocolKind
|
The wire protocol. |
required |
transport
|
Transport
|
An open or openable :class: |
required |
Raises:
| Type | Description |
|---|---|
WatlowConfigurationError
|
|
Source code in src/watlowlib/protocol/client.py
Base types¶
watlowlib.protocol.base ¶
Protocol seam: :class:ProtocolKind enum + :class:ProtocolClient Protocol.
The :class:Session holds a :class:ProtocolClient and dispatches every
command through execute(...). Variants are pure functions of
(ctx, request) — the client owns the wire codec and the per-port
serialization (lock).
Standard Bus and Modbus RTU specialize the request type:
- :class:
watlowlib.protocol.stdbus.client.StdBusProtocolClientisProtocolClient[bytes, StdBusReply]because watlowlib owns the inner-payload codec; the variant produces raw bytes ready to be framed. - :class:
watlowlib.protocol.modbus.client.ModbusProtocolClientisProtocolClient[ModbusOp, tuple[int, ...]]becauseanymodbusowns the wire codec; handing it bytes would be a layer violation.
See docs/design.md §4.
ProtocolClient ¶
Bases: Protocol
Per-device protocol client.
Implementations own the wire codec and the per-port lock. The
:class:watlowlib.devices.session.Session is the only caller; it
holds lock for the duration of a single command (request +
reply).
lock
property
¶
Per-client lock acquired by :meth:Session.execute.
One lock per port — a single :class:Session serializes its
own traffic, and :class:watlowlib.manager.WatlowManager
enforces one protocol per port across sessions.
dispose ¶
Mark the client unusable. Subsequent execute calls raise.
Synchronous because dispose is called from teardown paths that
don't always have an event loop. The client is responsible for
closing its transport (or signalling the owning :class:Session
to do so) — this method just trips the flag.
Source code in src/watlowlib/protocol/base.py
execute
async
¶
Send request to address, return the typed reply.
address travels with every call so one client can serve
multiple devices on a multi-drop RS-485 segment without
re-construction. Std Bus accepts 1..16, Modbus RTU accepts
1..247.
timeout overrides :attr:watlowlib.config.DEFAULTS.io_timeout_s
for this call only. command_name is threaded into log
events and error contexts; it is informational, not load-bearing
for dispatch.
Source code in src/watlowlib/protocol/base.py
ProtocolKind ¶
Bases: StrEnum
Wire protocol selected for a session.
AUTO triggers the conservative Std Bus → Modbus probe.
Standard Bus¶
watlowlib.protocol.stdbus ¶
Watlow Standard Bus protocol — BACnet MS/TP framing + Watlow payload.
This subpackage exposes the codec primitives so reverse-engineering
tools and offline decode utilities can use them directly. The
:class:StdBusProtocolClient sits on top, and the high-level
:class:watlowlib.devices.controller.Controller facade dispatches
through it via :class:watlowlib.devices.session.Session.
DataType ¶
Bases: IntEnum
Wire data-type tag bytes.
Tags fall into two families:
- fixed-width:
U8,U16,U32,S32,FLOAT— the value follows the tag with no length byte. - length-prefixed:
STRING,PACKED— a length byte follows the tag.
The "Wide Enumeration" data type from the EZ-ZONE register list
shares tag 0x0F with Enumeration. In every live capture so
far the count byte is 1 (single 16-bit word). Multi-word
behaviour is implemented but unverified.
ErrorCode ¶
Bases: IntEnum
Error response codes observed when the request selector is invalid.
The error response payload is two bytes: 0x02 (response
direction) followed by the code. There is no echo of the failing
class/member/instance.
Mapping to :class:watlowlib.devices.capability.Availability
(per docs/design.md §5b):
NO_SUCH_OBJECT/NO_SUCH_ATTRIBUTE→UNSUPPORTEDNO_SUCH_INSTANCE→ unchanged (the parameter exists; this loop / channel does not)
Frame
dataclass
¶
Decoded BACnet MS/TP frame as seen on Standard Bus.
FrameError ¶
Bases: ValueError
A wire frame failed structural or CRC validation.
FrameType ¶
Bases: IntEnum
BACnet MS/TP frame types observed on Standard Bus traffic.
BACnet MS/TP defines 0..7 plus 0x80..0xFF as proprietary.
Only 0x05 (request) and 0x06 (response) are honoured on the
wire by EZ-ZONE PM controllers — see
docs/protocol-stdbus-findings.md ("Frame-type space"). The rest
are documented for probing by reverse-engineering tooling.
ReadResponse
dataclass
¶
A decoded inner read response.
StdBusError ¶
Bases: RuntimeError
The controller returned an explicit error response.
Library callers should prefer :func:raise_for_error_code, which
surfaces the typed WatlowNoSuch* subclasses defined in
:mod:watlowlib.errors (those are what the session catches and
maps to :class:Availability). StdBusError is retained as a
convenience for ad-hoc decoding of captured payloads outside the
session.
Source code in src/watlowlib/protocol/stdbus/payload.py
StdBusProtocolClient ¶
:class:watlowlib.protocol.base.ProtocolClient for Standard Bus.
The client is address-agnostic: execute takes the destination
bus address per-call so one client can serve every device on a
multi-drop RS-485 segment. The :class:watlowlib.devices.session.Session
passes its bound address; :class:watlowlib.manager.WatlowManager
shares one client across controllers on the same physical port.
Source code in src/watlowlib/protocol/stdbus/client.py
dispose ¶
execute
async
¶
Send the inner request payload to address and return the framed reply.
timeout is a wall-clock bound on the entire request →
reply round-trip. The whole I/O section runs inside a single
:func:anyio.fail_after(timeout), so a hung device cannot
stall a caller for more than timeout seconds even when the
preamble scan and the body read each consume a substantial
slice. The transport's per-call timeouts are kept as defence
in depth.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
bytes
|
Inner Watlow payload bytes (e.g. produced by
:func: |
required |
address
|
int
|
Standard Bus address ( |
required |
timeout
|
float | None
|
Wall-clock bound on the round-trip. Optional
override of :attr: |
None
|
command_name
|
str
|
Threaded into log events for traceability. |
''
|
Raises:
| Type | Description |
|---|---|
WatlowConnectionError
|
client is disposed or transport not open. |
WatlowFrameError
|
framing failure (bad preamble, CRC mismatch, truncated body). |
WatlowTimeoutError
|
round-trip exceeded |
ValueError
|
|
Source code in src/watlowlib/protocol/stdbus/client.py
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | |
StdBusReply
dataclass
¶
Result of a single :meth:StdBusProtocolClient.execute call.
Attributes:
| Name | Type | Description |
|---|---|---|
frame |
StdBusFrame
|
Decoded outer BACnet MS/TP frame (frame_type, dst, src, payload). |
payload |
StdBusReplyPayload
|
Decoded inner Watlow payload — read response, write response, or error. |
raw_frame |
bytes
|
The complete on-wire bytes (preamble through data CRC). Stored for diagnostics; used by the session's WARNING log path on errors. |
WriteRequest
dataclass
¶
A decoded inner write request.
WriteResponse
dataclass
¶
A decoded inner write response.
addr_to_mac ¶
Map a Standard Bus address (1..16) to its MS/TP MAC.
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Source code in src/watlowlib/protocol/stdbus/tables.py
data_crc16 ¶
Compute the BACnet MS/TP data CRC-16.
Returns the host-order int; on the wire, send little-endian via
:func:data_crc16_le_bytes.
Source code in src/watlowlib/protocol/stdbus/_crc.py
data_crc16_le_bytes ¶
decode_frame ¶
Parse wire bytes into a :class:Frame, verifying both CRCs.
The caller is expected to have already framed on the 55 FF
preamble — :class:watlowlib.protocol.stdbus.client.StdBusProtocolClient
handles that during read.
Raises:
| Type | Description |
|---|---|
FrameError
|
short buffer, wrong preamble, header CRC mismatch, truncated body, or data CRC mismatch. |
Source code in src/watlowlib/protocol/stdbus/framing.py
decode_payload ¶
Parse an inner Watlow payload into a structured response/request/error.
Source code in src/watlowlib/protocol/stdbus/payload.py
decode_value ¶
Decode a single value starting at buf[0].
Returns (value, type_tag, consumed_bytes).
Source code in src/watlowlib/protocol/stdbus/tlv.py
encode_frame ¶
Serialise frame to wire bytes (preamble through data CRC).
Raises:
| Type | Description |
|---|---|
FrameError
|
|
Source code in src/watlowlib/protocol/stdbus/framing.py
encode_read_request ¶
Build the inner payload for reading a single parameter.
Source code in src/watlowlib/protocol/stdbus/payload.py
encode_value ¶
Encode value under type_tag to wire bytes (tag included).
Source code in src/watlowlib/protocol/stdbus/tlv.py
encode_write_request ¶
Build the inner payload for writing a single parameter.
Source code in src/watlowlib/protocol/stdbus/payload.py
header_crc8 ¶
Compute the BACnet MS/TP header CRC-8.
Returns the on-wire byte (one's complement of the running CRC).
Source code in src/watlowlib/protocol/stdbus/_crc.py
join_param ¶
mac_to_addr ¶
Map an MS/TP MAC (0x10..0x1F) back to its Standard Bus address.
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Source code in src/watlowlib/protocol/stdbus/tables.py
raise_for_error_code ¶
Raise the typed :class:watlowlib.errors.WatlowError for code.
Maps Std Bus error bytes to the typed subclasses the session uses
for :class:Availability updates. Unknown error codes raise
:class:watlowlib.errors.WatlowProtocolError.
Source code in src/watlowlib/protocol/stdbus/payload.py
split_param ¶
Split a Watlow Parameter ID into (class, member).
The published "Parameter ID" in user manuals is
Class * 1000 + Member — 4001 decodes to (4, 1),
8003 decodes to (8, 3).
Source code in src/watlowlib/protocol/stdbus/payload.py
Modbus RTU¶
watlowlib.protocol.modbus ¶
Modbus RTU adapter.
Thin wrapper over the in-house :mod:anymodbus package. The module
owns:
- :class:
ModbusOp— typed instruction emitted by Modbus variants. - :class:
ModbusProtocolClient— :class:ProtocolClientfor the Modbus wire. - :class:
ModbusBusTransport— :class:Transport-shaped adapter that hands the client a live :class:anymodbus.Buson demand.
The codec for the Modbus PDU itself lives in :mod:anymodbus; this
package never touches wire bytes. Modbus variants emit a typed
:class:ModbusOp, not bytes — see docs/design.md §5.
ModbusBusTransport ¶
Holds an :class:anymodbus.Bus behind the :class:Transport API.
Lifecycle:
open()calls :func:anymodbus.open_modbus_rtuand stores the resulting :class:Bus. Re-open raises :class:WatlowConnectionError.close()awaits :meth:Bus.aclose. Safe on an unopened or already-closed instance.write/read_exact/read_availableraise :class:NotImplementedError. The :class:ModbusProtocolClientnever calls them — it uses :attr:businstead.
Source code in src/watlowlib/protocol/modbus/transport.py
bus
property
¶
Return the live :class:anymodbus.Bus.
Raises :class:WatlowConnectionError if :meth:open has not
completed (or the transport has been closed).
ModbusEncoding
dataclass
¶
How a :class:DataType lays out across Modbus registers.
Attributes:
| Name | Type | Description |
|---|---|---|
register_count |
int
|
Number of 16-bit registers the value occupies. FLOAT / S32 / U32 → 2 regs; U16 / U8 / PACKED → 1. STRING is length-driven and reads its register count from the spec. |
word_order |
WordOrder
|
Inter-register order for multi-register values.
|
byte_order |
ByteOrder
|
Within-register byte order. Big-endian on every Watlow family observed to date. |
read_fn |
ModbusFn
|
Default Modbus function for a read of this type.
Always :attr: |
ModbusFn ¶
Bases: StrEnum
Modbus function selector.
The four operations exercised by the registry-driven workhorse
(:data:READ_PARAMETER / :data:WRITE_PARAMETER). Coil and
discrete-input ops are intentionally absent; they would be added
if a registry parameter ever needed them.
ModbusOp
dataclass
¶
One Modbus transaction in protocol-neutral form.
Attributes:
| Name | Type | Description |
|---|---|---|
fn |
ModbusFn
|
The Modbus function to invoke. |
address |
int
|
Zero-based register address. The Watlow registry
stores this as |
count |
int
|
Number of 16-bit registers to read. Ignored on
single-register writes; required on
:attr: |
values |
tuple[int, ...] | None
|
Register words to write (one |
ModbusProtocolClient ¶
:class:ProtocolClient for Modbus RTU.
The client is address-agnostic: execute takes the slave
address per-call so one client can serve every device on a
multi-drop bus. The slave_provider receives that address and
returns the live :class:Slave.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
slave_provider
|
SlaveProvider
|
Callable mapping address → live :class: |
required |
port
|
str
|
Transport label, threaded into log events / error contexts. |
''
|
Source code in src/watlowlib/protocol/modbus/client.py
dispose ¶
execute
async
¶
Run request against address and return the raw register tuple.
Reads return the read words; writes return () so callers
downstream can treat reads and writes uniformly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
ModbusOp
|
The typed Modbus operation produced by a
:class: |
required |
address
|
int
|
Modbus slave address ( |
required |
timeout
|
float | None
|
Per-call override of
:attr: |
None
|
command_name
|
str
|
Threaded into log events for traceability. |
''
|
Raises:
| Type | Description |
|---|---|
WatlowConfigurationError
|
|
WatlowConnectionError
|
client is disposed or the underlying
:class: |
WatlowModbusError
|
a Modbus-layer exception (mapped via
:func: |
Source code in src/watlowlib/protocol/modbus/client.py
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | |
encoding_for ¶
Return the Modbus encoding for data_type.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data_type
|
DataType
|
The wire data-type tag from the registry. |
required |
word_order_override
|
str | None
|
Per-row override (from
:attr: |
None
|
register_count_override
|
int | None
|
Per-row override (mainly for
:attr: |
None
|
Raises:
| Type | Description |
|---|---|
WatlowProtocolUnsupportedError
|
|
Source code in src/watlowlib/protocol/modbus/tables.py
remap_modbus_exception ¶
Wrap exc in the typed :class:WatlowError for its kind.
The caller is expected to raise the returned exception with
from exc so __cause__ preserves the original.
Returns exc re-typed as a :class:WatlowError subclass; never
returns None. Unmapped :mod:anymodbus errors fall back to
:class:WatlowModbusError. Non-:mod:anymodbus errors fall
through to a bare :class:WatlowProtocolError rather than being
swallowed — calling sites should normally only feed this function
instances of :class:ModbusError.