Integration structure
modbus-connection is a clean foundation for a built-in Home Assistant integration — one that ships in Home Assistant Core. The split it enforces — a connection owned at the top, stateless units handed down, typed components over them — lines up exactly with how Home Assistant wants a device integration structured.
The library requirement
Section titled “The library requirement”A built-in integration may not talk to the device directly. Home Assistant Core requires all protocol and device communication to live in a separate library published to PyPI; the integration itself is a thin layer that wires that library to Home Assistant’s entities, config flow and coordinator.
That requirement is precisely the library entrypoint pattern:
a standalone package, built on modbus-connection, that exposes a device object
over Components and consumes a ModbusUnit. Build that library first — it is
what the integration will import and list in its manifest.json requirements.
The recommended layering
Section titled “The recommended layering”An integration built this way has four clear layers:
- A device library (its own PyPI package) — the
entrypoint pattern: a top-level device
object over
Components, backend-neutral, consuming aModbusUnit. This has no Home Assistant dependency and is released and tested on its own. - modbus-connection — the connection + modelling foundation the library is built on.
- Home Assistant’s
modbus_connectionintegration — a shared integration in Core that owns the actualModbusConnectionand hands outModbusUnits. - Your device integration (in
homeassistant/components/<domain>/) — gets aModbusUnitfrom the shared integration, runs aDataUpdateCoordinatorthat calls the library’sasync_update(), and exposes entities that read the library’s typed attributes.
Keeping the device library separate is not just good practice here — it is a condition for merging into Core, and it means the hard part (the register map) gets tested against the mock with no Home Assistant in the loop.
Who owns the connection
Section titled “Who owns the connection”Your integration does not own the connection. In Home Assistant, a shared
modbus_connection integration owns the ModbusConnection and manages its
lifecycle. Your device integration depends on it and only ever obtains a
ModbusUnit for its configured unit id, which it passes straight into your
device library:
from homeassistant.components.modbus_connection import async_get_unit
async def async_setup_entry(hass, entry) -> bool: # CONF_CONNECTION is the modbus_connection config entry id; CONF_UNIT_ID the # station address. async_get_unit raises ConnectionNotReady (a # ConfigEntryNotReady subclass) if the shared link isn't up yet — Home # Assistant then retries setup for you. unit = async_get_unit( hass, entry.data[CONF_CONNECTION], int(entry.data[CONF_UNIT_ID]) ) device = MyDevice(unit) # your device library's entrypoint
# The connection does not self-reconnect: when the shared link drops, reload # this entry so setup re-runs and picks up a fresh unit once it is back. entry.async_on_unload( unit.on_connection_lost( lambda: hass.config_entries.async_schedule_reload(entry.entry_id) ) )
coordinator = MyCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator ...Your config flow stores just those two values — the connection entry id and the
unit id — not a host or port; the connection details live in the shared
modbus_connection entry.
unit.on_connection_lost returns an unsubscribe, so registering it with
entry.async_on_unload detaches the callback automatically when the entry
unloads.
This is the whole point of the connection / unit split: one physical Modbus link
is shared across every device integration on it, rather than each opening a
competing socket. You never call a connect function, never hold the connection,
and never call close() — the shared integration does all of that. You work
entirely in terms of the ModbusUnit you were handed, exactly as the device
library does.
The coordinator
Section titled “The coordinator”A DataUpdateCoordinator._async_update_data becomes a one-liner: refresh the
device, and let its typed attributes back the entities.
async def _async_update_data(self) -> None: try: await self.device.async_update() except ModbusError as err: raise UpdateFailed(str(err)) from errEach entity’s native value is then just an attribute read on the device library —
self.coordinator.device.sensors.outside_1 — with the field metadata (unit,
enum members) feeding the entity’s native_unit_of_measurement, device_class
and so on.
Errors map cleanly
Section titled “Errors map cleanly”Catch ModbusError in the coordinator
and raise UpdateFailed. The neutral hierarchy means the same handling works
whichever backend the integration ships:
ModbusConnectionError→ the link dropped; let the coordinator mark the device unavailable and the integration recreate the connection.ModbusTimeoutError(also a builtinTimeoutError) → a slow or absent response.ModbusExceptionError→ the device rejected the request (.exception_code).
Testing without hardware
Section titled “Testing without hardware”The library layer is fully testable with the shipped mock backend — a pytest plugin that implements the same Protocols. Your device library’s tests need no Home Assistant and no device; the integration layer then only has to test the Home Assistant wiring.
Checklist
Section titled “Checklist”- Device communication lives in a separate PyPI library, not the integration (a Core requirement).
- Device library has no Home Assistant import and is tested against the mock.
- The integration gets its
ModbusUnitfrom the sharedmodbus_connectionintegration viaasync_get_unit— it never opens or closes a connection. - Coordinator calls the library’s
async_update()and mapsModbusErrortoUpdateFailed. - Entities read typed attributes; field
unit=feeds entity metadata. - Read the official Modbus integration guide.