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.

Three 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.
F4P_PROFILE F4P Modbus RTU 19200 8-N-1 None at open — identify() reads register 901 and sets the session unit from the live value (see Series F4P).

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).

Series F4P

The Watlow Series F4P is a single-channel 1/4 DIN controller. The ISDC IR-301 blackbody controller (sold by Oriel / Newport as the M67032) is an F4P with a custom overlay and firmware, model label F4PH-CAAB-01AE, so F4P_PROFILE is how a logger reads the blackbody.

from watlowlib import F4P_PROFILE, open_device

# IR-301 on COM8 through a USB-RS-485 adapter, Modbus address 10.
# protocol / serial_settings default from the profile (Modbus RTU,
# 19200 8-N-1); open_device runs identify() before returning.
async with await open_device("COM8", profile=F4P_PROFILE, address=10) as ctl:
    pv = await ctl.read_pv()  # 597.0 °C
    sp = await ctl.read_setpoint()  # 600.0 °C
    power = await ctl.read_parameter("output_power")  # 13.0 %
    alarm = await ctl.read_parameter("alarm_1_status")  # 2 = Alarm Low

Identity. Register 0 ("Model, Diagnostic") reads 5280 on every F4P. identify() requires it, so a foreign device at the same address yields health=FAILED rather than a false match. The serial number comes from registers 1 and 2 (joined as a six-digit number; not yet checked against a nameplate) and the software number from register 3.

Scaling comes from the device. F4P registers hold integers with implied decimals, and the number of decimals is a setting: register 606, "Decimal, Analog Input 1". Every temperature row in F4P_PARAMETERS names 606 as its decimal_register and loads at the factory default of no decimals. identify() reads 606 and rebinds the session registry with scale = 10 ** -places, so read_pv() returns 597.0 for a raw 5970 on a unit showing one decimal. Open with identify=False and the raw integers come back unscaled until you call identify() yourself.

Units come from register 901. identify() reads 901 ("°F or °C, System": 0 = °F, 1 = °C) and sets the session's wire temperature unit, so Reading.unit is honest without an assert_wire_temperature_unit= argument. Register 1923 is not a unit register: the IR-301 manual calls it "Show °F or °C", but the F4P manual defines it as "show the unit indicator on the upper display" (0 = no, 1 = yes).

Read-only. Every F4P_PARAMETERS row is R, including registers the F4P manual lists as r/w (set point, alarm set points, PID set 1, input, output, and alarm configuration). The IR-301 is a live calibration source that runs at 600 °C and above; writes stay out of scope until its owner authorises them. When they arrive, register 25 ("Save Changes to EE", write-only) is the F4P's counterpart of the SD's register 17 for keeping high-rate set point writes out of EEPROM.

Confirmed on the bench unit: register 606 reads 1 (tenths) and 901 reads 1 (°C); the alarm set points are deviation bands (registers 702 and 719 read 2); and register 104, the IR-301 manual's cold-junction value (absent from the F4P table), reads 217 for 21.7 °C while the F4P's own CJC register 1500 reads 711, which is 71.1 °F, so 1500 is left untagged. Still to settle against the front panel: the % power scale (register 103; tenths, supported by the high power limit in register 714 reading 450 for a 45.0 % cap) and the serial-number word split (018883 from 1 / 8883). Registers 105 and 209 (cold-junction and system error) come from the IR-301 manual only and read 0.

Wiring the IR-301's rear port for RS-485

The IR-301's rear DB-9 (labelled "RS232C/RS422A/RS485") never answered on its factory RS-232 wiring. The bench unit was rewired internally to the F4P's RS-485 terminals: DB-9 pin 3 to terminal 12 (T+/R+), pin 2 to terminal 13 (T-/R-), pin 5 to terminal 16 (common), with 820 Ω bias resistors from terminal 11 (+5 V) to 12 and from 13 to 16 (the F4P manual, §8.12, suggests 1 kΩ pull-up and pull-down when comms fail). The controller only started answering after a power cycle following the rewiring, so power-cycle it first if it goes silent. The front panel's COM LED blinks on traffic. Single-register replies take 10-17 ms at 19200.

Discovery. The F4P's Communications menu offers only 9600 and 19200, so F4P_PROFILE.supported_baudrates is {9600, 19200} and find_devices(profiles=...) skips 38400 for it in the default sweep. The bench unit sits at address 10, which the default scan does not probe; pass it explicitly and restrict the scan to the adapter's port so the other instruments on the rig are left alone:

rows = await find_devices(ports=["COM8"], addresses=(10,), profiles=(F4P_PROFILE,))

Sweeping with DEVICE_PROFILES can also report the same address as a Series SD with health=PARTIAL: the SD has no model register to check, and its identity probe reads F4P registers that happen to exist. Prefer the F4P row (model 5280, health=OK).

Block reads. The IR-301 manual allows 1 to 32 registers per FC03 read, and the bench unit confirms it: registers 100-106 (process value, input error, alarm 1, % power, cold junction value and error, alarm 2) come back in one frame, as do 0-5 and 300-303. Registers the F4P does not map return the filler 0x8300 inside a block instead of an exception. watlowlib reads one parameter per call today; a logger that wants the whole status block per tick can issue the block read through session.client with a raw ModbusOp (the watlow-raw escape hatch).

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).
F4P* ControllerFamily.F4P Series F4P single-channel controller (Modbus RTU; bare-register map). No ASCII model register — identity is register 0 reading 5280 (see Series F4P).
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 None ⇔ ok 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".

Pass profiles=DEVICE_PROFILES (or a subset) to probe by device type instead of by protocol: each profile contributes its own protocol, factory framing, registry, and identity strategy. A profile that declares supported_baudrates (the F4P: 9600 and 19200) is skipped at the other bauds of the default sweep; pass an explicit baudrates= to probe it elsewhere.

from watlowlib import DEVICE_PROFILES, F4P_PROFILE, find_devices

rows = await find_devices(profiles=DEVICE_PROFILES)  # PM + SD + F4P, every port
rows = await find_devices(ports=["COM8"], addresses=(10,), profiles=(F4P_PROFILE,))

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

See also