Config flows
A config flow lets users set up an integration from the UI. The result of a
successful flow is a config entry — stored configuration that the core passes to
your integration's async_setup_entry. Config flows live in
apexos/config_entries.py; your integration implements one in config_flow.py and
sets "config_flow": true in its manifest.
The minimal flow
The sun integration ships the smallest possible flow
(apexos/components/sun/config_flow.py):
from apexos.config_entries import ConfigFlow, ConfigFlowResult
from .const import DEFAULT_NAME, DOMAIN
class SunConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Sun."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
if user_input is not None:
return self.async_create_entry(title=DEFAULT_NAME, data={})
return self.async_show_form(step_id="user")
Key mechanics:
- Subclassing
ConfigFlowwith thedomain=DOMAINclass keyword registers the handler for your domain (__init_subclass__does the registration). VERSIONis stored on created entries and drives config-entry migration later.- Inside a flow, the core object is available as
self.apex.
Steps and results
Each step is a method named async_step_<step_id> returning a ConfigFlowResult.
The first user-initiated step is always async_step_user. A step either shows a
form, creates an entry, or aborts:
self.async_show_form(step_id=..., data_schema=..., errors=...)— render a form.data_schemais avoluptuousschema;errorsmaps a field name (or"base") to an error key defined instrings.json.self.async_create_entry(title=..., data=...)— finish the flow and store the entry.self.async_abort(reason=...)— abort;reasonmust have a matching entry underconfig.abortinstrings.json.
The scaffolded template (python3 -m script.scaffold config_flow) shows the
standard validate-then-create pattern:
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.apex, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
validate_input(apex, data) should actually talk to the device/service before the
entry is created. If your client library is synchronous, wrap it:
await apex.async_add_executor_job(your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD])
Unique IDs
Assign each configured device/service a unique ID so it can only be set up once and so discovery can update existing entries:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
async_set_unique_id(unique_id, raise_on_progress=True)records the ID on the flow context (readable via theunique_idproperty)._abort_if_unique_id_configured(updates=..., reload_on_update=True, error="already_configured")aborts if an entry with that unique ID exists — optionally updating (and reloading) the existing entry with fresh data such as a changed host. The abort reason requires analready_configuredentry instrings.json._abort_if_unique_id_mismatch(reason="unique_id_mismatch")is used in reauth and reconfigure flows to guarantee the user is still talking to the same device._async_abort_entries_match({...})aborts when an existing entry matches all the given data — useful when no natural unique ID exists.
Discovery steps
If your manifest declares discovery (see the manifest reference),
implement the matching step; the flow's source will be one of the SOURCE_*
constants from apexos.config_entries:
async_step_zeroconf, async_step_ssdp, async_step_dhcp, async_step_usb,
async_step_bluetooth, async_step_mqtt, async_step_homekit,
async_step_discovery, async_step_hardware, async_step_integration_discovery,
plus async_step_import (migrating YAML config, SOURCE_IMPORT). Each receives a
typed service-info object (e.g. ZeroconfServiceInfo from
apexos.helpers.service_info.zeroconf).
Options flows
To let users tweak settings after setup, expose an options flow from your config flow class:
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return MyOptionsFlow()
OptionsFlowgives youself.config_entry(not available inside__init__— only after the flow is initialized) and the same step/form/result API; the first step isasync_step_init.OptionsFlowWithReloadautomatically reloads the config entry when the flow finishes with changed options (automatic_reload = True). Don't combine it with config-entry update listeners.OptionsFlowWithConfigEntryis deprecated — kept only for backward compatibility with custom integrations; do not use it in new code.
Reauthentication
When credentials stop working, start a reauth flow; the core calls
async_step_reauth with the entry's current data. The established pattern (from
apexos/components/nrgkick/config_flow.py):
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle initiation of reauthentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication."""
errors: dict[str, str] = {}
if user_input is not None:
reauth_entry = self._get_reauth_entry()
if info := await self._async_validate_credentials(...):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reauth_entry,
data_updates=user_input,
)
return self.async_show_form(step_id="reauth_confirm", ...)
async_update_reload_and_abort(entry, *, unique_id, title, data, data_updates, options, reason, reload_even_if_entry_is_unchanged=True) updates the entry,
schedules a reload, and aborts the flow. data_updates merges into existing data
(mutually exclusive with data, which replaces it). The abort reason defaults to
reauth_successful — or reconfigure_successful when the source is a reconfigure
flow. async_update_and_abort is the same without the reload.
Reconfiguration
async_step_reconfigure lets the user change settings such as the host. Use
self._get_reconfigure_entry() to fetch the entry, re-validate, verify identity
with _abort_if_unique_id_mismatch(), then finish with
async_update_reload_and_abort(reconfigure_entry, data_updates={...}).
Setting up from the entry
Once a flow creates an entry, the core calls into your __init__.py:
type MyConfigEntry = ConfigEntry[MyApi]
async def async_setup_entry(apex: ApexOS, entry: MyConfigEntry) -> bool:
"""Set up from a config entry."""
entry.runtime_data = MyApi(...) # typed per-entry runtime object
await apex.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(apex: ApexOS, entry: MyConfigEntry) -> bool:
"""Unload a config entry."""
return await apex.config_entries.async_unload_platforms(entry, _PLATFORMS)
Texts for the flow
All user-visible flow texts live in strings.json under the config key:
config.step.<step_id> (title, description, data field labels),
config.error.<error_key>, and config.abort.<reason>. Shared texts can be
referenced with the [%key:...%] syntax, e.g.
"[%key:common::config_flow::description::confirm_setup%]" as used by sun.