Logo
Published: 8/30/2025

Tutorial | Create your first Home Assistant integration

Home Assistant is home automation management software that offers great flexibility when it comes to managing our homes. Its multiple official and custom integrations allow us to connect a multitude of devices and services, but sometimes we find something we would like to integrate and there is no integration for it yet. In these cases, we can develop our own integration and even make it public so that other users can also benefit from it. Developing a Home Assistant integration is both simple and complex, or at least that is how it seems to me. The Home Assistant developers set strict rules to facilitate the development of custom integrations, but at the same time, I believe that these rules are not well explained in the documentation. That is why, through this tutorial, I will try to reflect everything I learned when I had to tackle this development for the first time. You can view all the code for this integration on my GitHub.

Creating an integration to check our public IP

Before we begin

To illustrate how to develop our first integration, we are going to develop one that allows us to have our public IP always updated as a Home Assistant sensor. To develop this application, we are going to use a community blueprint that will give us a solid foundation to start from. This blueprint is hosted at https://github.com/ludeeus/integration_blueprint and we can use it as a template by clicking on “Use this template.” We will create our repository with the name of our integration and clone the code to our PC. Therefore, the requirements to follow this tutorial are:

When we open the project with VS Code and the Dev Containers extension installed, we will be prompted to reopen the project within the container itself.

Integration types in Home Assistant

When creating a Home Assistant integration, we need to understand the IoT class of our integration. The type we choose will describe how our integration will communicate. The following classes are used in Home Assistant:

You can read more about IoT classes on the official_blog. We will use the class we need later on when configuring our integration, and although it may seem that it only describes how our integration will be, it also changes how we develop it; it is not the same to have to periodically update our service as it is to wait for Home Assistant to give us the command to make a modification. For this tutorial, as we want to obtain the public IP from time to time using an external service, we will use the cloud_polling class for that.

Configuration

The IoT class is just one of the settings we will need to modify from the blueprint for the integration to work properly, so let's see where we need to define these settings and which ones we need to change.

Developing our integration

Once the configurations have been made, it is time to address the logic of our code. As is usually the case in Python, we will begin with __init__.py. In this file, the first thing to note is the following code snippet:

PLATFORMS: list[Platform] = [
    Platform.SENSOR,
]

Here we will define what type of platforms our integration will use. These platforms can be sensors, lights, covers, etc. In our case, we will only need to use a simple sensor to store and keep a record of our IP. More information about the different platforms can be found on this documentation page.

Our integration entry point must also implement three functions that will allow us to load, unload, and reload the integration. Unloading and reloading do not require any further changes, but loading does. In the loading function, we will create the coordinator object, which is responsible for making requests to the API. A coordinator is used so that, when all entities consume from the same endpoint, the system is not overloaded with one request per entity. In our case, we will only have one entity, so the coordinator is not very important, but it will serve to organize the calls to the API. More information about the types of requests and the coordinator can be found here. Home Assistant will call a method of our coordinator every time it wants to update the data. We will see the class that defines our coordinator later. The time between calls to this method is defined with update_interval.

In addition to the coordinator, we create the object that contains the client that will be responsible for obtaining the IP information through an API. This client uses the asyncio session provided by Home Assistant, so we don't have to worry about creating the session. This object is stored in entry.runtime_data and will allow us to use it in the coordinator. Finally, all that remains is to load the data for the first time, load the platforms (which, as we defined above, only include the sensor), and add the listener for unloading the integration.

async def async_setup_entry(
    hass: HomeAssistant,
    entry: PublicDataConfigEntry,
) -> bool:
    """Set up this integration using UI."""
    coordinator = PublicIPDataUpdateCoordinator(
        hass=hass,
        logger=LOGGER,
        name=DOMAIN,
        update_interval=timedelta(minutes=5),
    )
    entry.runtime_data = PublicIPData(
        client=PublicIPClient(
            session=async_get_clientsession(hass),
        ),
        integration=async_get_loaded_integration(hass, entry.domain),
        coordinator=coordinator,
    )

    await coordinator.async_config_entry_first_refresh()

    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
    entry.async_on_unload(entry.add_update_listener(async_reload_entry))

    return True


async def async_unload_entry(
    hass: HomeAssistant,
    entry: PublicDataConfigEntry,
) -> bool:
    """Handle removal of an entry."""
    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)


async def async_reload_entry(
    hass: HomeAssistant,
    entry: PublicDataConfigEntry,
) -> None:
    """Reload config entry."""
    await hass.config_entries.async_reload(entry.entry_id)

We define the coordinator class in coordinator.py, which only contains the coordinator class with the _async_update_data method. This method is called by Home Assistant at the interval we defined, and through our client, will update the object with the data obtained from the API.

class PublicIPDataUpdateCoordinator(DataUpdateCoordinator):
    """Class to manage fetching data from the API."""

    config_entry: PublicDataConfigEntry

    async def _async_update_data(self) -> Any:
        """Update data via library."""
        try:
            return await self.config_entry.runtime_data.client.async_get_data()
        except PublicIPClientError as exception:
            raise UpdateFailed(exception) from exception

In data.py, we define the type of our ConfigEntry and its data. A ConfigEntry is basically a persistent object provided by Home Assistant to store our configurations. In this case, we use it to store both the client and the coordinator.

type PublicDataConfigEntry = ConfigEntry[PublicIPData]


@dataclass
class PublicIPData:
    """Data for the Public IP integration."""

    client: PublicIPClient
    coordinator: PublicIPDataUpdateCoordinator
    integration: Integration

To obtain information from the API, we have the api.py file. Before continuing with this section, I should clarify that there are two ways to call an API. We are going to define the library in a class within this same repository, which is the most common and simplest way for a custom integration, but Home Assistant recommends fetching data through a package published on Pypi. This way, you would keep isolated the package that is responsible for obtaining data from the device or service and is agnostic to the communication protocol, and you would only have to add that package as a dependency of the integration and make calls through this package (documentation).

class PublicIPClientError(Exception):
    """Exception to indicate a general API error."""


class PublicIPClientCommunicationError(
    PublicIPClientError,
):
    """Exception to indicate a communication error."""


class PublicIPClient:
    """Sample API Client."""

    def __init__(
        self,
        session: aiohttp.ClientSession,
    ) -> None:
        """Sample API Client."""
        self._session = session

    async def async_get_data(self) -> Any:
        """Get data from the API."""
        return await self._api_wrapper(method="get", url="https://ifconfig.me/ip")

    async def _api_wrapper(
        self,
        method: str,
        url: str,
        data: dict | None = None,
        headers: dict | None = None,
    ) -> Any:
        """Get information from the API."""
        try:
            async with async_timeout.timeout(10):
                response = await self._session.request(
                    method=method,
                    url=url,
                    headers=headers,
                    json=data,
                )
                return await response.text()

        except TimeoutError as exception:
            msg = f"Timeout error fetching information - {exception}"
            raise PublicIPClientCommunicationError(
                msg,
            ) from exception
        except (aiohttp.ClientError, socket.gaierror) as exception:
            msg = f"Error fetching information - {exception}"
            raise PublicIPClientCommunicationError(
                msg,
            ) from exception
        except Exception as exception:
            msg = f"Something really wrong happened! - {exception}"
            raise PublicIPClientError(
                msg,
            ) from exception

For this integration, the api.py library is very simple. We define the exceptions and, through a simple wrapper, we obtain the necessary data. To obtain our public IP, the coordinator simply calls async_get_data and makes the request to the API (in this case, ifconfig.me).

Before developing the sensor that will ultimately display this information in Home Assistant, we must configure config_flow.py. This file ensures that you can configure an integration through the interface. In our case, it is not really necessary because we do not need any user input. Normally, credentials are required to connect to the API, but it is not necessary in this case. Even so, to show how it is used, we will use this flow to ask the user what name they want to give to the sensor that will store the IP.

class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
    """Config flow for Public API."""

    VERSION = 1

    async def async_step_user(
        self,
        user_input: dict | None = None,
    ) -> config_entries.ConfigFlowResult:
        """Handle a flow initialized by the user."""
        _errors = {}
        if user_input is not None:
            try:
                await self._test_connection()
            except PublicIPClientCommunicationError as exception:
                LOGGER.error(exception)
                _errors["base"] = "connection"
            except PublicIPClientError as exception:
                LOGGER.exception(exception)
                _errors["base"] = "unknown"
            else:
                return self.async_create_entry(
                    title=user_input[CONF_NAME],
                    data=user_input,
                )

        return self.async_show_form(
            step_id="user",
            data_schema=vol.Schema(
                {
                    vol.Required(
                        CONF_NAME,
                        default=(user_input or {}).get(CONF_NAME, vol.UNDEFINED),
                    ): selector.TextSelector(
                        selector.TextSelectorConfig(
                            type=selector.TextSelectorType.TEXT,
                        ),
                    ),
                },
            ),
            errors=_errors,
        )

    async def _test_connection(self) -> None:
        """Validate connection."""
        client = PublicIPClient(
            session=async_create_clientsession(self.hass),
        )
        await client.async_get_data()

For this integration, the flow is not very complex, but it can easily become complicated if we require a lot of user data and checks. ConfigFlows are configured through a handler class and steps. We can request user information in several steps. For example, we can first ask for the country and, depending on the input, request credentials for one region or another. For our integration, we will only ask for the name of the sensor. The form that the user must fill out is validated through the voluptuous validation library. For each step of the configuration, there are several paths: if the user has not entered the data, the form with the entries of the type we need is displayed; if the user has entered the data, it is validated and the entities are created; and if there has been a validation or connection problem, the error is displayed.

For the texts to be displayed to the user and the constants to be used, we have several files. const.py has the domain definition that uniquely identifies our integration, other constants that identify the translations found in the translations folder and the logger definition.

LOGGER: Logger = getLogger(__package__)

DOMAIN = "public_ip"
CONF_NAME = "name"

Each language we need to support must have a json file with the corresponding language code in the file name.

{
    "config": {
        "step": {
            "user": {
                "description": "Write the sensor name to identify the public IP.",
                "data": {
                    "name": "Nombre"
                }
            }
        },
        "error": {
            "connection": "Unable to connect to the server.",
            "unknown": "Unknown error occurred."
        },
        "abort": {
            "already_configured": "This entry is already configured."
        }
    }
}

Note how the configuration texts are defined here and the name of each step (only one in our case) corresponds to the id we give it in the config flow.

Now we just need to bring our sensor to life, which is what we will actually see in Home Assistant and can be used for our dashboards and automations.

ENTITY_DESCRIPTIONS = (
    SensorEntityDescription(
        key="public_ip",
        icon="mdi:ip",
    ),
)


async def async_setup_entry(
    hass: HomeAssistant,  # noqa: ARG001 Unused function argument: `hass`
    entry: PublicDataConfigEntry,
    async_add_entities: AddEntitiesCallback,
) -> None:
    """Set up the sensor platform."""
    async_add_entities(
        PublicIPSensor(
            title=entry.title,
            coordinator=entry.runtime_data.coordinator,
            entity_description=entity_description,
        )
        for entity_description in ENTITY_DESCRIPTIONS
    )


class PublicIPSensor(PublicIPEntity, SensorEntity):
    """Public IP Sensor class."""

    def __init__(
        self,
        title: str,
        coordinator: PublicIPDataUpdateCoordinator,
        entity_description: SensorEntityDescription,
    ) -> None:
        """Initialize the sensor class."""
        super().__init__(coordinator)
        self._attr_name = f"{title}"
        self.entity_id = ENTITY_ID_FORMAT.format(f"{DOMAIN}_{title}")
        self.entity_description = entity_description

    @property
    def native_value(self) -> str | None:
        """Return the native value of the sensor."""
        return self.coordinator.data

As we saw in the configuration, when it was created correctly, the async_setup_entry method was called. This method is implemented by each platform that we have declared in our integration. In this method, all entities of the sensor type (only one in this case) are added through the declaration of the classes. Our sensor class contains data to define the entity and uses the title we asked the user for, both for the name and the id. And obviously, it has the native_value property, which is what Home Assistant reads and shows to the user. This property obtains the information from the data obtained by our coordinator.

Finally, only the entity.py file remains.

class PublicIPEntity(CoordinatorEntity[PublicIPDataUpdateCoordinator]):
    """Public IP entity class."""

    def __init__(self, coordinator: PublicIPDataUpdateCoordinator) -> None:
        """Initialize."""
        super().__init__(coordinator)
        self._attr_unique_id = coordinator.config_entry.entry_id

Here, we simply define the class that our sensors implement. In this case, the class is very simple and only implements the definition of unique_id, but as we add entities, it can be useful to avoid repeating code.

With this, we can now test the integration. To do so, if we are inside the Dev Container, we run bash scripts/develop, and an instance of Home Assistant with the integration installed will be executed. We will just have to add the integration and start testing it.

Screenshot of the integration in Home Assistant
Screenshot of the integration in Home Assistant
Screenshot of the integration in Home Assistant

The logo for our integration can be configured, but it is not a simple process. You must add the files corresponding to the logos you want to display in the Home Assistant brands repository by creating a Pull Request.

Publishing the integration

The repository contains several workflows to validate the integration once we upload our code to GitHub. It is highly recommended to take a look at the files in the .github folder where these workflows are located, as there are also template files in case any user needs to report an error or contribute to the repository.

In addition to the workflows that come with the blueprint, you may want to add the following workflow, which will create a release with the appropriate changelog each time you push a tag. Thus, when HACS updates the repository information and after the manifest.json file is updated with the appropriate version, it will show the user that an update is available, with the version duly displayed along with the list of changes made.

name: Create Release

on:
  push:
    tags:
      - 'v*'

jobs:
  create-release:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0

    - name: Extract tag name
      id: tag
      run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT

    - name: Generate changelog
      id: changelog
      run: |
        # Get the previous tag
        PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "")

        if [ -z "$PREVIOUS_TAG" ]; then
          # If no previous tag, get all commits
          CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges)
        else
          # Get commits since the previous tag
          CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges ${PREVIOUS_TAG}..HEAD)
        fi

        # Save changelog to output (escape newlines for GitHub Actions)
        echo "changelog<<EOF" >> $GITHUB_OUTPUT
        echo "$CHANGELOG" >> $GITHUB_OUTPUT
        echo "EOF" >> $GITHUB_OUTPUT

    - name: Create Release
      uses: actions/create-release@v1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        tag_name: ${{ steps.tag.outputs.tag }}
        release_name: Release ${{ steps.tag.outputs.tag }}
        body: |
          ## Changes in this release

          ${{ steps.changelog.outputs.changelog }}

          **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.tag.outputs.tag }}...HEAD
        draft: false
        prerelease: false

The workflow validation checks that our repository is correct, so if you see that it fails, check the log since something may be missing (the description, issue configuration, etc.).

Now all that remains is to show the installation instructions in the README. To install it, we can add the repository to HACS or simply create a special link that does it for us and add it to the README.

[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=JoseRMorales&category=Integration&repository=ha_public_ip)

Adding custom integrations with this button is very simple, but if you still want to publish the integration in the official HACS repository, you must follow these instructions.

And with that, we have created our first simple integration. From here, the possibilities are endless, so go ahead and develop them.