Сегодня мы продолжим изучать сетевые возможности Raspberry Pi, а точнее их реализацию на языке Python. В первой части мы рассмотрели базовые функции простейшего веб-сервера, работающего на Raspberry Pi. Сейчас мы пойдем дальше, и рассмотрим несколько способов, как сделать наш сервер интерактивным.
Статья рассчитана для начинающих.
Перед началом пара примечаний.
Во-первых, я и сам сомневался, стоит ли делать продолжение и ожидал больший поток критики и низких оценок, но как показал опрос в первой части, 85% читателей нашли приведенную там информацию полезной. Понимаю, что некоторых профи статьи «для чайников» раздражают, но все когда-то начинали, так что придется потерпеть.
Во-вторых, я буду писать про программирование, а не про администрирование. Так что вопросы настройки Raspbian, конфигов, VPN, безопасности и прочего, здесь рассматриваться не будут. Хотя это тоже важно, но нельзя объять необъятное. Здесь будет только про Python, и как сделать сервер на нем.
Кому все это неинтересно, могут нажать кнопку back в браузере прямо сейчас и не тратить свое ценное время ;)
А мы приступим.
Напомню, в предыдущей части мы закончили на том, что запустили на Raspberry Pi простой веб-сервер, показывающий статическую страницу:
Сейчас мы пойдем дальше, и сделаем наш сервер интерактивным, добавим на веб-страницу управление светодиодом. Разумеется, вместо светодиода может быть любое другое устройство, способное управляться от 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)
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>
arthuriantech
23.10.2019 00:58PUT не поддерживается для HTML-форм.
https://developer.mozilla.org/ru/docs/Web/HTML/Element/form#attr-methodTihon_V
23.10.2019 02:39Не задумывался о поддержке соответствующего атрибута.
Тогда POST, но уж точно не GET, иначе любой поисковой бот который случайно наткнется на веб-страницу будет включать/выключать свет.
LeshiyUrban
23.10.2019 05:23В таком простом проекте даже bottle.py сойдёт. А как только перестанет хватать (что вряд-ли) на flask всего несколько минут мигрировать.
И читать проще код будет, и функционала большеtrapwalker
23.10.2019 09:57Там автор про асинхронность затеял… Тогда aiohttp уж надо было брать. Уж куда компактнее и прозрачнее получилось бы.
DmitrySpb79 Автор
23.10.2019 13:42Асинхронность тут только на стороне Javascript. А asyncio для начинающих вообще штука мрачная, лучше и не пробовать :)
Revertis
23.10.2019 01:38А при заходе на страничку такую можно выводить состояние светодиода? Я понимаю, что сервер может хранить где-то флажок, но можно ли считать состояние пина и его уже вернуть?
DmitrySpb79 Автор
23.10.2019 07:59Я не пробовал, пишут что можно:
www.raspberrypi.org/forums/viewtopic.php?t=49451
xiaklizrum
23.10.2019 07:02напоминает картинку с хлебом и автобусом, вряд ли что-то серьезнее hello world'а на джанге будет работать нормально
Tihon_V
23.10.2019 13:05Будет. Но БД надо размещать на другом сервере, или использовать in-memory. Долгое время на raspberry pi использовал Asterisk (до 4-х конкурентных звонков) и пара WSGI-приложений.
Нельзя размещать приложения требовательные к:
— сети (реализована поверх usb)
— IO (microSD)
— времени (нет хардварных часов в комплекте)
А так ARM — вполне годится в качестве домашней тестовой площадки.
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()
ZaitsXL
23.10.2019 13:19Неплохо бы в назваии статьи (в первой части тоже) указывать какой именно сервер вы тут поднимаете, их ведь много хороших и разных
DmitrySpb79 Автор
23.10.2019 13:40Здесь не рассматривается запуск готового сервера, используется свой собственный на Python.
ZaitsXL
23.10.2019 13:42Я это понял, но какой сервер вы пишете на Python? Веб? БД? Терминалов? Этого из заголовка неясно, вот я о чем
zoldaten
23.10.2019 22:52Спасибо за статью.
Со светодиодами понятно, они уже пропитаны Дмитриями Осиповыми.
Напишите про диммер-полоску для светодиода. Для многих будет актуально. Как плавно гасить и включать с веб-страницы?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
Marwin
простите за оффтоп, опус чисто посмеяться. Так совпало, что сегодня решил приладить лежащую в столе малинку под принт сервер для USB принтера. Накатил raspbian+cups+samba. Дело нехитрое, почти все конфиги на дефолте. Принтер традиционный HP LaserJet 1020. Вобщем сюрпризов не ожидалось. Однако ж…
1) первая группа компов на win10 — малину видно по шаре, запросило логин-пасс, ввел, подошло, открыл шару принтеров, устанавливаю принтер — после выбора драйвера пишет нет связи с принтером, попробовал еще и еще… опа, сработало. Чудеса! Причем на двух машинах не с первого раза. Ладно, думаю, бывает, главное встало.
2) Едем дальше: windows server 2019 — шара ни по имени ни по ip не открывается, пишет сетевой путь не найден. Хотя в проводнике в нетворке малинка отображается как комп! Ну и ладно, не больно-то и хотелось с сервера печатать, всё равно у меня он типо NAS безлюдный.
3) Пришла очередь win7 — шару видно, заходим в принтеры — логин пасс не спрашивало вообще, но при этом открыло. Пробуем установить принтер — ошибка, нет связи с принтером. И магия третьей попытки уже не срабатывает, и даже с десятой не помогло. Пичалька.
Вобщем мораль сей басни такова… жись — боль. А скрещивание ежа с ужом да еще и на арме — боль вдвойне )
PS ну понятно, что надо подкрутить самбу и возможно настройки шары в виндах, завтра займусь. Но меня больше поразило именно количество разных комбинаций "неработоспособности" для одного и того же конфига в связке с разными виндами.
DmitrySpb79 Автор
По моему скромному опыту (я все же не админ) с шарами в Windows всегда геморрой :)