Skip to content

Controllers

Watlow controllers all do the same thing — read a process value, drive a setpoint, run a PID loop — and differ in how much extra they can do, not what they are. watlowlib exposes a single Controller class for every model. Family is a discriminator on DeviceInfo, capabilities are a flag bitmap derived from the part number, and family-specific behaviour is dispatched on capabilities, not by class hierarchy. See Design §5b for the full rationale.

One class, many families

open_device(...) always returns a Controller. The controller's DeviceInfo carries the active wire protocol, the part-number string read from the device, the family classification, the loop count, and the capability bitmap decoded from the part number.

async with await open_device(
    "/dev/ttyUSB0",
    protocol=ProtocolKind.STDBUS,
    address=1,
) as ctl:
    info = await ctl.identify()
    print(info.part_number.raw, info.family, info.protocol)
    print(info.capabilities)

Device profiles

A device type is exactly four things: a family, a parameter registry, the wire protocol + serial framing it speaks at the factory, and how to identify it. watlowlib bundles those into a frozen DeviceProfile so the device type is an explicit, first-class object rather than an implicit "always EZ-ZONE PM" assumption.

Two profiles ship:

Profile Family Default protocol Default framing Wire temp. unit
EZZONE_PROFILE PM Standard Bus 38400 8-N-1 None — you must assert it (PM firmware can misreport its own unit register; see Units).
SERIES_SD_PROFILE SD Modbus RTU 9600 8-N-1 Unit.FAHRENHEIT — the SD manual fixes Modbus temperatures to °F by default, and identity reads register 18 to confirm.

open_device takes the profile and derives everything else from it:

from watlowlib import open_device, SERIES_SD_PROFILE

# Series SD on COM11, Modbus address 10. protocol / serial_settings
# default from the profile (Modbus RTU, 9600 8-N-1) — no need to spell
# them out.
async with await open_device("COM11", profile=SERIES_SD_PROFILE, address=10) as ctl:
    pv = await ctl.read_pv()          # 68.2 °F  (S32 ÷1000)
    sp = await ctl.read_setpoint()    # 62.96 °F
    power = await ctl.read_parameter("output_power")  # 82.8 %  (signed S16 ÷100)

The default profile is EZZONE_PROFILE, so existing PM code is unchanged. protocol= / serial_settings= still override the profile defaults when you need a non-factory configuration. WatlowManager.add(..., profile=...) lets one manager mix an SD and a PM on different ports.

Series SD register scaling

SD registers store raw integers and imply decimal places: process value / setpoint at three (÷1000), power / percent at two (÷100). The registry carries this as ParameterSpec.scale and applies it on the Modbus read/write path only — read_pv() returns 68.2, and set_setpoint(62.96) lowers to the raw word 62960. Unscaled enum registers (units, input error) stay plain ints.

EEPROM wear on high-rate SD writes

The SD persists every register write to EEPROM by default. A ramping setpoint or tuning loop can wear it out. Call await ctl.set_persistent_writes(False, confirm=True) once after each power-up to keep subsequent writes in RAM only (the SD resets register 17 to 1 on every power cycle).

Family classification

Family is decided by the leading characters of the part-number string:

Prefix Family Notes
PM* ControllerFamily.PM EZ-ZONE PM. Reference family with full part-number decoder (case size, control type, power, output codes, comms options).
RM* ControllerFamily.RM EZ-ZONE RM. Discriminator only — no per-digit decoder yet.
ST* ControllerFamily.ST EZ-ZONE ST. Discriminator only.
F4T* ControllerFamily.F4T F4T. Discriminator only.
SD* ControllerFamily.SD Series SD PID controller (Modbus RTU only; bare-register map, no Std Bus). Has no ASCII model-name register — identity is numeric (see Device profiles).
anything else ControllerFamily.UNKNOWN First-class case — no priors, every call becomes a live probe.

classify_family(part_number) is the helper. Classification is case-insensitive and whitespace-tolerant. The PM decoder also populates a free-form PartNumber.details map (case size, control type, output codes, options string) — see decode_part_number.

Standard Bus is the EZ-ZONE PM factory default

Every PM ships from the factory in Standard Bus at 38400 8-N-1, address 1. Modbus RTU is opt-in and requires either a comms-option SKU that includes the Modbus stack or a front-panel mode flip on a dual-stack SKU. See Troubleshooting for first-contact paths and SKU caveats.

Capability flags

Capability is a Flag enum derived from the parsed part number — the bits encode SKU facts that don't depend on the device responding to any particular query.

Capability Source Meaning
HAS_MODBUS comms code (position 8) ∈ Modbus RTU stack ordered with the SKU.
HAS_BLUETOOTH comms code ∈ Bluetooth comms ordered.
HAS_ETHERNET comms code ∈ Ethernet comms ordered.
HAS_COOLING output_2 != 'A' Second control output present.
HAS_PROFILES control_type ∈ Ramp / soak engine.
PROFILE (same as above) Family-level profile capability.
LIMIT family / control_type Over/under-temperature limit.

Bits not derivable from the part-number string remain zero rather than being guessed. See capabilities_for_part_number.

Capabilities are priors, not contracts

The reverse-engineering sample behind these tables is small. The library treats the family table and capability bits as priors from observation, not protocol guarantees. The generic session attempts commands and updates the per-session availability cache on the device's response. Pre-I/O refusal happens after an observed WatlowNoSuchObjectError / WatlowModbusIllegalDataAddressError on the current session, or in targeted helper paths that would otherwise issue a known-bad write for a decoded SKU.

DeviceInfo.health carries the outcome of identify():

  • DeviceHealth.OK — every identity probe succeeded.
  • DeviceHealth.PARTIAL — part number captured but a secondary field (firmware, serial) missed.
  • DeviceHealth.FAILED — part number could not be read; capability decoding skipped.

See Safety and Design §5b for the gate-order rationale.

Identifying a controller

async with await open_device("/dev/ttyUSB0", address=1) as ctl:
    info = await ctl.identify()
    print(f"part:     {info.part_number.raw}")
    print(f"family:   {info.family}")
    print(f"firmware: {info.firmware_id}")
    print(f"serial:   {info.serial_number}")
    print(f"loops:    {info.loops}")
    print(f"protocol: {info.protocol} (configured: {info.configured_protocol})")
    print(f"caps:     {info.capabilities}")
    if info.protocol_mismatch:
        print("warning: EEPROM and active protocol disagree")

identify() runs the part-number / firmware / serial reads in one shot, parses the part number, and ORs the family prior with the SKU-decoded capability bits. Re-running it forces a refresh — useful after a change_protocol_mode(...) or a parameter write that may flip a capability. See Controller.identify.

DeviceInfo.protocol_mismatch flags the case where parameter 17009 (protocol mode) reports one wire protocol but the host is currently talking another — common on Std-Bus-only SKUs where 17009 was written to "Modbus" but the comms position-8 character means no Modbus stack ever shipped.

Multiple loops

Dual-loop SKUs (PM6/PM8/PM9 control type U) expose Controller.loop(n) for per-loop access:

async with await open_device("/dev/ttyUSB0", address=1) as ctl:
    info = await ctl.identify()
    if info.loops >= 2:
        loop2 = ctl.loop(2)
        pv = await loop2.read_pv()

loop(n) validates n against info.loops and returns a ControllerLoop bound to the same session. All single-loop SKUs default to loops=1; Controller.read_pv() is shorthand for Controller.loop(1).read_pv().

Units

Watlow PM controllers carry two display-unit registers:

ID Name What it does
3005 Display - Units Front-panel temperature scale (visible on the device).
17050 Communications - Display Units A label register that claims to drive the comms wire scale.

Both registers are reachable through the parameter API, but neither is a reliable source of truth for the unit of temperature values on the wire. On at least one PM3 firmware revision (PM3C1AJ, firmware id 5678), 17050 is label-only: writing it changes the enum the device reports when 17050 is read back, but does not affect the scale of temperature values exchanged over comms. The internal storage unit on that device is °F regardless of what either register says; verified empirically by writing a known setpoint and comparing the comms readback to the front-panel display.

watlowlib therefore does not infer Reading.unit from either register. The default is unit=None for temperature reads — an honest "I don't know" rather than a confident lie. To get a meaningful tag, verify the wire scale externally (the bundled watlow-diag probe-unit diagnostic does this — see Diagnosing the wire scale below) and then declare it at open time:

from watlowlib import Unit, open_device

async with await open_device(
    "/dev/ttyUSB0",
    address=1,
    assert_wire_temperature_unit=Unit.FAHRENHEIT,  # externally verified
) as ctl:
    pv = await ctl.read_pv()
    assert pv.unit is Unit.FAHRENHEIT  # tag matches value scale

The assertion is propagated to every Reading and Sample produced by the session. Without it, temperature tags stay None — the Reading.value is still the raw number off the wire; you just have to know what scale it's in yourself.

Inspection facade for parameter 17050

For diagnostics, the value of 17050 is reachable through a dedicated inspection facade. It does not feed Reading.unit:

# Read the cached label (one wire turn-around, then cached).
label = await ctl.read_comms_unit_label()  # Unit | None

# Flip the label (RWE; persists across power cycles).
await ctl.set_comms_unit_label(Unit.CELSIUS, confirm=True)

set_comms_unit_label accepts a Unit or a case-insensitive alias ("C", "F", "celsius", "degF", "°C"). Raw device codes (15 for Celsius, 30 for Fahrenheit) belong on the lower-level write_parameter("display_units", code) path. Note that writing 17050 has no documented effect on the wire scale on this firmware — it only changes what the device reports back when 17050 is read.

Diagnosing the wire scale (watlow-diag probe-unit)

watlow-diag probe-unit is a read-only diagnostic that infers the wire scale by comparing a known front-panel reading against the comms readback. Read-only by construction: it never writes anything to the device.

Procedure:

  1. Look at the front panel and note the value and unit of the parameter you'll compare against (default: setpoint). E.g. the panel shows SP = 50 with the °C indicator lit.
  2. Run:
watlow-diag probe-unit COM6 --panel-shows 50 --panel-unit C
  1. The probe reads the same parameter over comms and reports which scale matches. Sample output:
Reference comparison:
  panel shows : 50.0 C
  comms reads : 122.0 (setpoint instance=1)

Inference: fahrenheit
  panel-as-°C = 50.0, delta vs comms = 72.0
  panel-as-°F = 122.0, delta vs comms = 0.0

  → open_device(..., assert_wire_temperature_unit=Unit.FAHRENHEIT)
  1. Take the recommended assert_wire_temperature_unit= value into your open_device call. From then on, Reading.unit and Sample.unit will reflect that wire scale.

Useful flags:

  • --parameter setpoint|process_value|... — choose which temperature parameter to compare. Setpoint is usually the easiest (you set it yourself on the panel; PV drifts under control).
  • --epsilon 0.2 — match tolerance. Bump up if your panel rounds to whole degrees.
  • --json — emit the full report as JSON for downstream tooling.

The diagnostic is one-shot: hardware behaviour for a given SKU + firmware doesn't change between runs, so the recommended kwarg can be hard-coded once and reused.

Discovery

find_devices() probes the cartesian product of ports × baudrates × protocols × addresses and returns one DiscoveryResult per probe attempt. The default scan is narrow — every visible serial port (via anyserial.list_serial_ports), bauds 38400 / 19200 / 9600, both Watlow protocols, address 1 only — so a GUI Discover dialog can ask "is anything plugged in?" in under 15 seconds on a four-port rig.

from watlowlib import find_devices

rows = await find_devices()                # scan all ports, defaults
rows = await find_devices(ports=["/dev/ttyUSB0"])
rows = await find_devices(addresses=range(1, 17))  # multi-drop sweep

for row in rows:
    if row.ok:
        print(row.port, row.baudrate, row.protocol.value,
              row.address, row.device_info.part_number.raw)

A row's ok field is the single attribute callers filter on: populated device_info and error is Noneok is True. Silent / absent addresses surface as ok=False rows carrying a typed WatlowError so callers can distinguish "port wouldn't open" from "no reply at this address".

The watlow-discover CLI wraps find_devices() with a JSON / table renderer.

See also