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

Тогда проект отложили: не хватало знаний в программировании, а стоимость подходящей 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 метаданные об интеграции: имя, версию, необходимые библиотеки и тип подключения.

Код manifest.json
{
  "domain": "hdmi_switch",
  "name": "HDMI Switch",
  "version": "1.1.0",
  "requirements": ["pyserial>=3.5"],
  "iot_class": "local_push",
  "config_flow": false
}

const.py

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

Код 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 не распознает папку как пакет, и интеграция не загрузится.
Код __init __.py
"""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.

Код media_player.py
"""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.

Комментарии (5)


  1. empenoso
    04.04.2026 01:53

    Отличная статья! Но почему нет ни одной фотки?


    1. Dyakonovg Автор
      04.04.2026 01:53

      Оборудование было собрано в шкаф, чуть позже доработаю. Как раз сегодня все снял на ривизию.


    1. starfair
      04.04.2026 01:53

      Согласен! Но предлагаю доредактировать статью и код под спойлер убрать.


  1. Feer41rus
    04.04.2026 01:53

    Правильно я понял, что в шкафу есть одна тв приставка которая транслирует видео на любой телевизор по потребности?

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


    1. Dyakonovg Автор
      04.04.2026 01:53

      Да вы правильно поняли. Только у меня есть еще игровые приставки в которые я могу играть на разных экранах.