Как написать расширение для Home Assistant и подключить радио

Как написать расширение для Home Assistant и подключить радио

Владимир Туров
Владимир Туров Разработчик
24 января 2025

Разбираем основные этапы разработки HA-расширений на примере интеграции с Ka-Radio32.

Изображение записи

Два с половиной года назад я делал радио из игры Cyberpunk 2077 на базе проекта Ka-Radio32 и собственной интерпретации модели из игры. Радио спокойно использовалось по назначению все это время, но вот наступили новогодние праздники. Я решил настроить Home Assistant (HA) и объединить все домашние умные устройства в локальном хабе.

Напечатанное на 3D-принтере радио из Cyberpunk 2077 красного цвета.
Радио из Cyberpunk, которое я сделал.

Часть устройств добавилась без проблем, другая — сопротивлялась и требовала дополнительных действий. Но самодельное радио стояло особняком: для проекта Ka-Radio32 нет ни официальной интеграции, ни пользовательской. Хотя у Home Assistant широкая документация, начать разрабатывать свое расширение довольно сложно. В этой статье я разберу основные этапы на примере интеграции с Ka-Radio32.

Основа моей интеграции

Про пользовательскую интеграцию я слегка приукрасил. Есть репозиторий dariornelas/ha_karadio, который не обновлялся с 2022 года, а также комментарий на 4PDA от 2021 года, где отмечается, что проект заброшен, с багами, но «форумчанин» сделал свой патч под себя.

Репозиторий https://github.com/dariornelas/ha_karadio делает карточку медиаплеера для karadio, только проект походу заброшен и работает с ошибками.
У меня есть исправленная рабочая версия, скину ближе к вечеру.

В рабочем компоненте нужно заменить custom_components/karadio/media_player.py файлом из архива и ребутнуть HA.
Комментарий про рабочий патч ha_karadio.

Тем не менее, если не учитывать возможную неработоспособность проекта, у этой интеграции есть и другие недостатки.

  • Для настройки нет веб-интерфейса. Все производится только через файл configuration.yml.
  • У Ka-Radio32 можно редактировать список возможных радиостанций, а у существующей интеграции список-источник «прибит» в отдельном файле, который придется обновлять.

В общем, интеграция в каком-то виде существует, но удобством определенно не отличается. Исправим эти проблемы.

Подписывайтесь на мой Telegram-канал, там можно увидеть заметки по темам статей, над которыми я работаю, и небольшие познавательные посты.

Настройка окружения

У Home Assistant есть возможность автоматической настройки окружения через devcontainters и VSCode. Также инструмент поддерживает локальную разработку, но я выбрал именно контейнерный путь. Обратите внимание, что у контейнеров есть несколько особенностей, про которые легко забыть.

  • Devcontainters — это разработка внутри контейнера, связанная с git-репозиторием. Это удобно для внесения модификаций в ядро HA и во встроенные интеграции, но пользовательские интеграции распространяются отдельно и должны быть в отдельных репозиториях.
  • По умолчанию из контейнера пробрасываются только порты, необходимые для разработки и тестирования. Если вы подключаете дополнительные интеграции, которые настраивают свой порт, например HomeKit Bridge, то эти порты необходимо пробросить.

Обе проблемы решаются конфигурацией файла /.devcontainer/devcontainer.json:


    {
  "name": "Home Assistant Dev",
   // Port 5683 udp is used by Shelly integration
  "appPort": ["8123:8123", "5683:5683/udp"],

  // Удалено для краткости

  // Точки монтирования
  "mounts": [
"source=E:\\habr\\karadio-ha\\custom_components\\karadio32,target=${containerWorkspaceFolder}/config/custom_components/karadio32,type=bind",
  ]
}

Атрибут appPort перечисляет порты, которые необходимо пробросить из контейнера, а параметр mount позволяет перечислять точки монтирования каталогов из операционной системы. Таким образом, исходный код пользовательской интеграции можно поместить в отдельный репозиторий в системе контроля версий. Все пользовательские интеграции должны размещаться в каталоге /config/custom_compoments/.

В стандарте JSON нет комментариев, но devcontainers и VSCode — кажется, об этом не знают и проект HA отлично работает с комментарием про порт 5683 — используют один из диалектов, где комментарии возможны.

После правок в devcontainer.json среда разработки предложит пересобрать контейнер и перезапустится. Теперь можно запустить HA: Run Task → Run Home Assistant Core. Интерфейс HA доступен по адресу https://localhost:8123. Перед началом разработки необходимо провести первичную настройку: зарегистрироваться и войти.

Теперь у нас есть рабочий экземпляр Home Assistant, на котором можно сразу тестировать новую интеграцию. К сожалению, для применения изменений в исходном коде необходимо полностью перезапускать HA, а в случае изменений веб-интерфейса также следует обновить страницу со сбросом кэша.

Справка по терминам

Перед началом разработки необходимо разобраться с терминологий и выбрать домен интеграции. 
Домен (domain) — это короткое уникальное имя, однозначно описывающее интеграцию. Устройство, для которого разрабатывается интеграция, называется Ka-Radio32, поэтому я выбрал домен karadio32. Это достаточно короткое и однозначное имя, а также оно не пересекается с доменом другой пользовательской интеграцией — karadio.

Корректный домен — это важная часть всей интеграции. Имя каталога с исходным кодом должно совпадать с доменом в коде, иначе не будут работать переводы и не только.

Интеграция (integration) — реализация логики взаимодействия с внешним ресурсом. Ресурс может быть как физическим устройством, так и удаленным сервисом. Например, интеграция с прогнозом погоды не имеет физического воплощения. 

Платформа (platform) — собирательный образ компоненты, который описывает поведение и функции устройств. Например, платформа лампочка (light) может включаться, выключаться и менять цвет и яркость. Каждая интеграция может использовать одну или несколько платформ. 

Объект (entity) — экземпляр интеграции определенной платформы. Это аналогично объектно-ориентированному подходу: интеграция — это класс, а объект — это экземпляр класса. Каждый объект связан только с одной платформой. Например, если робот-пылесос имеет сигнальную лампу, то робот-пылесос будет состоять из двух объектов: пылесоса и лампы.

Устройство (device) — способ группировки объектов, которые относятся к одному устройству. Устройство может состоять из одного или более объекта. 
Запись (entry, config entry) — конфигурационная запись в Home Assistant для сохранения настроек объекта.

Кажется, этого достаточно. Перейдем к разработке.

Разработка и настройка интеграции

Разработка интеграции

Вот так выглядит минимальный набор файлов для создания интеграции:

  • manifest.json — описание интеграции для HA;
  • __init__.json — пустой файл, необходимый для интерпретатора Python, чтобы каталог считался модулем (по умолчанию пуст);
  • как минимум один файл с расширением платформы (platform).

Описание интеграции хорошо документировано и не должно вызывать вопросов. В манифесте нужно указать название проекта, домен, зависимости и ссылку на документацию интеграции. 
Для радио отлично подходит платформа media_player. В документации сказано, что хороший тон разработки — это не взаимодействовать с устройством напрямую из интеграции. В зависимостях манифеста интеграции нужно указать библиотеку из PyPI, которая умеет взаимодействовать с устройством. Таким образом, интеграция работает с библиотекой, библиотека — с устройством.

В моем проекте это правило будет несколько нарушено: интеграция будет взаимодействовать с «библиотекой», но библиотека представлена одним классом  и является частью интеграции. Сперва напишем класс-обертку — «библиотеку». Для этого обратимся к документации интерфейсов Ka-Radio32. Там описан необходимый набор функций для управления радио: регулирование громкости, выбор станций, включение-выключение, эндпоинт для получения статуса устройства. В Home Assistant для HTTP-запросов используется aiohttp.


    # karadio32.py

class Karadio32Api:
    """Wrapper for karadio32 HTTP API."""

    def __init__(self, host: str, session):
        """Init wrapper."""
        _LOGGER.info("Initializing KaradioAPI")
        self.session = session
        self.host = host.rstrip("/")

    async def _request(self, params: dict[str, str], raises=False):
        try:
            async with asyncio.timeout(TIMEOUT):
                response = await self.session.get(self.host, params=params)
                return await response.text()
        except Exception:
            if raises:
                raise
            return None

    async def version(self):
        return (await self._request({"version": ""})).strip()

    async def start(self):
        await self._request({"start": ""})

    async def stop(self):
        await self._request({"stop": ""})

    async def play(self, station_id: int):
        await self._request({"play": str(station_id)})

    async def set_volume(self, volume: float):
        level = max(0, min(volume, 1))
        await self._request({"volume": f"{255 * level:.0f}"})

Класс выглядит довольно просто: есть общий метод для совершения GET-запросов и отдельные методы для создания правильных аргументов. Полный текст класса можно посмотреть на GitHub

Теперь реализуем расширение для MediaPlayer. Так как расширение использует платформу MediaPlayer, то исходный файл обязательно должен называться по имени расширяемой платформы. В данном случае — media_player.py. В файле реализуем поведение объекта (entity) нашего радио.


    # media_player.py
DOMAIN = “karadio32”
SCAN_INTERVAL = timedelta(seconds=5)

class Karadio32(MediaPlayerEntity):
    def __init__(
        self,
        api: Karadio32Api
    ):
        super().__init__()
        self.api: Karadio32Api = api
        # Все устройства, настраиваемые через web
        # должны иметь уникальный идентфикатор
        self._attr_unique_id = f"KaRadio32-{api.host}"

        self.supported_features = (
            MediaPlayerEntityFeature.PLAY
            | MediaPlayerEntityFeature.STOP
            | MediaPlayerEntityFeature.SELECT_SOURCE
            | MediaPlayerEntityFeature.VOLUME_SET
        )

    async def async_media_stop(self):
        await self.api.stop()
        self._attr_state = MediaPlayerState.PAUSED

    async def async_media_play(self):
        await self.api.play(self._attr_source_list.index(self._attr_source))
        self._attr_state = MediaPlayerState.PLAYING

    async def async_select_source(self, source):
        pass

    async def async_set_volume_level(self, volume):
        self._attr_volume_level = volume
        await self.api.set_volume(volume)

    async def async_update(self):
        # Выполняется раз в SCAN_INTERVAL

В этом файле гораздо больше «магических» вещей, которые не очевидны, если просто смотреть в файл без чтения документации. Во-первых, в файле должна быть переменная DOMAIN с именем домена интеграции. Это тот самый домен, который должен совпадать с именем каталога. Во-вторых, радио не отдает информацию о себе в Home Assistant — значит, HA должен сам периодически ходить к радио за обновлениями. Для этого нужно определить переменную SCAN_INTERVAL, а в реализации объекта — метод async_update.

Обратите внимание, что объекты Home Assistant имеют как асинхронные версии функций с префиксом async_, так и их синхронные аналоги. Разработчик может реализовывать любой из вариантов.

У MediaPlayerEntity есть множество функций, при этом не каждый проигрыватель медиа может реализовать их все. Поэтому необходимо определить список поддерживаемых функций. Например, телевизор и радио не могут переключать на следующий трек. Для Ka-Radio32 список довольно ограниченный:

  • воспроизвести/поставить на паузу,
  • выбрать источник воспроизведения,
  • изменить громкость.

Для каждого предопределенного параметра есть собственный атрибут в классе. Например, глобальное состояние устройства хранится в _attr_state, а громкость — в _attr_media_volume. Это позволяет сократить количество бойлерплейта с геттерами, так как они уже реализованы в родительском классе.

Теперь у нас есть реализация объекта, но его необходимо как-то создать.

Простейшая настройка интеграции

Простейший способ настройки интеграции — через схему платформы и конфигурационный файл configuration.yml. Это способ требует описания схемы и функции async_setup_platform в файле платформы.


    # media_player.py
import voluptuous as vol

from homeassistant.const import CONF_URL
from homeassistant.helpers import config_validation as cv
from homeassistant.components.media_player import PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA

MEDIA_PLAYER_PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
    {
        vol.Required(CONF_URL): cv.string,
    }
)


async def async_setup_platform(
    hass: core.HomeAssistant,
    config: ConfigType,
    async_add_entities: Callable,
    discovery_info: Optional[DiscoveryInfoType] = None,
) -> None:
    session = async_get_clientsession(hass)
    api = Karadio32Api(config[CONF_URL], session)
    player = Karadio32(api)
    async_add_entities([player], update_before_add=True)

В этом подходе используется магическая переменная PLATFORM_SCHEMA, в которую добавляются поля для настройки объекта. В данном случае — только поле с именем CONF_URL типа string. Данные, провалидированные по схеме, попадут в переменную config и могут быть использованы для инициализации библиотек и создания объектов HA.

Как говорилось ранее, настройка такой интеграции производится напрямую в конфигурационном файле.


    media_player:
  - platform: karadio
    host: 192.168.1.3
Home Assistant, пользовательские интеграции.

Безусловно, это рабочий способ, но в веб-интерфейсе будет множество предупреждений, что где карту делали — туда и приходите у веб-интерфейса нет власти над тем, что прописано в файлах конфигурации. Поэтому переходим к более сложному варианту с конфигурацией из веб-интерфейса.

Включение веб-интерфейса

Для включения веб-интерфейса требуется сделать несколько действий. Во-первых, в манифесте необходимо добавить параметр config_flow со значением True. Во-вторых, порядок настройки должен быть определен наследником класса ConfigFlow в файле config_flow.py.


    from homeassistant import config_entries

# Схема аналогично
MAIN_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.string})

class Karadio32ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):

    # При открытии формы создания объекта
    # HA ищет метод с названием async_step_user
    async def async_step_user(self, user_input: dict[str, Any] | None = None):  

        # Сюда можно положить ошибку
        errors: dict[str, str] = {}

        # Сюда динамическое описание ошибки
        description_placeholders: dict[str, str] = {}

        # Если user_input содержит данные, 
        # то пользователь нажал кнопку “далее” и
        # прошел валидацию схемы
        if user_input is not None:
            # Мы можем проверить, что наше устройство то,
            # за кого себя выдает
            session = async_get_clientsession(self.hass)
            radio = Karadio32Api(user_input[CONF_URL], session)
            try:
                user_input["sw_version"] = await radio.version()
                user_input["source_list"] = await radio.source_list()

                # Если ошибки нет, то вызываем создание объекта
                # со всеми параметрами
                return self.async_create_entry(
                    title="KaRadio32",
                    data=user_input,
                )
            except TimeoutError:
                description_placeholders["reason"] = "TimeoutError"
            except Exception as e:
                description_placeholders["reason"] = str(e)

            # Ошибка должна быть с ключом base
            errors["base"] = "unreachable"
 
        # В веб-интерфейсе показываем форму...
        return self.async_show_form(
            # ...за которую отвечает функция async_step_{step_id}
            step_id="user",
            # ...со схемой
            data_schema=MAIN_SCHEMA,
            # ...с текстом ошибки
            errors=errors,
            # ...с дополнением в текстах
            description_placeholders=description_placeholders,
        )

Настройка через графический интерфейс позволяет проверить, что устройство действительно совместимо с нашей интеграцией. Также мне показалось уместным собрать версию прошивки устройства и выполнить относительно медленную операцию: собрать список известных станций, так как эти данные обновляются долго, но редко.

Можно также заметить, что ошибка unreachable — это не очень говорящее название. Дело в том, вместо текста можно использовать ключи локализации в файле strings.json.


    {
    "config": {
        "error": {
            "unreachable": "KaRadio32 seems to be unreachable. Reason: {reason}"
        },
        "step": {
            "user": {
                "title": "KaRadio32",
                "description": "Enter url with http://",
                "data": {
                    "url": "URL"
                }
            }
        }
    }
}

В этом файле находятся все ключи для текстов и соответствующие им строки. Если необходимо вывести динамическую информацию из кода, то в тексте можно использовать синтаксис f-string, а расширенную информацию передавать в словаре description_placeholders. Использование strings.json позволяет легко переводить интерфейс на любые языки. Более подробно создание локализаций описано в документации.

Home Assistant, KaRadio32.

Если все сделано верно, то при добавлении интеграции будет доступна форма с полем для ввода адреса, но создать объект не получится. Это потому, что добавление интеграции через веб-интерфейс ищет функцию async_setup_entry в файле __init__.py. Процесс обусловлен тем, что Home Assistant не знает, какие платформы должны использоваться при настройке, поэтому передает всю информацию в модуль.


    # Файл __init__.py

async def async_setup_entry(
    hass: core.HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
    hass.data.setdefault(DOMAIN, {})
    # Самостоятельно сохраняем запись
    hass.data[DOMAIN][entry.entry_id] = entry.data

    # Передаем настраиваться отдельные объекты для заданных платформ
    await hass.config_entries.async_forward_entry_setups(entry, ["media_player"])

    return True

В более сложных случаях в этой функции может быть логика выбора модели и подбора нужных платформ: радио, радио со светомузыкой, радио с термостатом… Но так как у нас единственная модель и платформа, то передаем создание объекта в нее.


    # Файл media_player.py

async def async_setup_entry(
    hass: core.HomeAssistant,
    config_entry: config_entries.ConfigEntry,
    async_add_entities,
):
    config = hass.data[DOMAIN][config_entry.entry_id]
    session = async_get_clientsession(hass)
    api = Karadio32Api(config[CONF_URL], session)
    player = Karadio32(api, config.get("source_list", []), config.get("sw_version"))
    async_add_entities([player], update_before_add=True)

В файле media_player аналогично определяем функцию async_setup_entry, которая незначительно отличается от async_setup_platform.

Теперь при создании объекта с радио скачивается список станций. Пусть этот список меняется редко, но все-таки меняется. Как можно дать пользователю возможность скачать список заново?

Редактирование настроек

Возможный способ — сделать в настройках объекта пункт «обновить сведения об устройстве». Для этого нужно определить наследника класса OptionsFlow и указать его в ConfigFlow.


    # Файл conflg_flow.py

from homeassistant import config_entries, core


class Karadio32ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):

    # ...

    @staticmethod
    @core.callback
    def async_get_options_flow(config_entry):
        # Определяем наш обработчик настроек
        return OptionsFlowHandler()

class OptionsFlowHandler(config_entries.OptionsFlow):

    # Здесь точка входа async_step_init,
    # в остальном все аналогично ConfigFlow
    async def async_step_init(
        self, user_input: dict[str, Any] = None
    ) -> dict[str, Any]:
        errors: dict[str, str] = {}
        description_placeholders: dict[str, str] = {}

        if user_input is not None:
            if user_input.pop("update_info", False):
                session = async_get_clientsession(self.hass)
                radio = Karadio32Api(self.config_entry.data[CONF_URL], session)
                try:
                    user_input[CONF_URL] = self.config_entry.data[CONF_URL]
                    user_input["sw_version"] = await radio.version()
                    user_input["source_list"] = await radio.source_list()
                except TimeoutError:
                    description_placeholders["reason"] = "TimeoutError"
                    errors["base"] = "unreachable"
                except Exception as e:
                    description_placeholders["reason"] = str(e)
                    errors["base"] = "unreachable"

            if not errors:
                # Если все ОК, то обновляем запись об интеграции
                self.hass.config_entries.async_update_entry(
                    self.config_entry, data=user_input
                )
                # Подаем сигнал что все ОК, но не создаем новых объектов
                return self.async_create_entry(title=None, data=None)

        # Схема создается непосредственно в функции,
        # чтобы подставить значения из конфигурации
        option_schema = vol.Schema(
            {
                vol.Required(
                    CONF_URL, default=self.config_entry.data[CONF_URL]
                ): cv.string,
                vol.Optional("update_info"): cv.boolean,
            }
        )

        return self.async_show_form(
            step_id="init",
            data_schema=option_schema,
            description_placeholders=description_placeholders,
        )
Home Assistant, добавление адреса KaRadio32.

Теперь если пользователь поставит галочку, то система обновит сведения об Ka-Radio32, в том числе заново скачает список станций. Задача выполнена!

Бонус: создание устройства и интеграция с HomeKit

Создание устройства

После создания основной логики интеграции появился небольшой азарт добавить дополнительные функции. Например, вместо «объекта» создать «устройство», в котором, конечно, по-прежнему будет всего один объект, но зато с дополнительной информацией. 

В Home Assistant не предполагается отдельное создание сущности «устройство». Вместо этого все объекты одного физического устройства должны сообщать общую информацию в поле device_info.


    class Karadio32(MediaPlayerEntity):
    @property
    def device_info(self) -> DeviceInfo:
        return DeviceInfo(
            identifiers={(DOMAIN, self.unique_id)},
            name=self.name,
            sw_version=self.sw_version,
        )

Интерфейс управления Ka-Radio32 в Home Assistant.

Вроде мелочь, а приятно.

Интеграция с HomeKit

HomeKit, выключатели KaRadio.

Не радио, а три выключателя. В официальном наборе интеграций есть HomeKit Bridge, который позволяет добавить Home Assistant как хаб в Apple HomeKit. HomeKit Bridge отлично экспортирует объекты платформы MediaPlayer в HomeKit, но не все так просто. Радио превращается в выключатели.

Это происходит потому, что родительский объект MediaPlayer неизвестен для HomeKit и делает все, что может. Вместо этого нужно замаскировать наше радио под телевизор или ресивер.


    class Karadio32(MediaPlayerEntity):
    _attr_device_class = MediaPlayerDeviceClass.RECEIVER

После этого радио станет почти нормальным. Разве что громкость придется искать в другом приложении.

Публикация интеграции

Home Assistant, настройки KaRadio.

У всех интеграций в Home Assistant есть качество на основе измеряемых метрик. Кажется, что попасть в основную кодовую базу затруднительно, да и нет особой мотивации. Вместо этого можно разместить свою интеграцию в Home Assistant Community Store. Там все еще не самая низкая планка качества, но есть возможность установки интеграций из пользовательских репозиториев.

Для возможности установки интеграции достаточно иметь открытый репозиторий. Но если добавить автоматических проверок и соблюсти все требования, то можно попасть в список предустановленных репозиториев.

Заключение 

Разработка для Home Assistant не выглядит простой из-за большого количества абстракций, но это достаточно гибкий инструмент. Помимо моей статьи вы можете ознакомиться с пятью заметками по созданию интеграции с GitHub от разработчика Aaron Godfrey. Это хорошее дополнение, которое под другим углом рассказывает тот же материал, но некоторые из фрагментов кода уже отмечены как deprecated. Если же вам понравилось мое решение, делайте форк репозитория на GitHub.