Skip to content

Ultraflex-Power/SmartpowerModbus

Repository files navigation

smartpower-modbus

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.

Install

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

How to use

  1. Connect the module. Wire the SmartPower control board to your serial adapter (RS-485 / USB-serial). Note the OS port name (COM5 on Windows, /dev/ttyUSB0 on Linux) and the Modbus slave ID configured on the module (1..247).

  2. Pick an entry point.

    • Python libraryfrom smartpower_modbus import SmartPowerClient and use it as a context manager. See Library usage.
    • CLIsmartpower-cli ... for one-off reads/writes, dumps, and probes. See CLI. If the entry point isn't on PATH, run python -m smartpower_modbus.cli ....
  3. 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 — omit model= in Python, or use the identify subcommand on the CLI. See Auto-detection.

  4. Read or write registers. Use Register.<NAME> (or the CLI's short names like OUT_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, …) use read_value() / write_value() or the CLI's -i / --interpret flag.

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 50

The 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 50

Supported SmartPower models

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

Per-model register differences

  • The Gen 2.0 firmware renames PA_COOLANT_FLOWMCB_COOLANT_FLOW (same address 0x200E). Both spellings resolve via Register.from_name(...).
  • Gen 2.0 and Gen 1.5 fix the firmware-side typo ACIVE_PROFILEACTIVE_PROFILE (same address 0x201E). Both spellings resolve.
  • INPUT_REG_THERMO_REG_LIMIT (0x2021), HOLD_REG_THERMO_REG_EXT_SP (0x3018), and HOLD_REG_THERMO_REG_EXT_LIMIT (0x3019) exist only on SmartPowerSolo and SmartPowerGen_1.0. Reading or writing them against the other models raises UnsupportedRegisterError before any wire activity.

Library usage

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) == 50

The model= argument accepts:

  • a SmartPowerModel member: SmartPowerModel.GEN_2_0
  • the canonical public string: "SmartPowerGen_2.0"
  • the Python member name: "GEN_2_0"

Interpreted (scaled) reads and writes

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 °C

Temperature 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",
    )

Tank-capacitor read / write

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 mF

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

Low-level (raw addresses, no validation)

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 50

Auto-detection

The 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.0

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

CLI

--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-models

identify is the only wire-touching subcommand that does not need --model — it auto-detects via PRODUCT_CODE.

CLI: interpreted reads/writes and temperature units

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 25

If the smartpower-cli entry point isn't on PATH, use python -m smartpower_modbus.cli ....

Errors

All raised exceptions inherit from SmartPowerError:

  • UnsupportedFirmwareBranchError — model / branch name not recognised
  • UnsupportedRegisterError — register not exposed by the selected model
  • ReadOnlyRegisterError — attempted to write a discrete input / input register
  • InvalidValueError — value out of range or wrong type
  • SerialPortError — could not open or hold the serial port
  • ModbusCommError — base for transport-level failures
    • ModbusTimeoutError, ModbusCrcError — retried automatically on reads (up to retries= attempts). Writes are not retried by default so a torn / duplicate write can't silently bump a setpoint twice; pass retry_writes=True to the client if every writable register on your bus is idempotent.
    • IllegalFunctionError, IllegalAddressError, IllegalValueError, SlaveDeviceFailureError — Modbus exception responses 0x01 / 0x02 / 0x03 / 0x04, not retried

Adding a new SmartPower model

  1. Add a new member to SmartPowerModel in smartpower_modbus/models.py with the canonical public name as the .value string.
  2. Add a new member to FirmwareBranch in smartpower_modbus/branches.py with the exact firmware-repo branch name as its .value.
  3. Add the new model → branch pair to _MODEL_TO_BRANCH and the model → product-code pair to _MODEL_TO_PRODUCT_CODE in models.py. The integrity asserts at the bottom of models.py will fail at import time if you forget.
  4. In smartpower_modbus/registers.py, append the new firmware branch to the branches= set of any existing Register it exposes, and add new Register members for any genuinely new addresses.
  5. Run pytest tests/.

Public API never changes shape — only the contents of these enums and the mapping table do.

Tests

pip install -e .[test]
pytest

Developer checks

CI 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 metadata

License

Released under the MIT License. Copyright (c) 2026 Ultraflex Power.

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages