Building a Home Assistant Custom Component Part 4: Options Flow
This is the fourth part of a multi-part tutorial to create a Home Assistant custom component.
- Part 1 - Project Structure and Basics
- Part 2 - Unit Testing and Continuous Integration
- Part 3 - Config Flow
- Part 4 - Options Flow (Reading Now!)
- Part 5 - Debugging
Introduction
In this post we will be adding an Options flow to our custom component. We are still using the same example project, github-custom-component-tutorial. You can find the diff for this post on the feature/part4 branch.
The options flow allows a user to configure additional options for the component at any
time by navigating to the integrations page and clicking the Options
button on the
card for your component. Generally speaking these configuration values are optional, whereas
values in the config flow are required to make the component function.
I highly suggest reading over the official documentation prior to continuing along with the tutorial.
Enable Options Support
Per the documentation, the first step is to define a method on your config flow class that lets it know that the component supports options. In our case we will add this to our GithubCustomConfigFlow class.
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
One slight modification from the official documentation is that our OptionsFlowHandler
class will require the instance of the config entry when initializing. This will be required
for nearly every component you may write as we will use the options
property of the
config_entry
to populate default values for our options flow form.
Configure Fields and Errors in strings.json
Just like our config flow, we need to name our data fields and error messages in the
strings.json
. These will be nested under an options
key. You will need to add these
for each language you choose to support in the translations
directory.
+ },
+ "options": {
+ "error": {
+ "invalid_path": "The path provided is not valid. Should be in the format `user/repo-name` and should be a valid github repository."
+ },
+ "step": {
+ "init": {
+ "title": "Manage Repos",
+ "data": {
+ "repos": "Existing Repos: Uncheck any repos you want to remove.",
+ "path": "New Repo: Path to the repository e.g. home-assistant-core",
+ "name": "New Repo: Name of the sensor."
+ },
+ "description": "Remove existing repos or add a new repo."
+ }
+ }
Define an OptionsFlow Handler
The next step is to write our class to handle the options flow. This will look very similar to the class we defined for our config flow so it should be familiar. For brevity I’m going to omit much of the logic in the class to try to simplify it to show the important parts. I’ll give a general overview of how it works then I’ll dive into the specific logic I added for our tutorial component.
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handles options flow for the component."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
self.config_entry = config_entry
async def async_step_init(
self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]:
"""Manage the options for the custom component."""
errors: Dict[str, str] = {}
# Grab all configured repos from the entity registry so we can populate the
# multi-select dropdown that will allow a user to remove a repo.
entity_registry = await async_get_registry(self.hass)
entries = async_entries_for_config_entry(
entity_registry, self.config_entry.entry_id
)
# Default value for our multi-select.
all_repos = {e.entity_id: e.original_name for e in entries}
repo_map = {e.entity_id: e for e in entries}
if user_input is not None:
# Validation and additional processing logic omitted for brevity.
# ...
if not errors:
# Value of data will be set on the options property of our config_entry
# instance.
return self.async_create_entry(
title="",
data={CONF_REPOS: updated_repos},
)
options_schema = vol.Schema(
{
vol.Optional("repos", default=list(all_repos.keys())): cv.multi_select(
all_repos
),
vol.Optional(CONF_PATH): cv.string,
vol.Optional(CONF_NAME): cv.string,
}
)
return self.async_show_form(
step_id="init", data_schema=options_schema, errors=errors
)
Override __init__
We must override __init__
so that it can accept a config_entry
instance which we
set as an attribute on the class. As mentioned above this is so we can access it’s
options
property to pre-populate data in our options flow form.
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
self.config_entry = config_entry
Define the Options Data Schema
Next up we define our options data schema. This is identical to how we define the schema for our config flow. We are defining the schema in the method itself so that we can supply a default value for the repos key which is dynamically evalulated in this method. If you do not need any dynamic values you can define it as a constant above just like we did with the schema for the config flow.
options_schema = vol.Schema(
{
vol.Optional("repos", default=list(all_repos.keys())): cv.multi_select(
all_repos
),
vol.Optional(CONF_PATH): cv.string,
vol.Optional(CONF_NAME): cv.string,
}
)
While I am not using default values for the other keys in the schema in my component, this is where you would generally look up existing options values from the config entry instance to set default values for your form. For example:
vol.Optional(CONF_PATH, default=self.config_entry.options[CONF_PATH])
Display the Options Form
There isn’t anything new here that we haven’t seen in the config flow. One thing to note
that is different from the config flow, is that the options flow only ever has a single
step named init
.
return self.async_show_form(
step_id="init", data_schema=options_schema, errors=errors
)
Save Options Data
When a user has submitted user_input
that validates we can then format and save our
options data by returning the asnyc_create_entry
method.
# Value of data will be set on the options property of our config_entry
# instance.
return self.async_create_entry(
title="",
data={some_option: user_input["some_option"]},
)
Register Options Update Listener
In order for our component to know that options have changed and to be able to act on them,
we must register and update a listener when initially setting up our config entry. In our
__init__.py
file we will define our update listener function and register it with the
config entry.
async def options_update_listener(
hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry
):
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
As you can see above the logic of the listener is very simple. It just reloads the config
entry so that it can act on the new options data that was saved. We must then register
the listener in our async_setup_entry
function.
hass_data = dict(entry.data)
# Registers update listener to update config entry when options are updated.
unsub_options_update_listener = entry.add_update_listener(options_update_listener)
# Store a reference to the unsubscribe function to cleanup if an entry is unloaded.
hass_data["unsub_options_update_listener"] = unsub_options_update_listener
hass.data[DOMAIN][entry.entry_id] = hass_data
The add_updated_listener
method returns an unsubscribe function that we will store for
later so that we can clean up the listener if the config entry is removed by the user.
One thing to note is that the update listener function will only get called if the data
passed to self.async_create_entry
in our Options Flow class is different then it
previously was. If nothing changed, the options update listener will not get called and
your config entry will not be reloaded.
Use Options Values During Setup
Now that we’ve setup our options flow, the user can enter values and they will be saved
on the config entry instance. The last step is to use those values while setting up our
platforms. In our sensor.py
we could then use the options values to change how our
sensors get setup. An example might look something like the following:
async def async_setup_entry(
hass: core.HomeAssistant,
config_entry: config_entries.ConfigEntry,
async_add_entities,
):
"""Setup sensors from a config entry created in the integrations UI."""
config = hass.data[DOMAIN][config_entry.entry_id]
some_option = config_entry.options.get("some_option")
session = async_get_clientsession(hass)
github = GitHubAPI(session, "requester", oauth_token=config[CONF_ACCESS_TOKEN])
sensors = [GitHubRepoSensor(github, repo, some_option) for repo in config[CONF_REPOS]]
async_add_entities(sensors, update_before_add=True)
Options Flow in the Github Custom Component
Now that I went over the general information on using an options flow, I wanted to return to the custom component we’ve been building in this tutorial. The options flow I added performs actions that I haven’t seen in any other options flows. Mainly it allows for removing repos that have been added as well as adding new repos via the options flow form. Below you can see a screenshot of what it looks like.
The multi-select allows a user to uncheck repos that they want to remove. The other two
inputs allow a user to add a new repo and give it an optional name. Clicking SUBMIT
will remove un-checked repos and add any new repo if one was specified.
Removing a Repo
The logic for removing repos looks like the following in our options flow class:
updated_repos = deepcopy(self.config_entry.data[CONF_REPOS])
# Remove any unchecked repos.
removed_entities = [
entity_id
for entity_id in repo_map.keys()
if entity_id not in user_input["repos"]
]
for entity_id in removed_entities:
# Unregister from HA
entity_registry.async_remove(entity_id)
# Remove from our configured repos.
entry = repo_map[entity_id]
entry_path = entry.unique_id
updated_repos = [e for e in updated_repos if e["path"] != entry_path]
We first determine which repos were unchecked by comparing the selected repos to the repos
that were originally configured in our config_entry
. Then we iterate through each entity_id
and remove it from the entity registry first, then from our list of repos initially
configured.
Adding a Repo
If the user enters a valid value for the path
input, we will then add a new repo. That logic
is shown below:
if user_input.get(CONF_PATH):
# Validate the path.
access_token = self.hass.data[DOMAIN][self.config_entry.entry_id][
CONF_ACCESS_TOKEN
]
try:
await validate_path(user_input[CONF_PATH], access_token, self.hass)
except ValueError:
errors["base"] = "invalid_path"
if not errors:
# Add the new repo.
updated_repos.append(
{
"path": user_input[CONF_PATH],
"name": user_input.get(CONF_NAME, user_input[CONF_PATH]),
}
)
If a value was provided we first validate it to ensure it’s a real GitHub repo. If it is
not we populate the errors
dict with the error key defined in our strings.json
. If
there are no errors we simply append the new repository to the existing list.
Updating the Sensors
When we succuessfully return from our options flow handler it will pass the list of
updated repos as the data keyword argument. This dict
will get set in the options
property of our config_entry
instance.
return self.async_create_entry(
title="",
data={CONF_REPOS: updated_repos},
)
We will access that data when setting up our sensors in sensor.py
. Before creating
our sensors we augment the initial configuration data with the updated repos which may
have had repos removed or added since the initial configuration in our config flow.
diff --git a/custom_components/github_custom/sensor.py b/custom_components/github_custom/sensor.py
index 9a62f8a..c893fa2 100644
--- a/custom_components/github_custom/sensor.py
+++ b/custom_components/github_custom/sensor.py
@@ -70,6 +70,9 @@ async def async_setup_entry(
):
"""Setup sensors from a config entry created in the integrations UI."""
config = hass.data[DOMAIN][config_entry.entry_id]
+ # Update our config to include new repos and remove those that have been removed.
+ if config_entry.options:
+ config.update(config_entry.options)
session = async_get_clientsession(hass)
github = GitHubAPI(session, "requester", oauth_token=config[CONF_ACCESS_TOKEN])
sensors = [GitHubRepoSensor(github, repo) for repo in config[CONF_REPOS]]
Unit Tests
Unit testing the options flow isn’t terribly different than testing the config flow, but it does require a few extra steps. The test below tests the case where the user unchecks an existing repo from the config entry.
@patch("custom_components.github_custom.sensor.GitHubAPI")
async def test_options_flow_remove_repo(m_github, hass):
"""Test config flow options."""
m_instance = AsyncMock()
m_instance.getitem = AsyncMock()
m_github.return_value = m_instance
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="kodi_recently_added_media",
data={
CONF_ACCESS_TOKEN: "access-token",
CONF_REPOS: [{"path": "home-assistant/core", "name": "HA Core"}],
},
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
# show initial form
result = await hass.config_entries.options.async_init(config_entry.entry_id)
# submit form with options
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"repos": []}
)
assert "create_entry" == result["type"]
assert "" == result["title"]
assert result["result"] is True
assert {CONF_REPOS: []} == result["data"]
We first need to create a mock config entry and add it to Home Assistant. Next we generate
the initial options flow and capture the flow id. The flow id is used when we call
hass.config_entries.options.async_configure
and pass in our user_input
data. In this
case we are simulating unchecking the only repo that was configured.
Check out the post on unit testing for more details on the fixtures and helpers used here.
Next Steps
At this point we now have a fully functional custom component that can be configured via
the configuration UI or a configuration.yaml
file. In the last post in this series I
will briefly cover testing and debugging your component locally using the
Visual Studio Code devcontainer provided by
Home Assistant.