10.6. Budowanie panelu¶
API JSON kamery jest w porządku dla curl, ale właściciel chce skierować telefon na adres URL i coś zobaczyć. Oznacza to serwowanie HTML, JS i CSS z kamery obok API.
10.6.1. Pliki statyczne z systemu plików¶
microdot.Response.send_file() odczytuje plik z systemu plików kamery i odsyła go z poprawnym Content-Type wywnioskowanym z rozszerzenia. Jedna trasa typu „łapacz” na dole tablicy routingu wysyła wszystko, co nie pasowało do trasy API:
@app.get('/<path:filename>')
async def static(request, filename):
if '..' in filename or filename.startswith('/'):
abort(403)
return Response.send_file('/sdcard/static/' + filename)
Konwerter <path:filename> z poprzedniej strony dopasowuje segmenty zawierające ukośniki – przechodzą zarówno app.js, jak i css/site.css. Dwuliniowe sprawdzenie poprawności odrzuca każdą ścieżkę próbującą wymknąć się z /sdcard/static/ za pomocą .. lub ścieżki bezwzględnej – bez niego ciekawski klient mógłby odczytać ../../boot.py lub /flash/secrets.txt. Sam send_file() nie wykonuje żadnej sanityzacji: otwiera dowolną ścieżkę, którą mu przekażesz.
10.6.2. Pliki panelu¶
Trzy pliki na kamerze w /sdcard/static/:
<!-- /sdcard/static/index.html -->
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Backyard cam</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Backyard cam</h1>
<img id="stream" src="/stream.jpg">
<label>Threshold:
<input id="threshold" type="range" min="0" max="100">
<span id="threshold-value"></span>
</label>
<h2>Recent events</h2>
<ul id="events"></ul>
<script src="app.js"></script>
</body>
</html>
// /sdcard/static/app.js
const slider = document.getElementById('threshold');
const sliderValue = document.getElementById('threshold-value');
async function loadConfig() {
const r = await fetch('/config');
const cfg = await r.json();
slider.value = cfg.threshold;
sliderValue.textContent = cfg.threshold;
}
loadConfig();
slider.addEventListener('change', async () => {
sliderValue.textContent = slider.value;
await fetch('/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({threshold: parseInt(slider.value)}),
});
});
/* /sdcard/static/style.css */
body { font-family: sans-serif; max-width: 720px; margin: 1em auto; }
img#stream { width: 100%; }
HTML ładuje strumień MJPEG bezpośrednio do <img> – przeglądarka obsługuje odpowiedź wieloczęściową w sposób przezroczysty. Suwak odczytuje próg z /config przy ładowaniu strony i wysyła nową wartość z powrotem przy każdej zmianie.
10.6.3. Typy MIME i kompresja¶
send_file() odczytuje rozszerzenie pliku i wybiera Content-Type z microdot.Response.types_map. .html staje się text/html, .js staje się text/javascript, .css staje się text/css, .jpg staje się image/jpeg. Rozszerzenia dodane do twojego panelu (.svg, .ico) są już zarejestrowane. Cokolwiek nieznanego domyślnie przyjmuje application/octet-stream – aby dodać nowe mapowanie, rozszerz Response.types_map raz przy starcie.
Dla zasobów wstępnie skompresowanych (style.css.gz, app.js.gz) przekaż compressed=True, a microdot serwuje plik .gz z Content-Encoding: gzip. Przeglądarka dekompresuje go w sposób przezroczysty, a ty oszczędzasz kilka KB przy każdym ładowaniu strony.
Właściciel otwiera teraz http://yard-cam.local/index.html i widzi podgląd na żywo, bieżący próg oraz pusty dziennik zdarzeń.