A Python library for Modbus RTU communication with SmartPower power-supply
modules (MOD-537-250 control board). Wraps
pymodbus with model-aware typed
register accessors, structured exceptions, and a small CLI.
pip install -e .Requires Python 3.10+ and pymodbus>=3.7,<4.
If you're working in a bare environment that doesn't install the package
itself, requirements.txt lists the same runtime dependencies, and
requirements-dev.txt adds the test/lint/type-check tools CI uses
(pytest, pytest-cov, ruff, mypy):
pip install -r requirements.txt # runtime only
pip install -r requirements-dev.txt # runtime + tests + lint + mypy-
Connect the module. Wire the SmartPower control board to your serial adapter (RS-485 / USB-serial). Note the OS port name (
COM5on Windows,/dev/ttyUSB0on Linux) and the Modbus slave ID configured on the module (1..247). -
Pick an entry point.
- Python library —
from smartpower_modbus import SmartPowerClientand use it as a context manager. See Library usage. - CLI —
smartpower-cli ...for one-off reads/writes, dumps, and probes. See CLI. If the entry point isn't on PATH, runpython -m smartpower_modbus.cli ....
- Python library —
-
Identify the model (optional). If you don't already know which SmartPower variant is on the bus, let the library auto-detect it via
PRODUCT_CODE— omitmodel=in Python, or use theidentifysubcommand on the CLI. See Auto-detection. -
Read or write registers. Use
Register.<NAME>(or the CLI's short names likeOUT_P) to address registers by name rather than raw addresses — the library validates kind, signedness, and that the register exists on the selected model before any wire activity. For physical units (Amps, Volts, °C, …) useread_value()/write_value()or the CLI's-i/--interpretflag.
Smallest working example:
from smartpower_modbus import Register, SmartPowerClient
with SmartPowerClient("COM5", slave_id=1) as client: # model auto-detected
print(client.model.value) # e.g. SmartPowerGen_2.0
print(client.read_value(Register.INPUT_REG_OUT_P)) # W (float)
client.write_value(Register.HOLD_REG_SP_P, 50.0) # 50 %Same idea from the CLI:
smartpower-cli --port COM5 --slave 1 identify
smartpower-cli --port COM5 --slave 1 --model SmartPowerGen_2.0 read -i OUT_P SP_P
smartpower-cli --port COM5 --slave 1 --model SmartPowerGen_2.0 write -i HOLD_REG_SP_P 50The walkthrough script example.py exercises the most common
read/write paths end-to-end:
python example.py --port COM5 --slave 1 --model SmartPowerGen_2.0 --sp-p 50The public API talks in product model names only:
SmartPowerModel member |
Public name (.value) |
PRODUCT_CODE |
Customer-facing platform |
|---|---|---|---|
SmartPowerModel.SOLO |
SmartPowerSolo |
55400400 |
SmartPower Solo |
SmartPowerModel.GEN_1_0 |
SmartPowerGen_1.0 |
55370250 |
SmartPower Gen 1.0 |
SmartPowerModel.GEN_1_5 |
SmartPowerGen_1.5 |
55370111 |
SmartPower Gen 1.5 |
SmartPowerModel.GEN_2_0 |
SmartPowerGen_2.0 |
55370112 |
SmartPower Gen 2.0 |
The PRODUCT_CODE column shows the value the firmware returns over
Modbus FC 0x2B/0x0E (Read Device Identification). The library uses this
for auto-recognition — see Auto-detection.
The mapping from these public model names to the firmware-repo branch that
ships on each model is internal — see
smartpower_modbus/models.py. The firmware
branch names are implementation detail and may change without notice; the
public model names will not.
- The Gen 2.0 firmware renames
PA_COOLANT_FLOW→MCB_COOLANT_FLOW(same address0x200E). Both spellings resolve viaRegister.from_name(...). - Gen 2.0 and Gen 1.5 fix the firmware-side typo
ACIVE_PROFILE→ACTIVE_PROFILE(same address0x201E). Both spellings resolve. INPUT_REG_THERMO_REG_LIMIT(0x2021),HOLD_REG_THERMO_REG_EXT_SP(0x3018), andHOLD_REG_THERMO_REG_EXT_LIMIT(0x3019) exist only on SmartPowerSolo and SmartPowerGen_1.0. Reading or writing them against the other models raisesUnsupportedRegisterErrorbefore any wire activity.
from smartpower_modbus import SmartPowerModel, Register, SmartPowerClient
with SmartPowerClient(
port="COM5", slave_id=1, model=SmartPowerModel.GEN_2_0,
) as client:
out_p = client.read(Register.INPUT_REG_OUT_P) # int (uint16)
in_t = client.read(Register.INPUT_REG_IN_COOLANT_T) # int (int16, signed)
fault = client.read(Register.INPUT_FAULT) # bool (discrete input)
client.write(Register.HOLD_REG_SP_P, 50)
assert client.read(Register.HOLD_REG_SP_P) == 50The model= argument accepts:
- a
SmartPowerModelmember:SmartPowerModel.GEN_2_0 - the canonical public string:
"SmartPowerGen_2.0" - the Python member name:
"GEN_2_0"
read() and write() operate on raw 16-bit register values. For
physical-unit access — Amps, Volts, Watts, Hz, °C, … — use
read_value() / write_value(). The scaling factors and SI units come
straight from the Modbus spec (rev A7):
out_p_W = client.read_value(Register.INPUT_REG_OUT_P) # Watts (float)
out_i_A = client.read_value(Register.INPUT_REG_OUT_I) # Amps (float)
in_t_C = client.read_value(Register.INPUT_REG_IN_COOLANT_T) # °C (float, default)
freq_Hz = client.read_value(Register.INPUT_REG_FREQ) # Hz (float)
client.write_value(Register.HOLD_REG_SP_P, 50.0) # 50.00 %
client.write_value(Register.HOLD_REG_THERMO_REG_EXT_SP, 25.0) # 25 °CTemperature unit can be set on the client (default Celsius) or overridden per call. Conversions are applied on top of the firmware's Kelvin encoding:
from smartpower_modbus import SmartPowerClient, TemperatureUnit
with SmartPowerClient(
"COM5", slave_id=1, model=SmartPowerModel.GEN_2_0,
temperature_unit=TemperatureUnit.FAHRENHEIT,
) as client:
print(client.read_value(Register.INPUT_REG_IN_COOLANT_T)) # °F
# One-off override:
in_k = client.read_value(
Register.INPUT_REG_IN_COOLANT_T, temperature_unit="K",
)The firmware exposes two tank-capacitor pairs as adjacent (value + exponent) holding registers:
HOLD_REG_CAP_VAL(0x3008) /HOLD_REG_CAP_EXP(0x3009)HOLD_REG_SECOND_CAP_VAL(0x3012) /HOLD_REG_SECOND_CAP_EXP(0x3013)
For convenience, the client exposes each pair as a single Farads-valued float:
c1 = client.read_capacitance() # primary, single float in F
c2 = client.read_second_capacitance() # secondary, single float in F
client.write_capacitance(100e-6) # 100 µF, atomic 2-register write
client.write_second_capacitance(1e-3) # 1 mFwrite_capacitance chooses the (val, exp) pair that maximises uint16
precision (mantissa in [6554, 65535] whenever possible), so a round-trip
through read_capacitance preserves the input to ~4 decimal digits.
Negative, nan/inf, or out-of-range values (exponent outside [-30, 6])
raise InvalidValueError. Both reads and writes use a single Modbus
transaction so the value / exponent pair cannot be torn by a concurrent
peer.
values = client.read_holding(0x3007, count=2)
client.write_holding(0x3007, 50)Walkthrough script:
python example.py --port COM5 --slave 1 --model SmartPowerGen_2.0 --sp-p 50The library can identify the connected SmartPower model automatically
using Modbus FC 0x2B/0x0E (Read Device Identification). Each firmware
ships with a unique PRODUCT_CODE constant — the library queries it on
connect and maps it to a SmartPowerModel.
Three ways to use it:
Implicit (auto-detect at connect). Pass model=None (or simply omit
model=) and the client identifies the device during connect():
from smartpower_modbus import SmartPowerClient, Register
with SmartPowerClient("COM5", slave_id=1) as client:
print(client.model.value) # "SmartPowerGen_2.0"
print(client.read(Register.INPUT_REG_OUT_P))Explicit identification.
with SmartPowerClient("COM5", slave_id=1) as client:
model = client.identify_model() # SmartPowerModel.GEN_2_0
info = client.read_device_info() # vendor / product_code / revision
code = client.read_product_code() # "55370112"From the CLI.
smartpower-cli --port COM5 --slave 1 identify
# Vendor: Ultraflex Power
# Product code: 55370112
# Revision: 1.0.0
# Detected: SmartPowerGen_2.0If the device reports a PRODUCT_CODE that doesn't match any known
model, the library raises UnsupportedFirmwareBranchError with the raw
code in the message so it can be added to
smartpower_modbus/models.py:_MODEL_TO_PRODUCT_CODE. If the device
returns Modbus exception 0x01 (Illegal Function), the library raises
IllegalFunctionError — the firmware on the slave does not implement
FC 0x2B and you must pass model= explicitly.
When both model= is given and auto-identification disagrees
(via an explicit identify_model() call), the explicit value wins and
a warning is logged.
--model accepts a public SmartPower model name.
smartpower-cli --port COM5 --slave 1 --model SmartPowerGen_2.0 read OUT_P OUT_I OUT_V
smartpower-cli --port COM5 --slave 1 --model SmartPowerGen_2.0 write HOLD_REG_SP_P 50
smartpower-cli --port COM5 --slave 1 --model SmartPowerGen_2.0 dump
smartpower-cli --port COM5 --slave 1 --model SmartPowerGen_2.0 probe
smartpower-cli --port COM5 --slave 1 identify
smartpower-cli --model SmartPowerGen_2.0 list-registers
smartpower-cli list-modelsidentify is the only wire-touching subcommand that does not need
--model — it auto-detects via PRODUCT_CODE.
Pass -i / --interpret to read, write, or dump to apply the
firmware's scaling factor and SI units. Combine with
--temperature-unit {C,K,F} (default C) to pick the temperature
display unit.
# Raw uint16:
smartpower-cli --port COM6 --slave 1 --model SmartPowerGen_1.0 read OUT_I IN_COOLANT_T
# INPUT_REG_OUT_I 0x2012 = 20 (0x0014)
# INPUT_REG_IN_COOLANT_T 0x200B = 2981 (0x0BA5)
# Interpreted (default Celsius):
smartpower-cli --port COM6 --slave 1 --model SmartPowerGen_1.0 read -i OUT_I IN_COOLANT_T
# INPUT_REG_OUT_I 0x2012 = 2 A
# INPUT_REG_IN_COOLANT_T 0x200B = 24.95 °C
# Same data, Fahrenheit:
smartpower-cli --port COM6 --slave 1 --model SmartPowerGen_1.0 --temperature-unit F read -i IN_COOLANT_T
# INPUT_REG_IN_COOLANT_T 0x200B = 76.91 °F
# Write a temperature setpoint in Celsius (the firmware stores Kelvin x10):
smartpower-cli --port COM6 --slave 1 --model SmartPowerGen_1.0 write -i HOLD_REG_THERMO_REG_EXT_SP 25If the smartpower-cli entry point isn't on PATH, use
python -m smartpower_modbus.cli ....
All raised exceptions inherit from SmartPowerError:
UnsupportedFirmwareBranchError— model / branch name not recognisedUnsupportedRegisterError— register not exposed by the selected modelReadOnlyRegisterError— attempted to write a discrete input / input registerInvalidValueError— value out of range or wrong typeSerialPortError— could not open or hold the serial portModbusCommError— base for transport-level failuresModbusTimeoutError,ModbusCrcError— retried automatically on reads (up toretries=attempts). Writes are not retried by default so a torn / duplicate write can't silently bump a setpoint twice; passretry_writes=Trueto the client if every writable register on your bus is idempotent.IllegalFunctionError,IllegalAddressError,IllegalValueError,SlaveDeviceFailureError— Modbus exception responses 0x01 / 0x02 / 0x03 / 0x04, not retried
- Add a new member to
SmartPowerModelinsmartpower_modbus/models.pywith the canonical public name as the.valuestring. - Add a new member to
FirmwareBranchinsmartpower_modbus/branches.pywith the exact firmware-repo branch name as its.value. - Add the new model → branch pair to
_MODEL_TO_BRANCHand the model → product-code pair to_MODEL_TO_PRODUCT_CODEinmodels.py. The integrity asserts at the bottom ofmodels.pywill fail at import time if you forget. - In
smartpower_modbus/registers.py, append the new firmware branch to thebranches=set of any existingRegisterit exposes, and add newRegistermembers for any genuinely new addresses. - Run
pytest tests/.
Public API never changes shape — only the contents of these enums and the mapping table do.
pip install -e .[test]
pytestCI runs the same commands; reproduce them locally before pushing:
pip install -e .[dev]
python -m py_compile example.py
python -m ruff check .
python -m mypy smartpower_modbus
python -m pytest -q --cov=smartpower_modbus --cov-report=term-missing
python -m build # sdist + wheel
python -m twine check --strict dist/* # packaging metadataReleased under the MIT License. Copyright (c) 2026 Ultraflex Power.