10.6. Bygga instrumentpanelen

Kamerans JSON-API är bra för curl, men ägaren vill rikta en telefon mot en URL och se något. Det innebär att servera HTML, JS och CSS från kameran vid sidan av API:et.

10.6.1. Statiska filer från filsystemet

microdot.Response.send_file() läser en fil från kamerans filsystem och skriver tillbaka den med rätt Content-Type härledd från filändelsen. En catch-all-rutt längst ner i routningstabellen skickar allt som inte matchade en API-rutt:

@app.get('/<path:filename>')
async def static(request, filename):
    if '..' in filename or filename.startswith('/'):
        abort(403)
    return Response.send_file('/sdcard/static/' + filename)

Konverteraren <path:filename> från föregående sida matchar segment som innehåller snedstreck – både app.js och css/site.css kommer igenom. Den tvåradiga rimlighetskontrollen avvisar varje sökväg som försöker ta sig ut ur /sdcard/static/ med .. eller en absolut sökväg – utan den skulle en nyfiken klient kunna läsa ../../boot.py eller /flash/secrets.txt. send_file() gör i sig ingen sanering: den öppnar vilken sökväg du än ger den.

10.6.2. Instrumentpanelfilerna

Tre filer på kameran under /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-koden laddar MJPEG-strömmen direkt i en <img> – webbläsaren hanterar multipart-svaret transparent. Reglaget läser tröskelvärdet från /config vid sidladdning och postar tillbaka det nya värdet vid varje ändring.

10.6.3. MIME-typer och komprimering

send_file() läser filens ändelse och väljer en Content-Type från microdot.Response.types_map. .html blir text/html, .js blir text/javascript, .css blir text/css, .jpg blir image/jpeg. Ändelser som du har lagt till på din instrumentpanel (.svg, .ico) är redan registrerade. Allt okänt får som standard application/octet-stream – för att lägga till en ny mappning, utöka Response.types_map en gång vid uppstart.

För tillgångar som du har förkomprimerat (style.css.gz, app.js.gz) skicka compressed=True så serverar microdot .gz-filen med Content-Encoding: gzip. Webbläsaren dekomprimerar transparent och du sparar några KB vid varje sidladdning.

Ägaren öppnar nu http://yard-cam.local/index.html och ser direktflödet, det aktuella tröskelvärdet och en tom händelselogg.