NooLite + Raspberry Pi + Telegram = розумний будинок

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 в ньому немає. На той момент у мене в будинку вже була організована мережа, що складається з Wi-fi-маршрутизатора 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 параметри
:data type: 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="Ви неавторизовані.\пДля авторизації відправте /auth password."
)
else:
return func(bot, update)
return wrapped

def log(func):
"""Декоратор логування"""
@functools.wraps(func)
def wrapped(bot, update):
logger.info('message Received: {}'.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 error or))
bot.sendMessage(chat_id=update.message.chat_id, text="{}".format(response error or))

@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 error or))
bot.sendMessage(chat_id=update.message.chat_id, text="{}".format(response error or))

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

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

@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*\пВлажность: *{}%*".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):
"""Періодична перевірка з датчиків температури

Якщо температура нижче, ніж встановлений мінімум -
надсилаємо повідомлення зареєстрованим користувачам
"""
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"reboot\n")

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 для голосового управління. Але процес реалізації тягне на окрему статтю. Якщо спільноті буде цікава ця тема, то готовий найближчим часом підготувати ще одну статтю.
Посилання по темі:
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.