Привет, Хабр.

Сегодня мы продолжим изучать сетевые возможности Raspberry Pi, а точнее их реализацию на языке Python. В первой части мы рассмотрели базовые функции простейшего веб-сервера, работающего на Raspberry Pi. Сейчас мы пойдем дальше, и рассмотрим несколько способов, как сделать наш сервер интерактивным.



Статья рассчитана для начинающих.

Перед началом пара примечаний.

Во-первых, я и сам сомневался, стоит ли делать продолжение и ожидал больший поток критики и низких оценок, но как показал опрос в первой части, 85% читателей нашли приведенную там информацию полезной. Понимаю, что некоторых профи статьи «для чайников» раздражают, но все когда-то начинали, так что придется потерпеть.

Во-вторых, я буду писать про программирование, а не про администрирование. Так что вопросы настройки Raspbian, конфигов, VPN, безопасности и прочего, здесь рассматриваться не будут. Хотя это тоже важно, но нельзя объять необъятное. Здесь будет только про Python, и как сделать сервер на нем.

Кому все это неинтересно, могут нажать кнопку back в браузере прямо сейчас и не тратить свое ценное время ;)

А мы приступим.

Напомню, в предыдущей части мы закончили на том, что запустили на Raspberry Pi простой веб-сервер, показывающий статическую страницу:

image

Сейчас мы пойдем дальше, и сделаем наш сервер интерактивным, добавим на веб-страницу управление светодиодом. Разумеется, вместо светодиода может быть любое другое устройство, способное управляться от GPIO, но со светодиодом провести опыт проще всего.

Подготовка


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

try:
    import RPi.GPIO as GPIO
except ModuleNotFoundError:
    pass

led_pin = 21

def raspberrypi_init():
    try:
        GPIO.setmode(GPIO.BCM)
        GPIO.setup(led_pin, GPIO.OUT)
    except:
        pass

def rasperrypi_pinout(pin: int, value: bool):
    print("LED ON" if value else "LED OFF")
    try:
        GPIO.output(pin, value)
    except:
        pass

def rasperrypi_cleanup():
    try:
        GPIO.cleanup()
    except:
        pass

Как можно видеть, каждая функция обращения к GPIO «обернута» в блок try-catch. Зачем это сделано? Это позволяет отлаживать сервер на любом ПК, включая Windows, что достаточно удобно. Теперь мы можем вставить эти функции в код веб-сервера.

Наша задача — добавить на веб-страницу кнопки, позволяющие из браузера управлять светодиодом. Будут рассмотрены 3 способа реализации.

Способ 1: Неправильный


Этот способ нельзя назвать красивым, зато он короткий и наиболее простой для понимания.

Создадим строку с HTML-страницей.

html = '''<html>
              <style>html{font-family: Helvetica; display:inline-block; margin: 0px auto; text-align: center;}
                 .button_led {display: inline-block; background-color: #e7bd3b; border: none; border-radius: 4px; color: white; padding: 16px 40px; text-decoration: none; font-size: 30px; margin: 2px; cursor: pointer;}
              </style>
              <body>
                 <h2>Hello from the Raspberry Pi!</h2>
                 <p><a href="/led/on"><button class="button button_led">Led ON</button></a></p>
                 <p><a href="/led/off"><button class="button button_led">Led OFF</button></a></p>
              </body>
            </html>'''

Здесь можно отметить 3 момента:

  • Мы используем CSS для указания стиля кнопок. Это можно было бы и не делать и обойтись всего 4 строчками HTML-кода, но тогда наша страница выглядела бы как «привет из 90х»:

  • Для каждой кнопки мы создаем локальную ссылку типа /led/on и /led/off
  • Смешивать ресурсы и код это плохой стиль программирования, и в идеале, HTML лучше хранить отдельно от кода на Python. Но моя цель — показать минимально работающий код, в котором минимум лишнего, так что некоторые вещи для простоты опущены. К тому же, это удобно, когда код можно просто скопировать из статьи, без лишней возни с дополнительными файлами.

Сам сервер мы уже рассматривали в предыдущей части, осталось добавить в него обработку строк '/led/on' и '/led/off'. Обновленный код целиком:

from http.server import BaseHTTPRequestHandler, HTTPServer

class ServerHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        print("GET request, Path:", self.path)
        if self.path == "/" or self.path.endswith("/led/on") or self.path.endswith("/led/off"):
            if self.path.endswith("/led/on"):
                rasperrypi_pinout(led_pin, True)
            if self.path.endswith("/led/off"):
                rasperrypi_pinout(led_pin, False)
            self.send_response(200)
            self.send_header('Content-type', 'text/html')
            self.end_headers()
            self.wfile.write(html.encode('utf-8'))
        else:
            self.send_error(404, "Page Not Found {}".format(self.path))

def server_thread(port):
    server_address = ('', port)
    httpd = HTTPServer(server_address, ServerHandler)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    httpd.server_close()

if __name__ == '__main__':

    port = 8000
    print("Starting server at port %d" % port)

    raspberrypi_init()

    server_thread(port)

    rasperrypi_cleanup()

Запускаем, и если все было сделано правильно, то мы можем управлять светодиодом через наш веб-сервер:



Тестировать сервер можно не только на Raspberry Pi, но и на Windows или OSX, в консоли будут сообщения LED ON, LED OFF при нажатии на соответствующую кнопку:



Теперь выясним, чем же этот способ плох, и почему он «неправильный». Этот пример вполне рабочий, и довольно часто копируется в разных туториалах. Но проблем тут две — во-первых, это неправильно перезагружать страницу целиком, когда мы лишь хотим зажечь светодиод. Но это еще полпроблемы. Вторая, и более серьезная, проблема в том, что когда мы нажимаем кнопку включения светодиода, адрес страницы становится http://192.168.1.106:8000/led/on. Браузеры обычно запоминают последнюю открытую страницу, и при последующем открытии браузера команда включения светодиода сработает еще раз, даже если мы этого не хотели. Поэтому мы перейдем к следующему, более правильному способу.

Способ 2: Правильный


Чтобы сделать все правильно, вынесем функции включения и выключения светодиода в отдельные запросы, а вызывать их будем асинхронно с помощью Javascript. Код HTML страницы теперь будет выглядеть так:

html = '''<html>
              <style>html{font-family: Helvetica; display:inline-block; margin: 0px auto; text-align: center;}
                 .button_led {display: inline-block; background-color: #e7bd3b; border: none; border-radius: 4px; color: white; padding: 16px 40px; text-decoration: none; font-size: 30px; margin: 2px; cursor: pointer;}
              </style>
              <script type="text/javascript" charset="utf-8">
                    function httpGetAsync(method, callback) {
                        var xmlHttp = new XMLHttpRequest();
                        xmlHttp.onreadystatechange = function() { 
                            if (xmlHttp.readyState == 4 && xmlHttp.status == 200)
                                callback(xmlHttp.responseText);
                        }
                        xmlHttp.open("GET", window.location.href + method, true);
                        xmlHttp.send(null);
                    }
    
                    function ledOn() {
                        console.log("Led ON...");
                        httpGetAsync("led/on", function(){ console.log("Done"); });
                    }
    
                    function ledOff() {
                        console.log("Led OFF...");
                        httpGetAsync("led/off", function(){ console.log("Done"); });
                    }                            
              </script>
              <body>
                 <h2>Hello from the Raspberry Pi!</h2>
                 <p><button class="button button_led" onclick="ledOn();">Led ON</button></p>
                 <p><button class="button button_led" onclick="ledOff();">Led OFF</button></p>
              </body>
            </html>'''

Как можно видеть, мы отказались от href, и вызываем функции ledOn и ledOff, которые в свою очередь, асинхронно вызывают соответствующие методы сервера (асинхронные методы нужны для того, чтобы страница не блокировалась, пока ответ от сервера не пришел).

Теперь осталось добавить на сервер обработку get-запросов:

    def do_GET(self):
        print("GET request, path:", self.path)
        if self.path == "/":
            self.send_response(200)
            self.send_header('Content-type', 'text/html')
            self.end_headers()
            self.wfile.write(html.encode('utf-8'))
        elif self.path == "/led/on":
            self.send_response(200)
            self.send_header('Content-type', 'text/plain')
            self.end_headers()
            rasperrypi_pinout(led_pin, True)
            self.wfile.write(b"OK")
        elif self.path == "/led/off":
            self.send_response(200)
            self.send_header('Content-type', 'text/plain')
            self.end_headers()
            rasperrypi_pinout(led_pin, False)
            self.wfile.write(b"OK")
        else:
            self.send_error(404, "Page Not Found {}".format(self.path))

Теперь, как можно видеть, страница уже не перезагружается при попытке зажечь светодиод, каждый метод делает только то, что и должен делать.

Способ 3: Более правильный


Вроде бы все уже работает. Но разумеется, приведенный код можно (и нужно) улучшить. Дело в том, что для управления светодиодом мы используем GET-запросы. Это экономит нам место в коде, но методологически это не совсем правильно — GET-запросы предназначены для чтения данных с сервера, они могут кешироваться браузером, и вообще говоря, не должны использоваться для изменения данных. Правильный способ — это использовать POST (для тех, кому интересны детали, подробнее тут).

Поменяем вызовы в HTML с get на post, ну а заодно, раз уж код у нас асинхронный, выведем статус ожидания ответа сервера и отображения результатов работы. Для локальной сети это заметно не будет, но для медленного соединения весьма удобно. Чтобы было интереснее, для передачи параметров будем использовать JSON.

Окончательный вариант выглядит так:

html = '''<html>
              <style>html{font-family: Helvetica; display:inline-block; margin: 0px auto; text-align: center;}
                 .button_led {display: inline-block; background-color: #e7bd3b; border: none; border-radius: 4px; color: white; padding: 16px 40px; text-decoration: none; font-size: 30px; margin: 2px; cursor: pointer;}
              </style>
              <script type="text/javascript" charset="utf-8">
                    function httpPostAsync(method, params, callback) {
                        var xmlHttp = new XMLHttpRequest();
                        xmlHttp.onreadystatechange = function() { 
                            if (xmlHttp.readyState == 4 && xmlHttp.status == 200)
                                callback(xmlHttp.responseText);
                            else
                                callback(`Error ${xmlHttp.status}`)
                        }
                        xmlHttp.open("POST", window.location.href + method, true);
                        xmlHttp.setRequestHeader("Content-Type", "application/json");
                        xmlHttp.send(params);
                    }

                    function ledOn() {
                        document.getElementById("textstatus").textContent = "Making LED on...";
                        httpPostAsync("led", JSON.stringify({ "on": true }), function(resp) { 
                            document.getElementById("textstatus").textContent = `Led ON: ${resp}`;
                        });
                    }

                    function ledOff() {
                        document.getElementById("textstatus").textContent = "Making LED off...";
                        httpPostAsync("led", JSON.stringify({ "on": false }), function(resp) { 
                            document.getElementById("textstatus").textContent = `Led OFF: ${resp}`;
                        });
                    }                            
              </script>
              <body>
                 <h2>Hello from the Raspberry Pi!</h2>
                 <p><button class="button button_led" onclick="ledOn();">Led ON</button></p>
                 <p><button class="button button_led" onclick="ledOff();">Led OFF</button></p>
                 <span id="textstatus">Status: Ready</span>
              </body>
            </html>'''

Добавим в сервер поддержку GET и POST запросов:

import json

class ServerHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        print("GET request, path:", self.path)
        if self.path == "/":
            self.send_response(200)
            self.send_header('Content-type', 'text/html')
            self.end_headers()
            self.wfile.write(html.encode('utf-8'))
        else:
            self.send_error(404, "Page Not Found {}".format(self.path))

    def do_POST(self):
        content_length = int(self.headers['Content-Length'])
        body = self.rfile.read(content_length)
        try:
            print("POST request, path:", self.path, "body:", body.decode('utf-8'))
            if self.path == "/led":
                data_dict = json.loads(body.decode('utf-8'))
                if 'on' in data_dict:
                    rasperrypi_pinout(led_pin, data_dict['on'])

                self.send_response(200)
                self.send_header('Content-type', 'text/plain')
                self.end_headers()
                self.wfile.write(b"OK")
            else:
                self.send_response(400, 'Bad Request: Method does not exist')
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
        except Exception as err:
            print("do_POST exception: %s" % str(err))

Как можно видеть, мы теперь используем одну функцию led, в которую с помощью json передается параметр «on», принимающий True или False (при вызове в HTML передается соответственно json-строка вида { «on»: true }). Также стоит обратить внимание на try-catch — это блокирует сервер от «падения», например, если кто-то пошлет строку с невалидным json на сервер.

Если все было сделано правильно, мы получаем сервер с обратной связью, который должен выглядеть примерно так:



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

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

Заключение


Если все было сделано правильно, мы получим мини-сервер, позволяющий управлять светодиодом или любым другим устройством через браузер с любого устройства сети.

Важно: Меры безопасности

Еще раз отмечу, что никакой защиты или аутентификации тут нет, так что не стоит «выкладывать» такую страницу в Интернет, если планируется управлять какой-то более-менее ответственной нагрузкой. Хотя случаи атак на подобные серверы мне неизвестны, но все же не стоит давать любому желающему возможность удаленно открыть дверь гаража или включить киловаттный обогреватель. При желании удаленного управления через такую страницу, стоит настроить VPN или что-то аналогичное.

В завершение повторюсь, что материал расчитан для начинающих, и надеюсь, это было более-менее полезно. Понятно, что не все на Хабре довольны наличием статей «для чайников», так что будет или нет следующая часть, будет зависеть от итоговых оценок. Если будет, то в ней будут рассмотрены фреймворки Flask и WSGI, так же будут рассмотрены базовые методы аутентификации.

Всем удачных экспериментов.

Комментарии (27)


  1. Marwin
    22.10.2019 23:25

    простите за оффтоп, опус чисто посмеяться. Так совпало, что сегодня решил приладить лежащую в столе малинку под принт сервер для USB принтера. Накатил raspbian+cups+samba. Дело нехитрое, почти все конфиги на дефолте. Принтер традиционный HP LaserJet 1020. Вобщем сюрпризов не ожидалось. Однако ж…


    1) первая группа компов на win10 — малину видно по шаре, запросило логин-пасс, ввел, подошло, открыл шару принтеров, устанавливаю принтер — после выбора драйвера пишет нет связи с принтером, попробовал еще и еще… опа, сработало. Чудеса! Причем на двух машинах не с первого раза. Ладно, думаю, бывает, главное встало.


    2) Едем дальше: windows server 2019 — шара ни по имени ни по ip не открывается, пишет сетевой путь не найден. Хотя в проводнике в нетворке малинка отображается как комп! Ну и ладно, не больно-то и хотелось с сервера печатать, всё равно у меня он типо NAS безлюдный.


    3) Пришла очередь win7 — шару видно, заходим в принтеры — логин пасс не спрашивало вообще, но при этом открыло. Пробуем установить принтер — ошибка, нет связи с принтером. И магия третьей попытки уже не срабатывает, и даже с десятой не помогло. Пичалька.


    Вобщем мораль сей басни такова… жись — боль. А скрещивание ежа с ужом да еще и на арме — боль вдвойне )


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


    1. DmitrySpb79 Автор
      23.10.2019 00:06
      +1

      По моему скромному опыту (я все же не админ) с шарами в Windows всегда геморрой :)


  1. Tihon_V
    22.10.2019 23:53
    +2

    Ух и накрутили… А чем плох способ с отправкой формы и методом PUT? Он работает одинаково на всех устройствах и не требует поддержки JS (У нас ведь IoT, верно? А на фоторамке работать будет?)

    Немного кода…
    <form action="/api/switch_light" method="put" id="disable">
    	<input type=hidden name=light value=0 />
    </form>
    <form action="/api/switch_light/" method="put" id="enable">
    	<input type=hidden name=light value=1 />
    </form>
    <button type="submit" form="disable" value="Submit">On</button>
    <button type="submit" form="enable" value="Submit">Off</button>


    1. DmitrySpb79 Автор
      23.10.2019 00:05
      +1

      Спасибо, с формой тоже возможный вариант.


    1. arthuriantech
      23.10.2019 00:58

      PUT не поддерживается для HTML-форм.
      https://developer.mozilla.org/ru/docs/Web/HTML/Element/form#attr-method


      1. Tihon_V
        23.10.2019 02:39

        Не задумывался о поддержке соответствующего атрибута.

        Тогда POST, но уж точно не GET, иначе любой поисковой бот который случайно наткнется на веб-страницу будет включать/выключать свет.


    1. LeshiyUrban
      23.10.2019 05:23

      В таком простом проекте даже bottle.py сойдёт. А как только перестанет хватать (что вряд-ли) на flask всего несколько минут мигрировать.
      И читать проще код будет, и функционала больше


      1. trapwalker
        23.10.2019 09:57

        Там автор про асинхронность затеял… Тогда aiohttp уж надо было брать. Уж куда компактнее и прозрачнее получилось бы.


        1. DmitrySpb79 Автор
          23.10.2019 13:42

          Асинхронность тут только на стороне Javascript. А asyncio для начинающих вообще штука мрачная, лучше и не пробовать :)


  1. Revertis
    23.10.2019 01:38

    А при заходе на страничку такую можно выводить состояние светодиода? Я понимаю, что сервер может хранить где-то флажок, но можно ли считать состояние пина и его уже вернуть?


    1. DmitrySpb79 Автор
      23.10.2019 07:59

      Я не пробовал, пишут что можно:
      www.raspberrypi.org/forums/viewtopic.php?t=49451


  1. xiaklizrum
    23.10.2019 07:02

    напоминает картинку с хлебом и автобусом, вряд ли что-то серьезнее hello world'а на джанге будет работать нормально


    1. Tihon_V
      23.10.2019 13:05

      Будет. Но БД надо размещать на другом сервере, или использовать in-memory. Долгое время на raspberry pi использовал Asterisk (до 4-х конкурентных звонков) и пара WSGI-приложений.

      Нельзя размещать приложения требовательные к:
      — сети (реализована поверх usb)
      — IO (microSD)
      — времени (нет хардварных часов в комплекте)

      А так ARM — вполне годится в качестве домашней тестовой площадки.


  1. trapwalker
    23.10.2019 10:19

    Вместо ваших подавляющих ошибки try-except'ов


    try:
        import RPi.GPIO as GPIO
    except ModuleNotFoundError:
        pass

    можно использовать куда более элегантные конструкции из стандартной библиотеки:


    with contextlib.suppress(ModuleNotFoundError):
        import RPi.GPIO as GPIO
    
    def rasperrypi_cleanup():
        with contextlib.suppress(Exception):
            GPIO.cleanup()

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


    class RPiDrvAbstract:
        def __init__(self, led_pin: int = 21):
            self.led_pin = led_pin
    
        def init(self):
            log.debug('GPIO init')
    
        def pin_out(self, pin: int, value: bool)
            log.debug(f'GPIO pin #{pin} set to {value}')
    
        def claenup(self):
            log.debug('GPIO cleanup')
    
    class RPiDrv(RPiDrvAbstract):
        def __init__(self, *av, **kw):
            super().__init__(*av, **kw)
            import RPi.GPIO as GPIO
            self.GPIO = GPIO
    
        def init(self):
            super().init()
            self.GPIO.setmode(GPIO.BCM)
            self.GPIO.setup(led_pin, GPIO.OUT)
    
        def rasperrypi_pinout(pin: int, value: bool):
            super().pin_out(pin, value)
            self.GPIO.output(pin, value)
    
        def cleanup(self):
            super().cleanup()
            self.GPIO.cleanup()
    
    try:
        drv = RPiDrv()
        drv.init()
    except Exception as e:
        log.warninig(f'RPi initialization error {e}. Mockup used.')
        drv = RPiDrvAbstract()
        drv.init()


    1. DmitrySpb79 Автор
      23.10.2019 13:23

      Спасибо, хороший вариант.


  1. ZaitsXL
    23.10.2019 13:19

    Неплохо бы в назваии статьи (в первой части тоже) указывать какой именно сервер вы тут поднимаете, их ведь много хороших и разных


    1. DmitrySpb79 Автор
      23.10.2019 13:40

      Здесь не рассматривается запуск готового сервера, используется свой собственный на Python.


      1. ZaitsXL
        23.10.2019 13:42

        Я это понял, но какой сервер вы пишете на Python? Веб? БД? Терминалов? Этого из заголовка неясно, вот я о чем


  1. zoldaten
    23.10.2019 22:52

    Спасибо за статью.
    Со светодиодами понятно, они уже пропитаны Дмитриями Осиповыми.
    Напишите про диммер-полоску для светодиода. Для многих будет актуально. Как плавно гасить и включать с веб-страницы?


    1. DmitrySpb79 Автор
      23.10.2019 23:20

      Дайте ссылку, что за полоска такая.

      Если обычная светодиодная лента — смотрите в сторону ШИМ, поддержка есть в Python. Примерное описание есть здесь: medium.com/@danidudas/how-to-connect-rgb-strip-led-lights-to-raspberry-pi-zero-w-and-control-from-node-js-70ddfec19f0b


      1. zoldaten
        24.10.2019 09:31

        Не совсем лента, но тоже PWM. Есть рука —


        1. DmitrySpb79 Автор
          24.10.2019 10:13

          Методически правильнее POST, да. По стандарту GET должен использоваться только для чтения: www.w3.org/Protocols/rfc2616/rfc2616-sec9.html

          Но это не значит, что многие так не делают :)


          1. zoldaten
            24.10.2019 14:08

            Методы одинаково быстро работают? И не будет ли здесь уместнее java? Проблема в том, что при быстрым взаимодействии (поркутить сразу несколько бегунков с короткими паузами), в том числе при выводе на эту же страницу видео с gstreamer, рука начинает откровенно подвисать и потом либо «нагоняет» упущенное потоком движений либо стопорится.

            *управление RGB в статье — артиллерия по воробьям. Сейчас rgb идут с ик-модулями, командам с пульта к которым можно обучить rm-mini например и управлять удаленно без rasberry. **А pigpio daemon (в статье о нем) еще тот фрукт.


            1. DmitrySpb79 Автор
              24.10.2019 14:13

              Возможно, паузы из-за того, что Flask настроен на работу в однопоточном режиме.

              stackoverflow.com/questions/14814201/can-i-serve-multiple-clients-using-just-flask-app-run-as-standalone

              Хотя возможно это было сделано специально, если PCA9685_pwm не поддерживает многопоточность.


              1. zoldaten
                24.10.2019 14:16

                То есть, PCA, рассчитанная на 16 серв, управляет каждым по очереди? Вот почему она 150 р. стоит!


                1. DmitrySpb79 Автор
                  24.10.2019 14:22

                  Все же нет, посмотрел сейчас исходник github.com/adafruit/Adafruit_Python_PCA9685/blob/master/Adafruit_PCA9685/PCA9685.py, каналы назначаютс отдельно.

                  Думаю, когда юзер двигает сразу несколько слайдеров, генерится большое число запросов одновременно. Возможно, можно настроить многопоточность в Flask, или даже имело бы смысл использовать websocket, стало бы быстрее.


                1. DmitrySpb79 Автор
                  24.10.2019 14:24

                  Самый простой вариант для пробы — добавить threaded=true как описано здесь: medium.com/@dkhd/handling-multiple-requests-on-flask-60208eacc154

                  Тогда желательно добавить блокировку, чтобы несколько запросов к PCA9685_pwm.set_pwm не выполнялись одновременно, иначе в I2C будут неправильные данные, если все смешается.