Config flow
Let users add your integration from the UI with a config flow, and make sure the same device is never set up twice.
A config flow is the dialog users go through when they add your integration: a few steps that collect what is needed, check that it works, and create a config entry. Discovery starts the same flow when Home Assistant finds one of your devices.
Because the flow validates everything before the entry exists, your integration does not have to check its configuration on every start.
Every integration in core needs a config flow. What it must do to meet the quality scale, like a data_description for every field and the split between data and options, is in the config-flow rule.
- Set
"config_flow": truein the manifest. - Create
config_flow.pywith a class that extendsConfigFlowand passes your domain. - Run
python3 -m script.hassfestonce, so Home Assistant knows your integration has a config flow.
The scaffold script does all three for you.
from typing import Any
import probatio
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
STEP_USER_SCHEMA = probatio.Schema({probatio.Required(CONF_HOST): str})
class ExampleConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Example."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow started by the user."""
errors: dict[str, str] = {}
if user_input is not None:
client = ExampleClient(
user_input[CONF_HOST],
session=async_get_clientsession(self.hass),
)
try:
device = await client.get_device()
except ExampleConnectionError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(device.serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=device.model,
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_SCHEMA,
errors=errors,
)Each step is a method named async_step_<step_id>. It is called once without input to show its form, and again with the input when the user submits it. A step returns what happens next: a form, a menu, a new entry, or an abort. Forms, menus, and progress covers all of them.
Schemas are written with Probatio (import probatio), the validation library Home Assistant uses, often combined with selectors.
Some step names have a fixed meaning. Home Assistant calls them for you:
| Step | When it runs |
|---|---|
user |
The user adds the integration from the UI. Also used for discovery if you do not implement the matching discovery step. |
reconfigure |
The user reconfigures an existing entry. See reauthentication and reconfiguration. |
reauth |
Your integration reports that its credentials no longer work |
bluetooth, dhcp, homekit, mqtt, ssdp, usb, zeroconf |
Your device was discovered through the matcher in your manifest |
hassio |
A Supervisor app announced itself to your integration |
integration_discovery |
Another integration found a device for you. See discovery. |
import |
Reserved for moving YAML configuration into a config entry |
discovery |
Deprecated. Called when a discovery step for the source is missing. |
A unique ID ties a config entry to the device or account behind it, so it cannot be set up twice. Set it in the flow with async_set_unique_id, and the config entry inherits it. It only needs to be unique within your integration.
# Abort if another flow for this device is already in progress
await self.async_set_unique_id(device.serial_number)
# Abort if a config entry for this device already exists
self._abort_if_unique_id_configured()When the flow aborts because the entry exists, users see the already_configured abort reason from your strings.json. Use the shared string for it:
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}A unique ID also lets users ignore a discovered device, so they are not asked about it again. That is why discovery requires one.
When a device on the local network gets a new IP address, the unique ID lets you update the existing entry from a discovery step. Pass the new values to _abort_if_unique_id_configured. If the data changed, Home Assistant updates the entry and reloads it:
await self.async_set_unique_id(serial_number)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})The reload only happens when the entry is loaded or waiting to retry its setup. Pass reload_on_update=False to update the entry without a reload. Even without changes, a discovery reloads an entry that is waiting to retry its setup: the device is evidently online again.
It must be a string, stable, and not something the user can change.
- Good: a serial number, a MAC address formatted with
homeassistant.helpers.device_registry.format_mac, a unique identifier printed on the device or burned into it, or a latitude and longitude. - Sometimes fine for local devices: a hostname, if part of it contains one of the good sources.
- Sometimes fine for cloud services: an email address in lowercase, a username (in lowercase if usernames are not case sensitive), or an account ID that cannot collide.
- Never: an IP address, a device name, a URL, or a hostname the user can change.
Get the MAC address from the device API or from discovery. Tools that read the ARP cache, like getmac, do not work in every network setup Home Assistant runs in.
If some of your devices have no unique ID, call this in the discovery step instead. Discovery then still works, as long as no entry of your integration exists yet:
if device_unique_id:
await self.async_set_unique_id(device_unique_id)
else:
await self._async_handle_discovery_without_unique_id()If you never get a unique ID, leave the discovery steps out. Home Assistant then calls your user step when it discovers a device, as long as your integration has no config entry yet.
A discovery step receives a service info object with what was found, like a ZeroconfServiceInfo. It has to:
- Make sure no other flow is already setting up the same device. Setting the unique ID usually takes care of that. When the same device can be found in several ways that give different unique IDs, implement
is_matching(self, other_flow)on your flow, and callself.hass.config_entries.flow.async_has_matching_flow(self). Home Assistant then callsis_matchingfor every other flow in progress. - Make sure the device is not already set up.
- Always ask the user to confirm. A discovery step must never create an entry by itself.
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered device."""
serial_number = discovery_info.properties["serialno"]
await self.async_set_unique_id(serial_number)
self._abort_if_unique_id_configured(
updates={CONF_HOST: discovery_info.host}
)
self._host = discovery_info.host
self.context["title_placeholders"] = {"name": serial_number}
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask the user to confirm the discovered device."""
if user_input is not None:
return self.async_create_entry(
title=self.unique_id, data={CONF_HOST: self._host}
)
self._set_confirm_only()
return self.async_show_form(step_id="discovery_confirm")_set_confirm_only() tells the frontend this step only asks for confirmation. See discovery for how devices get found in the first place.
If your integration is discoverable and needs no authentication, you might not need to write a flow at all. python3 -m script.scaffold integration generates this flow when you answer that your device is discoverable and needs no authentication. For an existing integration, run python3 -m script.scaffold config_flow_discovery. The flow checks whether any devices can be found before it finishes, supports every discovery method in the manifest, and allows a single config entry that takes care of all devices.
The generated config_flow.py has no flow class. It calls config_entry_flow.register_discovery_flow() with your domain, a title, and a function that returns whether any device can be found. Home Assistant only calls that function when a user adds the integration by hand and no discovered device is waiting to be set up.
The title of a flow in the UI, like the name of a discovered device, is chosen in this order:
- If the flow sets
title_placeholdersinself.context, and it is not empty:- With a
flow_titlein your translations, that string is used, with the placeholders filled in. - Without a
flow_title, thenameplaceholder is used, if there is one.
- With a
- The
titleof your translations. - The
nameof the manifest. - The domain.
Reauthentication flows set title_placeholders to {"name": <entry title>} by themselves.
All text of the flow lives in strings.json, under the config key: titles, descriptions, field labels, errors, and abort reasons. For example:
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Example device."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}While developing, run python3 -m script.translations develop to see changes to strings.json in the UI. See translations for every key, make your strings translatable for the rules, and forms, menus, and progress for the keys of a step.
A flow can start another flow, and let the frontend show it as soon as the first one finishes. Pass next_flow to async_create_entry, with the type and ID of the next flow:
from homeassistant.config_entries import SOURCE_USER, FlowType
result = await self.hass.config_entries.flow.async_init(
"other_domain", context={"source": SOURCE_USER}
)
return self.async_create_entry(
title="Example",
data={},
next_flow=(FlowType.CONFIG_FLOW, result["flow_id"]),
)Subentry flows and options flows need the config entry to exist before they start. To continue with one of those, override async_on_create_entry. It runs after the entry is created:
from homeassistant.config_entries import SubentryFlowContext
async def async_on_create_entry(
self, result: ConfigFlowResult
) -> ConfigFlowResult:
"""Start adding a location right after the entry is created."""
subentry_result = await self.hass.config_entries.subentries.async_init(
(result["result"].entry_id, "location"),
context=SubentryFlowContext(source=SOURCE_USER),
)
result["next_flow"] = (
FlowType.CONFIG_SUBENTRIES_FLOW,
subentry_result["flow_id"],
)
return resultFor helpers and other integrations with simple flows, SchemaConfigFlowHandler defines the whole config flow and options flow as data. Keep its limits in mind:
- All input is stored in the
optionsof the entry, not indata. That makes it unsuitable for connection details or API keys. - Extensive validation, unique IDs, or duplicate checks are often easier in a normal config flow.
- Starting the flow with steps other than
userandimportis discouraged.
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
)
async def validate_options(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate the options."""
if user_input[CONF_THRESHOLD] < 0:
# The error key must exist in strings.json
raise SchemaFlowError("negative_threshold")
return user_input
OPTIONS_SCHEMA = probatio.Schema(
{probatio.Required(CONF_THRESHOLD): NumberSelector()}
)
CONFIG_FLOW = {
"user": SchemaFlowFormStep(
probatio.Schema({probatio.Required(CONF_NAME): TextSelector()}),
next_step="options",
),
"options": SchemaFlowFormStep(
OPTIONS_SCHEMA, validate_user_input=validate_options
),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(
OPTIONS_SCHEMA, validate_user_input=validate_options
),
}
class ExampleConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for Example."""
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
# Reload the entry when the options change
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return the title of the config entry."""
return cast(str, options[CONF_NAME])Integrations in core need full test coverage of config_flow.py. Test every path: success, every error, every abort, and that a second setup of the same device aborts. See writing tests.