Build your first integration
Build a small, complete integration step by step, from the scaffold to a config flow, a coordinator, sensors, tests, and a pull request that meets the Bronze tier.
In this tutorial, you build a complete integration for an air quality monitor on your local network. It is set up from the UI, polls the monitor every minute, and shows its temperature, humidity, and fine dust as sensors. On the way, you touch every part that a new integration in core needs: a library, a config flow, a coordinator, entities, tests, documentation, a brand, and a pull request.
By the end, your integration meets every rule of the Bronze tier of the quality scale, the minimum for a new integration in core. Each step says which rules it takes care of, and the last steps check them all off.
The monitor and its library, exampleair, are made up for this tutorial. Follow along with your own device and its library: the shape of the code is the same, only the names and the data change. The code is modeled on theben_conexa, a small Bronze integration for a local device that was recently added to core, with a sensor platform, a coordinator, and the same set of tests. Where it helps, open its code next to this page.
New to the words config entry, platform, or coordinator? Read how an integration fits in first. It takes five minutes.
- A development environment. Set one up with your first contribution or the development environment guide. Run every command on this page from the root of your core checkout, inside that environment.
- A device or service that fits in core. It must be available for people to buy and use today, and have an actual user base. See create an integration. For a new integration, the project recommends that you publish it in the Marketplace first. A custom integration is built the same way, so everything you learn here carries over.
- Some Python. You should be comfortable with classes,
asyncandawait, and type hints. You do not need to know Home Assistant's code yet. - Time. Plan a few evenings. The review afterwards takes longer than writing the code, often weeks. That is normal.
Then create a branch for your integration, starting from the latest dev branch of Home Assistant. Do this first: core's pre-commit hooks include no-commit-to-branch, which refuses every commit on dev, rc, and master.
git fetch upstream
git checkout -b add-example-air upstream/devupstream is the remote of home-assistant/core that you added in your first contribution.
Your integration never talks to the device itself. A Python library on PyPI does that, and your integration only translates between the library and Home Assistant. If your device has no library yet, write one first: build a Python library explains how, and what it must meet before core accepts it.
This tutorial uses a small async library with this interface:
from dataclasses import dataclass
from aiohttp import ClientSession
@dataclass
class Status:
"""The status of an Example Air monitor."""
serial_number: str
name: str
firmware: str
temperature: float
humidity: float
pm25: int
class ExampleAirError(Exception):
"""Base error of the library."""
class ExampleAirConnectionError(ExampleAirError):
"""The monitor could not be reached."""
class ExampleAirClient:
"""Talk to an Example Air monitor."""
def __init__(self, host: str, session: ClientSession) -> None:
"""Initialize the client."""
async def get_status(self) -> Status:
"""Return the current status of the monitor."""Three things about it matter to Home Assistant:
- It is async, so it never blocks the event loop.
- It accepts an
aiohttpsession from the caller, so it can use the shared session of Home Assistant. - It raises its own exceptions, so the integration can tell an unreachable device from other problems.
Core also asks something of how the library is published. That is the dependency-transparency rule:
- The source is in a public repository, under an OSI-approved license like MIT or Apache 2.0.
- Every release is on PyPI, built and published by a public CI pipeline, not uploaded by hand.
- Every version on PyPI matches a tagged release in that repository.
Set this up before you start on the integration. You link the release workflow and the tag in your pull request, and reviewers check them.
While you develop, install your library in editable mode, so you can change the library and the integration at once. Where the library lives depends on your setup.
With a manual setup, everything runs directly on your computer. Keep the library in a folder next to your core checkout, activate the virtual environment, and install it from the core checkout:
uv pip install -e ../exampleairThe development container only has your core checkout mounted, so ../exampleair does not exist inside it. Clone the library inside the container instead, for example in your home folder, and install it from there:
git clone https://github.com/YOUR_GITHUB_USERNAME/exampleair ~/exampleair
uv pip install -e ~/exampleairPush your library changes often: when the container is rebuilt, everything outside your core checkout is gone. Once your library is released on PyPI, you can also skip the editable install, and install the released version with uv pip install exampleair==0.1.0.
Run the scaffold script:
python3 -m script.scaffold integrationIt asks a few questions. For this tutorial, the answers are:
| Question | Answer |
|---|---|
| What is the domain? | example_air |
| What is the name of your integration? | Example Air |
| What is your GitHub handle? | Your GitHub username |
| What PyPI package and version do you depend on? | exampleair==0.1.0 |
| How will your integration gather data? | local_polling |
| Does Home Assistant need the user to authenticate? | no |
| Is the device/service discoverable on the local network? | no |
| What is the integration type? | device |
| Can the user authenticate the device using OAuth2? | no |
The domain is permanent: it becomes the folder name, and it can never change. See the manifest.
Because the monitor is not discoverable, the script generates a config flow with a form. It then runs hassfest to validate the result, updates the requirement files, and generates the English translations. You now have:
homeassistant/components/example_air/
├── __init__.py
├── config_flow.py
├── const.py
├── manifest.json
├── quality_scale.yaml
└── strings.json
tests/components/example_air/
├── __init__.py
├── conftest.py
└── test_config_flow.pyThe generated code works, but it is a template: it has TODO comments and a PlaceholderHub class where your library goes. The next steps replace all of that.
Open manifest.json. The scaffold filled it from your answers, and added a few empty keys, for dependencies and discovery. Remove the empty keys: nearly every integration in core leaves them out. What is left:
{
"domain": "example_air",
"name": "Example Air",
"codeowners": ["@your-github-username"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/example_air",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["exampleair==0.1.0"]
}quality_scaleisbronzefrom the start: new integrations must reach at least Bronze. Until every Bronze rule inquality_scale.yamlisdoneorexempt, hassfest reports an error for it. You get there in step 11.requirementspins your library to an exact version with==. Hassfest requires that, and it is the other half of dependency-transparency.
The manifest explains every key.
The config flow is the dialog users see when they add the integration. It asks for the host, connects to the monitor to check that it works, and makes sure the same monitor is not added twice.
Replace config_flow.py with:
"""Config flow for the Example Air integration."""
import logging
from typing import Any, override
from exampleair import ExampleAirClient, ExampleAirConnectionError
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
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = probatio.Schema(
{probatio.Required(CONF_HOST): str}
)
class ExampleAirConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Example Air."""
VERSION = 1
@override
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 = ExampleAirClient(
user_input[CONF_HOST],
session=async_get_clientsession(self.hass),
)
try:
status = await client.get_status()
except ExampleAirConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(status.serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=status.name, data=user_input
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)What happens here:
- The first time,
user_inputisNone, and the step shows the form. - When the user submits, the flow connects to the monitor. If that fails, it shows the form again with an error. That is the test-before-configure rule.
- The serial number becomes the unique ID of the config entry.
_abort_if_unique_id_configuredstops the flow when that monitor is already set up. That is the unique-config-entry rule. Never use the IP address as unique ID: it changes. async_get_clientsessiongives you the shared web session of Home Assistant.@overridetells the type checker that this method replaces one of the parent class. Core's mypy configuration asks for it.
Schemas in core are written with Probatio, the validation library Home Assistant uses. Import it as probatio, without an alias.
All text the user sees lives in strings.json, never in the code. Replace it with:
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Example Air monitor."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"exceptions": {
"update_failed": {
"message": "An error occurred while fetching data from the monitor."
}
}
}The [%key:...%] references reuse strings that are already translated into every language. The data_description explains each field, which the config-flow rule asks for. The exceptions section is for the coordinator in the next step. After every change to strings.json, regenerate the English translations, which Home Assistant and the tests read:
python3 -m script.translations develop --integration example_airSee translations for every section of the file, and make your strings translatable for how to write them.
The monitor returns all its values in one request. A coordinator makes that one request every minute, and hands the result to all entities. It also marks them unavailable when the monitor stops answering, and logs that only once.
Create coordinator.py:
"""Coordinator for the Example Air integration."""
from datetime import timedelta
import logging
from typing import override
from exampleair import ExampleAirClient, ExampleAirError, Status
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type ExampleAirConfigEntry = ConfigEntry[ExampleAirCoordinator]
class ExampleAirCoordinator(DataUpdateCoordinator[Status]):
"""Fetch the status of an Example Air monitor."""
config_entry: ExampleAirConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ExampleAirConfigEntry,
client: ExampleAirClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(minutes=1),
)
self.client = client
@override
async def _async_update_data(self) -> Status:
"""Fetch the latest status from the monitor."""
try:
return await self.client.get_status()
except ExampleAirError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
) from errExampleAirConfigEntryis a typed config entry. Itsruntime_datais the coordinator, so your platforms get a typed coordinator for free.update_intervalis fixed by the integration. Users cannot change it, and the config flow must not ask for it. Pick an interval that fits the data: see appropriate-polling.UpdateFailedtells the coordinator the update failed. Its message is translated, from theexceptionssection ofstrings.json.- The coordinator lives in
coordinator.py, and the base entity inentity.py. That is the common-modules rule.
__init__.py connects it all. Home Assistant calls async_setup_entry for every config entry: at startup, and when a user adds a monitor. Replace the file with:
"""The Example Air integration."""
from exampleair import ExampleAirClient
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import ExampleAirConfigEntry, ExampleAirCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(
hass: HomeAssistant, entry: ExampleAirConfigEntry
) -> bool:
"""Set up Example Air from a config entry."""
client = ExampleAirClient(
entry.data[CONF_HOST], session=async_get_clientsession(hass)
)
coordinator = ExampleAirCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: ExampleAirConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
entry, _PLATFORMS
)async_config_entry_first_refreshfetches the first data before any entity exists. When the monitor is offline, it raisesConfigEntryNotReady, and Home Assistant retries the setup later. That is the test-before-setup rule.- The coordinator goes in
entry.runtime_data, not inhass.data. That is the runtime-data rule. _PLATFORMShas one platform. A new integration starts with one: more platforms come in follow up pull requests.async_unload_entrylets users remove or reload the integration without a restart.- There is no
async_setup, because this integration adds no actions of its own. If yours does, register them inasync_setup, not here. That is the action-setup rule, and for this integration it is exempt.
First, a base entity in entity.py. Every entity of the monitor shares the same device and the same coordinator:
"""Base entity for the Example Air integration."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ExampleAirCoordinator
class ExampleAirEntity(CoordinatorEntity[ExampleAirCoordinator]):
"""Base class for Example Air entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: ExampleAirCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
status = coordinator.data
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, status.serial_number)},
manufacturer="Example",
model="Example Air",
name=status.name,
serial_number=status.serial_number,
sw_version=status.firmware,
)CoordinatorEntitysubscribes the entity to the coordinator, and makes it unavailable when an update fails._attr_has_entity_name = Truenames each entity in the context of its device, like "Living room Temperature". That is the has-entity-name rule.DeviceInfoputs the monitor in the device registry, so users see one device with all its sensors.- The entities do not subscribe to anything themselves.
CoordinatorEntitysubscribes to the coordinator inasync_added_to_hass, and unsubscribes when the entity is removed. If you ever add a subscription of your own, like updates pushed by the library, follow the entity-event-setup rule. For this integration, that rule is exempt.
Then the sensor platform, in sensor.py:
"""Sensor platform for the Example Air integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from exampleair import Status
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
UnitOfDensity,
UnitOfRatio,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
)
from .coordinator import ExampleAirConfigEntry, ExampleAirCoordinator
from .entity import ExampleAirEntity
@dataclass(frozen=True, kw_only=True)
class ExampleAirSensorEntityDescription(SensorEntityDescription):
"""Describe an Example Air sensor."""
value_fn: Callable[[Status], float | int]
SENSORS: tuple[ExampleAirSensorEntityDescription, ...] = (
ExampleAirSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.temperature,
),
ExampleAirSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=UnitOfRatio.PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.humidity,
),
ExampleAirSensorEntityDescription(
key="pm25",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm25,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ExampleAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Example Air sensors."""
coordinator = entry.runtime_data
async_add_entities(
ExampleAirSensor(coordinator, description)
for description in SENSORS
)
class ExampleAirSensor(ExampleAirEntity, SensorEntity):
"""A sensor of an Example Air monitor."""
entity_description: ExampleAirSensorEntityDescription
def __init__(
self,
coordinator: ExampleAirCoordinator,
description: ExampleAirSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.data.serial_number}_{description.key}"
)
@property
@override
def native_value(self) -> float | int:
"""Return the value of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)- An entity description describes each sensor as data: its device class, unit, and how to get its value. Adding a sensor later is one more entry in
SENSORS. - The device class gives each sensor its name, its icon, and the right unit conversions. Because these sensors have a device class, they need no name of their own.
- The unique ID combines the serial number with the key. It never changes, and it is what lets users rename the entity. That is the entity-unique-id rule.
Entities and platforms has a page for every entity type, like the sensor entity.
Before Home Assistant can show your integration in the list, regenerate the files that list all integrations:
python3 -m script.hassfest --skip-plugins quality_scaleWithout --integration-path, hassfest validates every integration and updates generated files, like CODEOWNERS and homeassistant/generated/config_flows.py. Those changes belong in your pull request. It only writes them when it finds no errors, and your quality_scale.yaml is still all todo. That is why this run skips the quality scale check, the same way the scaffold script does. From step 11 on, you run it without --skip-plugins.
Now start your development instance. In the development container, open the Command Palette, select Tasks: Run Task, then Run Home Assistant Core. Go to http://localhost:8123, and under Settings > Devices & services, select Add integration and search for Example Air. Enter the address of your monitor, and you should see a new device with three sensors.
Things not working? Turn on debug logging for your integration and your library in config/configuration.yaml, and restart:
logger:
default: info
logs:
homeassistant.components.example_air: debug
exampleair: debugIntegration not in the list? Check the log for errors first, then do a hard refresh of your browser: the frontend caches aggressively. Your first contribution has more on running and debugging your code, including how to reach devices on your network from the development container.
Every new integration in core comes with tests, and the config flow needs full coverage. Tests run without the real device: you mock the library, and let Home Assistant run for real. Writing tests explains the ideas behind this.
Start with the shared fixtures, in tests/components/example_air/conftest.py:
"""Common fixtures for the Example Air tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from exampleair import Status
import pytest
from homeassistant.components.example_air.const import DOMAIN
from homeassistant.const import CONF_HOST
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.example_air.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_example_air_client() -> Generator[MagicMock]:
"""Mock the Example Air client."""
with (
patch(
"homeassistant.components.example_air.ExampleAirClient",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.example_air.config_flow"
".ExampleAirClient",
new=mock_client,
),
):
client = mock_client.return_value
client.get_status.return_value = Status(
serial_number="EA123456",
name="Living room",
firmware="1.2.0",
temperature=21.5,
humidity=45.0,
pm25=8,
)
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the config entry of a monitor."""
return MockConfigEntry(
domain=DOMAIN,
title="Living room",
unique_id="EA123456",
data={CONF_HOST: "192.168.1.50"},
)The client is patched where your integration imports it: once in __init__.py, once in config_flow.py.
Replace test_config_flow.py. Test the happy path, every error, and the duplicate check. Every error test ends with a created entry, to prove the flow recovers:
"""Test the Example Air config flow."""
from unittest.mock import MagicMock
from exampleair import ExampleAirConnectionError
import pytest
from homeassistant.components.example_air.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_setup_entry", "mock_example_air_client")
async def test_user_flow(hass: HomeAssistant) -> None:
"""Test adding a monitor."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.50"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Living room"
assert result["data"] == {CONF_HOST: "192.168.1.50"}
assert result["result"].unique_id == "EA123456"
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(
("exception", "error"),
[
pytest.param(
ExampleAirConnectionError, "cannot_connect", id="cannot_connect"
),
pytest.param(Exception, "unknown", id="unknown"),
],
)
async def test_user_flow_errors(
hass: HomeAssistant,
mock_example_air_client: MagicMock,
exception: type[Exception],
error: str,
) -> None:
"""Test errors, and that the flow recovers from them."""
mock_example_air_client.get_status.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.50"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_example_air_client.get_status.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.50"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_example_air_client")
async def test_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test that the same monitor cannot be added twice."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.60"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"These tests together are the config-flow-test-coverage rule:
- The happy path checks the created entry, including its unique ID. When the way the unique ID is built ever changes by accident, this test catches it.
- Every error the form can show has a test, and every one of those tests ends with a created entry. That proves a user can fix the problem and finish the setup. Pylint warns about an error test that never finishes the flow.
- The duplicate test enters a different host for a monitor with the same serial number, and expects the flow to abort with
already_configured. Every test ends in a final result: a created entry or an abort. - Together, they run every line of
config_flow.py. You check that with the coverage report below.
In test_init.py, test that the integration loads and unloads, and that setup is retried when the monitor is offline:
"""Test the setup of the Example Air integration."""
from unittest.mock import MagicMock
from exampleair import ExampleAirConnectionError
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_example_air_client")
async def test_load_unload(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test loading and unloading a config entry."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_setup_retry_when_offline(
hass: HomeAssistant,
mock_example_air_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that setup is retried when the monitor is offline."""
mock_example_air_client.get_status.side_effect = (
ExampleAirConnectionError
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRYIn test_sensor.py, a snapshot test checks every sensor at once: its state, unit, device class, and registry entry:
"""Test the Example Air sensors."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("mock_example_air_client")
async def test_sensors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the sensors of a monitor."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)The first run of a snapshot test creates the snapshot. Then run all tests with a coverage report:
pytest tests/components/example_air --snapshot-update
pytest tests/components/example_air \
--cov=homeassistant.components.example_air \
--cov-report term-missingOpen tests/components/example_air/snapshots/test_sensor.ambr and read it: a snapshot only proves something when you checked that it is right. The coverage report shows the lines no test reaches. config_flow.py must be at 100%. Running the tests has more ways to run them.
Seven Bronze rules are not about the code in core at all. They are met in two other repositories, with pull requests that go with yours. Start them now: they get reviewed separately, and your core pull request is easier to review when they are already there.
The icon and logo of your integration go in the brands repository, in a folder named after your domain: core_integrations/example_air/. Add at least icon.png and [email protected], and a logo if the brand has a wide one. Brands and brand images lists every file and the image requirements. That is the brands rule.
Every integration has a page on home-assistant.io. Write it in a pull request to the home-assistant.io repository, against the next branch, starting from the integration page template. Document an integration walks you through it, step by step.
For Bronze, the page needs three things, each a rule of its own:
- A description of the product, in the first lines, with a link to the manufacturer. That is docs-high-level-description.
- Setup steps, with anything users have to do before they start, as prerequisites. That is docs-installation-instructions.
- How to remove the integration, including anything left behind outside Home Assistant. That is docs-removal-instructions.
For Example Air, those parts of source/_integrations/example_air.markdown could look like this:
The **Example Air** {% term integration %} connects Home Assistant to
the air quality monitors of [Example](https://www.example.com). It shows
the temperature, humidity, and fine dust that your monitor measures.
## Prerequisites
1. Set up your monitor with the **Example** app, and connect it to your
network.
2. In the app, look up the IP address of your monitor.
{% include integrations/config_flow.md %}
## Removing the integration
This integration follows standard integration removal. No extra steps
are required.
{% include integrations/remove_device_service.md %}The other three docs rules of Bronze, docs-actions, docs-triggers, and docs-conditions, are about documenting actions, triggers, and conditions of the integration. Example Air has none of them, so those rules are exempt.
Open quality_scale.yaml. The scaffold listed every rule as todo. Hassfest requires every rule of the quality scale to be in the file, and every Bronze rule to be done, or exempt with a comment that says why. Your integration now meets all of them:
| Rule | Status | Where |
|---|---|---|
| action-setup | Exempt | No actions, step 6 |
| appropriate-polling | Done | One minute, step 5 |
| brands | Done | Step 10 |
| common-modules | Done | coordinator.py and entity.py, steps 5 and 7 |
| config-flow | Done | Step 4 |
| config-flow-test-coverage | Done | Step 9 |
| dependency-transparency | Done | Steps 1 and 3 |
| docs-actions | Exempt | No actions |
| docs-conditions | Exempt | No conditions |
| docs-high-level-description | Done | Step 10 |
| docs-installation-instructions | Done | Step 10 |
| docs-removal-instructions | Done | Step 10 |
| docs-triggers | Exempt | No triggers |
| entity-event-setup | Exempt | Only the coordinator, step 7 |
| entity-unique-id | Done | Step 7 |
| has-entity-name | Done | Step 7 |
| runtime-data | Done | Step 6 |
| test-before-configure | Done | Step 4 |
| test-before-setup | Done | Step 6 |
| unique-config-entry | Done | Step 4 |
Replace the file with:
rules:
# Bronze
action-setup:
status: exempt
comment: "The integration does not register actions."
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: "The integration does not register actions."
docs-conditions:
status: exempt
comment: "The integration does not provide conditions."
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-triggers:
status: exempt
comment: "The integration does not provide triggers."
entity-event-setup:
status: exempt
comment: "Entities only subscribe to the coordinator."
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: todo
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todoThe Silver, Gold, and Platinum rules stay todo for now. Some of them are already within reach, but leave them for later pull requests. Only mark a rule done when it really is: a rule that is done turns on extra checks in hassfest and pylint, and reviewers check the rest.
The docs and brand rules are done in this file, but they are met in the other pull requests. Reviewers check those pull requests, so link them in yours.
Do not take this page's word for it. Validate your integration the way CI does:
python3 -m script.hassfest \
--integration-path homeassistant/components/example_air
pytest tests/components/example_air \
--cov=homeassistant.components.example_air \
--cov-report term-missingHassfest now runs the validators of the rules you marked done, like config-flow, runtime-data, test-before-setup, and unique-config-entry. The coverage report must show 100% for config_flow.py. The list of all rules explains each rule in a line, and check it yourself explains what each check proves, and what it does not.
Publishing in the Marketplace first? Follow publish a custom integration instead, and come back to this step once your integration has matured and has a user base.
Before you push, run the linters on everything, and run hassfest one last time without skipping anything. Include every file they change, also outside your integration folder:
prek run --all-files
python3 -m script.hassfestYour pull request now contains your integration, its tests, the generated files, and the updated requirement files. Commit it on the branch you created at the start, push it to your fork, and open a pull request against the dev branch of home-assistant/core. Submitting a pull request walks through the template. For a new integration:
- Select New integration as the type of change.
- Explain what the device or service is, and link to the source repository of your library, its release workflow, and the tag of the release you depend on.
- Link the pull requests for the documentation and the brand.
- Keep it to what you built here: one platform, and nothing more. Diagnostics, actions, reauthentication, and options come later, one pull request at a time.
Then the wait starts. Reviews of new integrations take time, sometimes weeks. While you wait, review other pull requests: it is the best way to learn, and it makes the queue move for everyone. After you open a pull request explains what happens next.
Your integration is in. Now make it better, one small pull request at a time:
- Raise the tier to Silver: handle an offline device gracefully, add reauthentication if your device needs credentials, and reach more than 95% test coverage.
- Add diagnostics, so users can share what the integration sees when they report a bug.
- Support discovery, so Home Assistant finds the monitor by itself.
- Add a second platform, like a binary sensor for an air quality alarm.