Привет.
Сегодня мы продолжим изучать сетевые возможности 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, так же будут рассмотрены базовые методы аутентификации.
Всем удачных экспериментов.
Автор: DmitrySpb79