Три года назад у меня возникла идея создать систему управления аудио-видео потоками в доме. Хотелось, чтобы любой источник сигнала — кабельное ТВ, медиаплеер, игровая консоль или ПК — мог быть мгновенно переключен на любой телевизор в квартире. Достаточно нажать кнопку в смартфоне, и просмотр фильма продолжается в другой комнате без потери качества и необходимости бегать с пультами.

Тогда проект отложили: не хватало знаний в программировании, а стоимость подходящей HDMI-матрицы с возможностью внешнего управления казалась неподъемной.

Что изменилось

Спустя время ситуация поменялась. Во-первых, на Хабре появилась подробная статья от @metalstiv, в которой автор пошагово описал процесс реверс-инжиниринга протокола управления бюджетной матрицей 4×4 через RS-232. Это дало понимание, что задача решаема даже без глубоких технических знаний, если есть желание и немного помощи от современных инструментов.

Во-вторых, цены на оборудование стали доступнее. Была заказана матрица 4×4 с поддержкой 4K@60 Гц и управлением по RS-232. Параллельно приобрел USB-to-RS232 конвертер для подключения к управляющему устройству.

В-третьих, появился мощный ассистент в виде нейросети Qwen, которая помогла разобраться с протоколом обмена, написать код интеграции и адаптировать решения под Home Assistant.

Что понадобилось для реализации

  1. Home Assistant — центральная платформа умного дома. Выбрана за гибкость, открытость и огромное сообщество.

  2. USB-to-RS232 конвертер — для физического подключения матрицы к управляющему устройству (в моем случае — Raspberry Pi).

  3. HDMI Matrix 4×4 с поддержкой RS-232 — аппаратная основа системы. Коммутирует любой из четырех входов на любой из четырех выходов.

  4. Нейросеть Qwen — использовалась как интеллектуальный помощник: для анализа протокола, генерации кода, отладки команд и оптимизации интеграции.

Как это работает

Интеграция построена по модульному принципу и использует паттерн Coordinator для безопасной работы с последовательным портом. Вся логика разделена на три уровня: управление соединением, обработка команд и представление сущностей в Home Assistant.

Архитектура компонента

В основе лежит класс HDMISwitchCoordinator, который управляет подключением к матрице через RS-232. Поскольку работа с последовательным портом — блокирующая операция, вся коммуникация вынесена в отдельные потоки (threading), чтобы не замедлять основной цикл событий Home Assistant.

Координатор выполняет три ключевые задачи:

  • Инициализация порта с параметрами по умолчанию: /dev/ttyACM0, скорость 115200 бод, таймаут 5 секунд;

  • Отправка команд с потокобезопасной блокировкой (threading.Lock), что исключает конфликты при одновременных запросах;

  • Фоновое чтение ответов от устройства в цикле listenloop и передача статусов зарегистрированным слушателям.

Протокол обмена данными

Матрица принимает команды в текстовом формате. Для переключения источника используется команда cir, за которой следует шестнадцатеричное значение, рассчитываемое по формуле:

значение = 8 × (номер_зоны − 1) + (номер_входа − 1)

Например:

  • Переключить вход 1 на зону 1: cir 00

  • Переключить вход 3 на зону 2: cir 11 (8×1 + 2 = 10 → 0x0A, но в коде используется форматирование :02x, поэтому отправляется cir 0a)

Команда завершается символами перевода строки \r\n, что обеспечивает корректную обработку на стороне устройства.

Устройство может отправлять уведомления о текущем состоянии в формате s{zone}{input}, например s13 означает, что в зоне 1 активен вход 3. Координатор парсит такие сообщения и через механизм callback обновляет состояние соответствующей сущности в Home Assistant.

Сущности в Home Assistant

Для каждой зоны матрицы создаётся отдельная сущность типа media_player. Это позволяет:

  • Отображать текущий источник в интерфейсе;

  • Переключать входы через выпадающий список;

  • Использовать стандартные сервисы media_player.select_source;

  • Задавать кастомные названия источников через параметр sources в конфигурации.

Каждая сущность поддерживает:

  • Включение (переключение на первый источник в списке);

  • Выключение (переключение на второй источник, условно «заглушка»);

  • Отображение иконки входа через свойство entity_picture — если в папке /config/www/hdmi_switch/ размещены файлы img_1.png, img_2.png и т.д., они автоматически подставляются в карточку.

Структура файлов и установка

Для работы интеграции необходимо создать специальную структуру папок в директории конфигурации Home Assistant.

Структура папок

Все файлы интеграции должны находиться в папке custom_components/hdmi_switch/. Внутри также потребуется папка images для хранения иконок источников.

config/
├── custom_components/
│   └── hdmi_switch/
│       ├── __init__.py          # Основной файл инициализации и координатор
│       ├── manifest.json        # Манифест интеграции (версия, зависимости)
│       ├── const.py             # Константы (домен, настройки по умолчанию)
│       ├── media_player.py      # Логика сущностей медиаплеера
│       └── images/              # Папка для иконок источников (img_1.png и т.д.)
├── www/
│   └── hdmi_switch/             # Сюда копируются изображения автоматически
└── configuration.yaml           # Файл конфигурации Home Assistant

Установка через Samba

Самый удобный способ загрузить файлы интеграции — использовать сетевой доступ Samba, который предоставляется в Home Assistant OS.

  1. Включите Samba в Home Assistant:

    • Зайдите в Настройки -> Дополнения -> Магазин дополнений.

    • Найдите и установите дополнение "Samba share".

    • В конфигурации дополнения укажите имя пользователя и пароль.

    • Запустите дополнение.

  2. Подключитесь к файловой системе:

    • На компьютере с Windows откройте Проводник.

    • В адресной строке введите \\homeassistant\config (или IP-адрес вашего сервера).

    • Введите логин и пароль, заданные в дополнении Samba.

  3. Создайте структуру папок:

    • Перейдите в папку custom_components. Если её нет, создайте.

    • Внутри создайте папку hdmi_switch.

    • Внутри hdmi_switch создайте папку images.

  4. Загрузите файлы:

  5. Создайте текстовые файлы с именами __init __.py, manifest.json, const.py, media_player.py.

  6. Скопируйте в них соответствующий код (приведен ниже).

  7. Поместите изображения источников (img_1.png, img_2.png, img_3.png, img_4.png) в папку images.
    Ссылки на изобращения:
    а) img_1.png
    б) img_2.png
    в) img_3.png
    г) img_4.png

  8. Перезагрузите Home Assistant:

  9. Зайдите в Настройки -> Система -> Перезагрузка.

  10. После перезагрузки интеграция будет готова к настройке через configuration.yaml.

Код интеграции с комментариями

Ниже приведены основные файлы интеграции с подробными комментариями, объясняющими назначение каждого блока кода.

manifest.json

Файл манифеста сообщает Home Assistant метаданные об интеграции: имя, версию, необходимые библиотеки и тип подключения.

{
  "domain": "hdmi_switch",
  "name": "HDMI Switch",
  "version": "1.1.0",
  "requirements": ["pyserial>=3.5"],
  "iot_class": "local_push",
  "config_flow": false
}

const.py

Файл констант хранит настройки по умолчанию и идентификатор домена. Это упрощает поддержку кода, так как все магические числа и строки собраны в одном месте.

"""Константы для HDMI Switch."""
DOMAIN = "hdmi_switch"
DEFAULT_NAME = "HDMI Switch"
DEFAULT_PORT = "/dev/ttyACM0"
DEFAULT_COMMAND_BAUDRATE = 115200
DEFAULT_TIMEOUT = 5.0
DEFAULT_SOURCES = ["Вход 1", "Вход 2", "Вход 3", "Вход 4"]


""" Если в configuration.yaml не указать 
hdmi_switch:
  port: /dev/ttyACM1
  command_baudrate: 9600
  timeout: 10

То эти значения встанут по умолчанию
DEFAULT_PORT = "/dev/ttyACM0"
DEFAULT_COMMAND_BAUDRATE = 115200
DEFAULT_TIMEOUT = 5.0""""

__init __.py

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

Обратите внимание на имя файла инициализации: __init__.py. 
Оно должно содержать двойное подчеркивание слева и справа. 
Без этого Python не распознает папку как пакет, и интеграция не загрузится.
"""HDMI Switch Integration."""
import logging
import os
import shutil
import threading
import time
import serial
import asyncio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.discovery import load_platform
from .const import DOMAIN, DEFAULT_PORT, DEFAULT_COMMAND_BAUDRATE, DEFAULT_TIMEOUT

_LOGGER = logging.getLogger(__name__)

class HDMISwitchCoordinator:
    """Координатор для управления общим последовательным портом."""
    
    def __init__(self, hass, port, baudrate, timeout):
        self.hass = hass
        self.port = port
        self.baudrate = baudrate
        self.timeout = timeout
        self._serial = None
        self._lock = threading.Lock()
        self._stop_listener = False
        self._listener_thread = None
        self._listeners = {}  # zone_id -> callback function
        self._initialized = False
        
    def register_listener(self, zone_id, callback):
        """Зарегистрировать callback для зоны."""
        self._listeners[zone_id] = callback
        _LOGGER.debug("Зона %d: зарегистрирована в координаторе", zone_id)
        
    def unregister_listener(self, zone_id):
        """Удалить callback для зоны."""
        if zone_id in self._listeners:
            del self._listeners[zone_id]
            _LOGGER.debug("Зона %d: удалена из координатора", zone_id)
    
    def start(self):
        """Запустить поток инициализации и слушателя."""
        # Запускаем инициализацию в отдельном потоке, чтобы не блокировать event loop
        thread = threading.Thread(target=self._initialize_and_listen, daemon=True)
        thread.start()
        return True
    
    def _initialize_and_listen(self):
        """Инициализация порта и запуск слушателя (в потоке)."""
        if not self._open_serial():
            _LOGGER.error("Не удалось открыть порт в потоке инициализации")
            return
        
        self._initialized = True
        self._stop_listener = False
        self._listener_thread = threading.Thread(target=self._listen_loop, daemon=True)
        self._listener_thread.start()
        _LOGGER.info("Координатор запущен, порт %s открыт", self.port)
    
    def stop(self):
        """Остановить поток и закрыть порт."""
        self._stop_listener = True
        if self._listener_thread and self._listener_thread.is_alive():
            self._listener_thread.join(timeout=2)
        self._close_serial()
        _LOGGER.info("Координатор остановлен, порт закрыт")
    
    def _open_serial(self):
        """Открыть последовательный порт (вызывается в потоке)."""
        try:
            self._serial = serial.Serial(
                port=self.port,
                baudrate=self.baudrate,
                timeout=0.5,
                bytesize=serial.EIGHTBITS,
                parity=serial.PARITY_NONE,
                stopbits=serial.STOPBITS_ONE
            )
            # time.sleep здесь безопасен, т.к. мы в отдельном потоке
            time.sleep(0.2)
            _LOGGER.debug("Порт %s открыт успешно", self.port)
            return True
        except Exception as e:
            _LOGGER.error("Ошибка открытия порта %s: %s", self.port, e)
            return False
    
    def _close_serial(self):
        """Закрыть последовательный порт."""
        if self._serial:
            try:
                if self._serial.is_open:
                    self._serial.close()
                    _LOGGER.debug("Порт %s закрыт", self.port)
            except Exception as e:
                _LOGGER.error("Ошибка закрытия порта: %s", e)
            self._serial = None
    
    def send_command(self, command):
        """Отправить команду через порт (с блокировкой)."""
        # Ждем инициализации порта
        retries = 0
        while not self._initialized and retries < 10:
            time.sleep(0.1)
            retries += 1
            
        if not self._initialized:
            _LOGGER.error("Порт еще не инициализирован")
            return False
            
        with self._lock:
            try:
                if not self._serial or not self._serial.is_open:
                    if not self._open_serial():
                        return False
                
                _LOGGER.debug("Отправка команды: %s", command.strip())
                self._serial.write(command.encode('utf-8'))
                self._serial.flush()
                time.sleep(0.1)
                
                if self._serial.in_waiting > 0:
                    response = self._serial.readline().decode('utf-8', errors='ignore').strip()
                    _LOGGER.debug("Ответ устройства: %s", response)
                
                return True
            except Exception as e:
                _LOGGER.error("Ошибка отправки команды: %s", e)
                return False
    
    def _listen_loop(self):
        """Цикл чтения данных из порта в фоновом потоке."""
        _LOGGER.info("Поток слушателя запущен")
        while not self._stop_listener:
            try:
                with self._lock:
                    if self._serial and self._serial.is_open:
                        if self._serial.in_waiting > 0:
                            line = self._serial.readline().decode('utf-8', errors='ignore').strip()
                            if line:
                                _LOGGER.debug("Получено из порта: %s", line)
                                self._handle_incoming_status(line)
                        else:
                            pass
                    else:
                        self._open_serial()
                
                time.sleep(0.1)
            except Exception as e:
                _LOGGER.error("Ошибка в цикле слушателя: %s", e)
                time.sleep(1)
        
        _LOGGER.info("Поток слушателя остановлен")
    
    def _handle_incoming_status(self, status_line):
        """Обработка входящего статуса (например, 's11')."""
        if not status_line.startswith('s') or len(status_line) < 3:
            return
        
        try:
            zone = int(status_line[1])
            input_num = int(status_line[2])
            
            if zone in self._listeners:
                callback = self._listeners[zone]
                self.hass.add_job(callback, zone, input_num)
                _LOGGER.debug("Зона %d: статус передан в callback (вход %d)", zone, input_num)
        except ValueError as e:
            _LOGGER.debug("Не удалось распарсить статус %s: %s", status_line, e)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Настройка из configuration.yaml."""
    _LOGGER.info("HDMI Switch: async_setup вызван")
    
    # Копирование изображений (в executor, чтобы не блокировать поток)
    await hass.async_add_executor_job(setup_images, hass)
    
    if "hdmi_switch" not in config:
        _LOGGER.info("HDMI Switch: нет конфигурации в YAML")
        return True
    
    # Создаем координатор и сохраняем в hass.data
    hdmi_config = config["hdmi_switch"]
    port = hdmi_config.get("port", DEFAULT_PORT)
    baudrate = hdmi_config.get("command_baudrate", DEFAULT_COMMAND_BAUDRATE)
    timeout = hdmi_config.get("timeout", DEFAULT_TIMEOUT)
    
    coordinator = HDMISwitchCoordinator(hass, port, baudrate, timeout)
    hass.data[DOMAIN] = coordinator
    
    # Запускаем координатор (теперь это не блокирует event loop)
    coordinator.start()
    
    # Даем время на инициализацию порта перед загрузкой платформы
    await asyncio.sleep(0.5)
    
    _LOGGER.info("HDMI Switch: загрузка media_player платформы")
    load_platform(
        hass,
        "media_player",
        "hdmi_switch",
        hdmi_config,
        config
    )
    _LOGGER.info("HDMI Switch: платформа загружена")
    return True


async def async_unload_platforms(hass, config):
    """Выгрузка интеграции."""
    _LOGGER.info("HDMI Switch: выгрузка интеграции")
    if DOMAIN in hass.data:
        coordinator = hass.data[DOMAIN]
        coordinator.stop()
        del hass.data[DOMAIN]
    return True


def setup_images(hass):
    """Копирование изображений в папку www."""
    component_dir = os.path.dirname(__file__)
    images_dir = os.path.join(component_dir, "images")
    www_dir = os.path.join(hass.config.config_dir, "www", "hdmi_switch")
    
    os.makedirs(www_dir, exist_ok=True)
    
    if os.path.exists(images_dir):
        for filename in os.listdir(images_dir):
            if filename.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
                src = os.path.join(images_dir, filename)
                dst = os.path.join(www_dir, filename)
                shutil.copy2(src, dst)
                _LOGGER.info("HDMI Switch: Скопировано изображение %s", filename)
    else:
        _LOGGER.debug("HDMI Switch: Папка images не найдена")

media_player.py

Файл описывает сущности медиаплеера. Каждая зона матрицы представляется как отдельное устройство в Home Assistant.

"""HDMI Switch Media Player."""
import logging
import os
from homeassistant.components.media_player import MediaPlayerEntity, MediaPlayerEntityFeature
from homeassistant.const import STATE_ON, STATE_OFF
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN, DEFAULT_NAME, DEFAULT_SOURCES

_LOGGER = logging.getLogger(__name__)

SUPPORTED_FEATURES = (
    MediaPlayerEntityFeature.TURN_ON |
    MediaPlayerEntityFeature.TURN_OFF |
    MediaPlayerEntityFeature.SELECT_SOURCE
)


class HDMISwitchPlayer(MediaPlayerEntity):
    """HDMI Switch Media Player."""

    def __init__(self, coordinator, name, unique_id, zone, sources):
        self._coordinator = coordinator
        self._name = name
        self._unique_id = unique_id
        self._zone = zone
        self._sources = sources if sources else DEFAULT_SOURCES
        self._source_list = self._sources
        self._state = STATE_OFF
        self._source = None
        self._available = False
        
        _LOGGER.info("HDMI Switch: %s (Зона %d) инициализирован", name, zone)

    @property
    def unique_id(self):
        return self._unique_id

    @property
    def name(self):
        return self._name

    @property
    def state(self):
        return self._state

    @property
    def available(self):
        return self._available

    @property
    def supported_features(self):
        return SUPPORTED_FEATURES

    @property
    def source(self):
        return self._source

    @property
    def source_list(self):
        return self._source_list

    @property
    def icon(self):
        return "mdi:hdmi-port"

    @property
    def entity_picture(self):
        """Вернуть путь к изображению контента."""
        if self._state == STATE_OFF or not self._source:
            return None
        
        if self._source in self._sources:
            index = self._sources.index(self._source)
            input_number = index + 1
            image_file = f"img_{input_number}.png"
            image_path = f"/config/www/hdmi_switch/{image_file}"
            
            if os.path.exists(image_path):
                return f"/local/hdmi_switch/{image_file}"
        
        return None

    @property
    def device_info(self):
        return DeviceInfo(
            identifiers={(DOMAIN, self._unique_id)},
            name=self._name,
            manufacturer="HDMI Matrix",
            model=f"Switch Zone {self._zone}",
        )

    def _get_command(self, input_index):
        """Получить команду cir для зоны и входа."""
        val = 8 * (self._zone - 1) + input_index
        return f"cir {val:02x}\r\n"

    def _switch_input(self, source):
        """Переключить вход."""
        if source not in self._sources:
            _LOGGER.error("Неизвестный источник: %s", source)
            return False
        
        try:
            index = self._sources.index(source)
        except ValueError:
            _LOGGER.error("Источник %s не найден", source)
            return False
        
        command = self._get_command(index)
        _LOGGER.info("Зона %d: Переключение на %s (команда %s)",
            self._zone, source, command.strip())
        
        if self._coordinator.send_command(command):
            self._source = source
            self._state = STATE_ON
            self._available = True
            _LOGGER.info("Зона %d: Успешно переключено на %s", self._zone, source)
            self.schedule_update_ha_state()
            return True
        
        self._available = False
        return False

    def _status_callback(self, zone, input_num):
        """Callback для обновления статуса от координатора."""
        if zone != self._zone:
            return
        
        index = input_num - 1
        if 0 <= index < len(self._sources):
            new_source = self._sources[index]
            
            if new_source != self._source:
                _LOGGER.info("Зона %d: Обнаружено внешнее переключение на %s", 
                    self._zone, new_source)
                self._source = new_source
                self._state = STATE_ON
                self._available = True
                self.schedule_update_ha_state()

    def turn_on(self):
        """Включить (Вход 1)."""
        if self._sources:
            self._switch_input(self._sources[0])

    def turn_off(self):
        """Выключить (Вход 2)."""
        if len(self._sources) > 1:
            self._switch_input(self._sources[1])
            self._state = STATE_OFF
            self.schedule_update_ha_state()

    def select_source(self, source):
        """Переключить источник."""
        if source in self._source_list:
            self._switch_input(source)

    def update(self):
        """Нет опроса - состояние обновляется через callback."""
        pass

    async def async_added_to_hass(self):
        """Запуск при добавлении устройства в HA."""
        await super().async_added_to_hass()
        self._coordinator.register_listener(self._zone, self._status_callback)
        self._available = True
        _LOGGER.info("Зона %d: зарегистрирована в координаторе", self._zone)

    async def async_will_remove_from_hass(self):
        """Остановка при удалении устройства."""
        self._coordinator.unregister_listener(self._zone)
        _LOGGER.info("Зона %d: удалена из координатора", self._zone)
        await super().async_will_remove_from_hass()


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
    """Настройка платформы."""
    _LOGGER.info("HDMI Switch: async_setup_platform вызван")
    
    conf = discovery_info if discovery_info else config
    zones_config = conf.get("zones", [])
    
    # Получаем координатор из hass.data
    coordinator = hass.data.get(DOMAIN)
    if not coordinator:
        _LOGGER.error("Координатор HDMI Switch не найден")
        return
    
    _LOGGER.info("HDMI Switch: Найдено зон: %d", len(zones_config))
    
    if not zones_config:
        zones_config = [{
            "zone": 1,
            "name": conf.get("name", DEFAULT_NAME),
            "sources": conf.get("sources", None),
            "unique_id": conf.get("unique_id", None),
        }]
    
    players = []
    for zone_config in zones_config:
        zone = zone_config.get("zone", 1)
        name = zone_config.get("name", f"{DEFAULT_NAME} Зона {zone}")
        sources = zone_config.get("sources", None)
        unique_id = zone_config.get("unique_id", f"hdmi_switch_zone{zone}")
        
        _LOGGER.info("HDMI Switch: Зона %d - Источники: %s", zone, sources)
        
        player = HDMISwitchPlayer(
            coordinator=coordinator,
            name=name,
            unique_id=unique_id,
            zone=zone,
            sources=sources,
        )
        players.append(player)
    
    async_add_entities(players, False)
    _LOGGER.info("HDMI Switch: %d устройств добавлено", len(players))

Конфигурация

После размещения файлов необходимо добавить запись в configuration.yaml. Пример для матрицы 4x4 с двумя телевизорами:

hdmi_switch:
  port: /dev/ttyACM0
  command_baudrate: 115200
  timeout: 5
  zones:
    - zone: 1
      name: "HDMI Гостиная"
      unique_id: "hdmi_switch_living"
      sources:
        - "ТВ"
        - "Apple TV"
        - "PlayStation"
        - "NVIDIA Shield"
    - zone: 2
      name: "HDMI Спальня"
      unique_id: "hdmi_switch_bedroom"
      sources:
        - "ТВ"
        - "Apple TV"
        - "PlayStation"
        - "NVIDIA Shield"
    - zone: 3
      name: "HDMI Кухня"
      unique_id: "hdmi_switch_kitchen"
      sources:
        - "ТВ"
        - "Apple TV"
        - "PlayStation"
        - "NVIDIA Shield"
    - zone: 4
      name: "HDMI Офис"
      unique_id: "hdmi_switch_office"
      sources:
        - "ТВ"
        - "Apple TV"
        - "PlayStation"
        - "NVIDIA Shield"

После добавления конфигурации обязательно перезагрузите Home Assistant. В интерфейсе появятся новые устройства медиаплеер, которыми можно управлять через стандартные карточки Lovelace.