Skip to content

Built-in fields

A field is a descriptor you place on a Component. It owns the codec, which turns raw register words into a Python value, but holds no per-read state. Reading the attribute returns T | None: the decoded value, or None before the first read or when a sentinel decodes to “no value”.

Prefer the helpers below over constructing field classes directly. They are named presets (width, sign, sentinel, scale) over a small set of codecs. This page covers what each helper means and when to use it. The full signatures live in the field reference. Every helper lives in modbus_connection.model:

from modbus_connection.model import (
integer,
gauge,
boolean,
bit,
bits,
raw_register,
uint32,
int32,
uint64,
int64,
float32,
float64,
string,
enum,
flags,
coil,
discrete_input,
)

Most register helpers accept the same keyword arguments. Not every option applies to every helper. A string has no scale, for example. The field reference lists all of them. The options below need more explanation than a table row.

Numeric fields decode as raw * scale + offset. Pass offset for a device that reports a shifted value. For example, gauge(0, 0.1, offset=-100) decodes a temperature stored as raw * 0.1 - 100. Writable fields invert it as (value - offset) / scale.

Many devices send a reserved value to mean “this point is not implemented”. Pass that raw value as nan= and the field decodes it to None:

temperature = gauge(5, 0.1, nan=0x8000) # 0x8000 -> None

A device may define several distinct “no value” codes for the same register, such as one for an absent register and another for an unplugged probe. Pass all of them:

temperature = gauge(5, 0.1, nan=(0x8000, 0xF448)) # either -> None

The values are matched against the raw register word, before signed is applied. State them as they appear on the wire: 0xF448 rather than -3000.

A float field needs no sentinel. float32 and float64 decode a NaN reading to None on their own. Passing nan= to them is deprecated: it does nothing and raises a DeprecationWarning.

word_order selects the order of the 16-bit registers in a multi-register value. It defaults to "big" (the Modbus convention), covering the ABCD arrangement. Pass "little" for CDAB.


An unscaled integer register, such as a count, a percentage, or an address.

count = integer(4) # signed 16-bit int
percent = integer(7, signed=False) # 0..65535
shifted = integer(2, offset=-100) # raw - 100

A scaled numeric register, such as a 0.1-scaled temperature or a voltage. It is the one helper where scale is a required positional argument.

voltage = gauge(0, 0.1, unit="V") # raw * 0.1
temp = gauge(9, 0.1, offset=-40, unit="°C") # raw * 0.1 - 40

A single raw register word with no scaling, sign handling, or sentinel. Use it for a status word you decode yourself.

status = raw_register(7) # the word as-is, 0..65535

uint32 / int32 span two consecutive registers; uint64 / int64 span four. All take scale, offset, word_order, unit, and the write options.

energy = uint32(2, unit="Wh") # 32-bit over registers 2–3
signed_power = int32(10, word_order="little") # CDAB word order
lifetime = uint64(20, unit="Wh") # 64-bit over registers 20–23

float32 decodes an IEEE-754 single over two registers; float64 a double over four. Both take scale, offset, word_order, unit, and the write options.

flow = float32(40, unit="m³/h")
precise = float64(50)

string reads a fixed-length null-padded ASCII string over length registers (two characters per register).

serial = string(100, 8) # 8 registers -> up to 16 ASCII characters

Map a raw register to an IntEnum or IntFlag.

  • enum is an IntEnum field. A code with no member decodes to None, warned once per distinct value.
  • flags is an IntFlag field. Unknown bits are kept.
from enum import IntEnum, IntFlag
class Mode(IntEnum):
OFF = 0
HEAT = 1
COOL = 2
class Alarms(IntFlag):
OVERTEMP = 1
UNDERVOLT = 2
class Device(Component):
mode = enum(3, Mode)
alarms = flags(4, Alarms)

signed interprets the code as two’s-complement, for devices with negative enum codes (e.g. -1 sent as 0xFFFF). The default is unsigned.

Both helpers pass the enum class to NumberField(convert=...). convert accepts any Callable[[int], T], and an enum class is a callable that raises ValueError for unknown codes. It also accepts a Mapping[int, T], where a missing key means the same. Either way an unknown value decodes to None, warned once per distinct value. For a mapping an enum class cannot express, such as a mapping onto a StrEnum, pass the dict inline:

from enum import StrEnum
from modbus_connection.model import Component, NumberField
class State(StrEnum):
OFF = "off"
RUNNING = "running"
class Device(Component):
state: NumberField[State] = NumberField(
5, signed=False, convert={1: State.OFF, 4: State.RUNNING}
)

A callable converter signals an unknown value only by raising ValueError. Any other exception (including KeyError) is a bug and propagates, failing the read.

Many devices report on/off state as a 0/1 register rather than a coil. boolean decodes such a register to bool | None. Any value other than 0 or 1 decodes to None, warned once, so an out-of-spec code reads as unknown rather than truthy:

class Relay(Component):
output = boolean(0, writable=True) # holding register: 0 = off, 1 = on

Pass nan= for a device with a “no value” sentinel. The sentinel decodes to None without a warning. boolean reads the component’s register space. For a coil or discrete input, use the bit fields below.

Devices routinely pack several independent settings into one register. bit exposes one of them as a bool, and bits a run of them as an int:

class SiteLimit(Component):
limit_mode = bits(0xE000, 0, 3, writable=True) # bits 0-2
external_production = bit(0xE000, 10, writable=True)
negative_limit = bit(0xE000, 11, writable=True)

Fields at the same address are read together, so this costs one register. Writing one of them re-reads the register, replaces that field’s bits, and writes the word back. Every other setting is left alone, including one changed since the last poll by the device itself or by another writer. A value too wide for the run raises ValueError instead of being truncated.

Single-bit fields decode to bool | None. Each carries its own space, so a component may mix them freely.

  • coil is a coil (FC01). Pass writable=True to allow writes.
  • discrete_input is a discrete input (FC02). It has no writable option because discrete inputs are read-only.
class IO(Component):
relay = coil(0, writable=True)
fault = discrete_input(0) # distinct address space from coil 0

writable=True marks a field writable and writes the value as-is. Passing a validator callable instead both marks it writable and vets the value before each write. The validator receives the requested value and returns the value to write, or raises to reject it, before anything reaches the device:

def in_range(value: int) -> int:
if not 0 <= value <= 100:
raise ValueError(f"{value} out of range")
return value
class Boiler(Component):
setpoint = integer(0, writable=in_range)

The library ships no validators of its own. For ready-made ones, see probatio.

For registers, write() picks FC06 for a single word and FC16 for multiple. Pass force_fc16=True for a device that honours only FC16 even for one register.

scale_register points at a separate register whose signed int16 value is read alongside the field and applied as 10**sf. This is the SunSpec sunssf convention.

A write() on a dynamically-scaled field takes the engineering value. The scale factor is read fresh in the same write, and the value is snapped to the precision the factor grants before encoding. For example, 12.349 with a 10**-2 factor writes raw 1235. An exponent whose factor cannot scale, such as SunSpec’s not-implemented sunssf value, raises ValueError. A write never guesses a scale. A read decodes the same case to None.

A field may also declare scale_exponent_range=(low, high) for a spec that bounds the exponent. A register-sourced exponent outside the range decodes the value to None and refuses writes the same way. The SunSpec point types declare the sunssf spec range (-10..10) by default. See the SunSpec page for the pre-wired point types built on this.

Almost every device map is expressible with the helpers above. For the rest, shape the value in a property or construct a field class directly.

Shape the value in a @property to compose or transform several fields, or to unpack packed dates and times. Keep the field private and expose the computed value, so static typing stays exact:

from modbus_connection.model import Component, string
class Controller(Component):
_firmware = string(10, 4) # 4 registers of ASCII, e.g. "1.23"
@property
def model(self) -> str | None:
firmware = self._firmware
return f"TROVIS 5576 ({firmware})" if firmware is not None else None

Construct a field class directly when the codec itself is the problem, because the device packs its words in a way no helper decodes. The helpers on this page return instances of a small set of classes: NumberField, FloatField, StringField, RawField, PackedBitField / PackedBitsField for packed bits, and CoilField / DiscreteInputField for bits of their own. The field reference documents each class’s constructor and attributes.