python

Телеграм-бот для домашнего видео-наблюдения из подручных материалов

  • воскресенье, 5 ноября 2017 г. в 03:12:43
https://habrahabr.ru/post/341678/
  • Системы обмена сообщениями
  • Разработка для интернета вещей
  • Python


Disclaimer


Эта статья содержит некоторое количество программного кода, написанного на языке Python. Ввиду того, что автор статьи по профессии является сисадмином, но не программистом — стиль и качество этого кода, могут вызвать проявление неконтролируемых эмоций у профессионалов. Пожалуйста, немедленно прекратите чтение если вид неаккуратного или неоптимального кода может негативно сказаться на вашем психическом состоянии.


Постановка задачи


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



Из всего перечисленного, было решено построить систему домашнего видео-наблюдения с функционалом оповещения о вторжении. В качестве платформы был выбран телеграм-бот. Бот имеет следующие преимущества перед другими возможными реализациями (веб, мобильное приложение):


  • Не требуется установки дополнительного клиентского ПО
  • Серверная часть может работать с приватным 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) настолько обширен, что в рамках данной статьи его невозможно описать полностью, остановлюсь лишь на тех параметрах которые значимы или были изменены от стандартных:


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 пользователей для которых разрешен доступ, имя файла с последним снимком, путь к папке со всеми снимками.


config.py
token = '4345435465:AsdfzzsdxgsYnb8DxDtn2L5KjfePsXozjv-o0'
users=['1234567890','0987654321']
lastimage='/var/lib/motion/lastsnap.jpg'
motiondir='/var/lib/motion'

Собственно сам бот. В нем реализованы следующие функции:


  • Проверить, разрешен ли пользователю доступ
  • Сообщить неавторизованному пользователю его ID (чтобы он мог прийти с ним ко мне за доступом)
  • Показать последний сделанный снимок
  • Включить/выключить режим обнаружения
  • Сообщить всем пользователям бота, что режим работы изменен

bot.py
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 в имени и если включен режим обнаружения рассылает эти файлы всем пользователям бота.


sender.py
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;

Результат
Всё работает ровно так как и было задумано:


image