Logo
Publicado: 8/23/2025

Tutorial | Crea tu primera integración para Home Assistant

Home Assistant es un software de gestión domótica que nos permite una gran flexibilidad cuando se trata de gestionar nuestros hogares. Sus multiples integraciones oficiales y no oficiales permiten conectar multitud de dispositivos y servicios pero, a veces, encontramos algo que nos gustaría integrar y no existe aún ninguna integración para ello; para esos casos podemos desarrollar nuestra propia integración e incluso hacerla pública para que otros usuarios se beneficien también de ella. Desarrollar una integración de Home Assistant es a la vez simple pero complejo o al menos así me lo parece a mí, los desarrolladores de Home Assistant nos ponen unas estrictas reglas para facilitar el desarrollo de las integraciones personalizadas pero a su vez considero que estas reglas no están bien explicadas en la documentación, es por ello que a través de este tutorial intentaré reflejar todo lo aprendido cuando me tuve que enfrentar a este desarrollo por primera vez. Puedes consultar todo el código de esta integración en mi GitHub.

Creando una integración para consultar nuestra IP pública

Antes de empezar

Para ejemplificar como desarrollar nuestra primera integración vamos a desarrollar una que nos permita tener nuestra  IP pública siempre actualizada como sensor de Home Assistant. Para desarrollar esta aplicación vamos a hacer uso de un blueprint de la comunidad que nos permitirá tener una base sólida sobre la que empezar. Este blueprint se encuentra alojado en https://github.com/ludeeus/integration_blueprint y podemos utilizarlo como plantilla pulsando en "Usar esta plantilla". Crearemos nuestro repositorio con el nombre que tendrá nuestra integración y clonaremos el código en nuestra máquina. Por tanto los requisitos para seguir este tutorial son:

Cuando abramos el proyecto con VS Code y la extensión de Dev Containers instalada se nos avisará para reabrir el proyecto dentro del propio contenedor.

Tipos de integraciones en Home Assistant

A la hora de crear una integración de Home Assistant tenemos que entender cual es la clase IoT de nuestra integración. El tipo que elijamos describirá como se comunicará nuestra integración. Las siguientes clases son las que se usan en Home Assistant:

Podéis leer más sobre las clases IoT en el blog oficial. La clase que necesitemos la usaremos más adelante en la configuración de nuestra integración y, aunque puede parecer que solo describe como va a ser nuestra integración, también cambia algo la forma de desarrollar; no es lo mismo tener que actualizar periódicamente nuestro servicio a esperar que Home Assistant nos de la orden para realizar una modificación. Para este tutorial, como queremos obtener cada cierto tiempo la IP pública usando un servicio externo, usaremos la clase cloud_polling.

Configuración

La clase IoT es solo una de las configuraciones que deberemos modificar del blueprint para el correcto uso de la integración así que vamos a ver dónde debemos definir estas configuraciones y cuales debemos cambiar.

Programando nuestra integración

Una vez realizadas las configuraciones toca el turno del la lógica de nuestro código. Como suele ser habitual en Python empezaremos por el __init__.py. En este fichero lo primero a destacar es el siguiente fragmento de código:

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

Aquí definiremos que tipo de plataformas hará uso nuestra integración. Estas plataformas pueden ser sensores, luces, persianas, etc. En nuestro caso solo necesitaremos hacer uso de un sensor simple donde almacenar y guardar un registro de nuestra IP. Más información sobre las distintas plataformas pueden encontrarse en esta página de la documentación

Nuestro punto de entrada de la integración debe implementar además 3 funciones que permitirán cargar, desmontar y recargar la integración. El desmontar y la recarga no requiere más cambios pero la carga sí. En la función de carga crearemos el objeto coordinator, este objeto es el encargado de hacer las peticiones a la API. Se usa un coordinador para que, cuando todas las entidades consumen del mismo endpoint, evitar sobrecargar el sistema con una petición por entidad. En nuestro caso solo tendremos una entidad por lo que nos es muy importante el coordinador pero servirá para organizar las llamadas a la API. Más información sobre los tipos de peticiones y del coordinador puede leerse aquí. Home Assistant se encargará de llamar a un método de nuestro coordinador cada vez que quiera actualizar los datos. La clase que define nuestro coordinador la veremos más adelante. El tiempo entre llamadas a este método se define con update_interval.

Además del coordinador creamos el objeto que contiene el cliente que se encargará de obtener la información de la IP a través de una API. Este cliente hace uso de la session de asyncio que nos da Home Assistant por lo que no tenemos que preocuparnos por crear la sesión. Este objeto se guarda en entry.runtime_data y nos permitirá usarlo en el coordinador. Por último solo queda cargar por primera vez los datos, cargar las plataformas (que como hemos definido arriba solo tenemos la de sensor) y añadir el listener para desmontar la integración.

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)

La clase del coordinador la definimos en coordinator.py y solo contiene la clase del coordinador con el método _async_update_data. Este método es llamado por Home Assistant cada tiempo definido anteriormente y a través de nuestro cliente actualizará el objeto con los datos obtenidos de la 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

En data.py definimos el tipo de nuestro ConfigEntry y sus datos. Un ConfigEntry es basicamente un objeto persistente que nos facilita Home Assistant para guardar nuestras configuraciones, en este caso lo utilizamos para almacenar tanto el cliente como el coordinador.

type PublicDataConfigEntry = ConfigEntry[PublicIPData]


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

    client: PublicIPClient
    coordinator: PublicIPDataUpdateCoordinator
    integration: Integration

Para obtener información de la API tenemos el fichero api.py. Antes de continuar en esta sección he de aclarar de que existen 2 formas de llamar a una API. Nosotros vamos a definir la librería en una clase dentro de este mismo repositorio, es lo más común y sencillo para este tipo de integraciones hechas por la comunidad pero aún así Home Assistant recomienda realizar las llamadas a través de un paquete publicado en Pypi. De esta forma mantendrías por un lado el paquete independiente que se encarga de obtener los datos del dispositivo o servicio y es abstracto al protocolo de comunicación y por otro lado solo tendrías que añadir ese paquete como dependencia de la integración y hacer las llamadas a través de este paquete (documentación).

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

Para esta integración la librería de api.py es muy sencilla, definimos las excepciones y a través de un simple wrapper obtenemos los datos necesarios. Para obtener nuestra IP pública simplemente el coordinador llamará a async_get_data y hará la petición a la API (en este caso la de ifconfig.me). 

Antes de programar el sensor que finalmente mostrará esta información en Home Assistant debemos de configurar el config_flow.py. Este fichero se encarga de que, a través de la interfaz puedas configurar una integración. En nuestro caso no es realmente necesario porque no necesitamos ninguna entrada del usuario, normalmente se piden credenciales para poder conectarse a la API pero no es necesario en este caso. Aún así, para mostrar como se utiliza haremos uso de este flujo para pedir al usuario que nombre quiere poner al sensor que almacenará la 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()

Para esta integración el flujo no es muy complejo pero es fácil que si requerimos bastantes datos del usuario y comprobaciones este flujo se complique. Los ConfigFlow se configuran a través de una clase handler y pasos. Podemos pedir información del usuario en varios pasos, por ejemplo, primero preguntar por el país y dependiendo de la entrada pedir las credenciales de una región u otra. Para nuestra integración tan solo pediremos en nombre del sensor. El formulario que debe introducir el usuario se valida a través de la librería de validación voluptuous. Para cada paso de la configuración hay varios caminos: si el usuario no ha introducido los datos se muestra el formulario con las entradas del tipo que necesitemos, si el usuario ha introducido los datos se validan y se crean las entidades y si ha habido algún problema de validación o de conexión se muestra el error.

Para los textos que mostrar al usuario y las constantes a utilizar tenemos varios ficheros, const.py tiene la definición del dominio que identifica inequívocamente nuestra integración, otras constantes que identifican las traducciones que se encuentran en la carpeta translations y la definición del logger.

LOGGER: Logger = getLogger(__package__)

DOMAIN = "public_ip"
CONF_NAME = "name"

Cada idioma que debamos soportar tiene que tener un json con el código del idioma correspondiente en el nombre en el nombre del fichero.

{
    "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."
        }
    }
}

Observa como los textos de la configuración se definen aquí y el nombre de cada paso (solo uno en nuestro caso) corresponde con el id que le damos en el config flow.

Ya solo nos queda dar vida a nuestro sensor que es lo que realmente veremos en Home Assistant y podremos usar para nuestros dashboards y automatizaciones.

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

Como vimos en la configuración cuando se creaba correctamente se llamaba al método async_setup_entry. Este método lo implementa cada plataforma que hayamos declarado en nuestra integración. En este método se añaden todas las entidades del tipo sensor (solo una en este caso) a través de la declaración de las clases. La clase de nuestro sensor contiene datos para definir la entidad y hace uso del title que le hemos pedido al usuario, tanto para el nombre como para el id. Y obviamente tiene la propiedad native_value que es la que lee Home Assistant y muestra al usuario, esta propiedad obtiene la información de los datos obtenidos por nuestro coordinador.

Por último solo queda el fichero entity.py.

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

Aquí simplemente está definida la clase que implementan nuestros sensores. En este caso la clase es muy sencilla y solo implementa la definición del unique_id pero a medida que añadamos entidades puede sernos útil para no repetir código.

Ya con esto podemos probar la integración y para ello, si estamos dentro del Dev Container ejecutamos bash scripts/develop y se nos ejecutará una instancia de Home Assistant con la integración instalad. Tan solo tendremos que añadir la integración y empezar a probarla.

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

El logo de nuestra integración se puede configurar pero no de forma sencilla, se debe de añadir los ficheros correspondientes a los logos que quieras mostrar en el repositorio de brands the Home Assistant creando una Pull Request.

Publicando la integración

El repositorio contiene varios workflows para validar la integración una vez subamos nuestro código a GitHub. Es muy recomendable echar un vistazo a los ficheros de la carpeta .github donde se encuentran estos workflows ya que también están los ficheros de plantillas por si algún usuario tiene que reportar algún error o contribuir al repositorio.

Además de los workflows que vienen en el blueprint nos puede interesar añadir el siguiente workflow que creará una release con el changelog adecuado cada vez que hagamos push de una etiqueta. De esta forma HACS, al actualizar la información del repositorio y tras ser actualizado el fichero manifest.json con la versión adecuada, mostrará una actualización disponible al usuario con la versión debidamente mostrada junto con la lista de cambios realizados.

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

La validación que hace el workflow verifica que nuestro repositorio esté correcto por lo que si ves que falla revisa el log ya que es posible que falte algo (una descripción, una configuración de los issues...).

Ya con esto solo nos queda mostrar en el README las instrucciones para la instalación. Para poder instalarlo podemos añadir el repositorio a HACS o simplemente crear un enlace especial que lo haga por nosotros y añadirlo al 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)

Añadir integraciones personalizadas de esta forma es muy sencilla pero si aún así quieres publicar la integración en el repositorio oficial de HACS deberás seguir estas instrucciones.

Y con esto ya habríamos creado nuestra primera integración sencilla. A partir de aquí las posibilidades son infinitas por lo que atrévete a desarrollarlas.