Disclaimer
Эта статья содержит некоторое количество программного кода, написанного на языке Python. Ввиду того, что автор статьи по профессии является сисадмином, но не программистом — стиль и качество этого кода, могут вызвать проявление неконтролируемых эмоций у профессионалов. Пожалуйста, немедленно прекратите чтение если вид неаккуратного или неоптимального кода может негативно сказаться на вашем психическом состоянии.
Постановка задачи
Основной причиной реализации проекта, явилась простуда с вытекающими: избытком свободного времени и невозможностью выходить из дома. Порывшись у себя в столе я обнаружил:
- RaspberryPi 3 model B

- Вебкамера Logitech C270

- Карта памяти Kingston microSDHC 16 Гб
- Некоторое количество проводов и адаптеров
Из всего перечисленного, было решено построить систему домашнего видео-наблюдения с функционалом оповещения о вторжении. В качестве платформы был выбран телеграм-бот. Бот имеет следующие преимущества перед другими возможными реализациями (веб, мобильное приложение):
- Не требуется установки дополнительного клиентского ПО
- Серверная часть может работать с приватным IP адресом через NAT, при этом предъявляются минимальные требования к подключению (вплоть до 3G модема)
- Большая часть инфраструктуры находится на стороне сервис-провайдера, который за меня решил вопросы авторизации, безопаснос��и итп...
С помощью беглого анализа интернет-публикаций, существующие решения обнаружены не были.
Шаг1. Операционная система
В качестве операционной системы был использован Raspbian. Для тех кто не в курсе, это такая сборка Debian, оптимизированная под работу на железе RaspberryPi. Система характеризуется стабильностью, большим количеством доступного прикладного ПО, хорошей документацией. Установка системы тривиальна и многократно описана в разных источниках. Я не буду останавливаться на этом подробно, скажу лишь что всё сводится к скачиваню образа диска и записи его на SD-карту. (Очевидно, что использовалась версия без GUI (lite)) Относительно настроек по-умолчанию, были выполнены следующие изменения:
- Настройка OpenSSH сервера
- Настройка часового пояса
- Установка пакетов python3-pip, supervisor
apt-get install python3-pip supervisor
- Устновка модуля PyTelegramBotAPI
pip3 install PyTelegramBotAPI
Шаг2. Захват изображений
Изначально я планировал использовать какое-то готовое решение для сохранения изображений с веб-камеры, а затем самостоятельно заниматься детекцией движения, однако к моему счастью был обнаружен Motion — готовый продукт который делает именно то что мне надо: захватывает изображения с веб-камеры и определяет есть ли на них изменения. Пакет входит в стандартный репозиторий и его установка не вызывает сложностей:
apt-get install motion
Файл конфигурации (/etc/motion/motion.conf) настолько обширен, что в рамках данной статьи его невозможно описать полностью, остановлюсь лишь на тех параметрах которые значимы или были изменены от стандартных:
# Наша веб-камера videodevice /dev/video0 # Разрешение камеры (из тех. характеристик) width 1280 height 720 #Сколько раз в секунду снимать (от 2, до 100) #Влияет на загрузку CPU и определяет сколько сообщений вы получите в случае "вторжения" framerate 4 #Сколько секунд после того как движение закончилось будет происходить съемка event_gap 0 #Сохранять картинки в формате jpg со сжатием 75 output_pictures on quality 75 picture_type jpeg #Не сохранять видео ffmpeg_output_movies off #Каждые 30 секунд делать снимок (снапшот) "просто так" snapshot_interval 30 #Обводить белым прямоугольником область в которой обнаружено движение locate_motion_mode on locate_motion_style box #Путь для хранения файлов target_dir /var/lib/motion #Формат имени файла для снапшота (для нас здесь важно наличие слова snapshot) snapshot_filename %v-%Y%m%d%H%M%S-snapshot #Важный момент! Имя файлов для снимков с движением. #Я сильно упростил оригинальный вариант и у меня имена файлов имеют вид: #Количество секунд с 1970 года + порядковый номер снимка за эту секунду #таким образом имя файлов это всегда целое число которое только увеличивается #это сильно упростило парсинг, сортировку итп... picture_filename %s%q
Motion автоматически создает symlink на последний сохраненный снимок с именем lastsnap.jpg
Шаг 3. Программирование
Неожиданно писать пришлось значительно меньше, чем я изначально планировал. Программа состоит из двух небольших скриптов и конфигурационного файла. Дополнительно в двух текстовых файлах я храню информацию о режиме работы (включен или выключен режим обнаружения вторжения) и о последнем обработанном снимке.
В конфигурационном файле config.py хранится следующая информация: телеграм-api-токен (о том как его получить, подробно написано здесь), список ID пользователей для которых разрешен доступ, имя файла с последним снимком, путь к папке со всеми снимками.
token = '4345435465:AsdfzzsdxgsYnb8DxDtn2L5KjfePsXozjv-o0' users=['1234567890','0987654321'] lastimage='/var/lib/motion/lastsnap.jpg' motiondir='/var/lib/motion'
Собственно сам бот. В нем реализованы следующие функции:
- Проверить, разрешен ли пользователю доступ
- Сообщить неавторизованному пользователю его ID (чтобы он мог прийти с ним ко мне за доступом)
- Показать последний сделанный снимок
- Включить/выключить режим обнаружения
- Сообщить всем пользователям бота, что режим работы изменен
import config import telebot from telebot import types import logging import datetime logger = telebot.logger telebot.logger.setLevel(logging.INFO) # Outputs debug messages to console. bot = telebot.TeleBot(config.token, threaded=True) ### Функция проверки авторизации def autor(chatid): strid = str(chatid) for item in config.users: if item == strid: return True return False ### Функция массвой рассылки уведомлений def sendall(text): if len(config.users) > 0: for user in config.users: try: bot.send_message(user, text) except: print(str(datetime.datetime.now()) + ' ' + 'Ошибка отправки сообщения ' + text + ' пользователю ' + str( user)) ### Функция проверки режима def checkmode(): try: mode_file = open("mode.txt", "r") modestring = mode_file.read() mode_file.close() if modestring == '1': return True else: return False except: return False print(str(datetime.datetime.now()) + ' ' + 'Я бот, я запустился!') sendall(str(datetime.datetime.now()) + ' ' + 'Я бот, я запустился!') ### Главное меню @bot.message_handler(commands=['Меню', 'start', 'Обновить']) def menu(message): if autor(message.chat.id): markup = types.ReplyKeyboardMarkup() markup.row('/Обновить', '/Охрана') if checkmode(): bot.send_message(message.chat.id, 'Режим охраны ВКЛ.', reply_markup=markup) else: bot.send_message(message.chat.id, 'Режим охраны ВЫКЛ.', reply_markup=markup) try: f = open(config.lastimage, 'rb') bot.send_photo(message.chat.id, f) except: bot.send_message(message.chat.id, 'Фоток нет') else: markup = types.ReplyKeyboardMarkup() markup.row('/Обновить') bot.send_message(message.chat.id, 'Тебе сюда нельзя. Твой ID: ' + str(message.chat.id), reply_markup=markup) ### Смена режи��а @bot.message_handler(commands=['Охрана']) def toggle(message): if autor(message.chat.id): try: if checkmode(): last_file = open("mode.txt", "w") last_file.write('0') last_file.close() sendall('Пользователь ' + message.chat.first_name + ' выключил режим охраны') else: last_file = open("mode.txt", "w") last_file.write('1') last_file.close() sendall('Пользователь ' + message.chat.first_name + ' включил режим охраны') except: bot.send_message(message.chat.id, 'Ошибка смены режима') print(str(datetime.datetime.now()) + ' ' + "Ошибка смены режима") menu(message) if __name__ == '__main__': bot.polling(none_stop=False)
Второй скрипт, запускается с некоторой периодичностью, проверяет есть ли необработанные jpg файлы без слова snapshot в имени и если включен режим обнаружения рассылает эти файлы всем пользователям бота.
import datetime import logging import os import time import telebot import config logger = telebot.logger telebot.logger.setLevel(logging.INFO) # Outputs debug messages to console. bot = telebot.TeleBot(config.token, threaded=True) files = [] clearfiles = [] tosend = [] tosendfull = [] ### Функция проверки режима def checkmode(): try: mode_file = open("mode.txt", "r") modestring = mode_file.read() mode_file.close() if modestring == '1': return True else: return False except: return False ## Функция массовой пассылки фотографий def sendall(filename): for username in config.users: try: f = open(filename, 'rb') bot.send_photo(username, f) except: print( str(datetime.datetime.now()) + ' ' + 'Ошибка отправки файла ' + filename + ' пользователю ' + username) ## Функция записи последнего обработтанного файла def writeproc(filename): try: last_file = open("last.txt", "w") last_file.write(filename) last_file.close() return last_file.close() except: return False ## Функция чтения последнего обработанного файла def readproc(): try: last_file = open("last.txt", "r") lasstring = last_file.read() last_file.close() lastint = str(lasstring) return lastint except: return -1 ## Читаем последний обработанный файл processed = readproc() if processed == -1: print(str(datetime.datetime.now()) + ' ' + 'Не Удалось прочитать последний обработанный файл. Выходим') quit(2) ## Читаем список файлов files = os.listdir(config.motiondir) files = filter(lambda x: x.endswith('.jpg'), files) ## Очищаем список от снапшотов и расширений, сортируем for file in files: if ('snapshot' in file) or ('last' in file) or ('-' in file): pass else: clearfile = file[:-4] clearfiles.append(clearfile) clearfiles.sort() ## Выбираем список необработанных файлов for file in clearfiles: if int(file) > int(processed): tosend.append(file) ### Если есть что отправлять: if len(tosend) > 0: try: if writeproc(tosend[-1]) == False: print(str(datetime.datetime.now()) + ' ' + 'Ошибка записи последнего элемента. Выходим!') quit(2) else: print(str(datetime.datetime.now()) + ' ' + 'Последний элемент записан успешно') ### Отправляем только если успешно записали последний - иначе будет бесконечная отправка ## Сначала проверяем режим if checkmode(): ## Потом формируем список фалов с полным именем for filename in tosend: fullname = config.motiondir + '/' + filename + '.jpg' tosendfull.append(fullname) ## Потом отправляем неторопливо for filename in tosendfull: sendall(filename) time.sleep(1) else: print(str(datetime.datetime.now()) + ' ' + 'Режим отправки выключен') except: print(str(datetime.datetime.now()) + ' ' + 'Ошибка отправки') else: print(str(datetime.datetime.now()) + ' ' + 'Нечего отправлять')
Шаг 4. Собираем всё в кучу
Все скрипты я разместил в каталоге /home/bigbro/bot. Для запуска, контроля и логирования использовал supervisor. Соответственно в каталоге /etc/supervisor/conf.d я создал файлы примерно такого вида:
[program:bot] directory=/home/bigbro/bot command=/usr/bin/python3 /home/bigbro/botbot.py autostart=true autorestart=true stderr_logfile=/var/log/bot.err.log stdout_logfile=/var/log/bot.out.log
Для периодического запуска скрипта отправки, можно было использовать cron, но из соображений единообразия я тоже запускаю его через supervisor и такой bash-скрипт:
#!/bin/bash while true; do python3 sender.py ; sleep 30; done;
Результат
Всё работает ровно так как и было задумано:

