NooLite + Raspberry Pi + Telegram = умный дом

в 16:07, , рубрики: python, Python Telegram RaspberryPi NooLite, программирование микроконтроллеров, метки:

2 года назад передо мной встала задача реализовать удаленное управление обогревательными приборами в своем загородном доме. В данной статье я хочу поделиться моим вариантом автоматизации и удаленного управления, к которому я в итоге пришел. Постараюсь охватить весь процесс и подробности создания этого хобби-проекта и поделиться всеми сложностями, с которыми пришлось столкнуться. В процессе реализации, как видно из названия статьи, я использовал Noolite (о нем расскажу в статье), Telegram и совсем немного Python.

image

Введение

Данный проект берет свое начало в 2014 году, когда передо мной встала задача обеспечить удаленное управления обогревательными приборами в своем загородном доме. Дело в том, что практически каждые выходные мы с семьей проводим на даче. И если летом мы, задержавшись по тем или иным причинам в городе, приехав в дом могли сразу лечь спать, то зимой, когда температура опускается до -30 градусов, мне приходилось тратить по 3-4 часа на протопку дома. Я видел следующие пути решения данной проблемы:

  1. "Неумное решение" — можно оставлять включенными обогреватели со встроенными термостатами на минимальной температуре поддержания тепла. Собственно ничего "умного" в этом решении нет, но 24/7 работающие обогревательные приборы в деревянном загородном доме не внушают доверия. Хотелось хотя бы минимального контроля над их состоянием, автоматизации и какой-нибудь обратной связи;

  2. GSM-розетки — данным решением пользуются мои соседи по дачному участку. Если кто-то не знаком с ними, то это просто управляемый посредством SMS команд переходник, который включается в розетку, а сам обогреватель включается в него. Не самое бюджетное решение, если нужно обеспечить обогрев целого дома — ссылка на маркет. Я вижу его как самое простое и менее трудозатратное в реализации, но имеющее минусы в процессе эксплуатации, такие как: целый ворох сим карт и работы по поддержанию их положительного баланса, так как для каждой комнаты нужен минимум один обогреватель, ограниченность и неудобства их контроля по средствам SMS;

  3. "Умный дом" — собственно решения, построенные на реализации "умного дома".

Как наиболее перспективное решение мною был выбран третий вариант и следующим вопросом на повестке дня стал — "Какую платформу для реализации выбрать?".

Уже не помню сколько я потратил время на поиски подходящих вариантов, но в итоге из бюджетных и доступных в магазинах решений я нашел системы: NooLite и CoCo (сейчас уже переименовали в Trust). При их сравнении решающую роль для меня сыграло то, что у NooLite есть открытое и задокументированное API для управления любыми его блоками. На тот момент необходимости в нем не было, но я сразу отметил, какую гибкость в дальнейшем это может дать. Да и цена у NooLite была существенно ниже. В итоге я остановил свой выбор именно на NooLite.

Реализация 1 — автоматизация NooLite

Система NooLite состоит из силовых модулей (под разные типы нагрузок), датчиков (температура, влажность, движение) и управляющего ими оборудования: радио пульты, настенные выключателей, USB-адаптеров для компьютера или Ethernet-шлюза PR1132. Все это можно использовать в различных комбинациях, соединять их между собой напрямую или управлять через usb-адаптеры или шлюз, подробнее об этом можете почитать на официальном сайте производителя.

Для моей задачи центральным элементом умного дома я выбрал Ethernet-шлюза PR1132, который будет управлять силовыми блоками и получать информацию с датчиков. Для работы Ethernet-шлюза необходимо подключить его к сети кабелем, поддержки Wi-Fi в нем нет. На тот момент у меня в доме уже была организована сеть, состоящая из WiFi-маршрутизатора Asus rt-n16 и USB--модема для доступа к интернету. Поэтому весь монтаж NooLite для меня заключался лишь в том, чтобы подключить шлюз кабелем к маршрутизатору, расположить в доме радиодатчики температуры и смонтировать силовые блоки в центральном электрощитке.

У NooLite есть ряд силовых блоков для разной подключаемой нагрузки. Самый "мощный" блок может управлять нагрузкой до 5000 Вт. Если требуется управление большей нагрузкой, как в моем случае, то можно сделать подключение нагрузки через управляемое реле, которым, в свою очередь, будет управлять силовой блок NooLite.

image

Схема подключения

image

Ethernet-шлюза PR1132 и маршрутизатор Asus rt-n16

image

Беспроводной датчик температуры и влажности PT111

image

Электрощиток и силовой блок для наружного монтажа SR211 — в дальнейшем вместо этого блока я использовал блок для внутреннего монтажа и поместил его прямо в электрощитке

Ethernet-шлюз PR1132 имеет web-интерфейс через которой осуществляется привязка/отвязка силовых блоков, датчиков и управление ими. Сам интерфейс выполнен в довольно "топорном" минималистическом стиле, но этого вполне достаточно для доступа ко всему необходимому функционалу системы:

image

Настройки

image

Управление

image

Страница одной группы выключателей

Подробно о привязке и настройке всего этого — опять же на официальном сайте.

На тот момент я мог:

  • управлять обогревателями, находясь в локальной сети загородного дома, что было не очень-то и полезно, исходя из первоначальной задачи;
  • создавать таймеры включения/отключения по времени и дню недели.

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

Реализация 2 — удаленный доступ к умному дому

Первая реализация позволила частично решить мою задачу, но все-таки хотелось онлайн управления домом и наличие обратной связи. Я начал искать варианты организации доступа к дачной сети из вне.

Как я упомянул в предыдущем разделе — дачная сеть имеет доступ к интернету через usb модем одного из мобильных операторов. По умолчанию мобильные модемы имеют серый ip адрес и без дополнительных ежемесячных трат белого фиксированного ip не получить. При таком сером IP не помогут и различные no-ip сервисы.

Единственный вариант, который мне удалось на тот момент придумать — VPN. На городском маршрутизаторе у меня был настроен VPN-сервер, которым я время от времени пользовался. Мне было необходимо настроить на дачном роутере VPN-клиент и прописать статические маршруты до дачной сети.

image

Схема подключения

В результате дачный роутер постоянно держал VPN соединение с городским роутером и для доступа к шлюзу NooLite мне нужно было с клиентского устройства (ноутбук, телефон) подключится по VPN к городскому маршрутизатору.

На этом этапе я мог:

  • получить доступ к умному дому из любого места;

В целом это практически на 100% покрывало первоначальную задачу. Однако я понимал, что данная реализация далека от оптимальной и удобной в использовании, так как каждый раз я должен был выполнять ряд дополнительных действий по подключению к VPN. Для меня это не было особой проблемой, однако для остальных членов семьи это было не очень удобно. Так же в этой реализации было очень много посредников, что сказывалось на отказоустойчивости всей системы в целом. Однако на некоторое время я остановился именно на этом варианте.

Реализация 3 — Telegram bot

С появлением ботов в Telegram я взял на заметку, что это смогло бы стать довольно удобным интерфейсом для управления умным домом и, как только у меня появилось достаточно свободного времени, я приступил к разработке на Python 3.

Бот должен был где-то находится и, как самое энергоэффективное решение, я выбрал Raspberry Pi. Хоть это и был мой первый опыт работы с ним, особых сложностей в его настройке не возникло. Образ на карту памяти, ethernet кабель в порт и по ssh — полноценный Linux.

Как я уже говорил — у NooLite есть задокументированное API, которое и пригодилось мне на данном этапе. Для начала я написал простенькую обертку для более удобного взаимодействия с API:

noolite_api.py

"""
NooLite API wrapper
"""

import requests
from requests.auth import HTTPBasicAuth
from requests.exceptions import ConnectTimeout, ConnectionError
import xml.etree.ElementTree as ET

class NooLiteSens:
    """Класс хранения и обработки информации, полученной с датчиков

    Пока как таковой обработки нет
    """
    def __init__(self, temperature, humidity, state):
        self.temperature = float(temperature.replace(',', '.')) if temperature != '-' else None
        self.humidity = int(humidity) if humidity != '-' else None
        self.state = state

class NooLiteApi:
    """Базовый враппер для общения с NooLite"""
    def __init__(self, login, password, base_api_url, request_timeout=10):
        self.login = login
        self.password = password
        self.base_api_url = base_api_url
        self.request_timeout = request_timeout

    def get_sens_data(self):
        """Получение и прасинг xml данных с датчиков

        :return: список NooLiteSens объектов для каждого датчика
        :rtype: list
        """
        response = self._send_request('{}/sens.xml'.format(self.base_api_url))
        sens_states = {
            0: 'Датчик привязан, ожидается обновление информации',
            1: 'Датчик не привязан',
            2: 'Нет сигнала с датчика',
            3: 'Необходимо заменить элемент питания в датчике'
        }
        response_xml_root = ET.fromstring(response.text)
        sens_list = []
        for sens_number in range(4):
            sens_list.append(NooLiteSens(
                response_xml_root.find('snst{}'.format(sens_number)).text,
                response_xml_root.find('snsh{}'.format(sens_number)).text,
                sens_states.get(int(response_xml_root.find('snt{}'.format(sens_number)).text))
            ))
        return sens_list

    def send_command_to_channel(self, data):
        """Отправка запроса к NooLite

        Отправляем запрос к NooLite с url параметрами из data
        :param data: url параметры
        :type data: dict
        :return: response
        """
        return self._send_request('{}/api.htm'.format(self.base_api_url), params=data)

    def _send_request(self, url, **kwargs):
        """Отправка запроса к NooLite и обработка возвращаемого ответа

        Отправка запроса к url с параметрами из kwargs
        :param url: url для запроса
        :type url: str
        :return: response от NooLite или исключение
        """

        try:
            response = requests.get(url, auth=HTTPBasicAuth(self.login, self.password),
                                    timeout=self.request_timeout, **kwargs)
        except ConnectTimeout as e:
            print(e)
            raise NooLiteConnectionTimeout('Connection timeout: {}'.format(self.request_timeout))
        except ConnectionError as e:
            print(e)
            raise NooLiteConnectionError('Connection timeout: {}'.format(self.request_timeout))

        if response.status_code != 200:
            raise NooLiteBadResponse('Bad response: {}'.format(response))
        else:
            return response

# Кастомные исключения
NooLiteConnectionTimeout = type('NooLiteConnectionTimeout', (Exception,), {})
NooLiteConnectionError = type('NooLiteConnectionError', (Exception,), {})
NooLiteBadResponse = type('NooLiteBadResponse', (Exception,), {})
NooLiteBadRequestMethod = type('NooLiteBadRequestMethod', (Exception,), {})

А дальше с использованием пакета python-telegram-bot был написан сам бот:

telegram_bot.py

import os
import logging
import functools

import yaml
import requests
import telnetlib
from requests.exceptions import ConnectionError
from telegram import ReplyKeyboardMarkup, ParseMode
from telegram.ext import Updater, CommandHandler, Filters, MessageHandler, Job

from noolite_api import NooLiteApi, NooLiteConnectionTimeout,
    NooLiteConnectionError, NooLiteBadResponse

# Получаем конфигурационные данные из файла
config = yaml.load(open('conf.yaml'))

# Базовые настройка логирования
logger = logging.getLogger()
logger.setLevel(logging.INFO)
formatter = logging.Formatter(
    '%(asctime)s - %(filename)s:%(lineno)s - %(levelname)s - %(message)s'
)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)

# Подключаемся к боту и NooLite
updater = Updater(config['telegtam']['token'])
noolite_api = NooLiteApi(
    config['noolite']['login'],
    config['noolite']['password'],
    config['noolite']['api_url']
)
job_queue = updater.job_queue

def auth_required(func):
    """Декоратор аутентификации"""
    @functools.wraps(func)
    def wrapped(bot, update):
        if update.message.chat_id not in config['telegtam']['authenticated_users']:
            bot.sendMessage(
                chat_id=update.message.chat_id,
                text="Вы неавторизованы.nДля авторизации отправьте /auth password."
            )
        else:
            return func(bot, update)
    return wrapped

def log(func):
    """Декоратор логирования"""
    @functools.wraps(func)
    def wrapped(bot, update):
        logger.info('Received message: {}'.format(
            update.message.text if update.message else update.callback_query.data)
        )
        func(bot, update)
        logger.info('Response was sent')
    return wrapped

def start(bot, update):
    """Команда начала взаимодействия с ботом"""
    bot.sendMessage(
        chat_id=update.message.chat_id,
        text="Для начала работы нужно авторизоваться.n"
             "Для авторизации отправьте /auth password."
    )

def auth(bot, update):
    """Аутентификация

    Если пароль указан верно, то в ответ приходит клавиатура управления умным домом
    """
    if config['telegtam']['password'] in update.message.text:
        if update.message.chat_id not in config['telegtam']['authenticated_users']:
            config['telegtam']['authenticated_users'].append(update.message.chat_id)
        custom_keyboard = [
            ['/Включить_обогреватели', '/Выключить_обогреватели'],
            ['/Включить_прожектор', '/Выключить_прожектор'],
            ['/Температура']
        ]
        reply_markup = ReplyKeyboardMarkup(custom_keyboard)
        bot.sendMessage(
            chat_id=update.message.chat_id,
            text="Вы авторизованы.",
            reply_markup=reply_markup
        )
    else:
        bot.sendMessage(chat_id=update.message.chat_id, text="Неправильный пароль.")

def send_command_to_noolite(command):
    """Обработка запросов в NooLite.

    Отправляем запрос. Если возращается ошибка, то посылаем пользователю ответ об этом.
    """
    try:
        logger.info('Send command to noolite: {}'.format(command))
        response = noolite_api.send_command_to_channel(command)

    except NooLiteConnectionTimeout as e:
        logger.info(e)
        return None, "*Дача недоступна!*n`{}`".format(e)

    except NooLiteConnectionError as e:
        logger.info(e)
        return None, "*Ошибка!*n`{}`".format(e)

    except NooLiteBadResponse as e:
        logger.info(e)
        return None, "*Не удалось сделать запрос!*n`{}`".format(e)

    return response.text, None

# ========================== Commands ================================

@log
@auth_required
def outdoor_light_on(bot, update):
    """Включения уличного прожектора"""
    response, error = send_command_to_noolite({'ch': 2, 'cmd': 2})
    logger.info('Send message: {}'.format(response or error))
    bot.sendMessage(chat_id=update.message.chat_id, text="{}".format(response or error))

@log
@auth_required
def outdoor_light_off(bot, update):
    """Выключения уличного прожектора"""
    response, error = send_command_to_noolite({'ch': 2, 'cmd': 0})
    logger.info('Send message: {}'.format(response or error))
    bot.sendMessage(chat_id=update.message.chat_id, text="{}".format(response or error))

@log
@auth_required
def heaters_on(bot, update):
    """Включения обогревателей"""
    response, error = send_command_to_noolite({'ch': 0, 'cmd': 2})
    logger.info('Send message: {}'.format(response or error))
    bot.sendMessage(chat_id=update.message.chat_id, text="{}".format(response or error))

@log
@auth_required
def heaters_off(bot, update):
    """Выключения обогревателей"""
    response, error = send_command_to_noolite({'ch': 0, 'cmd': 0})
    logger.info('Send message: {}'.format(response or error))
    bot.sendMessage(chat_id=update.message.chat_id, text="{}".format(response or error))

@log
@auth_required
def send_temperature(bot, update):
    """Получаем информацию с датчиков"""
    try:
        sens_list = noolite_api.get_sens_data()
    except NooLiteConnectionTimeout as e:
        logger.info(e)
        bot.sendMessage(
            chat_id=update.message.chat_id,
            text="*Дача недоступна!*n`{}`".format(e),
            parse_mode=ParseMode.MARKDOWN
        )
        return
    except NooLiteBadResponse as e:
        logger.info(e)
        bot.sendMessage(
            chat_id=update.message.chat_id,
            text="*Не удалось получить данные!*n`{}`".format(e),
            parse_mode=ParseMode.MARKDOWN
        )
        return
    except NooLiteConnectionError as e:
        logger.info(e)
        bot.sendMessage(
            chat_id=update.message.chat_id,
            text="*Ошибка подключения к noolite!*n`{}`".format(e),
            parse_mode=ParseMode.MARKDOWN
        )
        return

    if sens_list[0].temperature and sens_list[0].humidity:
        message = "Температура: *{}C*nВлажность: *{}%*".format(
            sens_list[0].temperature, sens_list[0].humidity
        )
    else:
        message = "Не удалось получить данные: {}".format(sens_list[0].state)

    logger.info('Send message: {}'.format(message))
    bot.sendMessage(chat_id=update.message.chat_id,
                    text=message,
                    parse_mode=ParseMode.MARKDOWN)

@log
@auth_required
def send_log(bot, update):
    """Получение лога для отладки"""
    bot.sendDocument(
        chat_id=update.message.chat_id,
        document=open('/var/log/telegram_bot/err.log', 'rb')
    )

@log
def unknown(bot, update):
    """Неизвестная команда"""
    bot.sendMessage(chat_id=update.message.chat_id, text="Я не знаю такой команды")

def power_restore(bot, job):
    """Выполняется один раз при запуске бота"""
    for user_chat in config['telegtam']['authenticated_users']:
        bot.sendMessage(user_chat, 'Включение после перезагрузки')

def check_temperature(bot, job):
    """Периодическая проверка температуры с датчиков

    Eсли температура ниже, чем установленный минимум -
    посылаем уведомление зарегистрированным пользователям
    """
    try:
        sens_list = noolite_api.get_sens_data()
    except NooLiteConnectionTimeout as e:
        print(e)
        return
    except NooLiteConnectionError as e:
        print(e)
        return
    except NooLiteBadResponse as e:
        print(e)
        return
    if sens_list[0].temperature and 
                    sens_list[0].temperature < config['noolite']['temperature_alert']:
        for user_chat in config['telegtam']['authenticated_users']:
            bot.sendMessage(
                chat_id=user_chat,
                parse_mode=ParseMode.MARKDOWN,
                text='*Температура ниже {} градусов: {}!*'.format(
                    config['noolite']['temperature_alert'],
                    sens_list[0].temperature
                )
            )

def check_internet_connection(bot, job):
    """Периодическая проверка доступа в интернет

    Если доступа в интрнет нет и попытки его проверки исчерпаны -
    то посылаем по telnet команду роутеру для его перезапуска.
    Если доступ в интернет после этого не появился - перезагружаем Raspberry Pi
    """
    try:
        requests.get('http://ya.ru')
        config['noolite']['internet_connection_counter'] = 0
    except ConnectionError:
        if config['noolite']['internet_connection_counter'] == 2:
            tn = telnetlib.Telnet(config['router']['ip'])

            tn.read_until(b"login: ")
            tn.write(config['router']['login'].encode('ascii') + b"n")

            tn.read_until(b"Password: ")
            tn.write(config['router']['password'].encode('ascii') + b"n")

            tn.write(b"rebootn")

        elif config['noolite']['internet_connection_counter'] == 4:
            os.system("sudo reboot")
        else:
            config['noolite']['internet_connection_counter'] += 1

dispatcher = updater.dispatcher

dispatcher.add_handler(CommandHandler('start', start))
dispatcher.add_handler(CommandHandler('auth', auth))
dispatcher.add_handler(CommandHandler('Температура', send_temperature))

dispatcher.add_handler(CommandHandler('Включить_обогреватели', heaters_on))
dispatcher.add_handler(CommandHandler('Выключить_обогреватели', heaters_off))
dispatcher.add_handler(CommandHandler('Включить_прожектор', outdoor_light_on))
dispatcher.add_handler(CommandHandler('Выключить_прожектор', outdoor_light_off))

dispatcher.add_handler(CommandHandler('log', send_log))
dispatcher.add_handler(MessageHandler([Filters.command], unknown))

job_queue.put(Job(check_internet_connection, 60*5), next_t=60*5)
job_queue.put(Job(check_temperature, 60*30), next_t=60*6)
job_queue.put(Job(power_restore, 60, repeat=False))

updater.start_polling(bootstrap_retries=-1)

Данный бот запускается на Raspberry Pi под Supervisor, который контролирует его состояние и запускает его при перезагрузке.

image

Схема работы бота

При запуске бот:

  • посылает зарегистрированным пользователям сообщение о том, что он включился и готов к работе;
  • мониторит подключение к интернету. В условии работы через мобильный интернет были случаи, когда он пропадал. Поэтому была добавлена периодическая проверка доступности подключения. Если заданное количество проверок заканчивается неудачей, то сначала скрипт перезагружает через telnet маршрутизатор, а потом, если это не помогло, и сам Raspberry Pi;
  • мониторит температуру внутри помещения и отправляет пользователю уведомление, если она опустилась ниже заданного порога;
  • выполняет команды от зарегистрированных пользователей.

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

  • включение/выключение обогревателей;
  • включение/выключение уличного прожектора;
  • получение температуры с датчиков;
  • получение файла логов для дебага.

Пример общения с ботом:

image

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

В итоге я могу:

  • управлять умным домом из любого места с любого устройства со своей учетной записью Telegram;
  • получать информацию с датчиков, расположенных в доме.

Данная реализация на все 100% решила первоначальную задачу, была удобной и интуитивно понятной в использовании.

Заключение

Бюджет (по текущим ценам):

  • NooLite Ethernet-шлюз — 6.000 рублей
  • NooLite силовой датчик для управления нагрузкой — 1.500 рублей
  • NooLite датчик температуры и влажности — 3.000 рублей (без влажности дешевле)
  • Raspberry Pi — 4.000 рублей

На выходе у меня получилось довольно гибкая бюджетная система, которую можно легко расширять по мере необходимости (NooLite шлюз поддерживает до 32 каналов). Я и члены семьи могут с легкостью пользоваться ей без необходимости выполнять какие-то дополнительные действия: зашел в телеграмм — проверил температуру — включил обогреватели.

На самом деле данная реализация не последняя. Буквально неделю назад я подключил всю эту систему к Apple HomeKit, что позволило добавить управление через приложение для iOS "Дом" и соответствующую интеграцию с Siri для голосового управления. Но процесс реализации тянет на отдельную статью. Если сообществу будет интересна данная тема, то готов в ближайшее время подготовить еще одну статью.

Ссылки по теме:

Автор: AlekseevAV

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js