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;
Результат
Всё работает ровно так как и было задумано: