
Привет, Хабр!
Время от времени я возвращаюсь к своему PET-проекту приватного и независимого от сторонних сервисов голосового ассистента под кодовым названием «Альфа», который разработан для голосового взаимодействия с умным домом на базе Home Assistant. Простыми словами — это приватная умная колонка для управления умным домом. В этот раз я решил заняться вопросом «нормального» взаимодействия «Альфы» и Home Assistant. А что из этого вышло — читайте далее.
♳ Начало
Однажды, глядя на моё предыдущее решение по взаимодействию «Альфы» с Home Assistant, мой коллега сказал мне, что я знаю толк в технических извращениях и было бы лучше выделить время и разобраться как реализуются интеграции в Home Assistant, что упростит жизнь потенциальным пользователям моих умных колонок. И я с ним полностью согласен — вопрос о полноценной интеграции уже назревал давно.
♴ Выбор коммуникации
Один из важных вопросов перед разработкой интеграции — какой тип коммуникации будет использовать для связи с Home Assistant. Рассматривал следующие варианты:
Request-Response/HTTP (классический, используется в REST API);
WebSocket;
gRPC.
В итоге решил реализовать связь «Альфы» с Home Assistant на gRPC. С данной системой я уже работал в одном коммерческом проекте и она мне очень понравилась.
С коммуникацией определились, теперь предстоит определиться с логикой подключения колонки и об этом пойдет речь дальше.
♴ Логика взаимодействия
Чтобы упростить пользователю процесс подключения «Альфы» к Home Assistant, нужно опираться на его прошлый опыт. Ближайший распространённый опыт — это интеграция умной колонки «Алисы» от Яндекса.
Для примера вы можете посмотреть, как реализовано получение команд от Яндекс Станции. Другими словами, Home Assistant отслеживает событие yandex_speaker и при наличии автоматизации и совпадения полученного значения обрабатывает команду. Для наглядности ниже представлен пример автоматизации обработки событий:
automation: - alias: Расскажи шутку trigger: - platform: event event_type: yandex_speaker event_data: value: ничего не делай # фраза из Сценария Яндекса (раздел То) action: - service: media_player.play_media target: entity_id: '{{ trigger.event.data.entity_id }}' # ответить той же колонкой data: media_content_type: text media_content_id: '{{ ["На связи", "Смольный слушает", "Тут я"]|random }}' mode: single
И пример автоматизации для озвучивания событий (TTS) на Яндекс Станции:
script: alice_custom_volume1: sequence: - service: tts.yandex_station_say entity_id: media_player.yandex_station # замените на вашу колонку data: message: Внимание! Важное сообщение... options: volume_level: 0.8
Описанной выше логики вполне будет достаточно для взаимодействия с Home Assistant на первоначальном этапе — значит, будем действовать по аналогии.
Но в процессе разработки интеграции мне пришло в голову несколько идей, как улучшить взаимодействие «Альфы» с устройствами умного дома без создания сценариев в Home Assistant. Описанный выше метод получения команд от колонки мы оставим как резервный для очень сложных случаев.
♵ Разработка интеграции
На то, чтобы понять, как работает интеграция в Home Assistant, и на её разработку с отладкой у меня ушло чуть более пяти дней. Результат разработки вы можете видеть на моём GitHub.
Само собой, в данной статье я не планировал описывать процесс разработки интеграции Home Assistant — на эту тему полно информации в интернете, в том числе и на Хабре. Эта статья описывает взаимодействие с интеграцией на стороне «Альфы», и об этом я расскажу подробнее.
Итак, моя интеграция имеет следующие скромные возможности:
Голосовое управление умным домом;
Локальный TTS (озвучивание текста);
Мониторинг состояний устройств в реальном времени (на стороне «Альфы»);
Двусторонняя связь через gRPC;
Полная приватность - все данные передаются локально;
Поддержка подключения группы колонок.
♶ Установка интеграции в Home Assistant
Установка пользовательской интеграции — а именно так классифицируется наша интеграция — у большинства пользователей не должна вызвать проблем. Для установки интеграции в Home Assistant должен быть установлен менеджер пользовательских интеграций, он же HACS. Если HACS установлен, то для установки необходимо добавить репозиторий нашей интеграции. Ниже на скриншотах показан процесс добавления пользовательского репозитория:

На главной странице HACS необходимо нажать в правом верхнем углу три точки и выбрать из выпадающего меню пункт «Пользовательские репозитории»

И в поле «Репозиторий» вставить ссылку на GitHub нашей интеграции для подключения «Альфы».
После данного действия на главной странице HACS в разделе «Загруженные» должна появиться наша интеграция. Теперь мы можем перейти к процессу установки.
Чтобы наконец установить нашу интеграцию, нам нужно перейти в раздел Home Assistant: Настройки → Устройства и службы → Интеграции и нажать кнопку «+ Добавить интеграцию».

Далее необходимо найти нашу интеграцию «Alpha Connector» и выбрать её путем нажатия. Нажатие запустит процесс установки:

Как видно из скриншота, для работы интеграции требуется токен доступа, который необходимо сгенерировать в разделе профиля пользователя: Профиль → Безопасность → Долгосрочные токены доступа → Создать токен:

Затем дайте название токену и скопируйте его для последующей вставки в поле настроек интеграции «Alpha Connector». После этой процедуры интеграция будет установлена и готова к использованию.

♷ Внутри Альфы
Вот мы и подошли, пожалуй, к самому интересному. В этой части статьи я расскажу и покажу, как реализовано взаимодействие с нашей интеграцией внутри «Альфы».
Так как для обмена мы используем gRPC, нам необходимо сгенерировать на стороне «Альфы» файлы для его работы. Для этого нужно воспользоваться файлом alpha_speaker.proto — в нём записаны схемы данных и правила обмена. Данный файл необходимо скачать в рабочую директорию «Альфы» из репозитория нашей интеграции с помощью команды:
wget https://raw.githubusercontent.com/VGCH/alpha-private-speaker-connector/refs/heads/main/custom_components/alpha_speaker/proto/alpha_speaker.proto
Прежде чем генерировать файлы для gRPC, необходимо установить пакеты protobuf, grpcio, grpcio-tools. Это делается командой:
pip install protobuf grpcio grpcio-tools
Теперь с помощью команды мы можем сгенерировать необходимые для работы gRPC файлы:
python3 -m grpcio_tools.protoc -Iprotos --python_out=. --pyi_out=. --grpc_python_out=. alpha_speaker.proto
И чтобы отделить сгенерированные файлы от рабочих файлов «Альфы», генерация файлов выполняется в отдельной директории с именем protos.
После успешного выполнения команды в папке protos появятся три новых файла:
protos/ ├── alpha_speaker.proto # Исходная схема │ ├── alpha_speaker_pb2.py # Классы данных ├── alpha_speaker_pb2_grpc.py # Сетевой код └── alpha_speaker_pb2.pyi # Подсказаки типов
Для работы с gRPC-коммуникацией на стороне «Альфы» используется отдельный класс AlphaSpeakerClient, реализованный в файле smart_connect.py. Этот файл импортирован в основной скрипт. Ниже представлен его код.
smart_connect.py
import grpc import time import threading from protos import alpha_speaker_pb2 from protos import alpha_speaker_pb2_grpc class AlphaSpeakerClient: def __init__(self, host='192.168.0.102', port=50051, speaker_id="alpha_smart_assistant_02"): self.channel = grpc.insecure_channel(f'{host}:{port}') self.stub = alpha_speaker_pb2_grpc.AlphaSpeakerServiceStub(self.channel) self.speaker_id = speaker_id self.error_connect = False self.send_initial_state = True def register(self, speaker_name="Альфа приватная умная колонка", speaker_type="alpha_private", firmware_version="1.0.2026"): """Регистрация колонки на сервере""" request = alpha_speaker_pb2.SpeakerRegistration( speaker_id=self.speaker_id, speaker_name=speaker_name, speaker_type=speaker_type, firmware_version=firmware_version, capabilities=["tts", "voice_control", "state_monitoring", "audio_streaming"], settings={ "voice": "female", "language": "ru", "volume": "80", "audio_device": "default" } ) try: response = self.stub.RegisterAlphaSpeaker(request) print(f"Регистрация: {response.success}, Сообщение: {response.message}") return response.session_id except grpc.RpcError as e: print(f"Ошибка регистрации: {e}") def listen_for_tts(self, callback): """Прослушивание потока команд TTS от HA (Server Streaming)""" request = alpha_speaker_pb2.StateStreamRequest(speaker_id=self.speaker_id) print("Ожидание TTS команд от сервера...") try: # Этот цикл будет висеть и ждать новых сообщений от сервера for command in self.stub.StreamTTSCommands(request): if command.text: print(f"--- Новая команда TTS ---") print(f"Текст: {command.text}") print(f"Голос: {command.voice}, Громкость: {command.volume}") # Имитация выполнения и отправка ответа self.send_tts_report(command.message_id, True) callback(command.text, command.voice) except grpc.RpcError as e: print(f"Поток TTS прерван: {e}") def send_tts_report(self, message_id, success): """Отправка подтверждения о воспроизведении текста""" report = alpha_speaker_pb2.SpeakTextResponse( speaker_id=self.speaker_id, success=success, message="Воспроизведено успешно" if success else "Ошибка", message_id=message_id, timestamp=int(time.time()) ) self.stub.SendTTSResponse(report) def send_command(self, text): """Отправка команды в HA""" cmd = alpha_speaker_pb2.AlphaCommand( speaker_id=self.speaker_id, command_type="voice_action", voice_command=text, timestamp=int(time.time()) ) response = self.stub.SendAlphaCommand(cmd) print(f"Результат команды: {response.success}, {response.message}") def start_keepalive(self, callback, reconnect_status): """Фоновая отправка Ping для поддержания соединения""" self.error_connect = reconnect_status def ping_loop(): while self.error_connect != True: try: self.stub.KeepAlive(alpha_speaker_pb2.PingRequest(speaker_id=self.speaker_id)) time.sleep(15) except: self.error_connect = True callback(self.error_connect) print("Ошибка отправки пинга.") time.sleep(5) threading.Thread(target=ping_loop, daemon=True).start() # ВЗАИМОДЕЙСТВИЕ С УСТРОЙСТВАМИ HA (Команды) def control_device(self, entity_id, action, params=None): """Отправка команды в HA (например, включить свет)""" command = alpha_speaker_pb2.AlphaCommand( speaker_id=self.speaker_id, command_type=action, # "turn_on", "set_temperature" entity_id=entity_id, #parameters=params or {}, timestamp=int(time.time()) ) try: response = self.stub.SendAlphaCommand(command) print(f"HA Response: {response.message} (Event ID: {response.event_id})") return response except grpc.RpcError as e: print(f"Ошибка управления HA: {e}") # МОНИТОРИНГ СОСТОЯНИЙ (Streaming) def watch_states(self, callback, filters=None, initial_state = True): """Получение обновлений состояний устройств из HA в реальном времени""" request = alpha_speaker_pb2.StateStreamRequest( speaker_id=self.speaker_id, entity_filters=filters or ["switch.dvukhkanalnoe_rele_kanal_1"], send_initial_state=initial_state ) print("Подписка на обновления состояний HA...") #while self.error_connect != True: try: req = self.stub.StreamDeviceStates(request) initial_state = False for state in req: #print(f"Изменение: {state.friendly_name} ({state.entity_id}) -> {state.state}") callback(state.friendly_name, state.entity_id, state.state) #time.sleep(12) except grpc.RpcError as e: print(f"Поток состояний прерван: {e}") time.sleep(2) # ПОЛУЧЕНИЕ СПИСКА УСТРОЙСТВ def get_ha_devices(self, domains=None): """Запрос списка всех доступных устройств в HA""" req = alpha_speaker_pb2.DeviceListRequest( speaker_id=self.speaker_id, domains=domains) res = self.stub.GetAvailableDevices(req) print(f"Найдено устройств: {res.total_count}") for dev in res.devices: print(f"- {dev.friendly_name} [{dev.entity_id}]") def close(self): self.channel.close()
Назначение методов в коде класса подписано, поэтому, с вашего позволения, я не буду их описывать в подробностях.
Ниже показана реализация использования этого класса в основном скрипте: инициализация, регистрация колонки, подписка на обновления и т. д.
Использование класса в основном скрипте
from smart_connect import AlphaSpeakerClient from ha_device_manager import DeviceStateManager ha_manager = DeviceStateManager() status_smart_connect = True # Мониторим статус подключения к HA client = None errors_alarm = "" def status_connect_ac(Status: bool): print(Status) alpha_c_init() def alpha_connect_init(): global status_smart_connect global client global errors_alarm count_trys = 0 # Безопасно закрываем старый клиент if client: try: client.close() except Exception: pass # Игнорируем ошибки закрытия старого линка client = None try: # Инициализируем gRPC клиент client = AlphaSpeakerClient( host='192.168.0.102', # Адрес HomeAsisstant port=50051, speaker_id="alpha_smart_assistant_01" # Идентификатор колонки (должен быть уникальный) ) time.sleep(2) # Цикл регистрации while True: try: status_connet_grpc = client.register( speaker_name="Альфа с нейрочипом", speaker_type=config.prototype_name, firmware_version=config.version ) except Exception as register_error: # Если сам метод .register() падает из-за отсутствия сети print(f"Попытка {count_trys + 1}: Ошибка вызова register: {register_error}") status_connet_grpc = False time.sleep(3) count_trys += 1 if status_connet_grpc or count_trys >= 50: break # Запускаем пинг в фоне или обрабатываем ошибку if status_connet_grpc: status_smart_connect = True # Флаг успеха client.start_keepalive(status_connect_ac, False) else: status_smart_connect = False print("Ошибка: не удалось зарегистрироваться на gRPC сервере за 50 попыток") errors_alarm += "Ошибка подключения к Альфа коннект; " except Exception as e: # Перехватываем только системные исключения, сохраняя Ctrl+C print(f"Критическая ошибка инициализации умного дома: {e}") errors_alarm += f"Ошибка подключения к умному дому ({e}); " status_smart_connect = False # Функция-фидбека TTS def say_tts(phrase: str, voice: str): if phrase: if voice == "default": va_speak(phrase) else: va_speak(phrase, voice) def tts_worker(): client.listen_for_tts(callback=say_tts) def ha_dev_status(frandly_name, devices, status): if devices: print(f"{frandly_name} {devices}, {status}") ha_manager.check_and_update(devices, status) print(ha_manager.get_state(devices)) def alpha_c_init(): alpha_connect_init() if status_smart_connect: # Получаем статусы устройств # Запускаем наш воркер в отдельном потоке tts_thread = threading.Thread(target=tts_worker, daemon=True) tts_thread.start() # Запускаем воркер для получение статуса в отдельном потоке ha_states_thread = threading.Thread(target=get_ha_states, daemon=True) ha_states_thread.start() # Отправляем тестовую команду в Home Assistant client.send_command("Alpha is live!") def get_ha_states(): print("Чтение статусов: ") # Подписка на обновление датчиков, переключатели и т.п. (устройсвт HA) client.watch_states(filters=["light.", "switch.", "sensor.meteostantsiia", "button."], callback=ha_dev_status, initial_state=True) time.sleep(2) client.watch_states(filters=["light.", "switch.", "sensor.meteostantsiia", "button."], callback=ha_dev_status, initial_state=False) alpha_c_init()
В коде постарался максимально в комментариях описать функционал, то хочется акцентировать внимание на некоторые моменты. Например стока со следующим кодом:
client.watch_states(filters=["light.", "switch.", "sensor.meteostantsiia", "button."], callback=ha_dev_status, initial_state=False)
Здесь «Альфа» подписывается на обновления устройств умного дома. В метод watch_states передаются аргументы: filters — список фильтров устройств по доменам, и callback — функция обратного вызова, куда и будут приходить обновлённые данные. Если посмотреть функцию ha_dev_status, то мы увидим использование дополнительного объекта ha_manager (экземпляр класса DeviceStateManager):
def ha_dev_status(frandly_name, devices, status): if devices: print(f"{frandly_name} {devices}, {status}") ha_manager.check_and_update(devices, status) print(ha_manager.get_state(devices))
DeviceStateManager — это вспомогательный класс для хранения списка устройств и их статусов. Метод check_and_update проверяет и обновляет статус устройства по ключу, а get_state просто возвращает значение по ключу — в данной функции используется для отладки.
Ниже представлен полный код вспомогательного класса DeviceStateManager.
ha_device_manager.py
class DeviceStateManager: def __init__(self): # Инициализируем словарь для хранения состояний устройств self.state = {} def update_state(self, key, value): """Обновляет состояние устройства по ключу""" old_value = self.state.get(key) self.state[key] = value if old_value is not None: print(f"Обновлено: {key}: {old_value} -> {value}") else: print(f"Добавлено: {key}: {value}") def get_state(self, key): """Возвращает текущее состояние устройства по ключу""" return self.state.get(key, None) def add_data(self, data_list): """Добавляет или обновляет данные из списка кортежей (ключ, значение)""" for key, value in data_list: self.update_state(key, value) def get_all_states(self): """Возвращает все текущие состояния в виде списка кортежей""" return [(k, v) for k, v in self.state.items()] def check_and_update(self, key, new_value): """ Проверяет наличие ключа и обновляет его значение. Если ключа нет - добавляет его. Args: key: ключ устройства new_value: новое значение Returns: tuple: (old_value, was_updated) old_value: старое значение или None если ключа не было was_updated: True если ключ существовал, False если добавлен новый """ old_value = self.state.get(key) was_updated = old_value is not None self.state[key] = new_value if was_updated: print(f"Ключ '{key}' обновлен: {old_value} -> {new_value}") else: print(f"Ключ '{key}' добавлен: {new_value}") return old_value, was_updated def check_exists(self, key): """Проверяет наличие ключа в менеджере состояний""" exists = key in self.state if exists: print(f"Ключ '{key}' существует: {self.state[key]}") else: print(f"Ключ '{key}' не существует") return exists def remove_state(self, key): """Удаляет ключ из менеджера состояний""" if key in self.state: value = self.state.pop(key) print(f"Ключ '{key}' удален (было: {value})") return value else: print(f"Ключ '{key}' не найден для удаления") return None
Теперь «Альфа» может в реальном времени напрямую получать статусы устройств, без создания дополнительных сценариев в Home Assistant.Теперь «Альфа» может в реальном времени напрямую получать статусы устройств, без создания дополнительных сценариев в Home Assistant.
Теперь «Альфа» может в реальном времени напрямую получать статусы устройств, без создания дополнительных сценариев в Home Assistant. Ниже представлен пример реализации функции управления освещением с помощью «Альфы».
def light_on(): try: # Получаем состояния реле один раз в начале state_1 = ha_manager.get_state('switch.dvukhkanalnoe_rele_kanal_1') state_2 = ha_manager.get_state('switch.dvukhkanalnoe_rele_kanal_2') # Определяем текст ответа в зависимости от текущего состояния if state_1 == 'off' and state_2 == 'off': text = "Включила свет" elif state_1 == 'on' and state_2 == 'off': text = "Первый светильник уже включен, включила оставшийся!" elif state_1 == 'off' and state_2 == 'on': text = "Один светильник уже включен, включила оставшийся!" else: text = "Свет уже включен! Но я могу выключить, если попросите!" # Включаем те каналы, которые были выключены if state_1 == 'off': client.control_device("switch.dvukhkanalnoe_rele_kanal_1", "turn_on") if state_2 == 'off': client.control_device("switch.dvukhkanalnoe_rele_kanal_2", "turn_on") va_speak(text) except Exception as e: print(f"Ошибка в light_on: {e}") va_speak("Кажется я глюкнула! Возникла ошибка подключения к умному дому!")
Метод control_device отправляет команду конкретному устройству, которое отвечает за освещение. Идентификатор объекта можно посмотреть в его свойствах. По аналогии можно сделать и с озвучиванием данных метеостанции на «Альфе»:
Функция произношения метеоданных
import random def meteo(): led_on() try: # Список приветственных фраз start_phrases = [ 'На свя+зи ваш метеоролог!', 'Данные вашей метеостанции.', 'Прослушайте метеосводку.', 'Вот данные с вашей метеостанции!' ] # Инициализируем текст случайным приветствием speech = random.choice(start_phrases) + " ...." # Получаем данные датчиков и приводим к числу с плавающей точкой temp = float(ha_manager.get_state('sensor.meteostantsiia_temperatura_2')) hum = float(ha_manager.get_state('sensor.meteostantsiia_vlazhnost_2')) pres = float(ha_manager.get_state('sensor.meteostantsiia_atmosfernoe_davlenie_2')) v_bat = float(ha_manager.get_state('sensor.meteostantsiia_zariad_batarei_2')) # Словари единиц измерения для функции decimal2text units_f = (('целая', 'целых', 'целых'), 'f') units_m = (('процент', 'процента', 'процентов'), 'm') deg_exp = (('сотая градуса', 'сотых градуса', 'сотых градусов'), 'f') mm_exp = (('сотая миллиметра', 'сотых миллиметров', 'сотых миллиметров'), 'f') volt_exp = (('сотая вольта', 'сотых вольта', 'сотых вольт'), 'f') # Формируем озвучку температуры speech += " Температура воздуха: " speech += decimal2text(temp, int_units=units_f, exp_units=deg_exp) speech += " цельсия. " # Формируем озвучку влажности speech += " Относительная влажность: " speech += num2text(hum, units_m) # Формируем озвучку давления speech += "... Атмосферное давление: " speech += decimal2text(pres, int_units=units_f, exp_units=mm_exp) speech += " ртутного столба. " # Формируем озвучку заряда батареи speech += ".... Заряд батареи метеостанции: " speech += decimal2text(v_bat, int_units=units_f, exp_units=volt_exp) speech += " " # Отправляем итоговый текст в голосовой ассистент va_speak(speech) # Сохраняем global last_speech last_speech = speech except Exception as e: print(f"Ошибка в meteo: {e}") va_speak("Кажется я глюкнула! Возникла ошибка подключения к умному дому!")
В случае, если по каким-то причинам не удаётся реализовать логику управления устройствами умного дома на стороне «Альфы», всегда можно воспользоваться методом send_command и обработать запрос с помощью сценария автоматизации на стороне Home Assistant. Например:
# Команда со сотороны Альфы client.send_command("Alpha is live!")
Обработка команды на стороне Home Assistant (автоматизация):
alias: "Alpha: Ответ на тестовое команду" description: Отвечает на команду 'Alpha is live!' triggers: - event_type: alpha_speaker_command trigger: event conditions: - condition: template value_template: | {{ 'alpha is live' in trigger.event.data.voice_command|lower }} actions: - data: speaker_id: "{{ trigger.event.data.speaker_id }}" text: Я онлайн и готова к работе! Что вам нужно? action: alpha_speaker.send_tts mode: single
Здесь в ответ на отправленную команду, Home Assistant отправит запрос TTS и «Альфа» произнесет фразу «Я онлайн и готова к работе! Что вам нужно?». По аналогии можно обработать любые команды.
Также можно отправить TTS-запрос на «Альфу» с дополнительными параметрами, например изменить голос синтеза речи:
alias: Уведомление о включении ПК на Альфа колонку description: "" triggers: - trigger: state entity_id: - switch.pc conditions: - condition: state entity_id: switch.pc state: - "on" actions: - data: speaker_id: alpha_smart_assistant_01 text: Компьютер в комнате включен language: ru volume: 70 voice: eugene priority: true action: alpha_speaker.send_tts mode: single
В параметре voice можно передавать следующие голоса: aidar, baya, kseniya, eugene, xenia.
♸ Итоги
Ниже представлено видео демонстрации работы голосового ассистента.
Для удобства пользователей, которые не имеют доступа к YouTube, я разместил видео на доступном видеосервисе. Но основную часть видео с экспериментами с «Альфой» я выкладываю в шортсах на своём YouTube. Подписываться не призываю, просто посмотрите, если интересна тема.
Кстати, на видео — моя третья, продвинутая версия «Альфы», реализованная на платформе с NPU на 6 TOPS, соответственно работает с локальной LLM. Несмотря на то что описываемая интеграция разрабатывалась для новой версии, она прекрасно работает и с предыдущими версиями моих колонок.
Всё это было разработано более полугода назад, но только сейчас я наконец собрался и написал статью. Подобные проекты отнимают массу времени и финансов, особенно в текущих условиях, поэтому в данный момент моя мотивация упала до нуля. Раньше я частично компенсировал затраты публикациями в коммерческом блоге, но в последнее время и там не разгуляешься — оплата уже не покрывает расходы на создание статьи. А я описываю только свой опыт, который складывается не только из текста, но и из оборудования, комплектующих, времени на разработку и реализацию. Всё это, вопреки распространённому мнению, совсем недёшево. Это так, просто крик души, буду думать как поступать в данной ситуации.
В любом случае, спасибо, что уделили своё время на прочтение статьи. Всем успехов и удачных проектов! А если у вас остались вопросы или есть что добавить, то, как всегда, добро пожаловать в комментарии!
Ссылки к статье: