Удалённое управление умным домом через Telegram-бота и Node-RED

🤖 Удалённый пульт для умного дома: Telegram-бот + Node-RED

📅 Дата публикации: 8 июня 2026 ⏱️ Время чтения: ~35 минут

В предыдущей статье «Telegram + Node-RED: создаём чат-бота» мы рассмотрели создание простейшего бота и подключение токена к библиотеке node-red-contrib-telegrambot. Пришло время сделать реально удобное и практичное управление системой умного дома вне локальной сети.

В данной статье будут рассмотрены следующие автоматизации удаленного управления:

  • 📋 Создание стартового окна — с приветствием и вашими данными Telegram, а также окно меню и помощи с существующими командами
  • 💡 Управление освещением — просмотр состояния источников освещения и управление RGB-лампами (включение/выключение, яркость, цвет)
  • 🐳 Управление Docker-контейнерами — просмотр состояния контейнеров Portainer, запуск, остановка, перезагрузка
  • ✅ Автоматизация с отслеживанием задач Todoist — начата, будет доработана (ограничения API v1 и v2)
  • 🎂 Хранение дней рождения — внутри Node-RED без HA, создание, изменение, добавление, удаление
  • 🧘 Вызов сценария медитации — из другого потока (link call)
  • 🌤️ Погода с рекомендациями — из встроенной интеграции HA, советы что взять/надеть на улицу
  • 🧹 Управление роботом-пылесосом — запуск/отправка на базу, отслеживание состояния, хранение данных запуска и времени работы
  • 📊 Мониторинг датчиков и сенсоров — все датчики дома в одном месте

Данная статья будет со временем дополняться, так как есть желание переделать задачник, добавить календарики Google и Apple как минимум.

1. Стартовое окно (команды /start, /help, /menu)

Реализован блок приветствия по команде /start, который возвращает информацию об аккаунте (имя, username, id) и кнопку перехода в главное меню. Команда /help выводит справочную информацию, а /menu генерирует основную inline-клавиатуру для доступа ко всем функциям бота.

Схема блока приветствия и команд
Схема обработки команд /start, /help, /menu
Приветствие в Telegram
/start — приветствие
Помощь в Telegram
/help — справка
Главное меню в Telegram
/menu — главное меню
1.1 Пошаговая настройка

Общая архитектура

Поток обрабатывает команды /start, /help, /menu. Получает сообщение → маршрутизирует → запрашивает данные пользователя из API Telegram → формирует ответ → отправляет.

1. Приём сообщений (telegram receiver)

  • Тип узла: telegram receiver
  • Привязан к боту (ID конфигурации [ID_BOT_CONFIG])
  • Режим: polling
  • Выходные поля: msg.payload.content — текст, msg.payload.chatId — ID чата, msg.payload.userId — ID пользователя

2. Маршрутизатор команд (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правила: /start → выход 1, /help → выход 2, /menu → выход 3

3. Обработка /start — запрос данных пользователя

Узел «Запрос к API» (function):

const chatId = msg.payload.chatId; const botToken = "[ВАШ_ТОКЕН]"; msg.url = `https://api.telegram.org/bot${botToken}/getChat?chat_id=${chatId}`; msg.method = "GET"; msg.originalChatId = chatId; return msg;

Узел «HTTP-запрос» (http request):

  • Метод: GET
  • Возврат: объект (ret: "obj")

Узел «Обработчик /start» (function) — основная логика:

if (msg.payload && msg.payload.ok === true) { const chatData = msg.payload.result; const username = chatData.username || "без username"; const firstName = chatData.first_name || ""; const lastName = chatData.last_name || ""; const chatId = chatData.id; let welcomeMessage = `🏠 *Добро пожаловать!*\n\n`; welcomeMessage += `👤 *Информация о вас:*\n`; welcomeMessage += `• ID: ${chatId}\n`; welcomeMessage += `• Username: @${username}\n`; welcomeMessage += `• Имя: ${firstName} ${lastName}\n\n`; welcomeMessage += `🤖 Используйте /menu для доступа ко всем функциям\n`; welcomeMessage += `или /help для списка команд.`; const keyboard = { reply_markup: { inline_keyboard: [ [{ text: "📋 Перейти в меню", callback_data: "back_main" }] ] } }; msg.payload = { chatId: chatId, type: "message", content: welcomeMessage, options: { parse_mode: "Markdown", reply_markup: keyboard.reply_markup } }; return msg; } else { const chatId = msg.originalChatId; let welcomeMessage = `🏠 *Добро пожаловать!*\n\n👤 Ваш ID: ${chatId}\n\n🤖 Используйте /menu для доступа ко всем функциям.`; const keyboard = { reply_markup: { inline_keyboard: [ [{ text: "📋 Перейти в меню", callback_data: "back_main" }] ] } }; msg.payload = { chatId: chatId, type: "message", content: welcomeMessage, options: { parse_mode: "Markdown", reply_markup: keyboard.reply_markup } }; return msg; }

4. Обработка /help (function)

const helpText = `❓ *Помощь по командам бота* *Основные команды:* /start — Приветствие /menu — Главное меню /help — Эта справка *Управление домом:* /lights — Управление освещением /status — Статус датчиков *Продуктивность:* /tasks — Список задач /meditation — Медитация *IT и другое:* /docker — Docker контейнеры /birthdays — Дни рождения /vacuum — Пылесос 💡 Все функции доступны через кнопки в меню.`; msg.payload = { chatId: msg.payload.chatId, type: "message", content: helpText, options: { parse_mode: "Markdown" } }; return msg;

5. Обработка /menu (function)

const mainKeyboard = { reply_markup: { inline_keyboard: [ [{ text: "💡 Управление светом", callback_data: "menu_lights" }], [{ text: "📊 Мониторинг", callback_data: "menu_monitoring" }], [{ text: "🌤️ Погода", callback_data: "menu_weather" }], [{ text: "✅ Задачи", callback_data: "menu_tasks" }], [{ text: "🍅 Помидорка", callback_data: "menu_pomodoro" }, { text: "🧘 Медитация", callback_data: "menu_meditation" }], [{ text: "🧹 Пылесос", callback_data: "menu_vacuum" }, { text: "🐳 Docker", callback_data: "menu_docker" }], [{ text: "📅 Календарь", callback_data: "menu_calendar" }, { text: "🎂 Дни рождения", callback_data: "menu_birthdays" }], [{ text: "❓ Помощь", callback_data: "menu_help" }] ] } }; msg.payload = { chatId: msg.payload.chatId || msg.payload.userId, type: "message", content: "🏠 *Главное меню*\nВыберите действие:", options: { parse_mode: "Markdown", reply_markup: mainKeyboard.reply_markup } }; return msg;

6. Отправка сообщения (telegram sender)

  • Тип узла: telegram sender
  • Привязан к тому же боту
1.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените [ВАШ_ТОКЕН], [ВАШ_БОТ], [ВАШ_USERNAME], [ID_BOT_CONFIG], [IP_СЕРВЕРА] на свои значения.

[ { "id": "29d04a2fe2721f63", "type": "group", "z": "d91b643aa7cd4cef", "name": "Стартовая часть", "style": { "stroke": "#e3f3d3", "fill": "#ffC000", "label": true, "label-position": "sw", "color": "#000000" }, "nodes": [ "13444dc9a2835902", "f887ffa453bb5b92", "3f91011494f21d58", "79cb76a2fd7818a2", "4734286741b412c8", "04b0138e50a7fffe", "dc20b8f969aa8c88", "519f350edb45da7e", "3fb84020d2e07c3e" ], "x": 34, "y": 39, "w": 1272, "h": 230 }, { "id": "13444dc9a2835902", "type": "switch", "z": "d91b643aa7cd4cef", "g": "29d04a2fe2721f63", "name": "Маршрутизатор команд", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "eq", "v": "/start", "vt": "str"}, {"t": "eq", "v": "/help", "vt": "str"}, {"t": "eq", "v": "/menu", "vt": "str"} ], "checkall": "true", "repair": false, "outputs": 3, "x": 370, "y": 200, "wires": [ ["04b0138e50a7fffe"], ["3f91011494f21d58"], ["79cb76a2fd7818a2"] ] }, { "id": "f887ffa453bb5b92", "type": "function", "z": "d91b643aa7cd4cef", "g": "29d04a2fe2721f63", "name": "Обработчик /start", "func": "const chatId = msg.payload.chatId;\nlet welcomeMessage = `🏠 *Добро пожаловать домой!*\\n\\n`;\nwelcomeMessage += `👤 *Информация о вас:*\\n`;\nwelcomeMessage += `• ID: ${chatId}\\n`;\nwelcomeMessage += `• Username: @пользователь\\n`;\nwelcomeMessage += `• Имя: (будет после запроса)\\n\\n`;\nwelcomeMessage += `🤖 Я ваш персональный ассистент.\\n`;\nwelcomeMessage += `Используйте /menu для доступа ко всем функциям\\n`;\nwelcomeMessage += `или /help для списка команд.`;\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"📋 Перейти в меню\", callback_data: \"back_main\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: welcomeMessage, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 550, "y": 80, "wires": [[]] }, { "id": "3f91011494f21d58", "type": "function", "z": "d91b643aa7cd4cef", "g": "29d04a2fe2721f63", "name": "Обработчик /help", "func": "const helpText = `❓ *Помощь по командам бота*\n\n*Основные команды:*\n/start — Приветствие и информация о вас\n/menu — Главное меню\n/help — Эта справка\n\n*Управление домом (в разработке):*\n/lights — Управление освещением\n/status — Статус всех датчиков\n/windows — Состояние окон\n\n*Продуктивность (в разработке):*\n/tasks — Список задач\n/alarm — Будильник\n/pomodoro — Таймер Pomodoro\n/meditation — Медитация\n\n*IT и другое (в разработке):*\n/docker — Статус Docker контейнеров\n/calendar — Календарь событий\n/birthdays — Дни рождения\n/vacuum — Управление пылесосом\n\n💡 *Совет:* Все функции также доступны через кнопки в меню.`;\nmsg.payload = { chatId: msg.payload.chatId, type: \"message\", content: helpText, options: { parse_mode: \"Markdown\" } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 750, "y": 180, "wires": [["4734286741b412c8"]] }, { "id": "79cb76a2fd7818a2", "type": "function", "z": "d91b643aa7cd4cef", "g": "29d04a2fe2721f63", "name": "Обработчик /menu", "func": "const mainKeyboard = { reply_markup: { inline_keyboard: [ [{ text: \"💡 Управление светом\", callback_data: \"menu_lights\" }], [{ text: \"📊 Мониторинг\", callback_data: \"menu_monitoring\" }], [{ text: \"🌤️ Погода\", callback_data: \"menu_weather\" }], [{ text: \"✅ Задачи\", callback_data: \"menu_tasks\" }], [{ text: \"🍅 Помидорка\", callback_data: \"menu_pomodoro\" }, { text: \"🧘 Медитация\", callback_data: \"menu_meditation\" }], [{ text: \"🧹 Кракен\", callback_data: \"menu_vacuum\" }, { text: \"🐳 Docker\", callback_data: \"menu_docker\" }], [{ text: \"📅 Календарь\", callback_data: \"menu_calendar\" }, { text: \"🎂 Дни рождения\", callback_data: \"menu_birthdays\" }], [{ text: \"❓ Помощь\", callback_data: \"menu_help\" }] ] } };\nmsg.payload = { chatId: msg.payload.chatId || msg.payload.userId, type: \"message\", content: \"🏠 *Главное меню*\\nВыберите действие:\", options: { parse_mode: \"Markdown\", reply_markup: mainKeyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 760, "y": 220, "wires": [["4734286741b412c8"]] }, { "id": "4734286741b412c8", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "29d04a2fe2721f63", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "haserroroutput": false, "outputs": 1, "x": 1180, "y": 220, "wires": [[]] }, { "id": "04b0138e50a7fffe", "type": "function", "z": "d91b643aa7cd4cef", "g": "29d04a2fe2721f63", "name": "Запрос к API", "func": "const chatId = msg.payload.chatId;\nconst botToken = \"[ВАШ_ТОКЕН]\";\nmsg.url = `https://api.telegram.org/bot${botToken}/getChat?chat_id=${chatId}`;\nmsg.method = \"GET\";\nmsg.originalChatId = chatId;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 600, "y": 140, "wires": [["dc20b8f969aa8c88"]] }, { "id": "dc20b8f969aa8c88", "type": "http request", "z": "d91b643aa7cd4cef", "g": "29d04a2fe2721f63", "name": "Выполняет запрос данных", "method": "GET", "ret": "obj", "x": 820, "y": 140, "wires": [["519f350edb45da7e"]] }, { "id": "519f350edb45da7e", "type": "function", "z": "d91b643aa7cd4cef", "g": "29d04a2fe2721f63", "name": "Обработчик /start", "func": "if (msg.payload && msg.payload.ok === true) {\n const chatData = msg.payload.result;\n const username = chatData.username || \"без username\";\n const firstName = chatData.first_name || \"\";\n const lastName = chatData.last_name || \"\";\n const chatId = chatData.id;\n let welcomeMessage = `🏠 *Добро пожаловать домой!*\\n\\n`;\n welcomeMessage += `👤 *Информация о вас:*\\n`;\n welcomeMessage += `• ID: ${chatId}\\n`;\n welcomeMessage += `• Username: @${username}\\n`;\n welcomeMessage += `• Имя: ${firstName} ${lastName}\\n\\n`;\n welcomeMessage += `🤖 Я ваш персональный ассистент.\\n`;\n welcomeMessage += `Используйте /menu для доступа ко всем функциям\\n`;\n welcomeMessage += `или /help для списка команд.`;\n const keyboard = { reply_markup: { inline_keyboard: [[{ text: \"📋 Перейти в меню\", callback_data: \"back_main\" }]] } };\n msg.payload = { chatId: chatId, type: \"message\", content: welcomeMessage, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\n return msg;\n} else {\n const chatId = msg.originalChatId;\n let welcomeMessage = `🏠 *Добро пожаловать домой!*\\n\\n`;\n welcomeMessage += `👤 *Информация о вас:*\\n`;\n welcomeMessage += `• ID: ${chatId}\\n`;\n welcomeMessage += `• Username: (не удалось получить)\\n`;\n welcomeMessage += `• Имя: (не удалось получить)\\n\\n`;\n welcomeMessage += `🤖 Я ваш персональный ассистент.\\n`;\n welcomeMessage += `Используйте /menu для доступа ко всем функциям\\n`;\n welcomeMessage += `или /help для списка команд.`;\n const keyboard = { reply_markup: { inline_keyboard: [[{ text: \"📋 Перейти в меню\", callback_data: \"back_main\" }]] } };\n msg.payload = { chatId: chatId, type: \"message\", content: welcomeMessage, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\n return msg;\n}", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1050, "y": 140, "wires": [["4734286741b412c8"]] }, { "id": "3fb84020d2e07c3e", "type": "telegram receiver", "z": "d91b643aa7cd4cef", "g": "29d04a2fe2721f63", "name": "Прием сообщений", "bot": "[ID_BOT_CONFIG]", "x": 150, "y": 140, "wires": [["13444dc9a2835902"], []] }, { "id": "[ID_BOT_CONFIG]", "type": "telegram bot", "botname": "[ВАШ_БОТ]", "usernames": "[ВАШ_USERNAME]", "updatemode": "polling", "pollinterval": "300", "localbothost": "[IP_СЕРВЕРА]", "localbotport": 8443, "publicbotport": 8443 }, { "id": "bd8debbf6258bb88", "type": "global-config", "env": [], "modules": { "node-red-contrib-telegrambot": "17.4.12" } } ]

2. Обработка inline-кнопок (callback_query)

Поток перехватывает callback-запросы от нажатий на inline-кнопки в сообщениях бота, маршрутизирует их по типу действия и возвращает соответствующий ответ. Для ещё не реализованных функций отправляется сообщение-заглушка с кнопкой «Назад в меню».

Схема обработки callback-запросов
Схема обработки callback-запросов от inline-кнопок
2.1 Пошаговая настройка

1. Перехват нажатий кнопок (telegram event)

  • Тип узла: telegram event
  • Событие: callback_query
  • autoanswer: false — не отправляет автоматическое уведомление Telegram
  • Выходные поля: msg.payload.content — callback_data, msg.payload.chatId — ID чата, msg.payload.messageId — ID исходного сообщения

2. Маршрутизатор меню (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правила:
    • back_menu → выход 1 (вернуться в меню)
    • menu_help → выход 2 (показать помощь)
    • menu_calendar → выход 3 (заглушка)
    • menu_pomodoro → выход 4 (заглушка)
    • back_main → выход 5 (вернуться в главное меню)

3. Заглушка для нереализованных функций (function)

const keyboard = { reply_markup: { inline_keyboard: [ [{ text: "◀️ Назад в меню", callback_data: "back_main" }] ] } }; msg.payload = { chatId: msg.payload.chatId, type: "message", content: "🚧 *Этот раздел в разработке* 🚧\n\nСкоро здесь появится полезный функционал!", options: { parse_mode: "Markdown", reply_markup: keyboard.reply_markup } }; return msg;

4. Отправка сообщения (telegram sender)

  • Тип узла: telegram sender
  • Привязан к боту
2.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените [ID_BOT_CONFIG], [ВАШ_БОТ], [ВАШ_USERNAME], [IP_СЕРВЕРА] на свои значения.

[ { "id": "51a74554495a8ec2", "type": "group", "z": "d91b643aa7cd4cef", "name": "Главное меню", "style": { "stroke": "#ffffbf", "fill": "#ffff00", "label": true, "label-position": "sw", "color": "#000000" }, "nodes": [ "b56b312ad5310f68", "9ce493c2a53da075", "dd0e4b8080e0804d", "18a7c3468af60966" ], "x": 34, "y": 276.5, "w": 1172, "h": 135 }, { "id": "b56b312ad5310f68", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "51a74554495a8ec2", "name": "Обработчик нажатия кнопок", "bot": "[ID_BOT_CONFIG]", "event": "callback_query", "autoanswer": false, "x": 190, "y": 340, "wires": [["9ce493c2a53da075"]] }, { "id": "9ce493c2a53da075", "type": "switch", "z": "d91b643aa7cd4cef", "g": "51a74554495a8ec2", "name": "маршрутизатор menu", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "eq", "v": "back_menu", "vt": "str"}, {"t": "eq", "v": "menu_help", "vt": "str"}, {"t": "eq", "v": "menu_calendar", "vt": "str"}, {"t": "eq", "v": "menu_pomodoro", "vt": "str"}, {"t": "eq", "v": "back_main", "vt": "str"} ], "checkall": "true", "repair": false, "outputs": 5, "x": 450, "y": 340, "wires": [ ["79cb76a2fd7818a2"], ["3f91011494f21d58"], ["dd0e4b8080e0804d"], ["dd0e4b8080e0804d"], ["79cb76a2fd7818a2"] ] }, { "id": "dd0e4b8080e0804d", "type": "function", "z": "d91b643aa7cd4cef", "g": "51a74554495a8ec2", "name": "Пока-что заглушка", "func": "const keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]] } };\nmsg.payload = { chatId: msg.payload.chatId, type: \"message\", content: \"🚧 *Этот раздел в разработке* 🚧\\n\\nСкоро здесь появится полезный функционал!\", options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 720, "y": 360, "wires": [["18a7c3468af60966"]] }, { "id": "18a7c3468af60966", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "51a74554495a8ec2", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "haserroroutput": false, "outputs": 1, "x": 1080, "y": 360, "wires": [[]] }, { "id": "[ID_BOT_CONFIG]", "type": "telegram bot", "botname": "[ВАШ_БОТ]", "usernames": "[ВАШ_USERNAME]", "updatemode": "polling", "pollinterval": "300", "localbothost": "[IP_СЕРВЕРА]", "localbotport": 8443, "publicbotport": 8443 }, { "id": "cf4d3d1e0ea8594d", "type": "global-config", "env": [], "modules": { "node-red-contrib-telegrambot": "17.4.12" } } ]

3. Управление освещением (RGB, яркость, цвет)

Управление освещением строится на возможности отслеживания состояния всех лампочек одним сообщением, а дальше уже выбор конкретной и управление точечно ей, такими как включение и выключение, управление яркостью и смена цвета — всё это для RGB-лампочек.

Схема управления освещением
Схема потока управления освещением
Список ламп
Список ламп
Панель управления
Панель управления
Управление яркостью
Яркость +10%/‑10%
Палитра цветов
Палитра цветов
3.1 Пошаговая настройка

Общая архитектура

Этот поток реализует полноценное управление умным освещением через Telegram-бота: вывод списка ламп с их текущим статусом, индивидуальное управление каждой лампой (вкл/выкл, яркость, цвет), интеграция с Home Assistant через WebSocket API.

Часть 1. Вход в меню освещения

1.1. Перехват callback (telegram event)

  • Тип узла: telegram event
  • Событие: callback_query
  • Привязан к боту ([ID_BOT_CONFIG])

1.2. Маршрутизатор освещения (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правила (регулярные выражения):
    • light_control_.* → панель управления лампой
    • light_color_.* → палитра цветов
    • light_on_.* → включить
    • light_off_.* → выключить
    • light_brightness_.* → изменить яркость
    • light_color_set_.* → установить цвет
    • menu_lights → список ламп

Часть 2. Отображение списка ламп (menu_lights)

Узел «Lights Menu» (function) — сохраняет chatId в глобальную переменную.

Узлы «Лампа 1» и «Лампа 2» (api-current-state) — запрашивают состояние каждой лампы из Home Assistant.

Узлы нормализации (function) — приводят данные к единому формату: msg.topic = entity_id, msg.payload = "on"/"off".

Узел «Объединение ламп» (join) — собирает данные от обеих ламп в один объект: {"light.test_product_2": "on", "light.kolco2": "off"}.

Узел «Format Lights List» (function) — форматирует сообщение со статусами и создаёт кнопки управления для каждой лампы.

Часть 3. Панель управления лампой

Узел «Light Control Panel» (function) — создаёт панель управления для конкретной лампы с кнопками: Включить, Выключить, Яркость +10%, Яркость -10%, Цвет, Назад к списку.

let lightId = msg.payload.content.replace("light_control_", ""); const lightNames = { "[ВАШ_ENTITY_ID_1]": "💡 [ВАШЕ_НАЗВАНИЕ_ЛАМПЫ_1]", "[ВАШ_ENTITY_ID_2]": "💡 [ВАШЕ_НАЗВАНИЕ_ЛАМПЫ_2]" }; const controlKeyboard = { reply_markup: { inline_keyboard: [ [{ text: "🔆 Включить", callback_data: `light_on_${lightId}` }, { text: "❌ Выключить", callback_data: `light_off_${lightId}` }], [{ text: "📈 Яркость +10%", callback_data: `light_brightness_up_${lightId}` }], [{ text: "📉 Яркость -10%", callback_data: `light_brightness_down_${lightId}` }], [{ text: "🎨 Цвет", callback_data: `light_color_${lightId}` }], [{ text: "◀️ Назад к списку", callback_data: "menu_lights" }] ] } }; msg.payload = { chatId: chatId, type: "message", content: `🎮 *Управление лампой*\n${lightName}\n\nВыберите действие:`, options: { parse_mode: "Markdown", reply_markup: controlKeyboard.reply_markup } }; return msg;

Узел «Light Color Picker» (function) — показывает 7 основных цветов (красный, зелёный, синий, жёлтый, фиолетовый, оранжевый, белый) с защитой от двойного открытия (3 секунды).

Часть 4. Выполнение команд освещения

Узел «Route Light Command» (function) — разбирает callback_data и направляет команду на соответствующий API-узел.

if (data.startsWith("light_on_")) { const lightId = data.replace("light_on_", ""); return [msg, null, null, null, null]; } else if (data.startsWith("light_off_")) { // ... } else if (data.startsWith("light_brightness_up_")) { // ... } else if (data.startsWith("light_brightness_down_")) { // ... } else if (data.startsWith("light_color_set_")) { const withoutPrefix = data.replace("light_color_set_", ""); const lastUnderscoreIndex = withoutPrefix.lastIndexOf("_"); const lightId = withoutPrefix.substring(0, lastUnderscoreIndex); const colorName = withoutPrefix.substring(lastUnderscoreIndex + 1); return [null, null, null, null, msg]; }

API-узлы Home Assistant:

  • Light Onlight.turn_on
  • Light Offlight.turn_off
  • Brightness Uplight.turn_on с {"brightness_step_pct": 10}
  • Brightness Downlight.turn_on с {"brightness_step_pct": -10}
  • Set Colorlight.turn_on с {"color_name": "{{payload.color_name}}"}

Узел «Light Command Response» (function) — формирует сообщение-подтверждение с кнопками «Назад к управлению» и «В главное меню».

3.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените все плейсхолдеры [ВАШ_...] на свои значения.

[ { "id": "0f92fc930c9c0e37", "type": "group", "z": "d91b643aa7cd4cef", "name": "Освещение", "style": { "stroke": "#92d04f", "fill": "#92d04f", "label": true, "label-position": "sw", "color": "#000000" }, "nodes": [ "53548f8c35190c24", "3265e6c3a79c0230", "ba884f1df3c6c325", "22f7d792ee1ef7b7", "7b97ff2c7db9964d", "59593b4bd087ab1c", "26247f431a91ecef", "cced4a9f2b1d1397", "3a1a0bc2d173af2f", "59b989087a64d873", "323e4bb05385f518", "c429d3b3ab6c8090", "f198a68addc257e3", "cafae120e9e6d2a1", "d09dad056bd19a1a", "32cf6caf208f25c3", "954ad439bf9804bc", "95e2ef959d1a010f", "b23a5d629687e907", "dab4f66d2392f1eb" ], "x": 54, "y": 939, "w": 2092, "h": 310 }, { "id": "53548f8c35190c24", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Обработчик нажатия кнопок", "bot": "[ID_BOT_CONFIG]", "event": "callback_query", "autoanswer": false, "x": 210, "y": 1020, "wires": [["3265e6c3a79c0230"]] }, { "id": "3265e6c3a79c0230", "type": "switch", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "маршрутизатор light", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "regex", "v": "light_control_.*", "vt": "str", "case": false}, {"t": "regex", "v": "light_color_.*", "vt": "str", "case": false}, {"t": "regex", "v": "light_on_.*", "vt": "str", "case": false}, {"t": "regex", "v": "light_off_.*", "vt": "str", "case": false}, {"t": "regex", "v": "light_brightness_.*", "vt": "str", "case": false}, {"t": "regex", "v": "light_color_set_.*", "vt": "str", "case": false}, {"t": "eq", "v": "menu_lights", "vt": "str"} ], "checkall": "true", "repair": false, "outputs": 7, "x": 460, "y": 1020, "wires": [ ["ba884f1df3c6c325"], ["22f7d792ee1ef7b7"], ["3a1a0bc2d173af2f"], ["3a1a0bc2d173af2f"], ["3a1a0bc2d173af2f"], ["3a1a0bc2d173af2f"], ["f198a68addc257e3"] ] }, { "id": "ba884f1df3c6c325", "type": "function", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Light Control Panel", "func": "let lightId = msg.payload.content.replace(\"light_control_\", \"\");\nconst chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nconst lightNames = {\n \"[ВАШ_ENTITY_ID_1]\": \"💡 [ВАШЕ_НАЗВАНИЕ_ЛАМПЫ_1]\",\n \"[ВАШ_ENTITY_ID_2]\": \"💡 [ВАШЕ_НАЗВАНИЕ_ЛАМПЫ_2]\"\n};\nconst lightName = lightNames[lightId] || lightId;\nconst controlKeyboard = {\n reply_markup: {\n inline_keyboard: [\n [{ text: \"🔆 Включить\", callback_data: `light_on_${lightId}` }, { text: \"❌ Выключить\", callback_data: `light_off_${lightId}` }],\n [{ text: \"📈 Яркость +10%\", callback_data: `light_brightness_up_${lightId}` }],\n [{ text: \"📉 Яркость -10%\", callback_data: `light_brightness_down_${lightId}` }],\n [{ text: \"🎨 Цвет\", callback_data: `light_color_${lightId}` }],\n [{ text: \"◀️ Назад к списку\", callback_data: \"menu_lights\" }]\n ]\n }\n};\nmsg.payload = { chatId: chatId, type: \"message\", content: `🎮 *Управление лампой*\\n${lightName}\\n\\nВыберите действие:`, options: { parse_mode: \"Markdown\", reply_markup: controlKeyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 730, "y": 980, "wires": [["323e4bb05385f518"]] }, { "id": "22f7d792ee1ef7b7", "type": "function", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Light Color Picker", "func": "if (msg.payload.content && msg.payload.content.startsWith(\"light_color_set_\")) return null;\nlet lightId = msg.payload.content.replace(\"light_color_\", \"\");\nconst chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nconst paletteKey = `palette_${lightId}`;\nconst lastPaletteTime = global.get(paletteKey);\nconst now = Date.now();\nif (lastPaletteTime && (now - lastPaletteTime) < 3000) return null;\nglobal.set(paletteKey, now);\nconst colorKeyboard = {\n reply_markup: {\n inline_keyboard: [\n [{ text: \"🔴 Красный\", callback_data: `light_color_set_${lightId}_red` }],\n [{ text: \"🟢 Зеленый\", callback_data: `light_color_set_${lightId}_green` }],\n [{ text: \"🔵 Синий\", callback_data: `light_color_set_${lightId}_blue` }],\n [{ text: \"🟡 Желтый\", callback_data: `light_color_set_${lightId}_yellow` }],\n [{ text: \"💜 Фиолетовый\", callback_data: `light_color_set_${lightId}_purple` }],\n [{ text: \"🟠 Оранжевый\", callback_data: `light_color_set_${lightId}_orange` }],\n [{ text: \"⚪ Белый\", callback_data: `light_color_set_${lightId}_white` }],\n [{ text: \"◀️ Назад\", callback_data: `light_control_${lightId}` }]\n ]\n }\n};\nmsg.payload = { chatId: chatId, type: \"message\", content: \"🎨 *Выберите цвет:*\", options: { parse_mode: \"Markdown\", reply_markup: colorKeyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 730, "y": 1020, "wires": [["323e4bb05385f518"]] }, { "id": "7b97ff2c7db9964d", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Light Off", "server": "[ID_HA_SERVER]", "action": "light.turn_off", "entityId": ["{{payload.lightId}}"], "x": 980, "y": 1080, "wires": [["59b989087a64d873"]] }, { "id": "59593b4bd087ab1c", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Brightness Up", "server": "[ID_HA_SERVER]", "action": "light.turn_on", "entityId": ["{{payload.lightId}}"], "data": "{\"brightness_step_pct\": 10}", "x": 1000, "y": 1120, "wires": [["59b989087a64d873"]] }, { "id": "26247f431a91ecef", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Brightness Down", "server": "[ID_HA_SERVER]", "action": "light.turn_on", "entityId": ["{{payload.lightId}}"], "data": "{\"brightness_step_pct\": -10}", "x": 1010, "y": 1160, "wires": [["59b989087a64d873"]] }, { "id": "cced4a9f2b1d1397", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Set Color", "server": "[ID_HA_SERVER]", "action": "light.turn_on", "entityId": ["{{payload.lightId}}"], "data": "{\"color_name\": \"{{payload.color_name}}\"}", "x": 980, "y": 1200, "wires": [["59b989087a64d873"]] }, { "id": "3a1a0bc2d173af2f", "type": "function", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Route Light Command", "func": "if (!msg.payload) return null;\nlet data = msg.payload.content || msg.payload.data;\nif (!data) return null;\nconst chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nif (data.startsWith(\"light_on_\")) {\n const lightId = data.replace(\"light_on_\", \"\");\n return [{ payload: { chatId: chatId, lightId: lightId }, topic: \"on\" }, null, null, null, null];\n} else if (data.startsWith(\"light_off_\")) {\n const lightId = data.replace(\"light_off_\", \"\");\n return [null, { payload: { chatId: chatId, lightId: lightId }, topic: \"off\" }, null, null, null];\n} else if (data.startsWith(\"light_brightness_up_\")) {\n const lightId = data.replace(\"light_brightness_up_\", \"\");\n return [null, null, { payload: { chatId: chatId, lightId: lightId }, topic: \"brightness_up\" }, null, null];\n} else if (data.startsWith(\"light_brightness_down_\")) {\n const lightId = data.replace(\"light_brightness_down_\", \"\");\n return [null, null, null, { payload: { chatId: chatId, lightId: lightId }, topic: \"brightness_down\" }, null];\n} else if (data.startsWith(\"light_color_set_\")) {\n const withoutPrefix = data.replace(\"light_color_set_\", \"\");\n const lastUnderscoreIndex = withoutPrefix.lastIndexOf(\"_\");\n const lightId = withoutPrefix.substring(0, lastUnderscoreIndex);\n const colorName = withoutPrefix.substring(lastUnderscoreIndex + 1);\n return [null, null, null, null, { payload: { chatId: chatId, lightId: lightId, color_name: colorName }, topic: \"color\" }];\n}\nreturn null;", "outputs": 5, "timeout": 0, "noerr": 0, "x": 710, "y": 1120, "wires": [ ["c429d3b3ab6c8090"], ["7b97ff2c7db9964d"], ["59593b4bd087ab1c"], ["26247f431a91ecef"], ["cced4a9f2b1d1397"] ] }, { "id": "59b989087a64d873", "type": "function", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Light Command Response", "func": "const chatId = msg.payload?.chatId || msg.originalMessage?.chat?.id || msg.chatId;\nconst lightId = msg.lightId || msg.payload?.lightId;\nconst topic = msg.topic;\nlet resultMessage = \"\";\nif (topic === \"on\") resultMessage = \"🔆 Свет включен\";\nelse if (topic === \"off\") resultMessage = \"❌ Свет выключен\";\nelse if (topic === \"brightness_up\") resultMessage = \"📈 Яркость увеличена\";\nelse if (topic === \"brightness_down\") resultMessage = \"📉 Яркость уменьшена\";\nelse if (topic === \"color\") {\n const colorDisplay = { red: \"🔴 красный\", green: \"🟢 зеленый\", blue: \"🔵 синий\", yellow: \"🟡 желтый\", purple: \"💜 фиолетовый\", orange: \"🟠 оранжевый\", white: \"⚪ белый\" };\n const colorName = msg.payload?.color_name || \"\";\n resultMessage = `🎨 Цвет изменен на ${colorDisplay[colorName] || colorName}`;\n} else return null;\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад к управлению\", callback_data: `light_control_${lightId}` }, { text: \"🏠 В главное меню\", callback_data: \"back_main\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: `✅ ${resultMessage}`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1280, "y": 1140, "wires": [["323e4bb05385f518"]] }, { "id": "323e4bb05385f518", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "x": 1240, "y": 1020, "wires": [[]] }, { "id": "c429d3b3ab6c8090", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Light On", "server": "[ID_HA_SERVER]", "action": "light.turn_on", "entityId": ["{{payload.lightId}}"], "x": 980, "y": 1040, "wires": [["59b989087a64d873"]] }, { "id": "f198a68addc257e3", "type": "function", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Lights Menu", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"lights_chat_id\", chatId);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1390, "y": 1080, "wires": [["d09dad056bd19a1a", "cafae120e9e6d2a1"]] }, { "id": "cafae120e9e6d2a1", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Лампа 2", "server": "[ID_HA_SERVER]", "entity_id": "[ВАШ_ENTITY_ID_2]", "x": 1560, "y": 1100, "wires": [["954ad439bf9804bc"]] }, { "id": "d09dad056bd19a1a", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Лампа 1", "server": "[ID_HA_SERVER]", "entity_id": "[ВАШ_ENTITY_ID_1]", "x": 1560, "y": 1060, "wires": [["32cf6caf208f25c3"]] }, { "id": "32cf6caf208f25c3", "type": "function", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Нормализация Лампа 1", "func": "let lightName = msg.data?.entity_id || msg.payload?.entity_id || msg.topic || \"unknown\";\nlet lightState = (typeof msg.payload === \"object\") ? (msg.payload.state === \"on\" ? \"on\" : \"off\") : msg.payload;\nmsg.topic = lightName;\nmsg.payload = lightState;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1770, "y": 1060, "wires": [["95e2ef959d1a010f"]] }, { "id": "954ad439bf9804bc", "type": "function", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Нормализация Лампа 2", "func": "let lightName = msg.data?.entity_id || msg.payload?.entity_id || msg.topic || \"unknown\";\nlet lightState = (typeof msg.payload === \"object\") ? (msg.payload.state === \"on\" ? \"on\" : \"off\") : msg.payload;\nmsg.topic = lightName;\nmsg.payload = lightState;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1770, "y": 1100, "wires": [["95e2ef959d1a010f"]] }, { "id": "95e2ef959d1a010f", "type": "join", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Объединение ламп", "mode": "custom", "build": "object", "property": "payload", "propertyType": "msg", "key": "topic", "count": "2", "timeout": "5", "x": 1650, "y": 1180, "wires": [["b23a5d629687e907"]] }, { "id": "b23a5d629687e907", "type": "function", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Format Lights List", "func": "const lightsState = msg.payload;\nlet chatId = global.get(\"lights_chat_id\");\nconst lastSent = global.get(\"last_lights_message\");\nconst now = Date.now();\nif (lastSent && (now - lastSent) < 3000) return null;\nglobal.set(\"last_lights_message\", now);\nif (!chatId) chatId = msg.originalMessage?.chat?.id || msg.chatId;\nconst lightNames = {\n \"[ВАШ_ENTITY_ID_1]\": \"💡 [ВАШЕ_НАЗВАНИЕ_ЛАМПЫ_1]\",\n \"[ВАШ_ENTITY_ID_2]\": \"💡 [ВАШЕ_НАЗВАНИЕ_ЛАМПЫ_2]\"\n};\nlet message = \"💡 *Управление светом*\\n\\n\";\nconst keyboard = [];\nfor (const [entityId, state] of Object.entries(lightsState)) {\n const name = lightNames[entityId] || entityId;\n const status = state === \"on\" ? \"🟢 Включена\" : \"⚫ Выключена\";\n message += `${name}: ${status}\\n`;\n keyboard.push([{ text: name, callback_data: `light_control_${entityId}` }]);\n}\nkeyboard.push([{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]);\nglobal.set(\"lights_list\", Object.keys(lightsState));\nmsg.payload = { chatId: chatId, type: \"message\", content: message, options: { parse_mode: \"Markdown\", reply_markup: { inline_keyboard: keyboard } } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1870, "y": 1180, "wires": [["dab4f66d2392f1eb"]] }, { "id": "dab4f66d2392f1eb", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "0f92fc930c9c0e37", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "x": 2020, "y": 1020, "wires": [[]] }, { "id": "[ID_BOT_CONFIG]", "type": "telegram bot", "botname": "[ВАШ_БОТ]", "usernames": "[ВАШ_USERNAME]", "updatemode": "polling", "pollinterval": "300", "localbothost": "[IP_СЕРВЕРА]", "localbotport": 8443 }, { "id": "[ID_HA_SERVER]", "type": "server", "name": "Home Assistant", "version": 6, "addon": false }, { "id": "5c71334ad782912a", "type": "global-config", "env": [], "modules": { "node-red-contrib-telegrambot": "17.4.12", "node-red-contrib-home-assistant-websocket": "0.80.3" } } ]

4. Управление Docker-контейнерами (Portainer)

Ранее к Home Assistant был подключён Portainer, поэтому добавим возможность отслеживания состояния контейнеров с возможностью их перезагрузки, запуска и остановки. Также можно вытянуть различные данные о процессоре и т.д.

Схема управления Docker-контейнерами
Схема управления Docker-контейнерами
Список контейнеров
Список контейнеров
Управление контейнером
Панель управления контейнером
4.1 Пошаговая настройка

Общая архитектура

Этот поток позволяет просматривать список Docker-контейнеров и их статус (запущен/остановлен), управлять контейнерами (запуск, остановка, перезапуск), получать обратную связь о выполнении команд. Интеграция происходит через Home Assistant, который через Portainer управляет Docker-контейнерами.

Часть 1. Вход в меню Docker

1.1. Перехват callback (telegram event)

  • Тип узла: telegram event
  • Событие: callback_query
  • Привязан к боту ([ID_BOT_CONFIG])

1.2. Маршрутизатор Docker (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правила:
    • menu_docker → показать список контейнеров
    • docker_control_.* → открыть панель управления
    • docker_start_.* → запустить контейнер
    • docker_stop_.* → остановить контейнер
    • docker_restart_.* → перезапустить контейнер

Часть 2. Отображение списка контейнеров

Узел «Docker Menu» (function) — сохраняет chatId в глобальную переменную и формирует HTTP-запрос к Home Assistant API для получения состояния всех сущностей.

let chatId = msg.payload.chatId || msg.payload.message?.chat?.id; global.set("docker_chat_id", chatId); msg.url = "[ВАШ_IP_HA]:8123/api/states"; msg.method = "GET"; msg.headers = { "Authorization": "Bearer [ВАШ_ДОЛГОСРОЧНЫЙ_ТОКЕН_HA]", "Content-Type": "application/json" }; return msg;

Узел «Get HA Entities» (http request) — выполняет GET-запрос к Home Assistant API, получает JSON-массив всех сущностей.

Узел «Format Docker List» (function) — фильтрует сущности состояния Docker-контейнеров (оканчивающиеся на _state), преобразует технические имена в понятные, формирует список с иконками статуса (🟢/🔴) и кнопками управления.

const stateEntities = entities.filter(e => e.entity_id.endsWith("_state") && (e.entity_id.includes("mynodered") || e.entity_id.includes("homeassistant") || e.entity_id.includes("esphome") || e.entity_id.includes("mosquitto") || e.entity_id.includes("portainer") || e.entity_id.includes("netalertx")) ); // Маппинг имён if (containerName === "mynodered") containerName = "Node-RED"; if (containerName === "homeassistant") containerName = "Home Assistant"; if (containerName === "esphome") containerName = "ESPHome"; if (containerName === "mosquitto") containerName = "Mosquitto"; if (containerName === "portainer") containerName = "Portainer"; if (containerName === "netalertx") containerName = "NetAlertX"; // Формирование сообщения с иконками статуса const icon = isRunning ? "🟢" : "🔴"; message += `${icon} *${name}*: ${statusText}\n`; keyboard.push([{ text: `${icon} ${name}`, callback_data: `docker_control_${name}` }]);

Часть 3. Панель управления контейнером

Узел «Docker Control Panel» (function) — создаёт панель управления для выбранного контейнера с кнопками: «Запустить», «Остановить», «Перезапустить», «Назад к списку».

let containerName = msg.payload.content.replace("docker_control_", ""); const controlKeyboard = { reply_markup: { inline_keyboard: [ [{ text: "▶️ Запустить", callback_data: `docker_start_${containerName}` }], [{ text: "⏹️ Остановить", callback_data: `docker_stop_${containerName}` }], [{ text: "🔄 Перезапустить", callback_data: `docker_restart_${containerName}` }], [{ text: "◀️ Назад к списку", callback_data: "menu_docker" }] ] } };

Часть 4. Выполнение команд Docker

Узел «Handle Docker Command» (function) — разбирает callback_data и направляет команду на соответствующий API-узел.

Маппинг entity_id для управления контейнерами:

  • Запуск/остановка → switch.xxx_container (сервисы turn_on/turn_off)
  • Перезапуск → button.xxx_restart_container (сервис press)

API-узлы Home Assistant:

  • Docker Startswitch.turn_on
  • Docker Stopswitch.turn_off
  • Docker Restartbutton.press

Узел «Docker Command Response» (function) — формирует сообщение-подтверждение с кнопками «Назад к списку» и «В главное меню».

4.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените все плейсхолдеры [ВАШ_...] на свои значения.

[ { "id": "67d65c2359b258e5", "type": "group", "z": "d91b643aa7cd4cef", "name": "Docker", "style": { "stroke": "#92d04f", "fill": "#92d04f", "label": true, "label-position": "sw", "color": "#000000" }, "nodes": [ "eecc47fd380f012f", "132a5adf4cadda86", "6813233649ebb36f", "0aead6032a740aba", "f90f8202233ac6fc", "4b3080710211e8f6", "558574256ae327c0", "634f0fb22e895654", "221a78429049bf81", "57ef3b14cfc18f56", "16125cc85da93d5e", "28051a79b5c3df37" ], "x": 54, "y": 1739, "w": 1412, "h": 270 }, { "id": "eecc47fd380f012f", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Docker Start", "server": "[ID_HA_SERVER]", "action": "switch.turn_on", "entityId": ["{{entityId}}"], "x": 1010, "y": 1880, "wires": [["57ef3b14cfc18f56"]] }, { "id": "132a5adf4cadda86", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Обработчик нажатия кнопок", "bot": "[ID_BOT_CONFIG]", "event": "callback_query", "autoanswer": false, "x": 210, "y": 1840, "wires": [["6813233649ebb36f"]] }, { "id": "6813233649ebb36f", "type": "switch", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "маршрутизатор docker", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "eq", "v": "menu_docker", "vt": "str"}, {"t": "regex", "v": "docker_control_.*", "vt": "str", "case": false}, {"t": "regex", "v": "docker_start_.*", "vt": "str", "case": false}, {"t": "regex", "v": "docker_stop_.*", "vt": "str", "case": false}, {"t": "regex", "v": "docker_restart_.*", "vt": "str", "case": false} ], "checkall": "true", "repair": false, "outputs": 5, "x": 480, "y": 1840, "wires": [ ["0aead6032a740aba"], ["634f0fb22e895654"], ["221a78429049bf81"], ["221a78429049bf81"], ["221a78429049bf81"] ] }, { "id": "0aead6032a740aba", "type": "function", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Docker Menu", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"docker_chat_id\", chatId);\nmsg.url = \"[ВАШ_IP_HA]:8123/api/states\";\nmsg.method = \"GET\";\nmsg.headers = {\n \"Authorization\": \"Bearer [ВАШ_ДОЛГОСРОЧНЫЙ_ТОКЕН_HA]\",\n \"Content-Type\": \"application/json\"\n};\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 740, "y": 1780, "wires": [["f90f8202233ac6fc"]] }, { "id": "f90f8202233ac6fc", "type": "http request", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Get HA Entities", "method": "GET", "ret": "obj", "x": 920, "y": 1780, "wires": [["4b3080710211e8f6"]] }, { "id": "4b3080710211e8f6", "type": "function", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Format Docker List", "func": "const chatId = global.get(\"docker_chat_id\");\nconst entities = msg.payload;\nif (!entities || !Array.isArray(entities)) return null;\nconst stateEntities = entities.filter(e =>\n e.entity_id.endsWith(\"_state\") &&\n (e.entity_id.includes(\"mynodered\") || e.entity_id.includes(\"homeassistant\") ||\n e.entity_id.includes(\"esphome\") || e.entity_id.includes(\"mosquitto\") ||\n e.entity_id.includes(\"portainer\") || e.entity_id.includes(\"netalertx\"))\n);\nconst containerMap = new Map();\nstateEntities.forEach(entity => {\n let containerName = entity.entity_id.replace(\"sensor.\", \"\").replace(\"_state\", \"\").replace(\"_status\", \"\");\n if (containerName === \"mynodered\") containerName = \"Node-RED\";\n if (containerName === \"homeassistant\") containerName = \"Home Assistant\";\n if (containerName === \"esphome\") containerName = \"ESPHome\";\n if (containerName === \"mosquitto\") containerName = \"Mosquitto\";\n if (containerName === \"portainer\") containerName = \"Portainer\";\n if (containerName === \"netalertx\") containerName = \"NetAlertX\";\n const isRunning = entity.state === \"running\" || entity.state === \"on\";\n if (!containerMap.has(containerName)) {\n containerMap.set(containerName, { name: containerName, status: isRunning ? \"running\" : \"stopped\" });\n }\n});\nlet message = \"🐳 *Docker контейнеры*\\n\\n\";\nconst keyboard = [];\nfor (const [name, container] of containerMap) {\n const isRunning = container.status === \"running\";\n const icon = isRunning ? \"🟢\" : \"🔴\";\n const statusText = isRunning ? \"Работает\" : \"Остановлен\";\n message += `${icon} *${name}*: ${statusText}\\n`;\n keyboard.push([{ text: `${icon} ${name}`, callback_data: `docker_control_${name}` }]);\n}\nkeyboard.push([{ text: \"🔄 Обновить список\", callback_data: \"menu_docker\" }]);\nkeyboard.push([{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]);\nmsg.payload = { chatId: chatId, type: \"message\", content: message, options: { parse_mode: \"Markdown\", reply_markup: { inline_keyboard: keyboard } } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1110, "y": 1780, "wires": [["558574256ae327c0"]] }, { "id": "558574256ae327c0", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "x": 1340, "y": 1820, "wires": [[]] }, { "id": "634f0fb22e895654", "type": "function", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Docker Control Panel", "func": "let containerName = msg.payload.content.replace(\"docker_control_\", \"\");\nconst chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"docker_chat_id\", chatId);\nconst controlKeyboard = {\n reply_markup: {\n inline_keyboard: [\n [{ text: \"▶️ Запустить\", callback_data: `docker_start_${containerName}` }],\n [{ text: \"⏹️ Остановить\", callback_data: `docker_stop_${containerName}` }],\n [{ text: \"🔄 Перезапустить\", callback_data: `docker_restart_${containerName}` }],\n [{ text: \"◀️ Назад к списку\", callback_data: \"menu_docker\" }]\n ]\n }\n};\nmsg.payload = { chatId: chatId, type: \"message\", content: `🎮 *Управление контейнером*\\n📦 ${containerName}\\n\\nВыберите действие:`, options: { parse_mode: \"Markdown\", reply_markup: controlKeyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 760, "y": 1820, "wires": [["558574256ae327c0"]] }, { "id": "221a78429049bf81", "type": "function", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Handle Docker Command", "func": "const chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nconst data = msg.payload.content;\nlet action = \"\", containerName = \"\";\nif (data.startsWith(\"docker_start_\")) { action = \"start\"; containerName = data.replace(\"docker_start_\", \"\"); }\nelse if (data.startsWith(\"docker_stop_\")) { action = \"stop\"; containerName = data.replace(\"docker_stop_\", \"\"); }\nelse if (data.startsWith(\"docker_restart_\")) { action = \"restart\"; containerName = data.replace(\"docker_restart_\", \"\"); }\nif (!containerName || !action) return null;\nglobal.set(\"docker_chat_id\", chatId);\nglobal.set(\"docker_action\", action);\nglobal.set(\"docker_container\", containerName);\nconst startStopMap = {\n \"Node-RED\": \"switch.mynodered_container\",\n \"Home Assistant\": \"switch.homeassistant_container\",\n \"ESPHome\": \"switch.esphome_container\",\n \"Mosquitto\": \"switch.mosquitto_container\",\n \"Portainer\": \"switch.portainer_container\",\n \"NetAlertX\": \"switch.netalertx_container\"\n};\nconst restartMap = {\n \"Node-RED\": \"button.mynodered_restart_container\",\n \"Home Assistant\": \"button.homeassistant_restart_container\",\n \"ESPHome\": \"button.esphome_restart_container\",\n \"Mosquitto\": \"button.mosquitto_restart_container\",\n \"Portainer\": \"button.portainer_restart_container\",\n \"NetAlertX\": \"button.netalertx_restart_container\"\n};\nlet entityId = \"\", haAction = \"\";\nif (action === \"start\") { entityId = startStopMap[containerName]; haAction = \"switch.turn_on\"; }\nelse if (action === \"stop\") { entityId = startStopMap[containerName]; haAction = \"switch.turn_off\"; }\nelse if (action === \"restart\") { entityId = restartMap[containerName]; haAction = \"button.press\"; }\nif (!entityId) return null;\nconst statusMsg = { payload: { chatId: chatId, type: \"message\", content: `🔄 Выполняется ${action} контейнера ${containerName}...`, options: { parse_mode: \"Markdown\" } } };\nnode.send(statusMsg);\nmsg.entityId = entityId;\nmsg.haAction = haAction;\nmsg.containerName = containerName;\nif (action === \"start\") return [msg, null, null];\nelse if (action === \"stop\") return [null, msg, null];\nelse if (action === \"restart\") return [null, null, msg];\nreturn null;", "outputs": 3, "timeout": 0, "noerr": 0, "x": 780, "y": 1920, "wires": [ ["eecc47fd380f012f"], ["16125cc85da93d5e"], ["28051a79b5c3df37"] ] }, { "id": "57ef3b14cfc18f56", "type": "function", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Docker Command Response", "func": "const chatId = global.get(\"docker_chat_id\");\nconst action = global.get(\"docker_action\");\nconst containerName = global.get(\"docker_container\");\nif (!chatId) return null;\nlet resultMessage = \"\";\nif (action === \"start\") resultMessage = `▶️ Контейнер ${containerName} запущен`;\nelse if (action === \"stop\") resultMessage = `⏹️ Контейнер ${containerName} остановлен`;\nelse if (action === \"restart\") resultMessage = `🔄 Контейнер ${containerName} перезапущен`;\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад к списку\", callback_data: \"menu_docker\" }, { text: \"🏠 В главное меню\", callback_data: \"back_main\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: `✅ ${resultMessage}`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nglobal.set(\"docker_action\", null);\nglobal.set(\"docker_container\", null);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1260, "y": 1920, "wires": [["558574256ae327c0"]] }, { "id": "16125cc85da93d5e", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Docker Stop", "server": "[ID_HA_SERVER]", "action": "switch.turn_off", "entityId": ["{{entityId}}"], "x": 1010, "y": 1920, "wires": [["57ef3b14cfc18f56"]] }, { "id": "28051a79b5c3df37", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "67d65c2359b258e5", "name": "Docker Restart", "server": "[ID_HA_SERVER]", "action": "button.press", "entityId": ["{{entityId}}"], "x": 1020, "y": 1960, "wires": [["57ef3b14cfc18f56"]] }, { "id": "[ID_BOT_CONFIG]", "type": "telegram bot", "botname": "[ВАШ_БОТ]", "usernames": "[ВАШ_USERNAME]", "updatemode": "polling", "pollinterval": "300", "localbothost": "[IP_СЕРВЕРА]", "localbotport": 8443 }, { "id": "[ID_HA_SERVER]", "type": "server", "name": "Home Assistant", "version": 6, "addon": false }, { "id": "74b4366b57c8eefd", "type": "global-config", "env": [], "modules": { "node-red-contrib-home-assistant-websocket": "0.80.3", "node-red-contrib-telegrambot": "17.4.12" } } ]

5. Todoist задачи через календари HA

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

📋 Анализ вариантов интеграции задач (развернуть)

Вариант 1. Встроенный локальный список задач Home Assistant (Local To-do)

Описание: Home Assistant имеет встроенную интеграцию «Список задач (Local To-do)», которая не требует подключения внешних сервисов и полностью работает локально. Интеграция создаёт сущности типа todo.* (например, todo.moi_zadachi), с которыми можно взаимодействовать через сервисы todo.add_item, todo.update_item, todo.get_items и todo.remove_item.

Результат: ✅ Технически реализуемо, но требует доработки.

Достоинства:

  • Не требует регистрации во внешних сервисах
  • Работает локально, быстро, без задержек
  • Полностью совместим со всеми сервисами Home Assistant
  • Данные хранятся в конфигурации HA

Недостатки:

  • Нет синхронизации с мобильными устройствами
  • Нет встроенных push-уведомлений
  • Требуется реализация собственного интерфейса для добавления дедлайнов

Вердикт: Отложен в пользу облачного решения с синхронизацией.


Вариант 2. Todoist (облачный сервис задач)

Описание: Todoist — один из самых популярных облачных сервисов для управления задачами. Интеграция с Home Assistant осуществляется через официальную интеграцию, которая подключается по API-токену. После подключения в Home Assistant появляются сущности:

  • calendar.* – календари для каждого проекта (показывают задачи с датами)
  • todo.* – списки задач (должны показывать все задачи, но в текущей версии не заполняются)

Рассмотренные способы подключения:

  • Прямой вызов Todoist REST API v2 — ❌ API отключён (410 Gone)
  • Прямой вызов Todoist Sync API v9 — ❌ API отключён (410 Gone)
  • Палитра @foxleigh81/node-red-contrib-todoist-api — ❌ Использует устаревшие эндпоинты
  • HA сущности todo.* — ❌ Не синхронизируют задачи
  • HA сущности calendar.* — ✅ Работает, но только задачи с датами

Достоинства:

  • Отличное мобильное приложение с push-уведомлениями
  • Синхронизация между устройствами
  • Поддержка подзадач, меток, приоритетов
  • Бесплатный тариф покрывает базовые потребности

Недостатки:

  • Старые версии API полностью отключены Todoist
  • Требуется аккаунт в сервисе
  • Данные хранятся в облаке (не локально)

Итоговое решение в рамках ВКР: Использование календарей Todoist в Home Assistant (calendar.get_events). Ограничение – отображаются только задачи с установленной датой. Этого достаточно для реализации просмотра задач по проектам.


Вариант 3. Google Tasks

Описание: Интеграция с Google Tasks позволяет синхронизировать задачи из аккаунта Google. Подключается через Google API и требует настройки OAuth-авторизации. В Home Assistant создаются сущности типа todo.* для каждого списка задач.

Результат: ✅ Технически реализуемо, но имеет ограничения.

Достоинства:

  • Интеграция с экосистемой Google
  • Доступно на всех устройствах через Google

Недостатки:

  • Google Tasks API принимает только дату для дедлайна (время игнорируется)
  • Сложная настройка OAuth-авторизации
  • Меньший функционал по сравнению с Todoist

Вариант 4. Vikunja (самостоятельный хостинг)

Описание: Vikunja — это open-source сервер для управления задачами, который можно развернуть на собственном сервере. Интеграция с Home Assistant осуществляется через REST API. Требует установки и настройки отдельного сервера Vikunja.

Результат: ⚠️ Рассматривался, но не внедрялся.

Достоинства:

  • Полный контроль над данными (self-hosted)
  • Открытый исходный код
  • Поддержка дедлайнов, приоритетов, проектов

Недостатки:

  • Требуется установка и обслуживание отдельного сервера
  • Нет готовой интеграции в Home Assistant (только через REST API)
  • Нет мобильного приложения (только веб-интерфейс)

Вердикт: Отложен из-за сложности развёртывания и отсутствия нативной интеграции с HA.

Схема просмотра задач
Схема просмотра задач из Todoist через календари HA
Выбор проекта
Выбор проекта
Список задач
Список задач с дедлайнами
5.1 Пошаговая настройка

Общая архитектура

Этот поток позволяет просматривать задачи из Todoist, синхронизированные с календарями Home Assistant. Реализована навигация: выбор проекта → просмотр задач с дедлайнами, отображение статуса выполнения (выполнено/не выполнено), подсветка просроченных задач.

Важно: Реализован только просмотр, без создания/редактирования задач.

Схема потока (укрупнённо)

  • Нажатие кнопки → Маршрутизация → Три направления:
    • menu_tasks → Главное меню задач → Выбор действия
    • tasks_by_project → Меню выбора проекта → Список проектов
    • tasks_by_project_.* → Запрос к HA Calendar API → Форматирование списка задач → Отправка

Часть 1. Вход в меню задач

1.1. Перехват callback (telegram event)

  • Тип узла: telegram event
  • Событие: callback_query
  • Привязан к боту ([ID_BOT_CONFIG])

1.2. Маршрутизатор задач (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правила:
    • menu_tasks → главное меню задач
    • tasks_by_project → меню выбора проекта
    • tasks_by_project_.* (regex) → просмотр задач проекта

Часть 2. Главное меню задач

Узел «Tasks Main Menu» (function) — сохраняет chatId в глобальную переменную и показывает главное меню задач с кнопками: «По проектам», «Назад в меню».

let chatId = msg.payload.chatId || msg.payload.message?.chat?.id; global.set("tasks_chat_id", chatId); const tasksKeyboard = { reply_markup: { inline_keyboard: [ [{ text: "🎯 По проектам", callback_data: "tasks_by_project" }], [{ text: "◀️ Назад в меню", callback_data: "back_main" }] ] } }; msg.payload = { chatId: chatId, type: "message", content: "✅ *Todoist задачи*\n\nПросмотр задач доступен через проекты:", options: { parse_mode: "Markdown", reply_markup: tasksKeyboard.reply_markup } }; return msg;

Часть 3. Меню выбора проекта

Узел «Tasks By Projects Menu» (function) — показывает список проектов с эмодзи, каждый — кнопка для просмотра задач.

const projects = [ { name: "[НАЗВАНИЕ_ПРОЕКТА_1]", emoji: "[ЭМОДЗИ_1]", entity: "[CALENDAR_ENTITY_ID_1]" }, { name: "[НАЗВАНИЕ_ПРОЕКТА_2]", emoji: "[ЭМОДЗИ_2]", entity: "[CALENDAR_ENTITY_ID_2]" } ]; const keyboard = []; projects.forEach(project => { keyboard.push([{ text: `${project.emoji} ${project.name}`, callback_data: `tasks_by_project_${project.entity}` }]); }); keyboard.push([{ text: "◀️ Назад к задачам", callback_data: "menu_tasks" }]); msg.payload = { chatId: chatId, type: "message", content: "🎯 *Выберите проект:*", options: { parse_mode: "Markdown", reply_markup: { inline_keyboard: keyboard } } }; return msg;

Часть 4. Получение задач из календаря

Узел «Get Tasks from Calendar» (function) — извлекает entity_id календаря из callback_data, формирует URL для запроса к Home Assistant Calendar API (период 2000-2099 годы, чтобы получить все задачи).

const chatId = global.get("tasks_chat_id"); const data = msg.payload.content; const entityId = data.split("_").pop(); const haUrl = "[ВАШ_IP_HA]:8123"; const haToken = "[ВАШ_ДОЛГОСРОЧНЫЙ_ТОКЕН_HA]"; global.set("tasks_current_entity", entityId); const startDate = new Date(); startDate.setFullYear(2000, 0, 1); const endDate = new Date(); endDate.setFullYear(2099, 11, 31); msg.url = `${haUrl}/api/calendars/${entityId}?start=${startDate.toISOString()}&end=${endDate.toISOString()}`; msg.method = "GET"; msg.headers = { "Authorization": `Bearer ${haToken}`, "Content-Type": "application/json" }; return msg;

Узел «HTTP Request HA» (http request) — выполняет GET-запрос к Home Assistant API.

Узел «Format Calendar Response» (function) — обрабатывает ответ, форматирует даты в русском стиле, отмечает выполненные задачи (✅) и невыполненные (◻️), подсвечивает просроченные задачи (🔴 и ⚠️), добавляет кнопки обновления и навигации.

function formatDateToRussian(dateString) { const months = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]; const date = new Date(dateString); return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`; } // Определение просрочки (МСК) const nowMsk = new Date(new Date().getTime() + 3 * 60 * 60 * 1000); const todayMskStr = nowMsk.toISOString().split('T')[0]; if (dueDate < todayMskStr && !isCompleted) { isOverdue = true; dueText = `🔴 (просрочено до ${formatDateToRussian(dueDate)})`; } // Иконка выполнения const checkBox = isCompleted ? "✅" : "◻️";
5.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените все плейсхолдеры [ВАШ_...] на свои значения.

[ { "id": "e19002771a4f43f9", "type": "group", "z": "d91b643aa7cd4cef", "name": "Задачи (не фулл)", "style": { "stroke": "#ffff00", "fill": "#ffff00", "label": true, "label-position": "sw", "color": "#000000" }, "nodes": [ "tasks_callback_router", "telegram_event_tasks", "tasks_main_menu", "tasks_by_project_menu", "tasks_get_calendar_events", "tasks_http_request", "tasks_format_calendar_response", "f7f6dc1e350e7d88" ], "x": 14, "y": 2059, "w": 1482, "h": 250 }, { "id": "tasks_callback_router", "type": "switch", "z": "d91b643aa7cd4cef", "g": "e19002771a4f43f9", "name": "маршрутизатор задач", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "eq", "v": "menu_tasks", "vt": "str"}, {"t": "eq", "v": "tasks_by_project", "vt": "str"}, {"t": "regex", "v": "tasks_by_project_.*", "vt": "str", "case": false} ], "checkall": "true", "repair": false, "outputs": 3, "x": 370, "y": 2120, "wires": [ ["tasks_main_menu"], ["tasks_by_project_menu"], ["tasks_get_calendar_events"] ] }, { "id": "telegram_event_tasks", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "e19002771a4f43f9", "name": "Обработчик задач", "bot": "[ID_BOT_CONFIG]", "event": "callback_query", "autoanswer": false, "x": 130, "y": 2160, "wires": [["tasks_callback_router"]] }, { "id": "tasks_main_menu", "type": "function", "z": "d91b643aa7cd4cef", "g": "e19002771a4f43f9", "name": "Tasks Main Menu", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"tasks_chat_id\", chatId);\nconst tasksKeyboard = { reply_markup: { inline_keyboard: [[{ text: \"🎯 По проектам\", callback_data: \"tasks_by_project\" }], [{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: \"✅ *Todoist задачи*\\n\\nПросмотр задач доступен через проекти:\", options: { parse_mode: \"Markdown\", reply_markup: tasksKeyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 670, "y": 2100, "wires": [["f7f6dc1e350e7d88"]] }, { "id": "tasks_by_project_menu", "type": "function", "z": "d91b643aa7cd4cef", "g": "e19002771a4f43f9", "name": "Tasks By Projects Menu", "func": "let chatId = global.get(\"tasks_chat_id\");\nconst projects = [\n { name: \"[НАЗВАНИЕ_ПРОЕКТА_1]\", emoji: \"[ЭМОДЗИ_1]\", entity: \"[CALENDAR_ENTITY_ID_1]\" },\n { name: \"[НАЗВАНИЕ_ПРОЕКТА_2]\", emoji: \"[ЭМОДЗИ_2]\", entity: \"[CALENDAR_ENTITY_ID_2]\" }\n];\nconst keyboard = [];\nprojects.forEach(project => { keyboard.push([{ text: `${project.emoji} ${project.name}`, callback_data: `tasks_by_project_${project.entity}` }]); });\nkeyboard.push([{ text: \"◀️ Назад к задачам\", callback_data: \"menu_tasks\" }]);\nmsg.payload = { chatId: chatId, type: \"message\", content: \"🎯 *Выберите проект:*\", options: { parse_mode: \"Markdown\", reply_markup: { inline_keyboard: keyboard } } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 750, "y": 2140, "wires": [["f7f6dc1e350e7d88"]] }, { "id": "tasks_get_calendar_events", "type": "function", "z": "d91b643aa7cd4cef", "g": "e19002771a4f43f9", "name": "Get Tasks from Calendar", "func": "const chatId = global.get(\"tasks_chat_id\");\nconst data = msg.payload.content;\nconst entityId = data.split(\"_\").pop();\nconst haUrl = \"[ВАШ_IP_HA]:8123\";\nconst haToken = \"[ВАШ_ДОЛГОСРОЧНЫЙ_ТОКЕН_HA]\";\nglobal.set(\"tasks_current_entity\", entityId);\nglobal.set(\"tasks_chat_id\", chatId);\nconst startDate = new Date();\nstartDate.setFullYear(2000, 0, 1);\nconst endDate = new Date();\nendDate.setFullYear(2099, 11, 31);\nmsg.url = `${haUrl}/api/calendars/${entityId}?start=${startDate.toISOString()}&end=${endDate.toISOString()}`;\nmsg.method = \"GET\";\nmsg.headers = { \"Authorization\": `Bearer ${haToken}`, \"Content-Type\": \"application/json\" };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 770, "y": 2220, "wires": [["tasks_http_request"]] }, { "id": "tasks_http_request", "type": "http request", "z": "d91b643aa7cd4cef", "g": "e19002771a4f43f9", "name": "HTTP Request HA", "method": "GET", "ret": "obj", "x": 990, "y": 2260, "wires": [["tasks_format_calendar_response"]] }, { "id": "tasks_format_calendar_response", "type": "function", "z": "d91b643aa7cd4cef", "g": "e19002771a4f43f9", "name": "Format Calendar Response", "func": "const chatId = global.get(\"tasks_chat_id\");\nlet events = msg.payload || [];\nif (events.events && Array.isArray(events.events)) events = events.events;\nif (!Array.isArray(events)) events = [];\nif (events.length === 0) {\n const emptyKeyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад к проектам\", callback_data: \"tasks_by_project\" }], [{ text: \"🏠 В главное меню\", callback_data: \"back_main\" }]] } };\n msg.payload = { chatId: chatId, type: \"message\", content: \"✅ Нет задач в этом проекте!\", options: { parse_mode: \"Markdown\", reply_markup: emptyKeyboard.reply_markup } };\n return msg;\n}\nfunction formatDateToRussian(dateString) {\n const months = [\"января\", \"февраля\", \"марта\", \"апреля\", \"мая\", \"июня\", \"июля\", \"августа\", \"сентября\", \"октября\", \"ноября\", \"декабря\"];\n const date = new Date(dateString);\n return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;\n}\nconst nowMsk = new Date(new Date().getTime() + 3 * 60 * 60 * 1000);\nconst todayMskStr = nowMsk.toISOString().split('T')[0];\nlet message = `📋 *Проект*\\n\\n`;\nlet taskNumber = 1;\nevents.forEach(event => {\n const content = event.summary || \"Без названия\";\n const isCompleted = (event.status === \"completed\" || event.completed === true);\n const checkBox = isCompleted ? \"✅\" : \"◻️\";\n let dueDate = null;\n if (event.start) {\n if (typeof event.start === \"string\") dueDate = event.start.split('T')[0];\n else if (event.start.date) dueDate = event.start.date;\n }\n let dueText = \"\", isOverdue = false;\n if (dueDate && !isCompleted) {\n if (dueDate < todayMskStr) {\n isOverdue = true;\n dueText = `🔴 (просрочено до ${formatDateToRussian(dueDate)})`;\n } else if (dueDate === todayMskStr) {\n dueText = `📅 СЕГОДНЯ (до ${formatDateToRussian(dueDate)})`;\n } else {\n dueText = `📅 до ${formatDateToRussian(dueDate)}`;\n }\n }\n message += `${taskNumber}. ${checkBox} ${content}\\n`;\n if (dueText) message += `${dueText}\\n`;\n if (isOverdue) message += `⚠️ Просрочено!\\n`;\n message += `\\n`;\n taskNumber++;\n});\nconst keyboard = [[{ text: \"🔄 Обновить список\", callback_data: `tasks_by_project_${global.get(\"tasks_current_entity\")}` }], [{ text: \"◀️ Назад к проектам\", callback_data: \"tasks_by_project\" }], [{ text: \"🏠 В главное меню\", callback_data: \"back_main\" }]];\nmsg.payload = { chatId: chatId, type: \"message\", content: message, options: { parse_mode: \"Markdown\", reply_markup: { inline_keyboard: keyboard } } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1220, "y": 2220, "wires": [["f7f6dc1e350e7d88"]] }, { "id": "f7f6dc1e350e7d88", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "e19002771a4f43f9", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "x": 1370, "y": 2120, "wires": [[]] } ]

6. Дни рождения (хранение в файле Node-RED)

Достаточно простенькая ветка по вносу/редактированию и удалению дат дней рождения, вносимых вручную через бота. Всё работает только через Node-RED, сохранение данных при перезагрузке контейнера хранится в файле birthdays.json, в который пишутся все данные и вытягиваются после перезапуска.

Схема управления днями рождения
Схема управления днями рождения
Меню месяцев
Меню месяцев
Список именинников
Список именинников
Добавление
Добавление
6.1 Пошаговая настройка

Общая архитектура

Этот поток реализует просмотр дней рождения по месяцам, добавление новых записей, редактирование существующих, удаление записей, автоматические ежедневные напоминания (в 09:00). Данные хранятся в JSON-файле /data/birthdays.json в файловой системе Node-RED.

Часть 1. Вход в меню дней рождения

1.1. Перехват callback (telegram event)

  • Тип узла: telegram event
  • Событие: callback_query
  • Привязан к боту ([ID_BOT_CONFIG])

1.2. Маршрутизатор ДР (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правила:
    • menu_birthdays → показать меню месяцев
    • birthdays_month_.* → показать именинников месяца
    • birthday_add → начать добавление
    • birthday_delete_.* → удалить запись
    • birthday_edit_.* → начать редактирование

Часть 2. Меню месяцев

Узел «Birthdays Menu» (function) — загружает данные из файла (при первом запуске), показывает список месяцев с количеством именинников, добавляет кнопку добавления и возврата.

const fs = global.get("fs"); const filePath = '/data/birthdays.json'; let birthdays = global.get("birthdays"); if (!birthdays || Object.keys(birthdays).length === 0) { try { if (fs.existsSync(filePath)) { const data = fs.readFileSync(filePath, 'utf8'); birthdays = JSON.parse(data); global.set("birthdays", birthdays); } } catch (err) { node.warn("Ошибка загрузки: " + err.message); } } // Подсчёт записей по месяцам const monthCounts = new Array(12).fill(0); for (const [name, data] of Object.entries(birthdays)) { if (data && data.date) { const month = parseInt(data.date.split(".")[1]) - 1; if (month >= 0 && month < 12) monthCounts[month]++; } } // Формирование меню с кнопками месяцев

Часть 3. Список именинников месяца

Узел «Get Birthdays by Month» (function) — фильтрует записи по выбранному месяцу, сортирует по дням, показывает возраст (если указан год), добавляет кнопки ✏️ Редактировать и 🗑️ Удалить для каждой записи.

function calculateAge(dateStr, year) { if (!year) return null; const today = new Date(); const birthDate = new Date(year, parseInt(dateStr.split(".")[1]) - 1, parseInt(dateStr.split(".")[0])); let age = today.getFullYear() - birthDate.getFullYear(); const m = today.getMonth() - birthDate.getMonth(); if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) age--; return age; } // Сортировка по дню месяца filtered.sort((a, b) => { const dayA = parseInt(a.date.split(".")[0]); const dayB = parseInt(b.date.split(".")[0]); return dayA - dayB; });

Часть 4. Добавление дня рождения

Узел «Add Birthday Dialog» (function) — устанавливает флаг ожидания ввода, показывает инструкцию по формату, добавляет кнопку отмены.

Формат ввода: Фамилия Имя Отчество | ДД.ММ | ГГГГ (год можно не указывать). Примеры: Иванова Анна Сергеевна | 10.03 | 1999, Петров Сергей | 15.03.

Часть 5. Обработка текстового ввода

Узел «Text Input Handler» (function) — центральный обработчик добавления и редактирования. Проверяет формат ввода, сохраняет/обновляет запись, сохраняет данные в JSON-файл, показывает успешный результат с действиями.

function saveBirthdays(data) { try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); node.warn("Данные сохранены в " + filePath); } catch (err) { node.warn("Ошибка сохранения: " + err.message); } } // Валидация формата if (!text.includes("|")) { /* ошибка */ } const parts = text.split("|").map(p => p.trim()); const fullName = parts[0]; const date = parts[1]; const year = parts[2] ? parseInt(parts[2]) : null; const dateRegex = /^\d{2}\.\d{2}$/; if (!dateRegex.test(date)) { /* ошибка */ }

Часть 6. Редактирование и удаление

Узел «Edit Birthday Dialog» (function) — устанавливает флаг ожидания редактирования, сохраняет старое имя для удаления при обновлении, добавляет кнопки «Удалить» и «Отмена».

Узел «Delete Birthday» (function) — удаляет запись из глобальной переменной, сохраняет обновлённые данные в файл, подтверждает удаление.

Часть 7. Автоматические напоминания

Cron-триггер (inject) — настройки: crontab: "00 09 * * *" (каждый день в 09:00).

Узел формирования напоминания (function) — находит все ДР в ближайшие 7 дней (с учётом перехода года), формирует красивое напоминание с указанием «СЕГОДНЯ!», «ЗАВТРА!» или «через N дней».

var today = new Date(); today.setHours(0, 0, 0, 0); var todayTime = today.getTime(); var upcoming = []; for (var name in birthdays) { var parts = data.date.split("."); var day = parseInt(parts[0]); var month = parseInt(parts[1]) - 1; var birthdayThisYear = new Date(today.getFullYear(), month, day); var birthdayTime = birthdayThisYear.getTime(); if (birthdayTime < todayTime) { birthdayThisYear = new Date(today.getFullYear() + 1, month, day); birthdayTime = birthdayThisYear.getTime(); } var diffDays = Math.ceil((birthdayTime - todayTime) / (1000 * 60 * 60 * 24)); if (diffDays >= 0 && diffDays <= 7) { upcoming.push({ name: name, date: data.date, days: diffDays, year: data.year }); } }
⚠️ Необходимая инициализация в settings.js
functionGlobalContext: {
    fs: require('fs')
}
6.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените [ID_BOT_CONFIG], [ВАШ_БОТ], [ВАШ_USERNAME], [IP_СЕРВЕРА] на свои значения.

[ { "id": "f811330c0e1eb8a5", "type": "group", "z": "d91b643aa7cd4cef", "name": "Дни рождения", "style": { "stroke": "#92d04f", "fill": "#92d04f", "label": true, "label-position": "sw", "color": "#000000" }, "nodes": [ "f5041138e2385cc7", "2c32ea40a69be537", "8d5ddc8b131a576f", "c2d56fa3506ee2ee", "3af710e543dc0c0d", "82877cab97683bf3", "3e5f23b5391c9235", "b04acdd5308acdc2", "1e91e0afa5b55e23", "9cc4ac57a3e0a3a1", "cron_notify", "notify_function" ], "x": 74, "y": 2359, "w": 1092, "h": 310 }, { "id": "f5041138e2385cc7", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Обработчик ДР", "bot": "[ID_BOT_CONFIG]", "event": "callback_query", "autoanswer": false, "x": 190, "y": 2480, "wires": [["2c32ea40a69be537"]] }, { "id": "2c32ea40a69be537", "type": "switch", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "маршрутизатор ДР", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "eq", "v": "menu_birthdays", "vt": "str"}, {"t": "regex", "v": "birthdays_month_.*", "vt": "str", "case": false}, {"t": "eq", "v": "birthday_add", "vt": "str"}, {"t": "regex", "v": "birthday_delete_.*", "vt": "str", "case": false}, {"t": "regex", "v": "birthday_edit_.*", "vt": "str", "case": false} ], "checkall": "true", "repair": false, "outputs": 5, "x": 430, "y": 2480, "wires": [ ["8d5ddc8b131a576f"], ["3af710e543dc0c0d"], ["82877cab97683bf3"], ["b04acdd5308acdc2"], ["1e91e0afa5b55e23"] ] }, { "id": "8d5ddc8b131a576f", "type": "function", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Birthdays Menu", "func": "const fs = global.get(\"fs\");\nconst filePath = '/data/birthdays.json';\nlet birthdays = global.get(\"birthdays\");\nif (!birthdays || Object.keys(birthdays).length === 0) {\n try {\n if (fs.existsSync(filePath)) {\n const data = fs.readFileSync(filePath, 'utf8');\n birthdays = JSON.parse(data);\n global.set(\"birthdays\", birthdays);\n }\n } catch (err) {}\n}\nbirthdays = global.get(\"birthdays\") || {};\nlet chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"birthdays_chat_id\", chatId);\nconst monthNames = [\"Январь\",\"Февраль\",\"Март\",\"Апрель\",\"Май\",\"Июнь\",\"Июль\",\"Август\",\"Сентябрь\",\"Октябрь\",\"Ноябрь\",\"Декабрь\"];\nconst monthCounts = new Array(12).fill(0);\nfor (const [name, data] of Object.entries(birthdays)) {\n if (data && data.date) {\n const month = parseInt(data.date.split(\".\")[1]) - 1;\n if (month >= 0 && month < 12) monthCounts[month]++;\n }\n}\nlet message = \"🎂 *Дни рождения*\\n\\n\";\nconst keyboard = [];\nfor (let i = 0; i < monthNames.length; i++) {\n const count = monthCounts[i];\n const displayCount = count > 0 ? ` (${count})` : \"\";\n message += `${i + 1}. ${monthNames[i]}${displayCount}\\n`;\n keyboard.push([{ text: `${monthNames[i]}${displayCount}`, callback_data: `birthdays_month_${i + 1}` }]);\n}\nkeyboard.push([{ text: \"➕ Добавить день рождения\", callback_data: \"birthday_add\" }]);\nkeyboard.push([{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]);\nmsg.payload = { chatId: chatId, type: \"message\", content: message, options: { parse_mode: \"Markdown\", reply_markup: { inline_keyboard: keyboard } } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 720, "y": 2400, "wires": [["c2d56fa3506ee2ee"]] }, { "id": "c2d56fa3506ee2ee", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "x": 1040, "y": 2480, "wires": [[]] }, { "id": "3af710e543dc0c0d", "type": "function", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Get Birthdays by Month", "func": "let chatId = global.get(\"birthdays_chat_id\");\nlet data = msg.payload.content;\nlet month = parseInt(data.split(\"_\").pop());\nlet birthdays = global.get(\"birthdays\") || {};\nlet filtered = [];\nfor (const [name, info] of Object.entries(birthdays)) {\n const infoMonth = parseInt(info.date.split(\".\")[1]);\n if (infoMonth === month) {\n filtered.push({ name: name, date: info.date, year: info.year || null });\n }\n}\nfiltered.sort((a, b) => {\n const dayA = parseInt(a.date.split(\".\")[0]);\n const dayB = parseInt(b.date.split(\".\")[0]);\n return dayA - dayB;\n});\nconst monthNames = [\"Январь\",\"Февраль\",\"Март\",\"Апрель\",\"Май\",\"Июнь\",\"Июль\",\"Август\",\"Сентябрь\",\"Октябрь\",\"Ноябрь\",\"Декабрь\"];\nfunction calculateAge(dateStr, year) {\n if (!year) return null;\n const today = new Date();\n const birthDate = new Date(year, parseInt(dateStr.split(\".\")[1]) - 1, parseInt(dateStr.split(\".\")[0]));\n let age = today.getFullYear() - birthDate.getFullYear();\n const m = today.getMonth() - birthDate.getMonth();\n if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) age--;\n return age;\n}\nif (filtered.length === 0) {\n const emptyKeyboard = { reply_markup: { inline_keyboard: [[{ text: \"➕ Добавить\", callback_data: \"birthday_add\" }], [{ text: \"◀️ Назад к месяцам\", callback_data: \"menu_birthdays\" }]] } };\n msg.payload = { chatId: chatId, type: \"message\", content: `📅 *${monthNames[month - 1]}*\\n\\n✅ Нет дней рождений в этом месяце.`, options: { parse_mode: \"Markdown\", reply_markup: emptyKeyboard.reply_markup } };\n return msg;\n}\nlet message = `📅 *${monthNames[month - 1]}*\\n\\n`;\nconst keyboard = [];\nfiltered.forEach(person => {\n const age = calculateAge(person.date, person.year);\n message += `🎂 ${person.date} — *${person.name}*`;\n if (age) message += ` (${age} лет)`;\n message += `\\n`;\n keyboard.push([\n { text: `✏️ ${person.name.substring(0, 20)}`, callback_data: `birthday_edit_${person.name}` },\n { text: `🗑️ Удалить`, callback_data: `birthday_delete_${person.name}` }\n ]);\n});\nmessage += `\\nВыберите действие:`;\nkeyboard.push([{ text: \"➕ Добавить\", callback_data: \"birthday_add\" }]);\nkeyboard.push([{ text: \"◀️ Назад к месяцам\", callback_data: \"menu_birthdays\" }]);\nmsg.payload = { chatId: chatId, type: \"message\", content: message, options: { parse_mode: \"Markdown\", reply_markup: { inline_keyboard: keyboard } } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 730, "y": 2440, "wires": [["c2d56fa3506ee2ee"]] }, { "id": "82877cab97683bf3", "type": "function", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Add Birthday Dialog", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"birthdays_chat_id\", chatId);\nglobal.set(\"birthdays_waiting_for\", \"birthday_data\");\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Отмена\", callback_data: \"menu_birthdays\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: \"✏️ *Добавление дня рождения*\\n\\nВведите данные в формате:\\n\\n`Фамилия Имя Отчество | ДД.ММ | ГГГГ`\\n\\n📌 *Примеры:*\\n`Иванова Анна Сергеевна | 10.03 | 1999`\\n`Петров Сергей | 15.03` (без отчества и года)\", options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 740, "y": 2480, "wires": [["c2d56fa3506ee2ee"]] }, { "id": "3e5f23b5391c9235", "type": "function", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Text Input Handler", "func": "const fs = global.get(\"fs\");\nconst filePath = '/data/birthdays.json';\nfunction saveBirthdays(data) { try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } catch (err) { node.warn(\"Ошибка сохранения: \" + err.message); } }\nlet birthdays = global.get(\"birthdays\") || {};\nif (global.get(\"birthdays_waiting_for\") === \"birthday_data\" && msg.payload.chatId === global.get(\"birthdays_chat_id\")) {\n const text = msg.payload.content;\n global.set(\"birthdays_waiting_for\", null);\n if (!text.includes(\"|\")) { /* ошибка */ return; }\n const parts = text.split(\"|\").map(p => p.trim());\n if (parts.length < 2 || !parts[0] || !parts[1]) { /* ошибка */ return; }\n let fullName = parts[0];\n const date = parts[1];\n const year = parts[2] ? parseInt(parts[2]) : null;\n const dateRegex = /^\\d{2}\\.\\d{2}$/;\n if (!dateRegex.test(date)) { /* ошибка */ return; }\n birthdays[fullName] = { date: date, year: year };\n global.set(\"birthdays\", birthdays);\n saveBirthdays(birthdays);\n const month = parseInt(date.split(\".\")[1]);\n const keyboard = { reply_markup: { inline_keyboard: [[{ text: \"📅 Посмотреть в этом месяце\", callback_data: `birthdays_month_${month}` }], [{ text: \"➕ Добавить еще\", callback_data: \"birthday_add\" }], [{ text: \"◀️ Назад к месяцам\", callback_data: \"menu_birthdays\" }]] } };\n const successMsg = { payload: { chatId: msg.payload.chatId, type: \"message\", content: `✅ День рождения *${fullName}* (${date}) добавлен!`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } } };\n node.send(successMsg);\n return;\n}\nif (global.get(\"birthdays_waiting_for\") === \"birthday_edit\" && msg.payload.chatId === global.get(\"birthdays_chat_id\")) {\n const text = msg.payload.content;\n const oldName = global.get(\"birthdays_edit_name\");\n global.set(\"birthdays_waiting_for\", null);\n if (!text.includes(\"|\")) { /* ошибка */ return; }\n const parts = text.split(\"|\").map(p => p.trim());\n if (parts.length < 2 || !parts[0] || !parts[1]) { /* ошибка */ return; }\n let fullName = parts[0];\n const date = parts[1];\n const year = parts[2] ? parseInt(parts[2]) : null;\n const dateRegex = /^\\d{2}\\.\\d{2}$/;\n if (!dateRegex.test(date)) { /* ошибка */ return; }\n delete birthdays[oldName];\n birthdays[fullName] = { date: date, year: year };\n global.set(\"birthdays\", birthdays);\n saveBirthdays(birthdays);\n const month = parseInt(date.split(\".\")[1]);\n const keyboard = { reply_markup: { inline_keyboard: [[{ text: \"📅 Посмотреть в этом месяце\", callback_data: `birthdays_month_${month}` }], [{ text: \"✏️ Редактировать другого\", callback_data: \"menu_birthdays\" }], [{ text: \"◀️ Назад к месяцам\", callback_data: \"menu_birthdays\" }]] } };\n const successMsg = { payload: { chatId: msg.payload.chatId, type: \"message\", content: `✅ День рождения *${fullName}* (${date}) обновлён!`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } } };\n node.send(successMsg);\n return;\n}\nreturn null;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 450, "y": 2560, "wires": [["c2d56fa3506ee2ee"]] }, { "id": "b04acdd5308acdc2", "type": "function", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Delete Birthday", "func": "const fs = global.get(\"fs\");\nconst filePath = '/data/birthdays.json';\nfunction saveBirthdays(data) { try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } catch (err) {} }\nlet chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nlet name = msg.payload.content.replace(\"birthday_delete_\", \"\");\nlet birthdays = global.get(\"birthdays\") || {};\nif (birthdays[name]) {\n delete birthdays[name];\n global.set(\"birthdays\", birthdays);\n saveBirthdays(birthdays);\n msg.payload = { chatId: chatId, type: \"message\", content: `✅ День рождения *${name}* удален!`, options: { parse_mode: \"Markdown\" } };\n} else {\n msg.payload = { chatId: chatId, type: \"message\", content: `❌ Запись для *${name}* не найдена.`, options: { parse_mode: \"Markdown\" } };\n}\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 740, "y": 2540, "wires": [["c2d56fa3506ee2ee"]] }, { "id": "1e91e0afa5b55e23", "type": "function", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Edit Birthday Dialog", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nlet name = msg.payload.content.replace(\"birthday_edit_\", \"\");\nglobal.set(\"birthdays_chat_id\", chatId);\nglobal.set(\"birthdays_waiting_for\", \"birthday_edit\");\nglobal.set(\"birthdays_edit_name\", name);\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"🗑️ Удалить\", callback_data: `birthday_delete_${name}` }], [{ text: \"◀️ Отмена\", callback_data: \"menu_birthdays\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: `✏️ *Редактирование дня рождения*\\n\\nТекущие данные для *${name}*.\\n\\nВведите новые данные в формате:\\n\\n\\`Фамилия Имя Отчество | ДД.ММ | ГГГГ\\``, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 700, "y": 2580, "wires": [["c2d56fa3506ee2ee"]] }, { "id": "9cc4ac57a3e0a3a1", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Обработчик сообщений", "bot": "[ID_BOT_CONFIG]", "event": "message", "autoanswer": false, "x": 210, "y": 2560, "wires": [["3e5f23b5391c9235"]] }, { "id": "cron_notify", "type": "inject", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Напоминания", "crontab": "00 09 * * *", "once": false, "x": 210, "y": 2620, "wires": [["notify_function"]] }, { "id": "notify_function", "type": "function", "z": "d91b643aa7cd4cef", "g": "f811330c0e1eb8a5", "name": "Формирование напоминания", "func": "var birthdays = global.get(\"birthdays\") || {};\nvar chatId = global.get(\"birthdays_chat_id\");\nif (!chatId) return null;\nvar today = new Date();\ntoday.setHours(0, 0, 0, 0);\nvar todayTime = today.getTime();\nvar upcoming = [];\nfor (var name in birthdays) {\n var data = birthdays[name];\n if (!data || !data.date) continue;\n var parts = data.date.split(\".\");\n var day = parseInt(parts[0]);\n var month = parseInt(parts[1]) - 1;\n var birthdayThisYear = new Date(today.getFullYear(), month, day);\n var birthdayTime = birthdayThisYear.getTime();\n if (birthdayTime < todayTime) {\n birthdayThisYear = new Date(today.getFullYear() + 1, month, day);\n birthdayTime = birthdayThisYear.getTime();\n }\n var diffDays = Math.ceil((birthdayTime - todayTime) / (1000 * 60 * 60 * 24));\n if (diffDays >= 0 && diffDays <= 7) {\n upcoming.push({ name: name, date: data.date, days: diffDays, year: data.year });\n }\n}\nif (upcoming.length > 0) {\n upcoming.sort(function(a, b) { return a.days - b.days; });\n var message = \"🎉 *Напоминание о днях рождения!*\\n\\n\";\n for (var i = 0; i < upcoming.length; i++) {\n var person = upcoming[i];\n var daysText = person.days === 0 ? \"🎉 СЕГОДНЯ! 🎉\" : (person.days === 1 ? \"📅 ЗАВТРА!\" : \"📅 через \" + person.days + \" дня\");\n var ageText = person.year ? \" — \" + (new Date().getFullYear() - person.year) + \" лет\" : \"\";\n message += \"🎂 \" + person.date + \" — *\" + person.name + \"*\" + ageText + \"\\n \" + daysText + \"\\n\\n\";\n }\n msg.payload = { chatId: chatId, type: \"message\", content: message, options: { parse_mode: \"Markdown\" } };\n return msg;\n}\nreturn null;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 360, "y": 2620, "wires": [["c2d56fa3506ee2ee"]] }, { "id": "[ID_BOT_CONFIG]", "type": "telegram bot", "botname": "[ВАШ_БОТ]", "usernames": "[ВАШ_USERNAME]", "updatemode": "polling", "pollinterval": "300", "localbothost": "[IP_СЕРВЕРА]", "localbotport": 8443 }, { "id": "ef2ca67c0771fa6d", "type": "global-config", "env": [], "modules": { "node-red-contrib-telegrambot": "17.4.12" } } ]

7. Вызов сценария медитации

Ранее был реализован на другом потоке вызов сценария медитации. Здесь сделана простенькая ветка вызова с телефона без обращения к Алисе.

Схема вызова медитации
Схема вызова сценария медитации
Запуск
Запуск медитации
7.1 Пошаговая настройка

Общая архитектура

Этот поток позволяет открыть меню медитации, запустить сценарий релаксации (приглушение света, включение музыки), получить подтверждение о начале медитации через 1 минуту.

Ключевая особенность: Использует link call для запуска автоматизации в Home Assistant (отправляет команду в другой поток).

Часть 1. Вход в меню медитации

1.1. Перехват callback (telegram event)

  • Тип узла: telegram event
  • Событие: callback_query
  • Привязан к боту ([ID_BOT_CONFIG])
  • autoanswer: false

1.2. Маршрутизатор (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правила:
    • menu_meditation → показать меню медитации
    • meditation_start → запустить сценарий

Часть 2. Меню медитации

Узел «Meditation Menu» (function) — показывает меню с кнопкой «Начать медитацию» и даёт инструкции пользователю.

let chatId = msg.payload.chatId || msg.payload.message?.chat?.id; const meditationKeyboard = { reply_markup: { inline_keyboard: [ [{ text: "🧘 Начать медитацию", callback_data: "meditation_start" }], [{ text: "◀️ Назад в меню", callback_data: "back_main" }] ] } }; msg.payload = { chatId: chatId, type: "message", content: "🧘 *Медитация*\n\nНастройте свет, включите спокойную музыку и начните практику.\n\nНажмите кнопку ниже, чтобы запустить сценарий отдыха:", options: { parse_mode: "Markdown", reply_markup: meditationKeyboard.reply_markup } }; return msg;

Часть 3. Запуск медитации

Узел «Start Meditation» (function) — сохраняет chatId в глобальную переменную, формирует команду { command: "start_meditation" } для передачи в другой поток.

let chatId = msg.payload.chatId || msg.payload.message?.chat?.id; global.set("meditation_chat_id", chatId); msg.payload = { command: "start_meditation" }; return msg;

Узел «Call Meditation Flow» (link call) — отправляет команду в другой поток Node-RED. Настройки: links: ["[ID_LINK_IN]"], timeout: "30" секунд.

🔗 Примечание: Узел link in с ID [ID_LINK_IN] должен существовать в другом месте конфигурации (автоматизация Home Assistant).

Узел «Задержка 1 минута» (delay) — ожидает 1 минуту перед отправкой подтверждения пользователю. Это даёт время на выполнение автоматизации в Home Assistant.

Узел «Meditation Response» (function) — через 1 минуту после нажатия кнопки отправляет подтверждение, что свет приглушён и музыка играет, с кнопкой возврата в главное меню.

const chatId = global.get("meditation_chat_id"); if (!chatId) return null; const keyboard = { reply_markup: { inline_keyboard: [[{ text: "◀️ Назад в меню", callback_data: "back_main" }]] } }; msg.payload = { chatId: chatId, type: "message", content: "🧘 *Медитация началась!*\n\nСвет приглушен, музыка играет.\nНаслаждайтесь моментом покоя.", options: { parse_mode: "Markdown", reply_markup: keyboard.reply_markup } }; return msg;
7.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените [ID_BOT_CONFIG], [ID_LINK_IN], [ВАШ_БОТ], [ВАШ_USERNAME], [IP_СЕРВЕРА] на свои значения.

[ { "id": "1e184013dbafdd3b", "type": "group", "z": "d91b643aa7cd4cef", "name": "Медитация", "style": { "stroke": "#92d04f", "fill": "#92d04f", "label": true, "label-position": "sw", "color": "#001f60" }, "nodes": [ "8fccb91f87903b54", "37acdb85f753c54d", "dc849ead1bb224e1", "78370be4dc08e199", "ec8ca1c0ac2be3c8", "80a3e6e0984c2deb", "2ac8f632d0448c1f", "f04bb2ec130f0035" ], "x": 54, "y": 1539, "w": 1212, "h": 170 }, { "id": "8fccb91f87903b54", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "1e184013dbafdd3b", "name": "Обработчик нажатия кнопок", "bot": "[ID_BOT_CONFIG]", "event": "callback_query", "autoanswer": false, "x": 210, "y": 1580, "wires": [["37acdb85f753c54d"]] }, { "id": "37acdb85f753c54d", "type": "switch", "z": "d91b643aa7cd4cef", "g": "1e184013dbafdd3b", "name": "маршрутизатор menu", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "eq", "v": "menu_meditation", "vt": "str"}, {"t": "eq", "v": "meditation_start", "vt": "str"} ], "checkall": "true", "repair": false, "outputs": 2, "x": 470, "y": 1580, "wires": [ ["dc849ead1bb224e1"], ["78370be4dc08e199"] ] }, { "id": "dc849ead1bb224e1", "type": "function", "z": "d91b643aa7cd4cef", "g": "1e184013dbafdd3b", "name": "Meditation Menu", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nconst meditationKeyboard = { reply_markup: { inline_keyboard: [[{ text: \"🧘 Начать медитацию\", callback_data: \"meditation_start\" }], [{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: \"🧘 *Медитация*\\n\\nНастройте свет, включите спокойную музыку и начните практику.\\n\\nНажмите кнопку ниже, чтобы запустить сценарий отдыха:\", options: { parse_mode: \"Markdown\", reply_markup: meditationKeyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 710, "y": 1580, "wires": [["80a3e6e0984c2deb"]] }, { "id": "78370be4dc08e199", "type": "function", "z": "d91b643aa7cd4cef", "g": "1e184013dbafdd3b", "name": "Start Meditation", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"meditation_chat_id\", chatId);\nmsg.payload = { command: \"start_meditation\" };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 700, "y": 1620, "wires": [ ["ec8ca1c0ac2be3c8"], ["f04bb2ec130f0035"] ] }, { "id": "ec8ca1c0ac2be3c8", "type": "link call", "z": "d91b643aa7cd4cef", "g": "1e184013dbafdd3b", "name": "Call Meditation Flow", "links": ["[ID_LINK_IN]"], "linkType": "static", "timeout": "30", "x": 900, "y": 1620, "wires": [[]] }, { "id": "80a3e6e0984c2deb", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "1e184013dbafdd3b", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "x": 1140, "y": 1580, "wires": [[]] }, { "id": "2ac8f632d0448c1f", "type": "function", "z": "d91b643aa7cd4cef", "g": "1e184013dbafdd3b", "name": "Meditation Response", "func": "const chatId = global.get(\"meditation_chat_id\");\nif (!chatId) return null;\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: \"🧘 *Медитация началась!*\\n\\nСвет приглушен, музыка играет.\\nНаслаждайтесь моментом покоя.\", options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1120, "y": 1660, "wires": [["80a3e6e0984c2deb"]] }, { "id": "f04bb2ec130f0035", "type": "delay", "z": "d91b643aa7cd4cef", "g": "1e184013dbafdd3b", "name": "Задержка 1 минута", "pauseType": "delay", "timeout": "1", "timeoutUnits": "minutes", "x": 900, "y": 1660, "wires": [["2ac8f632d0448c1f"]] }, { "id": "[ID_BOT_CONFIG]", "type": "telegram bot", "botname": "[ВАШ_БОТ]", "usernames": "[ВАШ_USERNAME]", "updatemode": "polling", "pollinterval": "300", "localbothost": "[IP_СЕРВЕРА]", "localbotport": 8443 }, { "id": "f9ec15a2562cf4e4", "type": "global-config", "env": [], "modules": { "node-red-contrib-telegrambot": "17.4.12" } } ]

8. Погода с рекомендациями

Максимально простой сценарий, который выводит погоду на улице и даёт рекомендации: например, взять зонтик или SPF-крем. Погода берётся из встроенной интеграции Home Assistant, но можно сделать интеграцию с Яндекс/Гисметео и т.д.

Схема погоды
Схема получения погоды и генерации рекомендаций
Погода в Telegram
Прогноз погоды с рекомендациями
8.1 Пошаговая настройка

Общая архитектура

Этот поток позволяет получить текущую погоду из Home Assistant, отобразить температуру, влажность, ветер, давление, облачность, УФ-индекс, получить персонализированные рекомендации на основе погодных условий и конвертировать давление из гПа в мм рт. ст.

Часть 1. Вход в меню погоды

1.1. Перехват callback (telegram event)

  • Тип узла: telegram event
  • Событие: callback_query
  • Привязан к боту ([ID_BOT_CONFIG])
  • autoanswer: false

1.2. Маршрутизатор (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правило: menu_weather → показать погоду

Часть 2. Инициализация

Узел «Init погода» (function) — сохраняет chatId в глобальную переменную для последующей отправки ответа.

let chatId = msg.payload.chatId || msg.payload.message?.chat?.id; global.set("weather_chat_id", chatId); return msg;

Часть 3. Получение данных погоды

Узел «Get Погода» (api-current-state) — запрашивает текущее состояние погоды из Home Assistant.

  • Entity ID: weather.forecast_home_assistant (ваш погодный сервис)
  • Выходные поля: msg.payload — состояние (sunny, rainy и т.д.), msg.data.attributes — детальные параметры

Пример ответа от HA:

{ "payload": "sunny", "data": { "attributes": { "temperature": 22.5, "humidity": 65, "wind_speed": 12, "pressure": 1013, "cloud_coverage": 20, "uv_index": 5 } } }

Часть 4. Анализ и форматирование

Узел «Send погода» (function) — выполняет извлечение данных, конвертацию давления из гПа в мм рт. ст., генерацию рекомендаций и формирование сообщения.

const temp = msg.data.attributes.temperature; const humidity = msg.data.attributes.humidity; const wind = msg.data.attributes.wind_speed; const pressure_hpa = msg.data.attributes.pressure; const cloud = msg.data.attributes.cloud_coverage; const uv = msg.data.attributes.uv_index; const condition = msg.payload; // Конвертация давления: гПа → мм рт. ст. const pressure_mm = (pressure_hpa * 0.750062).toFixed(1); let recommendations = []; if (condition === "rainy") recommendations.push("☔ Возьмите зонт — на улице дождь!"); if (temp > 25) recommendations.push("🧴 Жарко! Используйте солнцезащитный крем и пейте воду."); if (temp < 10) recommendations.push("🧥 Холодно! Наденьте куртку."); if (temp < 0) recommendations.push("🧣 Мороз! Шапка и перчатки обязательны."); if (humidity > 70) recommendations.push("💧 Высокая влажность — может быть душно."); if (wind > 15) recommendations.push("💨 Сильный ветер! Будьте осторожны."); if (pressure_hpa < 1000) recommendations.push("📉 Низкое давление — возможна головная боль."); if (pressure_hpa > 1030) recommendations.push("📈 Высокое давление — метеозависимым быть внимательнее."); if (uv > 5) recommendations.push("🧴 Высокий УФ-индекс! Обязательно используйте солнцезащитный крем."); if (cloud > 80 && condition !== "rainy") recommendations.push("☁️ Пасмурно, солнца не будет."); if (temp >= 18 && temp <= 25 && condition !== "rainy" && condition !== "cloudy") recommendations.push("🌿 Отличная погода для прогулки!"); if (recommendations.length === 0) recommendations.push("✅ Погода спокойная. Хорошего дня!");

Полный список рекомендаций

  • ☔ Дождь → «Возьмите зонт»
  • ❄️ Снег → «Идет снег, оденьтесь теплее»
  • 🕶️ Солнечно → «Не забудьте очки и крем»
  • 🧴 Температура > 25°C → «Пейте воду, используйте крем»
  • 🧥 Температура < 10°C → «Наденьте куртку»
  • 🧣 Температура < 0°C → «Шапка и перчатки обязательны»
  • 💧 Влажность > 70% → «Может быть душно»
  • 🏜️ Влажность < 30% → «Увлажните воздух дома»
  • 💨 Ветер > 15 км/ч → «Будьте осторожны»
  • ⚠️ Ветер > 30 км/ч → «Лучше оставаться дома»
  • 📉 Давление < 1000 гПа → «Возможна головная боль»
  • 📈 Давление > 1030 гПа → «Метеозависимым быть внимательнее»
  • 🧴 УФ-индекс > 5 → «Обязательно используйте крем»
  • ☁️ Облачность > 80% → «Солнца не будет»
  • 🌿 Температура 18-25°C, без дождя и облачности → «Отличная погода для прогулки»
8.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените [ID_BOT_CONFIG], [ID_HA_SERVER], [ВАШ_БОТ], [ВАШ_USERNAME], [IP_СЕРВЕРА] на свои значения.

[ { "id": "9840988744a3239e", "type": "group", "z": "d91b643aa7cd4cef", "name": "Погода", "style": { "stroke": "#92d04f", "fill": "#92d04f", "label": true, "label-position": "sw", "color": "#000000" }, "nodes": [ "f357a12ea8af31ce", "06e0c7e3fba12102", "198e4eb80d4e4577", "cd0c09ad47743b6f", "afb611c40f5d9824", "ce786ab16fe372d6" ], "x": 134, "y": 459, "w": 1272, "h": 90 }, { "id": "f357a12ea8af31ce", "type": "switch", "z": "d91b643aa7cd4cef", "g": "9840988744a3239e", "name": "маршрутизатор погода", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "eq", "v": "menu_weather", "vt": "str"} ], "checkall": "true", "repair": false, "outputs": 1, "x": 550, "y": 500, "wires": [["198e4eb80d4e4577"]] }, { "id": "06e0c7e3fba12102", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "9840988744a3239e", "name": "Обработчик нажатия кнопок", "bot": "[ID_BOT_CONFIG]", "event": "callback_query", "autoanswer": false, "x": 290, "y": 500, "wires": [["f357a12ea8af31ce"]] }, { "id": "198e4eb80d4e4577", "type": "function", "z": "d91b643aa7cd4cef", "g": "9840988744a3239e", "name": "Init погода", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"weather_chat_id\", chatId);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 750, "y": 500, "wires": [["cd0c09ad47743b6f"]] }, { "id": "cd0c09ad47743b6f", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "9840988744a3239e", "name": "Get Погода", "server": "[ID_HA_SERVER]", "entity_id": "weather.forecast_home_assistant", "outputProperties": [ { "property": "payload", "propertyType": "msg", "value": "string", "valueType": "entityState" }, { "property": "data", "propertyType": "msg", "value": "", "valueType": "entity" } ], "x": 910, "y": 500, "wires": [["afb611c40f5d9824"]] }, { "id": "afb611c40f5d9824", "type": "function", "z": "d91b643aa7cd4cef", "g": "9840988744a3239e", "name": "Send погода", "func": "let chatId = global.get(\"weather_chat_id\");\nif (!chatId) return null;\nconst temp = msg.data.attributes.temperature;\nconst humidity = msg.data.attributes.humidity;\nconst wind = msg.data.attributes.wind_speed;\nconst pressure_hpa = msg.data.attributes.pressure;\nconst cloud = msg.data.attributes.cloud_coverage;\nconst uv = msg.data.attributes.uv_index;\nconst condition = msg.payload;\nconst pressure_mm = (pressure_hpa * 0.750062).toFixed(1);\nlet rain = \"no\";\nif (condition === \"rainy\" || condition === \"pouring\" || condition === \"rain\") rain = \"rain\";\nelse if (condition === \"snowy\" || condition === \"snow\") rain = \"snow\";\nlet weatherIcon = \"🌤️\";\nif (condition === \"sunny\" || condition === \"clear\") weatherIcon = \"☀️\";\nelse if (condition === \"rainy\") weatherIcon = \"☔\";\nelse if (condition === \"snowy\") weatherIcon = \"❄️\";\nelse if (condition === \"cloudy\") weatherIcon = \"☁️\";\nelse if (condition === \"foggy\") weatherIcon = \"🌫️\";\nlet recommendations = [];\nif (rain === \"rain\") recommendations.push(\"☔ Возьмите зонт — на улице дождь!\");\nelse if (rain === \"snow\") recommendations.push(\"❄️ Идет снег, оденьтесь теплее!\");\nif (condition === \"sunny\" || condition === \"clear\") recommendations.push(\"🕶️ Солнечно! Не забудьте очки и крем от загара.\");\nif (temp > 25) recommendations.push(\"🧴 Жарко! Используйте солнцезащитный крем и пейте воду.\");\nif (temp < 10) recommendations.push(\"🧥 Холодно! Наденьте куртку.\");\nif (temp < 0) recommendations.push(\"🧣 Мороз! Шапка и перчатки обязательны.\");\nif (humidity > 70) recommendations.push(\"💧 Высокая влажность — может быть душно.\");\nelse if (humidity < 30) recommendations.push(\"🏜️ Низкая влажность — увлажните воздух дома.\");\nif (wind > 15) recommendations.push(\"💨 Сильный ветер! Будьте осторожны.\");\nif (wind > 30) recommendations.push(\"⚠️ Очень сильный ветер — лучше оставаться дома.\");\nif (pressure_hpa < 1000) recommendations.push(\"📉 Низкое давление — возможна головная боль.\");\nelse if (pressure_hpa > 1030) recommendations.push(\"📈 Высокое давление — метеозависимым быть внимательнее.\");\nif (uv > 5) recommendations.push(\"🧴 Высокий УФ-индекс! Обязательно используйте солнцезащитный крем.\");\nif (cloud > 80 && rain !== \"rain\") recommendations.push(\"☁️ Пасмурно, солнца не будет.\");\nif (temp >= 18 && temp <= 25 && rain !== \"rain\" && condition !== \"cloudy\") recommendations.push(\"🌿 Отличная погода для прогулки!\");\nif (recommendations.length === 0) recommendations.push(\"✅ Погода спокойная. Хорошего дня!\");\nlet message = `${weatherIcon} *Погода на улице*\\n\\n`;\nmessage += `🌡️ Температура: ${temp}°C\\n`;\nmessage += `💧 Влажность: ${humidity}%\\n`;\nmessage += `💨 Ветер: ${wind} км/ч\\n`;\nmessage += `📊 Давление: ${pressure_mm} мм рт. ст.\\n`;\nmessage += `☁️ Облачность: ${cloud}%\\n`;\nif (uv > 0) message += `☀️ УФ-индекс: ${uv}\\n`;\nmessage += `🌧️ Осадки: ${rain === \"rain\" ? \"Дождь\" : (rain === \"snow\" ? \"Снег\" : \"Нет\")}\\n\\n`;\nmessage += `*📋 Рекомендации:*\\n`;\nmessage += recommendations.join(\"\\n\");\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]] } };\nglobal.set(\"weather_chat_id\", null);\nmsg.payload = { chatId: chatId, type: \"message\", content: message, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1070, "y": 500, "wires": [["ce786ab16fe372d6"]] }, { "id": "ce786ab16fe372d6", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "9840988744a3239e", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "x": 1280, "y": 500, "wires": [[]] }, { "id": "[ID_BOT_CONFIG]", "type": "telegram bot", "botname": "[ВАШ_БОТ]", "usernames": "[ВАШ_USERNAME]", "updatemode": "polling", "pollinterval": "300", "localbothost": "[IP_СЕРВЕРА]", "localbotport": 8443 }, { "id": "[ID_HA_SERVER]", "type": "server", "name": "Home Assistant", "version": 6, "addon": false }, { "id": "0f9f74fa04aeb272", "type": "global-config", "env": [], "modules": { "node-red-contrib-telegrambot": "17.4.12", "node-red-contrib-home-assistant-websocket": "0.80.3" } } ]

9. Мониторинг датчиков и сенсоров

Ветка для мониторинга показаний всех датчиков и сенсоров, которые есть в доме. В данном случае это окна, лампочки, датчик BME680 (температура, влажность, давление, качество воздуха), а также PIR-датчик движения.

Схема мониторинга
Схема мониторинга датчиков и сенсоров
Меню мониторинга
Меню мониторинга
Телеметрия
Телеметрия
9.1 Пошаговая настройка

Схема потока (укрупнённо)

  • Главное меню → Мониторинг → Три основных направления:
    • Телеметрия (monitor_telemetry) → меню выбора показателя → запрос конкретного датчика → ответ
    • PIR датчик (monitor_pir) → сохранение chatId → запрос состояния → ответ
    • Окно (monitor_window) → сохранение chatId → запрос состояния → ответ

Часть 1. Вход в меню мониторинга

1.1. Перехват callback (telegram event)

  • Тип узла: telegram event
  • Событие: callback_query
  • Привязан к боту ([ID_BOT_CONFIG])

1.2. Маршрутизатор мониторинга (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правила:
    • monitor_telemetry → меню телеметрии
    • monitor_pir → PIR датчик движения
    • monitor_window → состояние окна
    • sensor_temperature → температура
    • sensor_humidity → влажность
    • sensor_pressure → давление
    • sensor_iaq → качество воздуха (IAQ)
    • menu_monitoring → главное меню мониторинга

Часть 2. Главное меню мониторинга

Узел «Monitoring Menu» (function) — показывает главное меню мониторинга с тремя категориями: Телеметрия, PIR датчик, Окно.

let chatId = msg.payload.chatId || msg.payload.message?.chat?.id; const monitoringKeyboard = { reply_markup: { inline_keyboard: [ [{ text: "🌡️ Телеметрия", callback_data: "monitor_telemetry" }], [{ text: "🚶 PIR (Датчик движения)", callback_data: "monitor_pir" }], [{ text: "🪟 Окно", callback_data: "monitor_window" }], [{ text: "◀️ Назад в меню", callback_data: "back_main" }] ] } }; msg.payload = { chatId: chatId, type: "message", content: "📊 *Мониторинг*\nВыберите категорию:", options: { parse_mode: "Markdown", reply_markup: monitoringKeyboard.reply_markup } }; return msg;

Часть 3. Телеметрия (датчики окружающей среды)

Узел «Telemetry Menu» (function) — показывает меню выбора конкретного датчика телеметрии: температура, влажность, давление, качество воздуха (IAQ).

Запрос температуры:

  • Init Temperature → сохраняет chatId
  • Get Temperature (api-current-state) → entity_id: sensor.test_product_temperatura
  • Send температура → форматирует ответ с кнопкой «Назад в телеметрию»

Запрос влажности: аналогично, entity_id: sensor.test_product_vlazhnost

Запрос давления: аналогично, entity_id: sensor.test_product_davlenie

Запрос качества воздуха (IAQ):

  • Init IAQ → сохраняет chatId
  • Get IAQ (api-current-state) → entity_id: sensor.test_product_kachestvo_vozdukha
  • Send IAQ → расшифровывает значения и показывает цветовую индикацию
let iaqText = ""; const iaqValue = String(msg.payload).toLowerCase(); if (iaqValue === "good" || iaqValue === "excellent") iaqText = "🟢 Отличное"; else if (iaqValue === "moderate" || iaqValue === "fair") iaqText = "🟡 Умеренное"; else if (iaqValue === "poor" || iaqValue === "unhealthy") iaqText = "🟠 Плохое"; else if (iaqValue === "very poor" || iaqValue === "bad" || iaqValue === "very unhealthy") iaqText = "🔴 Очень плохое"; else iaqText = msg.payload;

Часть 4. PIR датчик движения

Init PIR (function) — сохраняет chatId в глобальную переменную.

Get PIR (api-current-state) — entity_id: binary_sensor.test_product_zaniatost

Send PIR (function) — формирует ответ с иконкой 🚶‍♂️/💤 и статусом «Детектировано движение» / «Нет движения».

let state = msg.payload; let status = state === "on" ? "🔴 Детектировано движение" : "⚫ Нет движения"; let icon = state === "on" ? "🚶‍♂️" : "💤"; const keyboard = { reply_markup: { inline_keyboard: [[{ text: "◀️ Назад в мониторинг", callback_data: "menu_monitoring" }]] } }; msg.payload = { chatId: chatId, type: "message", content: `${icon} *PIR Датчик движения:*\n${status}`, options: { parse_mode: "Markdown", reply_markup: keyboard.reply_markup } }; global.set("pir_chat_id", null);

Часть 5. Датчик окна

Init Window (function) — сохраняет chatId.

Get Window (api-current-state) — entity_id: binary_sensor.test_product_dver_1

Send Window (function) — формирует ответ с иконкой ⚠️/✅ и статусом «Открыто» / «Закрыто».

9.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените [ID_BOT_CONFIG], [ID_HA_SERVER], [ВАШ_БОТ], [ВАШ_USERNAME], [IP_СЕРВЕРА] на свои значения.

[ { "id": "40709ee4bf606d70", "type": "group", "z": "d91b643aa7cd4cef", "name": "Мониторинг", "style": { "stroke": "#92d04f", "fill": "#92d04f", "label": true, "label-position": "sw", "color": "#000000" }, "nodes": [ "c02b1bb4ad8d2e09", "4e9afbc910a02ab8", "ab02b6f1fea816e8", "db3db795046abbc9", "0d4eb56076d0399c", "cd4dbf063a566602", "abe9f8b03d91c29b", "3c7b031394b504b4", "934b95c49a6d60c4", "395a5bc4df7a7ea3", "01881c0fe29e38ed", "6257721be80c11d0", "51f07c88e8c27a53", "20eb7c78346b83f9", "45ce9898c0c48b11", "cfde414470594a31", "d261af2ad13a9a31", "2358f1f5037610f2", "14283ab4cc1ed28b", "c7a764c75a822b04", "318a4dee4563cb3c", "0c9e1265af100a13", "f1827f9626cf94c1" ], "x": 34, "y": 579, "w": 1552, "h": 330 }, { "id": "c02b1bb4ad8d2e09", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Get Temperature", "server": "[ID_HA_SERVER]", "entity_id": "sensor.test_product_temperatura", "x": 930, "y": 660, "wires": [["20eb7c78346b83f9"]] }, { "id": "4e9afbc910a02ab8", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Get Humidity", "server": "[ID_HA_SERVER]", "entity_id": "sensor.test_product_vlazhnost", "x": 910, "y": 700, "wires": [["45ce9898c0c48b11"]] }, { "id": "ab02b6f1fea816e8", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Get Pressure", "server": "[ID_HA_SERVER]", "entity_id": "sensor.test_product_davlenie", "x": 910, "y": 740, "wires": [["cfde414470594a31"]] }, { "id": "db3db795046abbc9", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Get IAQ", "server": "[ID_HA_SERVER]", "entity_id": "sensor.test_product_kachestvo_vozdukha", "x": 900, "y": 780, "wires": [["d261af2ad13a9a31"]] }, { "id": "0d4eb56076d0399c", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Get PIR", "server": "[ID_HA_SERVER]", "entity_id": "binary_sensor.test_product_zaniatost", "x": 1020, "y": 820, "wires": [["abe9f8b03d91c29b"]] }, { "id": "cd4dbf063a566602", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Get Window", "server": "[ID_HA_SERVER]", "entity_id": "binary_sensor.test_product_dver_1", "x": 1030, "y": 860, "wires": [["3c7b031394b504b4"]] }, { "id": "abe9f8b03d91c29b", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Send PIR", "func": "let chatId = global.get(\"pir_chat_id\");\nif (!chatId) { chatId = msg.originalMessage?.chat?.id || msg.chatId; }\nif (!chatId) return null;\nlet state = msg.payload;\nlet status = state === \"on\" ? \"🔴 Детектировано движение\" : \"⚫ Нет движения\";\nlet icon = state === \"on\" ? \"🚶‍♂️\" : \"💤\";\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад в мониторинг\", callback_data: \"menu_monitoring\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: `${icon} *PIR Датчик движения:*\\n${status}`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nglobal.set(\"pir_chat_id\", null);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1160, "y": 820, "wires": [["01881c0fe29e38ed"]] }, { "id": "3c7b031394b504b4", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Send Window", "func": "let chatId = global.get(\"window_chat_id\");\nif (!chatId) { chatId = msg.originalMessage?.chat?.id || msg.chatId; }\nif (!chatId) return null;\nlet state = msg.payload;\nlet status = state === \"on\" ? \"🪟 Открыто\" : \"🔒 Закрыто\";\nlet icon = state === \"on\" ? \"⚠️\" : \"✅\";\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад в мониторинг\", callback_data: \"menu_monitoring\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: `${icon} *Окно:* ${status}`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nglobal.set(\"window_chat_id\", null);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1200, "y": 860, "wires": [["01881c0fe29e38ed"]] }, { "id": "934b95c49a6d60c4", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Обработчик нажатия кнопок", "bot": "[ID_BOT_CONFIG]", "event": "callback_query", "autoanswer": false, "x": 190, "y": 680, "wires": [["395a5bc4df7a7ea3"]] }, { "id": "395a5bc4df7a7ea3", "type": "switch", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "маршрутизатор monitoring", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "eq", "v": "monitor_telemetry", "vt": "str"}, {"t": "eq", "v": "monitor_pir", "vt": "str"}, {"t": "eq", "v": "monitor_window", "vt": "str"}, {"t": "eq", "v": "sensor_temperature", "vt": "str"}, {"t": "eq", "v": "sensor_humidity", "vt": "str"}, {"t": "eq", "v": "sensor_pressure", "vt": "str"}, {"t": "eq", "v": "sensor_iaq", "vt": "str"}, {"t": "eq", "v": "menu_monitoring", "vt": "str"} ], "checkall": "true", "repair": false, "outputs": 8, "x": 460, "y": 680, "wires": [ ["2358f1f5037610f2"], ["6257721be80c11d0"], ["51f07c88e8c27a53"], ["14283ab4cc1ed28b"], ["c7a764c75a822b04"], ["318a4dee4563cb3c"], ["0c9e1265af100a13"], ["f1827f9626cf94c1"] ] }, { "id": "01881c0fe29e38ed", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "x": 1460, "y": 640, "wires": [[]] }, { "id": "6257721be80c11d0", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Init PIR", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"pir_chat_id\", chatId);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 880, "y": 820, "wires": [["0d4eb56076d0399c"]] }, { "id": "51f07c88e8c27a53", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Init Window", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"window_chat_id\", chatId);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 870, "y": 860, "wires": [["cd4dbf063a566602"]] }, { "id": "20eb7c78346b83f9", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Send температура", "func": "let chatId = global.get(\"telemetry_chat_id\");\nif (!chatId) return null;\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад в телеметрию\", callback_data: \"monitor_telemetry\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: `🌡️ Температура: ${msg.payload}°C`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1130, "y": 660, "wires": [["01881c0fe29e38ed"]] }, { "id": "45ce9898c0c48b11", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Send влажность", "func": "let chatId = global.get(\"telemetry_chat_id\");\nif (!chatId) return null;\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад в телеметрию\", callback_data: \"monitor_telemetry\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: `💧 Влажность: ${msg.payload}%`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1130, "y": 700, "wires": [["01881c0fe29e38ed"]] }, { "id": "cfde414470594a31", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Send давление", "func": "let chatId = global.get(\"telemetry_chat_id\");\nif (!chatId) return null;\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад в телеметрию\", callback_data: \"monitor_telemetry\" }]] } };\nmsg.payload = { chatId: chatId, type: \"message\", content: `📊 Давление: ${msg.payload} мм.рт`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1120, "y": 740, "wires": [["01881c0fe29e38ed"]] }, { "id": "d261af2ad13a9a31", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Send IAQ", "func": "let chatId = global.get(\"telemetry_chat_id\");\nif (!chatId) return null;\nconst keyboard = { reply_markup: { inline_keyboard: [[{ text: \"◀️ Назад в телеметрию\", callback_data: \"monitor_telemetry\" }]] } };\nlet iaqText = \"\";\nconst iaqValue = String(msg.payload).toLowerCase();\nif (iaqValue === \"good\" || iaqValue === \"excellent\") iaqText = \"🟢 Отличное\";\nelse if (iaqValue === \"moderate\" || iaqValue === \"fair\") iaqText = \"🟡 Умеренное\";\nelse if (iaqValue === \"poor\" || iaqValue === \"unhealthy\") iaqText = \"🟠 Плохое\";\nelse if (iaqValue === \"very poor\" || iaqValue === \"bad\" || iaqValue === \"very unhealthy\") iaqText = \"🔴 Очень плохое\";\nelse iaqText = msg.payload;\nmsg.payload = { chatId: chatId, type: \"message\", content: `🏭 Качество воздуха (IAQ): ${iaqText}`, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1100, "y": 780, "wires": [["01881c0fe29e38ed"]] }, { "id": "2358f1f5037610f2", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Telemetry Menu", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nconst telemetryKeyboard = {\n reply_markup: {\n inline_keyboard: [\n [{ text: \"🌡️ Температура\", callback_data: \"sensor_temperature\" }],\n [{ text: \"💧 Влажность\", callback_data: \"sensor_humidity\" }],\n [{ text: \"📊 Давление\", callback_data: \"sensor_pressure\" }],\n [{ text: \"🏭 Качество воздуха (IAQ)\", callback_data: \"sensor_iaq\" }],\n [{ text: \"◀️ Назад к мониторингу\", callback_data: \"menu_monitoring\" }]\n ]\n }\n};\nmsg.payload = { chatId: chatId, type: \"message\", content: \"🌡️ *Телеметрия*\\nВыберите показатель:\", options: { parse_mode: \"Markdown\", reply_markup: telemetryKeyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 800, "y": 620, "wires": [["01881c0fe29e38ed"]] }, { "id": "14283ab4cc1ed28b", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Init", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"telemetry_chat_id\", chatId);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 770, "y": 660, "wires": [["c02b1bb4ad8d2e09"]] }, { "id": "c7a764c75a822b04", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Init", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"telemetry_chat_id\", chatId);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 770, "y": 700, "wires": [["4e9afbc910a02ab8"]] }, { "id": "318a4dee4563cb3c", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Init", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"telemetry_chat_id\", chatId);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 770, "y": 740, "wires": [["ab02b6f1fea816e8"]] }, { "id": "0c9e1265af100a13", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Init", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nglobal.set(\"telemetry_chat_id\", chatId);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 770, "y": 780, "wires": [["db3db795046abbc9"]] }, { "id": "f1827f9626cf94c1", "type": "function", "z": "d91b643aa7cd4cef", "g": "40709ee4bf606d70", "name": "Monitoring Menu", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nconst monitoringKeyboard = {\n reply_markup: {\n inline_keyboard: [\n [{ text: \"🌡️ Телеметрия\", callback_data: \"monitor_telemetry\" }],\n [{ text: \"🚶 PIR (Датчик движения)\", callback_data: \"monitor_pir\" }],\n [{ text: \"🪟 Окно\", callback_data: \"monitor_window\" }],\n [{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]\n ]\n }\n};\nmsg.payload = { chatId: chatId, type: \"message\", content: \"📊 *Мониторинг*\\nВыберите категорию:\", options: { parse_mode: \"Markdown\", reply_markup: monitoringKeyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 570, "y": 840, "wires": [["01881c0fe29e38ed"]] } ]

10. Управление роботом-пылесосом

Ветка для отслеживания состояния робота-пылесоса, а также его управления (запуск/отправка на базу). Помимо этого, сохраняются все запуски за день и их время работы.

Важно: Первоначально робот был подключен через Yandex Station, но он некорректно передаёт статус уборки (только «уборка» и «ожидает», и то «уборка» у него, когда он стоит на базе). Поэтому была подключена интеграция Tuya, так как робот от них. Теперь все данные верны, а также можно уровень заряда и т.д. вытащить в статистику.

Схема управления пылесосом
Схема управления роботом-пылесосом
Меню пылесоса
Меню управления с историей запусков
10.1 Пошаговая настройка

Часть 1. Вход в меню пылесоса

1.1. Перехват callback (telegram event)

  • Тип узла: telegram event
  • Событие: callback_query
  • Привязан к боту ([ID_BOT_CONFIG])

1.2. Маршрутизатор кракен (switch)

  • Тип узла: switch
  • Свойство: msg.payload.content
  • Правила:
    • vacuum_start → запустить уборку
    • vacuum_return → отправить на базу
    • menu_vacuum → показать статус и меню

Часть 2. Получение статуса и меню

Узел «Init Vacuum» (function) — извлекает chatId из разных вариантов входного сообщения и сохраняет в глобальную переменную.

let chatId = msg.payload.chatId || msg.payload.message?.chat?.id || msg.originalMessage?.chat?.id || msg.chatId; if (!chatId) return null; global.set("vacuum_chat_id", chatId); return msg;

Узел «Get Vacuum State» (api-current-state) — entity_id: select.kraken_mode (ваш пылесос).

Узел «Vacuum Menu» (function) — расшифровывает статусы, показывает уровень заряда батареи, историю уборок за сегодня, формирует меню управления.

// Расшифровка статусов if (vacuumValue === "smart") stateText = "🧠 Интеллектуальная уборка"; else if (vacuumValue === "standby") stateText = "⚫ На базе (ожидание)"; else if (vacuumValue === "chargego") stateText = "🏠 Возвращается на базу"; else if (vacuumValue === "Интеллектуальный") { modeText = "🎮 Режим: 🧠 Интеллектуальный"; stateText = "🧹 Уборка"; } // ... другие режимы // Уровень заряда батареи let batteryIcon = batteryLevel > 50 ? "🔋" : (batteryLevel > 20 ? "🪫" : "⚠️");

Часть 3. Запуск уборки

Узел «Handle Vacuum Start» (function) — сохраняет время начала уборки в историю (с учётом МСК).

const nowMSK = new Date(new Date().getTime() + 3 * 60 * 60 * 1000); const today = nowMSK.toISOString().split('T')[0]; const currentTime = nowMSK.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); history[today].push({ startTime: currentTime, startTimestamp: nowMSK.getTime() });

Узел «Запуск кракена» (api-call-service) — domain: vacuum, service: start, entityId: vacuum.kraken.

Часть 4. Возврат на базу

Узел «Кракен домой» (api-call-service) — service: return_to_base.

Часть 5. Подтверждение команд

Узел «Vacuum Command Response» (function) — отправляет пользователю подтверждение выполнения команды.

Часть 6. Автоматическое обновление истории

Узел poll-state — каждые 60 секунд проверяет состояние пылесоса (entityId: select.kraken_mode, updateInterval: 60 секунд, outputOnChanged: true).

Узел «Vacuum History Updater» (function) — отслеживает переход из режима уборки в режим ожидания, вычисляет длительность уборки в минутах, обновляет запись в истории.

const wasCleaning = previousState === "smart" || previousState === "Интеллектуальный" || ...; const isNowStandby = (vacuumValue === "standby" || vacuumValue === "docked" || vacuumValue === "chargego"); if (wasCleaning && isNowStandby) { // Вычисляем длительность уборки в минутах const durationMinutes = Math.round((nowMSK.getTime() - lastRun.startTimestamp) / 60000); lastRun.duration = durationMinutes; }

Часть 7. Голосовое управление через Алису

Узел «Слушаем Алису» (server-events) — eventType: yandex_intent.

Узел «Проверка команды» (switch) — правила: "кракен" → запуск, "кракен домой" → возврат на базу.

10.2 Полный код

JSON для импорта в Node-RED (Меню → Import). Замените [ID_BOT_CONFIG], [ID_HA_SERVER], [ВАШ_БОТ], [ВАШ_USERNAME], [IP_СЕРВЕРА] на свои значения.

[ { "id": "ae80c63cc7a187a8", "type": "group", "z": "d91b643aa7cd4cef", "name": "Кракен", "style": { "stroke": "#92d04f", "fill": "#92d04f", "label": true, "label-position": "sw", "color": "#000000" }, "nodes": [ "a871849add4eba6b", "3c226d0e2c1d350a", "710c480369b6d296", "cf3ef468bea33966", "94e2d8668499aa57", "7957df743ee8ed55", "0f497795b39b0f72", "d17e197ff82db659", "b329518f06007f67", "805e72f66ea5268e", "f4e8904d29e5d12a", "d9ce82172a7b3e6f", "562c0d610a9da509", "f59df362018dd210", "daa0a2e1a4c1f103" ], "x": 54, "y": 1279, "w": 1292, "h": 230 }, { "id": "a871849add4eba6b", "type": "function", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Init Vacuum", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id || msg.originalMessage?.chat?.id || msg.chatId;\nif (!chatId) return null;\nglobal.set(\"vacuum_chat_id\", chatId);\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 270, "y": 1420, "wires": [["3c226d0e2c1d350a"]] }, { "id": "3c226d0e2c1d350a", "type": "api-current-state", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Get Vacuum State", "server": "[ID_HA_SERVER]", "entity_id": "select.kraken_mode", "outputProperties": [ { "property": "payload", "propertyType": "msg", "value": "string", "valueType": "entityState" }, { "property": "data", "propertyType": "msg", "value": "", "valueType": "entity" } ], "x": 450, "y": 1420, "wires": [["710c480369b6d296"]] }, { "id": "710c480369b6d296", "type": "function", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Vacuum Menu", "func": "let chatId = global.get(\"vacuum_chat_id\");\nif (!chatId) return null;\nlet vacuumValue = msg.payload;\nif (typeof vacuumValue === \"object\") vacuumValue = vacuumValue.state || vacuumValue.payload || \"unknown\";\nlet stateText = \"\", stateIcon = \"\", modeText = \"\";\nif (vacuumValue === \"smart\") { stateText = \"🧠 Интеллектуальная уборка\"; stateIcon = \"🧹\"; }\nelse if (vacuumValue === \"standby\") { stateText = \"⚫ На базе (ожидание)\"; stateIcon = \"🔋\"; }\nelse if (vacuumValue === \"chargego\") { stateText = \"🏠 Возвращается на базу\"; stateIcon = \"🏠\"; }\nelse if (vacuumValue === \"Интеллектуальный\") { modeText = \"🎮 Режим: 🧠 Интеллектуальный\"; stateText = \"🧹 Уборка\"; stateIcon = \"🧹\"; }\nelse if (vacuumValue === \"Следовать за стеной\") { modeText = \"🎮 Режим: 🧱 Следовать за стеной\"; stateText = \"🧹 Уборка\"; stateIcon = \"🧹\"; }\nelse if (vacuumValue === \"Швабра\") { modeText = \"🎮 Режим: 💧 Швабра\"; stateText = \"🧹 Уборка\"; stateIcon = \"🧹\"; }\nelse if (vacuumValue === \"Спираль\") { modeText = \"🎮 Режим: 🌀 Спираль\"; stateText = \"🧹 Уборка\"; stateIcon = \"🧹\"; }\nelse { stateText = `Статус: ${vacuumValue}`; stateIcon = \"❓\"; }\nlet batteryLevel = msg.data?.attributes?.battery_level || null;\nlet batteryText = \"\";\nif (batteryLevel !== null) {\n const batteryIcon = batteryLevel > 50 ? \"🔋\" : (batteryLevel > 20 ? \"🪫\" : \"⚠️\");\n batteryText = `\\n🔋 Батарея: ${batteryIcon} ${batteryLevel}%`;\n}\nconst nowMSK = new Date(new Date().getTime() + 3 * 60 * 60 * 1000);\nconst today = nowMSK.toISOString().split('T')[0];\nconst history = global.get(\"vacuum_history\") || {};\nconst todayRuns = history[today] || [];\nlet historyText = \"\", totalDuration = 0;\nif (todayRuns.length === 0) historyText = \"❌ Сегодня не запускался\";\nelse {\n historyText = \"📅 *Сегодняшние запуски:*\\n\";\n todayRuns.forEach((run, index) => {\n const durationText = run.duration ? `${run.duration} мин` : \"в процессе\";\n historyText += `${index + 1}. ${run.startTime} → ${durationText}\\n`;\n if (run.duration) totalDuration += run.duration;\n });\n if (totalDuration > 0) {\n const hours = Math.floor(totalDuration / 60);\n const minutes = totalDuration % 60;\n historyText += `\\n⏱️ *Общее время уборки:* ${hours > 0 ? `${hours} ч ` : \"\"}${minutes} мин`;\n }\n}\nlet message = `${stateIcon} *Кракен (робот-пылесос)*\\n\\n📊 Состояние: ${stateText}`;\nif (modeText) message += `\\n${modeText}`;\nmessage += batteryText + `\\n\\n${historyText}\\n\\nВыберите действие:`;\nconst keyboard = {\n reply_markup: {\n inline_keyboard: [\n [{ text: \"🧹 Запустить уборку\", callback_data: \"vacuum_start\" }],\n [{ text: \"🏠 Отправить на базу\", callback_data: \"vacuum_return\" }],\n [{ text: \"🔄 Обновить статус\", callback_data: \"menu_vacuum\" }],\n [{ text: \"◀️ Назад в меню\", callback_data: \"back_main\" }]\n ]\n }\n};\nmsg.payload = { chatId: chatId, type: \"message\", content: message, options: { parse_mode: \"Markdown\", reply_markup: keyboard.reply_markup } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 640, "y": 1420, "wires": [["d9ce82172a7b3e6f"]] }, { "id": "cf3ef468bea33966", "type": "function", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Handle Vacuum Start", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nif (!chatId) chatId = global.get(\"vacuum_chat_id\");\nif (!chatId) return null;\nglobal.set(\"vacuum_chat_id\", chatId);\nconst nowMSK = new Date(new Date().getTime() + 3 * 60 * 60 * 1000);\nconst today = nowMSK.toISOString().split('T')[0];\nconst currentTime = nowMSK.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });\nlet history = global.get(\"vacuum_history\") || {};\nif (!history[today]) history[today] = [];\nhistory[today].push({ startTime: currentTime, startTimestamp: nowMSK.getTime() });\nif (history[today].length > 10) history[today] = history[today].slice(-10);\nglobal.set(\"vacuum_history\", history);\nmsg.vacuumCommand = \"start\";\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 720, "y": 1320, "wires": [["94e2d8668499aa57"]] }, { "id": "94e2d8668499aa57", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Запуск кракена", "server": "[ID_HA_SERVER]", "action": "vacuum.start", "entityId": ["vacuum.kraken"], "x": 930, "y": 1320, "wires": [["d17e197ff82db659"]] }, { "id": "7957df743ee8ed55", "type": "function", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Handle Vacuum Return", "func": "let chatId = msg.payload.chatId || msg.payload.message?.chat?.id;\nif (!chatId) chatId = global.get(\"vacuum_chat_id\");\nif (!chatId) return null;\nglobal.set(\"vacuum_chat_id\", chatId);\nmsg.vacuumCommand = \"return\";\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 730, "y": 1360, "wires": [["0f497795b39b0f72"]] }, { "id": "0f497795b39b0f72", "type": "api-call-service", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Кракен домой", "server": "[ID_HA_SERVER]", "action": "vacuum.return_to_base", "entityId": ["vacuum.kraken"], "x": 940, "y": 1360, "wires": [["d17e197ff82db659"]] }, { "id": "d17e197ff82db659", "type": "function", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Vacuum Command Response", "func": "const chatId = global.get(\"vacuum_chat_id\");\nif (!chatId) return null;\nconst command = msg.vacuumCommand;\nlet resultMessage = command === \"start\" ? \"🧹 Кракен начал уборку!\" : (command === \"return\" ? \"🏠 Кракен возвращается на базу\" : \"✅ Команда выполнена\");\nmsg.payload = { chatId: chatId, type: \"message\", content: `✅ ${resultMessage}`, options: { parse_mode: \"Markdown\" } };\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 1190, "y": 1340, "wires": [[]] }, { "id": "b329518f06007f67", "type": "function", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Vacuum History Updater", "func": "let vacuumValue = msg.payload;\nif (typeof vacuumValue === \"object\") vacuumValue = vacuumValue.state || vacuumValue.payload || \"unknown\";\nlet previousState = global.get(\"vacuum_previous_state\") || \"unknown\";\nglobal.set(\"vacuum_previous_state\", vacuumValue);\nconst wasCleaning = previousState === \"smart\" || previousState === \"Интеллектуальный\" || previousState === \"Следовать за стеной\" || previousState === \"Швабра\" || previousState === \"Спираль\";\nconst isNowStandby = (vacuumValue === \"standby\" || vacuumValue === \"docked\" || vacuumValue === \"chargego\");\nif (wasCleaning && isNowStandby) {\n const nowMSK = new Date(new Date().getTime() + 3 * 60 * 60 * 1000);\n const today = nowMSK.toISOString().split('T')[0];\n let history = global.get(\"vacuum_history\") || {};\n if (history[today] && history[today].length > 0) {\n for (let i = history[today].length - 1; i >= 0; i--) {\n if (!history[today][i].endTime) {\n history[today][i].endTime = nowMSK.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });\n const durationMinutes = Math.round((nowMSK.getTime() - history[today][i].startTimestamp) / 60000);\n history[today][i].duration = durationMinutes;\n break;\n }\n }\n global.set(\"vacuum_history\", history);\n }\n}\nreturn null;", "outputs": 1, "timeout": 0, "noerr": 0, "x": 670, "y": 1460, "wires": [[]] }, { "id": "805e72f66ea5268e", "type": "switch", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "маршрутизатор кракен", "property": "payload.content", "propertyType": "msg", "rules": [ {"t": "eq", "v": "vacuum_start", "vt": "str"}, {"t": "eq", "v": "vacuum_return", "vt": "str"}, {"t": "eq", "v": "menu_vacuum", "vt": "str"} ], "checkall": "true", "repair": false, "outputs": 3, "x": 470, "y": 1360, "wires": [ ["cf3ef468bea33966"], ["7957df743ee8ed55"], ["a871849add4eba6b"] ] }, { "id": "f4e8904d29e5d12a", "type": "telegram event", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Обработчик нажатия кнопок", "bot": "[ID_BOT_CONFIG]", "event": "callback_query", "autoanswer": false, "x": 210, "y": 1360, "wires": [["805e72f66ea5268e"]] }, { "id": "d9ce82172a7b3e6f", "type": "telegram sender", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Отправка сообщения", "bot": "[ID_BOT_CONFIG]", "x": 840, "y": 1420, "wires": [[]] }, { "id": "562c0d610a9da509", "type": "poll-state", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Poll State", "server": "[ID_HA_SERVER]", "updateInterval": "60", "updateIntervalUnits": "seconds", "outputOnChanged": true, "entityId": "select.kraken_mode", "x": 410, "y": 1460, "wires": [["b329518f06007f67"]] }, { "id": "f59df362018dd210", "type": "server-events", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Слушаем Алису", "server": "[ID_HA_SERVER]", "eventType": "yandex_intent", "x": 240, "y": 1320, "wires": [["daa0a2e1a4c1f103"]] }, { "id": "daa0a2e1a4c1f103", "type": "switch", "z": "d91b643aa7cd4cef", "g": "ae80c63cc7a187a8", "name": "Проверка команды", "property": "payload.event.text", "propertyType": "msg", "rules": [ {"t": "eq", "v": "кракен", "vt": "str"}, {"t": "eq", "v": "кракен домой", "vt": "str"} ], "checkall": "true", "repair": false, "outputs": 2, "x": 460, "y": 1320, "wires": [ ["cf3ef468bea33966"], ["7957df743ee8ed55"] ] }, { "id": "[ID_BOT_CONFIG]", "type": "telegram bot", "botname": "[ВАШ_БОТ]", "usernames": "[ВАШ_USERNAME]", "updatemode": "polling", "pollinterval": "300", "localbothost": "[IP_СЕРВЕРА]", "localbotport": 8443 }, { "id": "[ID_HA_SERVER]", "type": "server", "name": "Home Assistant", "version": 6, "addon": false }, { "id": "cb623d923d2ab315", "type": "global-config", "env": [], "modules": { "node-red-contrib-home-assistant-websocket": "0.80.3", "node-red-contrib-telegrambot": "17.4.12" } } ]
Мы используем cookie-файлы. Это нужно для лучшей работы сайта. Продолжая пользоваться сайтом, вы соглашаетесь с этим.