Школьный звонок на Raspberry Pi с удаленным управлением

в 20:55, , рубрики: diy или сделай сам, linux, python, Raspberry Pi, метки: , , ,

Школьный звонок на Raspberry Pi с удаленным управлением Доброго времени суток, уважаемыее. Не секрет, что одноплатные Linux сомпьютеры на базе SoC на сегоднящний день получили широкое распространение как среди любителей, так и среди более-мелее профессиональных пользователей. Все больше и больше задач можно решить с помощью микрокомпьютеров, и даже тех задач, которые раньше решались исключительно при помощи микроконтроллеров. Казалось бы, использование полноценного, хоть и мелкого компьютера для решения простых задач — это тот еще оверкилл, однако давайте разберемся, так ли это плохо? Эта статья является ответом на наш небольшой спор сином devzona по этому поводу.

Предыстория

Казалось бы, что может быть более явной нишей для применения микроконтроллеров, чем автоматизация школьного звонка? Именно так думал неизвестный разработчик лет 5-7 назад, когда собирал вот такое замечательное устройство.
Школьный звонок на Raspberry Pi с удаленным управлением
Собрано, судя по всему, на МК серии 8050, имеет на боту часики реального времени, умеет это время показывать на самодельной светодиодной матрице, и самое главное, умеет вовремя дергать релюшку, включающую школьный звонок. Устройство благополучно работает уже много лет, претензий к нему не было. Однако, все течет и меняется, и однажды простая Харьковская школа с углубленным изучением чего-то там решила пройти переаттестацию в лицей с еще более углубленным изучением того самого. Такая переаттестация, помимо всего прочего, требует перехода с 45-минутных уроков на пары, состоящие из двух академических часов по 40 минут. Тут-то и пришла беда. Разработчик часиков на МК благополучно спился уехал за границу, исходников не оставил, возможности перенастройки не предусмотрел. Именно с этой проблемой постучался ко мне в Скайп одним осенним днем мой друг Костя.

Осмотрев пациента пришло понимание, что быстрее чем за пару недель его переделать под требования заказчика не получится. По сути, нужно переписывать код с нуля. И, внезапно, вечером этого же дня курьер из DHL привез мне очередной Raspberry. Тут и пришла идея сделать свои часики, да не просто часики, а с магией. Ведь у нас есть целый микрокомпьютер с полноценным линуксом не борту, руки развязаны, возможности безграничны!

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

Утром, после переговоров с заказчиком, задача была поставлена так: устройство должно конфигурироваться при помощи любого ПК, без дополнительного софта (дорого), уметь подтягивать точное время из интернета (по звонкам можно синхронизировать часы, все звонки строго с точностью до секунды), уметь работать автономно, и, как дополнительная опция на будущее, должны уметь получать конфигурацию звонков с удаленного сервера. Например, районо может самостоятельно выкладывать конфиг звонков для учебных заведений определенного типа. Задача поставлена, приступаем к реализации.

Для реализации проекта нам нужно следующее:

  • Демон, умеющий дергать нужную GPIO ножку в нужное время
  • Веб-интерфейс для конфигурирования времени звонков
  • Часы реального времени
  • Силовая электроника для управления школьными звонками

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

Итак, приступим.

Часы реального времени

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

Школьный звонок на Raspberry Pi с удаленным управлением

На этой же страничке доступен для скачивания софт, способный получать и записывать данные в эти RTC. Софт я немного переделал под себя, дабы визуализировать показания RTC перед синхронизацией с системным временем.

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

/usr/local/bin/update_rtc

#!/bin/bash

LOG="/var/log/rtc-sync.log"
DATE=`date`

sleep 30

echo "*** $DATE" >>$LOG

until ping -nq -c3 8.8.8.8; do
    echo "No network, updating system clock from RTC." >>$LOG
    rtc-pi 2>&1
    exit
done

echo "Network detected. Updating RTC." >>$LOG
date +%Y%m%d%H%M%S |xargs ./rtc-pi 2>&1

/etc/init.d/rtc

#!/bin/sh
# /etc/init.d/rtc 

### BEGIN INIT INFO
# Provides:          RTC controll
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Simple script to start RTC sync
# Description:       A simple script from prostosergik <serge.liskovsky@gmail.com> which will run script that synchronizes RTC module clock with system clock at startup.
### END INIT INFO


case "$1" in
  start)
    echo "RTC sync..."
    /usr/local/bin/update_rtc& 2>&1
    ;;
  stop)
    echo "Stopping RTC Sync..."
    # kill application you want to stop
    killall update_rtc
    ;;
  *)
    echo "Usage: /etc/init.d/rtc {start|stop}"
    exit 1
    ;;
esac

exit 0

… и активируем автозагрузку:

sudo update-rc.d rtc defaults

Эти два файла позволяют синхронизировать системные часики малинки с RTC в случае, если после загрузки не обнаружена сеть, или обновить время в RTC, если есть обнаружена. Через 30 сек после загрузки ntpd должен бы уже успеть обновить системные часы. В худшем случае, в RTC будет записано последнее время, когда Raspberry был включен. Я знаю, что это решение далеко не идеальное, но лучшего придумать не смог. Единственное, что приходит в голову — добавить строчку в крон для обновления RTC раз в 2-3 часа, дабы быть уверенным, что в часах реального времени более-менее точные данные. Если многоуважаемое сообщество подскажет лучшее решение — буду только рад.

Веб-сервер

Тут долго думать не пришлось. Основная задача сервера — показывать две странички и обрабатывать один POST запрос. Хрестоматийная реализация веб-сервера на Python просто напрашивается сама собой.

webserver.py

#!/usr/bin/python
# -*- coding: utf-8 -*-

import cgi, re, json
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
import collections

from config import * 

class MainRequestHandler(BaseHTTPRequestHandler):

    def do_GET(self):

        if self.path == '/':
            
            lessons = readSchedule()
            schedule = ''
            for lesson in lessons:
                schedule += u"<b>Час "+lesson+"</b>: "+lessons[lesson].get('start', '--:--') + " - " + lessons[lesson].get('end', '--:--') + "<br />"
            
            data = {
                'schedule': schedule.encode('utf-8')
            }
            
            TemplateOut(self, 'index.html', data)
            return

        elif self.path == '/form.html':
            
            lessons = readSchedule()

            form = ''
            for lesson in lessons:
                form += u"<div class='form_block'><label>Час "+lesson+"</label> <input type='text' name='lesson_"+lesson+"_start' value='"+lessons[lesson].get('start', '--:--') + "'> - <input type='text' name='lesson_"+lesson+"_end' value='"+lessons[lesson].get('end', '--:--') + "'> </div> """

            data = {
                'form': form.encode('utf-8')
            }
            
            TemplateOut(self, 'form.html', data)
            return
        
        elif self.path == '/remote.html':
            
            lessons = readScheduleRemote()

            form = ''
            for lesson in lessons:
                form += u"<div class='form_block'><label>Час "+lesson+"</label> <input type='text' name='lesson_"+lesson+"_start' value='"+lessons[lesson].get('start', '--:--') + "'> - <input type='text' name='lesson_"+lesson+"_end' value='"+lessons[lesson].get('end', '--:--') + "'> </div> """

            data = {
                'form': form.encode('utf-8')
            }
            
            TemplateOut(self, 'form.html', data)
            return

        else:
            try:
                TemplateOut(self, self.path)
            except IOError:
                self.send_error(404, 'File Not Found: %s' % self.path)

    def do_POST(self):

        # Parse the form data posted
        form = cgi.FieldStorage(
            fp=self.rfile, 
            headers=self.headers,
            environ={
                'REQUEST_METHOD':'POST',
                'CONTENT_TYPE':self.headers['Content-Type'],
            }
        )

        lessons = {}
        if self.path.endswith('save'):

            # Echo back information about what was posted in the form
            for field in form.keys():

                field_item = form[field]

                if type(field_item) == type([]):
                    pass # no arrays processing now
                else:
                    if field_item.filename:
                        pass #no files now.
                    else:
                        if re.match('lesson_([d]+)_(start|end)', field):
                            (lesson, state) = re.findall('lesson_([d]+)_(start|end)', field)[0]
                            try:
                                lessons[lesson]
                            except Exception:
                                lessons[lesson] = {}

                            lessons[lesson][state] = field_item.value

            # printlessons
            json_s = json.dumps(lessons)

            if json_s:
                try:
                    f = open(JSON_FILE, 'w+') 
                    f.write(json_s)
                    f.close()

                    HTMLOut(self, 'Saved OK.' + JS_REDIRECT)
                except IOError, e:
                    # raise e
                    HTMLOut(self, 'Error saving. IO error. '+e.message)
            else:
                HTMLOut(self, 'Json Error.')
        else:
            self.send_error(404, 'Wrong POST url: %s' % self.path)

        return



def Redirect(request, location):
    request.send_response(301)
    request.send_header('Location', location)
    request.end_headers()           
    return     

def Headers200(request):
    request.send_response(200)
    request.send_header('Content-type',    'text/html')
    request.end_headers()         
    return   

def TemplateOut(request, out_file, data = {}):

    f = open(SCRIPT_DIR + out_file) 
    out = f.read()
    f.close()

    #tiny template engine
    for key, var in data.items():
        out = out.replace("{{"+key+"}}", var)

    HTMLOut(request, out)

def HTMLOut(request, html):
    Headers200(request)

    f = open(SCRIPT_DIR + 'base.html') 
    out = f.read()
    f.close()

    out = out.replace("{{content}}", html)
    request.wfile.write(out)

def readSchedule():

    try:
        f = open(JSON_FILE, 'r') 
        json_s = f.read()
        f.close()
    except IOError:
        return [] 
    
    try:
        lessons = json.loads(json_s)
    except Exception:
        return []

    lessons = collections.OrderedDict(sorted(lessons.items()))

    return lessons

def readScheduleRemote():

    import urllib2

    try:
        response = urllib2.urlopen(REMOTE_URL)
        json_s = response.read()    
    except Exception:
        return []

    try:
        lessons = json.loads(json_s)
    except Exception:
        return []

    lessons = collections.OrderedDict(sorted(lessons.items()))

    return lessons

def main():
    try:
        server = HTTPServer(('', 8088), MainRequestHandler)
        print 'Started httpserver...'
        server.serve_forever()
    except KeyboardInterrupt:
        print '^C received, shutting down server.'
        server.socket.close()

if __name__ == '__main__':
    main()

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

Что делает этот скрипт, думаю, понятно и без комментариев. Обработчик GET-запросов попросту отдает клиенту две формочки и главную страницу, заполняя переменную данными про текущее расписание. Обработчик POST-запросов сохраняет данные из формы в JSON-файл, который и является базой звонков.

Собственно, управлятор школьным звонком

Благодаря замечательной библиотеке GPIO для Python, моргать светодиодиком школьным звонком с малинки очень просто. Этим занимается такой скрипт:

daemon.py

#!/usr/bin/python
# -*- coding: utf-8 -*-

import time
import threading
import json

import RPi.GPIO as GPIO

from config import * 

GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)

GPIO.setup(25, GPIO.OUT)
GPIO.output(25, False)

def read_schedule():
    schedule = []
    try:
        f = open(JSON_FILE, 'r') 
        json_s = f.read()
        f.close()
        try:
            json_data = json.loads(json_s)
        except Exception, e:
            json_data = []

        for lesson in json_data.values():
            start = lesson.get('start', False)
            end = lesson.get('end', False)
            if start is not False:
                # print start.split(":")
                (s_h, s_m) = start.split(":")
                schedule.append({'h': int(s_h), 'm':int(s_m)})
                del s_h
                del s_m
            if end is not False:
                (e_h, e_m) = end.split(":")
                schedule.append({'h': int(e_h), 'm':int(e_m)})            
                del e_h
                del e_m

        return schedule

        # schedule 
    except IOError, e:
        return []
    except Exception, e:
        return []

class Alarm(threading.Thread):
    def __init__(self):
        super(Alarm, self).__init__()
        self.schedule = read_schedule()
        self.keep_running = True

    def run(self):
        try:
            while self.keep_running:
                now = time.localtime()

                for schedule_item in self.schedule:
                    if now.tm_hour == schedule_item['h'] and now.tm_min == schedule_item['m']:
                        
                        print "Ring start..."
                        GPIO.output(25, True)
                        
                        time.sleep(5)
                        
                        print "Ring end..."
                        GPIO.output(25, False)

                        self.schedule = read_schedule() #reload schedule if it was changed
                        time.sleep(55) # more than 1 minute                        

                #print "Check at "+str(now.tm_hour)+':'+str(now.tm_min)+':'+str(now.tm_sec) 

                time.sleep(1)
        except Exception, e:
            raise e
            # return
    def die(self):
        self.keep_running = False

alarm = Alarm()

def main():
    try:
        alarm.start()
        print 'Started daemon...'
        while True:
            continue
    except KeyboardInterrupt:
        print '^C received, shutting down daemon.'
        alarm.die()

if __name__ == '__main__':
    main()

Скрипт создает новый поток, в котором проверяет время каждую секунду. Если время найдено в файле расписания, то на 5 секунд включаем звонок (подаем высокий уровень на ножку 25 GPIO). После каждого звонка перечитываем расписание, на случай, если оно было изменено из веб-интерфейса. Все прозрачно и просто.

Демонизируем и дрессируем смотрового пса

Действуя по аналогии с автозапуском синхронизации RTC, создаем следующие файлики:

/etc/init.d/schedule_daemon

#!/bin/sh
 
### BEGIN INIT INFO
# Provides:          schedule_daemon
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# description: School Ring Schedule daemon
# processname: School Ring Schedule daemon
### END INIT INFO

export SCHEDULE_ROOT=/home/pi/ring_app
export PATH=$PATH:$SCHEDULE_ROOT

SERVICE_PID=`ps -ef | grep daemon.py | grep -v grep | awk 'END{print $2}'`
 
usage() {
        echo "service schedule_daemon {start|stop|status}"
        exit 0
}

case $1 in
 
    start) 
        if [ $SERVICE_PID ];then
            echo "Service is already running. PID: $SERVICE_PID"
        else
            $SCHEDULE_ROOT/daemon.py& 2>&1
        fi
        ;;
    stop) 
        if [ $SERVICE_PID ];then
            kill -9 $SERVICE_PID
        else
            echo "Service is not running"
        fi
        ;;
    status) 

        if [ $SERVICE_PID ];then
            echo "Running. PID: $SERVICE_PID"
        else
            echo "Not running"
        fi
        ;;
    *) usage
        ;;
esac

/etc/init.d/schedule_webserver

#!/bin/sh
 
### BEGIN INIT INFO
# Provides:          schedule_webserver
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# description: School Ring Schedule web-server
# processname: School Ring Schedule web-server
### END INIT INFO

export SCHEDULE_ROOT=/home/pi/ring_app
export PATH=$PATH:$SCHEDULE_ROOT

SERVICE_PID=`ps -ef | grep webserver.py | grep -v grep | awk 'END{print $2}'`
 
usage() {
        echo "service schedule_webserver {start|stop|status}"
        exit 0
}

case $1 in
 
    start) 
        if [ $SERVICE_PID ];then
            echo "Service is already running. PID: $SERVICE_PID"
        else
            $SCHEDULE_ROOT/webserver.py& 2>&1
        fi
        ;;
    stop) 
        if [ $SERVICE_PID ];then
            kill -9 $SERVICE_PID
        else
            echo "Service is not running"
        fi
        ;;
    status) 
        if [ $SERVICE_PID ];then
            echo "Running. PID: $SERVICE_PID"
        else
            echo "Not running"
        fi
        ;;
    *) usage
        ;;
esac

И скрипты «сторожевых собачек» для них. Эти скрипты проверяют, запущен ли сервис, и, при необходимости, запускают его.

/etc/init.d/schedule_daemon_wd

#!/bin/sh
 
### BEGIN INIT INFO
# Provides:          schedule_daemon_wd
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# description: School Ring Schedule daemon watchdog
# processname: School Ring Schedule daemon watchdog
### END INIT INFO

export SCHEDULE_ROOT=/home/pi/ring_app
export PATH=$PATH:$SCHEDULE_ROOT

SERVICE_PID=`ps -ef | grep daemon.py | grep -v grep | awk '{print $2}'`
 
check_service() {
        if [ -z $SERVICE_PID ];then
            service schedule_daemon start
        fi
}
 
check_service

usage() {
    echo "schedule_daemon_wd {start|stop|status}"
    exit 0
}
 
case $1 in
    start ) 

        if [ $SERVICE_PID ];then
            echo "schedule_daemon is already running. PID: $SERVICE_PID"
        else
            service schedule_daemon start
        fi
        ;;
    stop ) 

        if [ $SERVICE_PID ];then
            service schedule_daemon stop
        else
            echo "schedule_daemon is already stopped"
        fi
        ;;
    status) 
        if [ $SERVICE_PID ];then
            echo "schedule_daemon is running. PID: $SERVICE_PID"
        else
            echo "schedule_daemon is not running"
        fi
        ;;
    *) usage
        ;;
esac

/etc/init.d/schedule_webserver_wd

#!/bin/sh
 
### BEGIN INIT INFO
# Provides:          schedule_webserver_wd
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# description: School Ring Schedule web-server watchdog
# processname: School Ring Schedule web-server watchdog
### END INIT INFO

export SCHEDULE_ROOT=/home/pi/ring_app
export PATH=$PATH:$SCHEDULE_ROOT

SERVICE_PID=`ps -ef | grep webserver.py | grep -v grep | awk '{print $2}'`
 
check_service() {
        if [ -z $SERVICE_PID ];then
            service schedule_webserver start
        fi
}
 
check_service

usage() {
    echo "schedule_webserver_wd {start|stop|status}"
    exit 0
}
 
case $1 in
    start ) 

        if [ $SERVICE_PID ];then
            echo "schedule_webserver is already running. PID: $SERVICE_PID"
        else
            service schedule_webserver start
        fi
        ;;
    stop ) 

        if [ $SERVICE_PID ];then
            service schedule_webserver stop
        else
            echo "schedule_webserver is already stopped"
        fi
        ;;
    status) 

        if [ $SERVICE_PID ];then
            echo "schedule_webserver is running. PID: $SERVICE_PID"
        else
            echo "schedule_webserver is not running"
        fi
        ;;
    *) usage
        ;;
esac

Аналогично, делаем эти скрипты автоматически загружаемыми при старте системы:

sudo update-rc.d schedule_daemon_wd defaults
sudo update-rc.d schedule_webserver_wd defaults

И добавляем в крон новые задания:

/etc/cron.d/wd.cron

#Watchdog tasks 

* * * * * /etc/init.d/schedule_daemon_wd
* * * * * /etc/init.d/schedule_webserver_wd

Теперь мы можем быть уверены, что оба демона запустились и будут стабильно работать. Не забываем добавить новую строчку в конце wd.cron, иначе crond будет его игнорировать!

Немного про силовую электронику

Вся силовая часть собрана совершенно стандартно. Суммарная мощность звонков в школе около 0.5 КВт, так что симистора BC137X в паре с оптроном MOC3061 вполне достаточно для коммутации этого хозяйства. Как показала практика, 3.3 вольта логической единицы достаточно для уверенного включения оптрона.

Школьный звонок на Raspberry Pi с удаленным управлением

Можно было бы применить тут и реле, но как-то я не доверяю контактам, когда есть такие замечательные полупроводники. Фотографию макета преднамеренно не выкладываю, т.к. до красивого монтажа так и не дошло.

Чего не хватает

Конечно, имея полноценный Linux-компьютер в своем распоряжении, можно «наворачивать» функциональность до бесконечности, причем времени на разработку уйдет сравнительно мало. Именно это обстоятельство говорит в пользу применения микрокомпьютеров для решения задач, с которыми, казалось бы, справится и микроконтроллер. Однако, все-же перечислю то, чего, по моему мнению, не хватает текущей реализации:

Во первых, безопасность. Стоило бы заморочиться на простой HTTP-Auth хотя бы, или, дописав немного скрипт, сделать базу паролей для входа в «админ-панель» системы. Да и над фильтрацией данных поработать стоит, как до, так и после отправки формы.
Во вторых, нужно бы добавить добавление/удаление академ. часов в форму. Внимательный читатель заметил, что это можно сделать попросту дорисовав в форму на клиентской стороне необходимые поля при помощи, например, простенького JavaScript кода.
В третьих, мне так хотелось сделать «тревожную кнопку» на главной, которая запускала бы звонок за 5-10 секунд. Пусть это будет маленькая задачка для пытливых умов читателей, благо, все необходимое для этого есть в статье.
В четвертых, не хватает блока бесперебойного питания. Ввиду отказа заказчика от разработки, до него мы так и не дошли.

Чем всё закончилось

К сожалению, Харьковская гимназия с углубленным изучением чего-то там решила, что собрать по 3 гривны с каждого родителя это очень, очень трудно, и нам в итоге дали от ворот поворот, поэтому реализация остановилась на действующем прототипе, который не содержит некоторых важных для конечной системы элементов. Но время, потраченное на разработку не прошло даром. Опыт разработки приложений для работы с железом на Python мне, надеюсь, не раз пригодится в жизни, тем более на загородном участке заканчивается строительство дома, в котором предусмотрена возможность управления всем из единого мозгового центра. Если смог управлять звонком, то и лампочки по расписанию включать смогу.

Послесловие

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

На реализацию всего вышеописанного было потрачено чуть более трех часов. Доведение до ума того, что есть требует еще столько же. Традиционно попрошу не пинать сильно за кривоватый местами код и возможные ошибки. Это моя первая статья на Хабре, и первый реализованный проект на Python. Всегда рад поправкам, пожеланиям и предложениям. Скриншоты и видео работы выложу по требованию.

С нетерпением жду реализации комрадом devzona подобного функционала, но только на основе Arduino. Уверен, мне есть чему у него поучиться в плане разработки устройств на микроконтроллерах. Статья обещает быть воистину захватывающей.

Автор: prostosergik

Источник


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


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