
Три года назад у меня возникла идея создать систему управления аудио-видео потоками в доме. Хотелось, чтобы любой источник сигнала — кабельное ТВ, медиаплеер, игровая консоль или ПК — мог быть мгновенно переключен на любой телевизор в квартире. Достаточно нажать кнопку в смартфоне, и просмотр фильма продолжается в другой комнате без потери качества и необходимости бегать с пультами.
Тогда проект отложили: не хватало знаний в программировании, а стоимость подходящей HDMI-матрицы с возможностью внешнего управления казалась неподъемной.
Что изменилось
Спустя время ситуация поменялась. Во-первых, на Хабре появилась подробная статья от @metalstiv, в которой автор пошагово описал процесс реверс-инжиниринга протокола управления бюджетной матрицей 4×4 через RS-232. Это дало понимание, что задача решаема даже без глубоких технических знаний, если есть желание и немного помощи от современных инструментов.
Во-вторых, цены на оборудование стали доступнее. Была заказана матрица 4×4 с поддержкой 4K@60 Гц и управлением по RS-232. Параллельно приобрел USB-to-RS232 конвертер для подключения к управляющему устройству.
В-третьих, появился мощный ассистент в виде нейросети Qwen, которая помогла разобраться с протоколом обмена, написать код интеграции и адаптировать решения под Home Assistant.
Что понадобилось для реализации
Home Assistant — центральная платформа умного дома. Выбрана за гибкость, открытость и огромное сообщество.
USB-to-RS232 конвертер — для физического подключения матрицы к управляющему устройству (в моем случае — Raspberry Pi).
HDMI Matrix 4×4 с поддержкой RS-232 — аппаратная основа системы. Коммутирует любой из четырех входов на любой из четырех выходов.
Нейросеть 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.
-
Включите Samba в Home Assistant:
Зайдите в Настройки -> Дополнения -> Магазин дополнений.
Найдите и установите дополнение "Samba share".
В конфигурации дополнения укажите имя пользователя и пароль.
Запустите дополнение.
-
Подключитесь к файловой системе:
На компьютере с Windows откройте Проводник.
В адресной строке введите
\\homeassistant\config(или IP-адрес вашего сервера).Введите логин и пароль, заданные в дополнении Samba.
-
Создайте структуру папок:
Перейдите в папку
custom_components. Если её нет, создайте.Внутри создайте папку
hdmi_switch.Внутри
hdmi_switchсоздайте папкуimages.
Загрузите файлы:
Создайте текстовые файлы с именами __
init __.py,manifest.json,const.py,media_player.py.Скопируйте в них соответствующий код (приведен ниже).
Поместите изображения источников (
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Перезагрузите Home Assistant:
Зайдите в Настройки -> Система -> Перезагрузка.
После перезагрузки интеграция будет готова к настройке через
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)

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

Dyakonovg Автор
04.04.2026 01:53Да вы правильно поняли. Только у меня есть еще игровые приставки в которые я могу играть на разных экранах.
empenoso
Отличная статья! Но почему нет ни одной фотки?
Dyakonovg Автор
Оборудование было собрано в шкаф, чуть позже доработаю. Как раз сегодня все снял на ривизию.
starfair
Согласен! Но предлагаю доредактировать статью и код под спойлер убрать.