Skip to content

Component groups

A ComponentGroup stands in for its members at update time. It takes a list of components instead of declaring fields, and offers the same async_update() / async_read_raw() / notify() surface over one pooled set of block reads spanning every member. Fields, writes and listeners stay on the members themselves.

A physical device is usually several sub-systems on one unit, such as a water heater, three heating circuits, and a set of sensors. Polling each one separately means many small Modbus reads where a few larger ones would do. A group fetches adjacent registers from different components in the same Modbus call, and each component’s listeners still fire after the update.

from modbus_connection.model import ComponentGroup
group = ComponentGroup(unit, [water_heater, circuit_1, circuit_2, circuit_3])
await group.async_update() # one pooled set of reads; each component notified

The ComponentGroup builds its pooled read plan from the components’ static layout and reuses it on every later poll. Reshaping a member through restrict_fields is supported at any time. It invalidates the pooled plan, which is rebuilt from the narrowed layout on the next update. The member list itself is fixed at construction. Assigning to a member’s layout attributes directly (its fields or range tuples) bypasses that invalidation and is not supported. Build a new ComponentGroup (or Component) for those.

Because reads across a group are pooled, read_holding_registers is called once per contiguous block spanning whichever components fall in it, rather than once per component. For a device with dozens of scattered fields this typically collapses tens of reads into a handful.

Pooling never widens a read beyond what the members already allow. A member that declared no readable ranges merges exactly as it would if it refreshed alone, and it shares a block with another member only where their blocks meet. Where a member did declare ranges, that map applies to the pooled read as it does to a solo one: inside a range the planner still merges freely over registers no field claims.

The readable address ranges and planning limits come from the components, because they describe one device’s address map. Components in a group must therefore agree:

  • Readable ranges apply per address space, and the group merges what its members declare for each one. Ranges are resolved to the addresses the members read, so a member placed with base_offset contributes its shifted map. Members at different offsets each describe their own part of the device.
  • Two members whose resolved ranges overlap without matching describe the same addresses two different ways. That raises ValueError.
  • Every component must share max_span, which caps a pooled block’s width.

The range rules are a guard. A group is one device, so its members cannot disagree about that device’s map.

class Base(Component):
register_ranges = ((0, 6), (9, 40))
coil_ranges = ((0, 15),)
class WaterHeater(Base): ...
class Circuit(Base): ...
# All share the same ranges, so the group accepts them.
group = ComponentGroup(unit, [WaterHeater(unit), Circuit(unit, index=1)])
await group.async_update()

Group updates fail the same way individual ones do. If the device answers one of the pooled block reads with a Modbus exception response, async_update() raises the typed exception for its code. This holds for any block across the pooled members. See A refused block read for what the exception carries.

  • One sub-system, or sub-systems polled on different schedules: individual Component.async_update().
  • Several sub-systems of one device polled together: a ComponentGroup.
  • A layout not known until runtime (from config): a ManualComponent.