Manual components
When the field layout comes from config (e.g. YAML) rather than a typed class
— there’s no Component subclass to declare — use a ManualComponent. It’s the
imperative twin of Component: you
add() targets by key at runtime, and it pools them into as few reads as
possible, mixing all four tables (holding, input, coils, discrete inputs) in one
update.
from modbus_connection.model import ManualComponent, gauge, uint32, coil, discrete_input
mc = ManualComponent(unit, max_gap=16)mc.add("flow_temp", gauge(40, 0.1)) # holding (default)mc.add("energy", uint32(2), space="input") # input registersmc.add("relay", coil(5, writable=True)) # coils (FC01)mc.add("alarm", discrete_input(9)) # discrete inputs (FC02)
data = await mc.async_update() # {"flow_temp": 21.5, "energy": 100000, ...}mc.get("flow_temp") # 21.5await mc.write("relay", True) # per-key write (holding / coils only)How it differs from Component
Section titled “How it differs from Component”Component |
ManualComponent |
|
|---|---|---|
| Layout | class attributes, known statically | add()ed by key at runtime |
| Value access | typed attribute (meter.voltage) |
get(key) / the dict from async_update() |
| Addressing | index / stride / base_offset |
absolute address, no index/stride |
| Read planning | ✅ pooled | ✅ pooled (same machinery) |
Joins a ComponentGroup |
✅ | ❌ |
It reuses the same planning, write (validator / force_fc16), bit, and
repeating-group machinery as Component. It just has no class to hang typed
descriptors on, so values come out by key.
Adding and removing targets
Section titled “Adding and removing targets”mc.add(key, target, *, space=None)mc.remove(key)- A register target takes its
spaceonadd()—"holding"(default) or"input". - A bit target’s space is fixed by the factory (
coil→ FC01,discrete_input→ FC02); passingspacefor a bit raises. - A
repeating_groupcan beadd()ed like any other target; its instances come out viaget(key)as alistof sub-components (sized at poll time for a register count). - The field
addressis absolute — there is noindex/stride.
add() and remove() invalidate the cached plan, so it re-plans on the next
update. Adding a key that already exists replaces the previous target.
Reading values
Section titled “Reading values”mc.get("flow_temp") # the decoded value for one key (None if not yet read)mc.values # a copy of every decoded value as a dictasync_update() returns the same dict it stores, so you can use either the return
value or get() afterwards.
Writing
Section titled “Writing”await mc.write("relay", True)Writes go by key and share Component.write’s behaviour — the writable
validator, and FC06 / FC16 selection with force_fc16. Only holding registers and
coils are writable; a discrete input or input register is read-only, so writing
one raises.
Readable ranges
Section titled “Readable ranges”Ranges are per-table keyword arguments on the constructor —
holding_ranges / input_ranges / coil_ranges / discrete_ranges. Any table
left unset falls back to gap-based planning:
ManualComponent(unit, holding_ranges=((0, 40),), input_ranges=((500, 520),))A worked example: config-driven fields
Section titled “A worked example: config-driven fields”This is exactly the shape you want when mapping a user’s configuration onto a
device. The Home Assistant YAML page
builds a full example on top of ManualComponent.
def build(unit, config: list[dict]) -> ManualComponent: mc = ManualComponent(unit) for entry in config: mc.add( entry["name"], gauge(entry["address"], entry.get("scale", 1.0)), space=entry.get("space", "holding"), ) return mc