A Home Assistant custom integration for Modbus devices — over TCP gateways, transparent RTU-over-TCP bridges, or directly attached serial adapters — a ground-up rewrite of modbus_local_gateway that also draws on homeassistant-solax-modbus, built around four ideas:
- Block reads. Instead of one Modbus round trip per entity, each poll
merges everything into as few reads as possible: overlapping and adjacent
registers combine, small unused holes are bridged (cheaper to read than to
ask twice), and blocks respect the device's
max_register_readand the protocol limit of 125 registers. Real device files: an Eastron SDM630 drops from 85 transactions per poll to 7; a Pichler LG350 from 129 to 5. Registers the device chokes on are learned from failed reads and routed around; stubborn ones can be declared up front (bad_addresses,split_before). - Home Assistant is not shadowed. Every entity has an
ha:block that is passed to the real per-platformEntityDescriptionand validated against it — any entity feature HA supports (today or in a future release) can be set there, and typos fail loudly with the file, entity, and valid choices in the message. - Symmetric conversions. Whatever the integration can decode it can also
encode for writing (
multiplier/offset,map,sum_scale, masked bit fields via read-modify-write). Onlyflagsis inherently read-only. - Templates, not code. Derived values are declared as Jinja over the device's register values and re-rendered every poll, and the same engine drives writes. Device quirks become a few lines of YAML, not a plugin.
Failed bridged blocks fall back to unbridged reads automatically, and addresses a device refuses to serve are remembered and never bridged again. Offline devices back off exponentially (up to 5 min) instead of hammering the gateway. One failing entity does not take down the rest; it just becomes unavailable — and a register that keeps failing while the device answers everything else is quarantined out of the read plan and re-probed every 10 minutes, so a single bad address never costs permanent traffic.
Typical use cases: energy meters (Eastron SDM), solar inverters and hybrid storage (Growatt), heat pumps (Dimplex, Husdata gateways), ventilation units (Pichler, Salda), motor drives (Schneider Altivar), relay boards (Waveshare, Finder) — or any other device that speaks Modbus TCP, directly or through an RS-485 gateway.
- In HACS, choose Custom repositories in the three-dot menu, add
https://github.com/dmatscheko/modbus_connectwith type Integration (or click).
- Install Modbus Connect and restart Home Assistant.
Copy custom_components/modbus_connect/ into
<ha_config>/custom_components/ and restart Home Assistant.
It can be installed side by side with modbus_local_gateway; entities are
independent.
Add the Modbus Connect integration in Settings → Devices & services
(or click
):
pick the device definition and name the device, then choose how it is reached
— over the network (Modbus TCP, or RTU over TCP for transparent bridges) or
through a directly attached RS-485/RS-232 adapter — and enter the connection.
The Modbus device ID and the entity-ID prefix are prefilled from the chosen
device file, and the connection is verified with a real read from the device
before anything is created — a wrong Modbus ID fails right there instead of
producing an entry full of unavailable entities. Add the integration once per
Modbus device — several devices can share one gateway or adapter.
Worth knowing: entity IDs are assigned when an entity is first created, so changing the prefix later does not rename existing entities. The entry's options (gear icon) set a minimum poll interval — a floor over the device file's cadences that only ever slows polling down. Reconfigure (three-dot menu) changes the device file, name, or connection without removing the entry.
Every device is described by one YAML file — a bundled one from the table
below, or your own in <ha_config>/modbus_connect/ (your files survive
updates and override built-in files with the same name; invalid files are
skipped, with the entity and reason shown right in the config flow's device
picker and in the log).
A few lines per entity are enough:
input:
phase_1_voltage:
address: 0x0000
type: float32
ha:
platform: sensor
name: Phase 1 voltage
device_class: voltage
unit_of_measurement: VThe complete format — register types and conversions, writable entities,
composite template: entities (climate, fan, cover, …), entity groups, and
read-planning hints — is documented in the
device file reference.
Definitions ship with the integration; pull requests with new files are welcome. Any Modbus TCP device not listed here works too — write a device file for it.
Tested so far: only the five ✓ files below have been tested on real
hardware and are actively maintained here — each has a hand-written source
(support/devicedocs/<slug>/device.yaml) that the bundled file is generated
from. The rest are community-contributed, mostly converted from
modbus_local_gateway and
homeassistant-solax-modbus;
they should work but have not been verified against hardware here, so treat
them as a starting point and please report corrections.
| Manufacturer | Model | File | Tested |
|---|---|---|---|
| Dimplex | Sole/Wasser-Wärmepumpe SI 11TU | dimplex-si-11tu.yaml |
✓ |
| Eastron | SDM-230 | eastron-sdm230.yaml |
|
| Eastron | SDM-630 | eastron-sdm630.yaml |
|
| ebyte | ME31-AXAX404 | ebyte-me31-axax404.yaml |
|
| Finder | 7M.24 | finder-7m24.yaml |
|
| Finder | 7M.38 | finder-7m38.yaml |
|
| Fröling | BWP300 PV | froeling-bwp300-pv.yaml |
|
| Growatt | MIC 2500TL-X | growatt-mic-2500tl-x.yaml |
|
| Growatt | MIN 6000TL-XH | growatt-min-6000tl-xh.yaml |
|
| Growatt | MOD 6000TL-X | growatt-mod-6000tl-x.yaml |
|
| Growatt | MOD 10KTL3-XH | growatt-mod-10ktl3-xh.yaml |
|
| Growatt | SPH3600TL BL_UP | growatt-sph-3600tl-bl-up.yaml |
|
| Husdata | H60 | husdata-h60.yaml |
|
| Pichler | Lüftungsgerät LG 150 – LG 250 | pichler-lg150-lg250.yaml |
✓ |
| Pichler | Lüftungsgerät LG 350 – LG 450 | pichler-lg350-lg450.yaml |
✓ |
| Salda | RIS / RIRS (MCB) | salda-ris-mcb.yaml |
|
| Schneider Electric | Altivar ATV312 | schneider-atv312.yaml |
|
| Schneider Electric | Altivar ATV312 Expert | schneider-atv312-expert.yaml |
|
| SolaX Power | X3-Hybrid G4 | solax-x3-hybrid-g4.yaml |
✓ |
| SolaX Power | X3-HAC (11 kW EV charger) | solax-x3-hac.yaml |
✓ |
| Varmann | Qtherm | varmann-qtherm.yaml |
|
| Waveshare | Modbus POE ETH Relay 30CH | waveshare-modbus-poe-eth-relay-30ch.yaml |
|
| Waveshare | Modbus RTU Relay (D) | waveshare-modbus-rtu-relay-d.yaml |
Big devices expose far more settings and sensors than most people want, so a
device file can tag its entities into named groups — a basic / standard /
advanced detail tier, plus per-subsystem groups (how, is in the
device file reference).
Switches on the device's companion Configuration device turn whole groups
on and off; basic is the always-visible baseline and has no switch, while
standard is enabled by default — so a fresh install shows the everyday set
and leaves the deeper detail one switch away. The bundled SolaX X3-Hybrid G4 file, for example, keeps its
parallel-mode, EPS, and generator register blocks in groups of their own —
the same opt-ins the solax-modbus integration offers as config checkboxes,
except a switch flip materializes the entities (and their register reads) at
runtime.
Hidden entities are not merely disabled — they stop being provided and drop out of the Modbus read plan entirely. Home Assistant greys them out but keeps their registry rows, so renames, areas, and enabled/disabled states all come back when the group does. The Remove hidden entities button deletes those greyed-out leftovers (including stale rows from an earlier device file) without touching anything that is currently provided.
Everything the Energy Dashboard needs is a first-class entity of the bundled hybrid file (the solax-modbus integration offers the same values as copies on a virtual device behind three enable switches — here the groups reveal the real sensors instead):
| Dashboard slot | Entity | Group |
|---|---|---|
| Grid consumption | today_s_import_energy |
advanced |
| Return to grid | today_s_export_energy |
advanced |
| Solar production | today_s_solar_energy |
basic |
| — per string | pv_energy_1, pv_energy_2 |
solar_details |
| Battery in | battery_input_energy_today |
basic |
| Battery out | battery_output_energy_today |
basic |
| Grid → battery | e_charge_today, live grid_to_battery_power |
grid_to_battery |
| Home consumption | home_consumption_energy, live house_load |
home_consumption |
The solar_details, home_consumption, and grid_to_battery groups mirror
upstream's Enable PV Variant Detail / Home Consumption / Grid to Battery
Sensors switches. The kWh sensors among them have no native counter on the
device, so they integrate the matching power over time — the device file's
integrate
feature, no Integral helper needed.
The integration polls. Each cycle collects every entity that is due, plans the minimal set of block reads (see the top of this page), executes them over one shared TCP connection per gateway, and decodes all values from the result. The device file sets each entity's poll cadence; the config-entry option is only a floor that slows polling down, never speeds it up (the exact precedence is in the device file reference). Writes are confirmed by reading the register back immediately.
The Configuration companion device carries the read diagnostics: a Reads per refresh sensor (how many block reads a full refresh issues — usually far below the entity count, that gap being the merge win), a Read failures problem indicator for the last 5 minutes, and a Failed reads running total. Both failure entities count unrecovered failures only, so a healthy device never writes them to the recorder.
A register that keeps failing while the device answers everything else — the
signature of a wrong address in a device file — is quarantined: the entity
goes unavailable, its registers leave the read plan, and a probe every
10 minutes lifts the quarantine as soon as the device serves them again. The
log warns with the entity and address; Download diagnostics lists
quarantined and per-entity failure counts (failed_reads_by_key, worst
first). A register the device genuinely never serves is best removed from the
file or declared in
bad_addresses.
Entities behave like any other Home Assistant entities:
automation:
- alias: Boost ventilation while cooking
triggers:
- trigger: state
entity_id: binary_sensor.kitchen_hood_running
to: "on"
actions:
- action: fan.set_percentage
target:
entity_id: fan.pichler_lg350_ventilation
data:
percentage: 75
- alias: Warn on inverter fault
triggers:
- trigger: state
entity_id: sensor.growatt_mod_6000tl_x_fault_flags
conditions: "{{ trigger.to_state.state not in ('', 'unknown', 'unavailable') }}"
actions:
- action: notify.mobile_app_phone
data:
message: "Inverter fault: {{ trigger.to_state.state }}"- Serial adapters must be visible to Home Assistant. For direct RTU use
the adapter has to show up as a device on the machine running Home
Assistant (in containers, map it in; prefer stable
/dev/serial/by-id/…paths). A networked RS-485 bridge works either as a Modbus TCP gateway or in transparent mode with Modbus RTU over TCP as the protocol (also the right choice forser2net). - No discovery. Modbus has no discovery protocol; the gateway address must be entered manually.
- Reads are capped at 125 registers per transaction by the Modbus
protocol;
max_register_readcan only lower that. flagsentities are read-only — a bit field cannot be written back as a whole. Usemaskwithread_modify_writeto write single fields.- Writes go through the same conversions as reads, so a value that
cannot be encoded (e.g. not in the
map) is rejected instead of written. - One device per config entry. A gateway serving several Modbus device IDs needs one entry per device (they share the TCP connection automatically).
-
"Failed to connect" in the config flow — check host/port, and that nothing else holds the gateway's only TCP slot; many cheap RS-485 gateways allow exactly one client.
-
Some entities are unavailable — the device rejected their addresses (wrong device file, or the register only exists on other firmware). The log lists every address the device refused. Those addresses are excluded from gap bridging automatically.
-
Everything is unavailable — the device did not answer at all: wrong Modbus device ID, or the gateway is up while the RS-485 side is down. The integration logs once when a device becomes unreachable and once when it recovers, and retries with exponential backoff (up to 5 min).
-
Wrong values — usually byte order: try
swap: word,byte, orword_byte, and checkmultiplier. -
Diagnostics: the device page offers Download diagnostics with the parsed device definition, poll planning state, and current values (host redacted).
-
Debug logging:
logger: logs: custom_components.modbus_connect: debug pymodbus: info
Remove the integration entry in Settings → Devices & services (this
deletes its entities and device), then remove Modbus Connect in HACS (or
delete custom_components/modbus_connect/ manually) and restart Home
Assistant. Your own device files in <ha_config>/modbus_connect/ are never
deleted automatically.
Custom integrations cannot carry an official quality scale rating (the
manifest says custom), but the code follows the
integration quality scale
up to and including the Platinum rules — connection test before setup,
reconfigure flow, translations (English and German), translated exceptions,
diagnostics, parallel-update limits, strict typing, async I/O throughout, and
98% test coverage. quality_scale.yaml
documents every rule, including the exemptions (Modbus has no discovery, no
authentication, and no fixed entity set to translate).
python3 -m venv .venv
.venv/bin/pip install -r requirements_test.txt
.venv/bin/python -m pytest tests/ --cov=custom_components.modbus_connect
.venv/bin/ruff check custom_components tests support
.venv/bin/mypy custom_components/modbus_connect # strict, see pyproject.tomlLayout: models.py (plain dataclasses), codec.py (registers ↔ values,
pure), planner.py (block planning, pure), client.py (pymodbus wrapper),
schema.py (YAML validation), coordinator.py (polling, cache, backoff,
writes), entity.py + thin platform modules including template-driven
climate.py. The pure modules have no Home Assistant imports and are tested
standalone; tests/test_e2e_server.py proves the transaction counts against
a real TCP server. The device-file YAML format is documented in
docs/device_files.md.
Brand assets live in support/brand/ (SVG sources and build_brand.py to
regenerate them and the PNGs in custom_components/modbus_connect/brand/,
which Home Assistant ≥ 2026.3 serves locally), alongside
support/modbus_cli.py — a standalone Modbus debugging CLI (probe, read
with decoded views, write, register scan; see its --help),
support/modbus_scanner/ — a live web-UI register scanner that colours
registers by change rate, generates a device-file skeleton, and overlays an
existing device file to test it against the device (--demo needs no
hardware) — and
support/build_json_schema.py, which regenerates the editor schema for
device files (docs/device_files.schema.json);
a test fails when the committed schema is stale.
Releases are cut from the GitHub Actions tab: run the Release workflow
and enter the version (e.g. 0.3.0). It re-runs the full gate (ruff, mypy,
tests), bumps manifest.json/pyproject.toml when the version is new, tags
vX.Y.Z, and publishes a GitHub release with generated notes — the version
HACS then offers to users.