← How-Tos
raspberry-pi 1 hr ago ◯ 5 min read

Writing a Custom Home Assistant Integration in Python: Config Flow, Entities, and HACS Publishing

home assistantpythoncustom integrationconfig flowhacsdataupdatecoordinatorasynchowto

This site has covered writing custom ESPHome components in C++ for extending a specific device's firmware, but that's a different job from writing a native Home Assistant integration in Python — the code that runs inside Home Assistant Core itself and talks to a device, service, or API directly, without an ESPHome device in the loop at all. If you've got a piece of hardware or a cloud API that nobody has integrated yet, or you want tighter control than a YAML-based integration gives you, this is the path. This guide covers the integration structure, config flow for UI-based setup, entity platforms, and publishing through HACS.

Why Write a Native Integration

A native Python integration makes sense when you're bridging Home Assistant to something that isn't an ESPHome device: a local HTTP API on a piece of hardware, a cloud service with its own SDK, a serial-connected instrument, or a protocol Home Assistant doesn't already speak. If your project is an ESP32 or similar microcontroller you're building yourself, writing an ESPHome external component is usually still the easier path — a native integration is for the Home Assistant side of the equation, not the device firmware side.

Integration Structure

A custom integration lives in config/custom_components/your_integration_name/ and needs, at minimum:

Config Flow: UI Setup Instead of YAML

Modern Home Assistant integrations are expected to support config flow — the "Add Integration" UI wizard — rather than requiring hand-edited YAML. A minimal config flow subclasses homeassistant.config_entries.ConfigFlow, implements an async_step_user method that presents a form (host, API key, or whatever your device needs), validates the input by actually attempting to connect, and creates a config entry on success:

class MyDeviceConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): errors = {} if user_input is not None: try: await validate_connection(user_input[CONF_HOST]) except CannotConnect: errors["base"] = "cannot_connect" else: return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors=errors)

Validating the connection during setup — not just accepting whatever the user typed — is what separates an integration that feels reliable from one that silently fails later with an unhelpful "unavailable" entity.

Entities and the DataUpdateCoordinator

For anything that polls a device or API on an interval, use a DataUpdateCoordinator rather than having each entity independently poll the device — this centralizes the fetch, handles errors and backoff in one place, and avoids every sensor hammering the same device separately. Entities then subclass CoordinatorEntity and read from the coordinator's shared data rather than making their own network calls. This pattern is the difference between an integration with five sensors making five redundant HTTP requests every update cycle and one that makes a single request and fans the result out.

The Async Trap

Home Assistant's core runs on a single asyncio event loop, and the single most common mistake in a first custom integration is calling a blocking library function (a synchronous requests.get() call, a blocking serial read, anything that doesn't await) directly inside an async method. This doesn't crash — it silently stalls the entire Home Assistant event loop for the duration of the blocking call, which shows up as the whole instance becoming briefly unresponsive. Wrap any blocking call with hass.async_add_executor_job() to run it in a thread pool instead, or use an async-native client library if one exists for the device or API you're integrating.

Testing Locally Before Publishing

Use Home Assistant's dev container or a dedicated test instance (not your production smart home) to iterate — drop your integration folder into its custom_components directory, restart, and add it through the UI like any other integration. Run hassfest (Home Assistant's own manifest and structure validator, available as a GitHub Action or locally) before you consider the integration done; it catches a long list of manifest and structure issues that otherwise only surface during HACS review or a user's bug report.

Publishing Through HACS

To distribute through the Home Assistant Community Store, structure your GitHub repository with a hacs.json file at the root declaring the integration name and type, keep the actual integration code inside a custom_components/your_domain/ path matching what users will end up with locally, tag releases with semantic version numbers, and submit the repository for inclusion in the default HACS store (or let users add it as a custom repository in the meantime). A clear README with setup instructions and supported device/feature coverage matters more for adoption than almost anything else in the submission.

Writing a native integration is a bigger commitment than an ESPHome YAML package or a Node-RED flow, but it's the right tool when you need first-class entities, a proper setup UI, and the kind of reliability that comes from Home Assistant's own update and error-handling patterns rather than gluing something together externally. Start with a device you actually own and use daily — you'll find the rough edges in your own integration a lot faster than any amount of reading the developer docs will surface them.