У меня созрела идея повторить и немного адаптировать решение под свои задачи — но с одним важным отличием: я решил доверить написание кода и текста искусственному интеллекту.

Три года назад у меня возникла идея создать систему управления аудио-видео потоками в доме. Хотелось, чтобы любой источник сигнала — кабельное ТВ, медиаплеер, игровая консоль или ПК — мог быть мгновенно переключен на любой телевизор в квартире. Достаточно нажать кнопку в смартфоне, и просмотр фильма продолжается в другой комнате без потери качества и необходимости бегать с пультами.
Тогда проект отложили: не хватало знаний в программировании, а стоимость подходящей 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.
Для каждой зоны матрицы создаётся отдельная сущность типа 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"""Константы для 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.
Оно должно содержать двойное подчеркивание слева и справа.
Без этого 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 не найдена")Файл описывает сущности медиаплеера. Каждая зона матрицы представляется как отдельное устройство в 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.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Чиним Home Assistant, который окирпичился без интернета | 0 | 9.23 | 09-01-2026 |
| 2 | Умная теплица на Raspberry Pi 4: управляем GPIO, ESP8266 и автоматизацией через веб-интерфейс. Часть 1 | 0 | 16.9 | 11-03-2026 |
| 3 | Жесты вместо горячих клавиш: подключаем ИИ к веб-камере | 0 | 12.98 | 10-06-2026 |
| 4 | Как утки с СДВГ довели меня до опенсорса: зачем я собрал утилиту для перевода коротких видео на домашней видеокарте | 0 | 6.06 | 27-06-2026 |
| 5 | ИИ - помощник, базара нет, Хоть и сильно подорвал привычки ... | 0 | 4.58 | 29-07-2026 |
| 6 | Чтобы чайник и холодильник не ссорились: технологии, которые сделают дома умнее | 0 | 0 | 15-12-2025 |
| 7 | Ассистент или агент: я делал одну контент-машину тремя способами | 0 | 13.37 | 09-08-2026 |
| 8 | Как я переводы видео автоматизировать собирался | 0 | 7.8 | 19-03-2026 |
| 9 | И еще немного о QR кодах | 0 | 8.08 | 02-01-2026 |
| 10 | Ваш мозг устает от идеального порядка. Пора включить режим хаоса ... | 0 | 5 | 18-07-2026 |