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 — a water heater, three heating circuits, 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, not 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 — they describe one device’s address map — so components in a group must 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 actually 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 can’t 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 — here for any block across the pooled members. See When a block read fails 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.