Skip to content

Repeating groups

Devices that expose several identical sub-units (heating circuits, channels, phases, MPPT modules) repeat the same registers at a fixed step. A repeating_group models one instance as a Component and gives the parent a typed list of them. The count is a fixed int, or a register the device reports at poll time. For example, a SunSpec multiple-MPPT model (160) carries an N point saying how many modules follow.

from modbus_connection.model import Component, integer, repeating_group
from modbus_connection.model.sunspec import uint16
class MPPTModule(Component): # one module, at instance 0's addresses
dc_w = integer(11, scale_register=2)
dc_v = integer(10, scale_register=1)
class Inverter(Component):
modules = repeating_group(uint16(8), MPPTModule, stride=20) # N at register 8
inv = Inverter(unit)
await inv.async_update()
inv.modules # list[MPPTModule]
inv.modules[0].dc_w # typed per-instance access
await inv.modules[2].write("dc_w", ...) # writes go through the instance

For a layout a group cannot express, such as a sub-unit whose registers are interleaved by type across the map, place the instances by hand with index and a per-field stride.

count is a RegisterField (read each poll) or a fixed int. Instance i has every address of its declared layout shifted by i * stride on top of the parent’s own placement. stride is therefore the block length.

  • A fixed int count is static, so its instances fold into the component’s normal read. No extra pass is needed.
  • A RegisterField count needs a second pass. The count is read first, then the sized-out instances, pooled among themselves. The count must be known before the instances it sizes can be planned. A float-typed count field (a SunSpec uint16 N point) is accepted, and the decoded count is truncated.

An unimplemented or unreadable count yields no instances. A component with a repeating_group can refresh on its own or be pooled in a ComponentGroup. The group reads the counts in its pooled read, then refreshes each member’s groups.

A sub-unit that declares readable ranges constrains the reads of every instance. Each instance’s map resolves like its fields, shifted by its own place in the repeat. The maps are merged into the plan its instances are read from.

class Channel(Component):
register_ranges = ((0, 1), (4, 5)) # 2-3 unreadable inside a channel
a = integer(0)
b = integer(4)
class Meter(Component):
channels = repeating_group(2, Channel, stride=10)
# Reads 0, 4, 10 and 14, and never across the gaps the channel declares unreadable.

A repeating_group’s component_class is itself a Component, so it may declare its own repeating_group: a sub-unit that repeats within each instance (channels within each module, cells within each string). Nesting works in any combination of fixed and register counts, to any depth. Each instance’s addresses shift by its parent’s stride, and the shifts compose additively down the levels.

class Cell(Component):
voltage = uint16(0)
class String(Component):
cells = repeating_group(uint16(1), Cell, stride=1) # per-string cell count
class Battery(Component):
strings = repeating_group(uint16(0), String, stride=100) # string count

A register count at any level adds a read pass for the level below it, because the count must be read before the instances it sizes can be planned. A two-deep tree with register counts at both levels therefore polls in three passes: the outer count, then the inner counts, then the leaves. Fixed int counts add no pass at any level. They fold into the enclosing read.

A nested group’s register count shifts with the enclosing instance by default: string i above reads its cell count at 1 + i * 100. Pass count_in_block=False when the count is a point of the outermost layout instead, as a SunSpec NPt point in the model’s fixed block is. Every instance then reads it at the same address.

class Curve(Component):
# NPt is at model offset 5, whatever curve this is
points = repeating_group(uint16(5), Point, stride=2, count_in_block=False)
class VoltVar(Component):
n_crv = uint16(4)
n_pt = uint16(5)
curves = repeating_group(uint16(4), Curve, stride=30)

Without the flag, curve 1 would read its count at 35. A device that answers 0 there silently gives it no points.

stride may be a callable instead of an int, for a block whose width is only known once the device has been read. The callable receives the component that owns the outermost block, after its fixed block has been read.

In SunSpec model 705 each curve is a ten-register header followed by NPt points, and NPt is a point of the model. The sub-components declare their fields where the first instance has them, as any repeated block does:

class VoltVarPt(Component):
v = int16(25) # the first point follows the first curve's header
var = int16(26)
class VoltVarCrv(Component):
act_pt = uint16(15) # the first curve follows the fixed block
pt = repeating_group(uint16(5), VoltVarPt, stride=2, count_in_block=False)
class VoltVar(Component):
n_pt = uint16(5)
n_crv = uint16(6)
crv = repeating_group(uint16(6), VoltVarCrv, stride=lambda m: 10 + 2 * m.n_pt)

A group with a callable stride is read in the second pass, like a register-counted group, even with a fixed int count. The trip models (707–710) use that: each curve holds three same-shaped regions of 1 + 3 * NPt registers, so they are one repeating_group(3, TripRegion, stride=_region), with a property per region for the spec’s names. Such a group is empty until the first update. The callable runs on every poll. If its result changes, the instances are rebuilt where it now puts them. On a ManualComponent the callable receives the ManualComponent. Read the values it needs with get().

By default a scaled field’s scale_register stays put across instances. It names a shared scale factor in the parent’s fixed block. A sub-unit that carries its own scale factor per repeat sets the scale_in_block class attribute. Each instance’s scale registers then shift with it:

from modbus_connection.model import Component, integer, repeating_group
class Channel(Component):
scale_in_block = True # each channel carries its own scale factor
a = integer(0, scale_register=1)
class Meter(Component):
channels = repeating_group(integer(4, signed=False), Channel, stride=2)

Without scale_in_block, every channel would read its scale factor from the one address relative to the block start. With it, channel i’s scale_register shifts by i * stride like the rest of its block.