10.6. Het dashboard bouwen

De JSON-API van de cam is prima voor curl, maar de eigenaar wil een telefoon op een URL richten en iets zien. Dat betekent HTML, JS en CSS vanaf de cam serveren naast de API.

10.6.1. Statische bestanden vanuit het bestandssysteem

microdot.Response.send_file() leest een bestand vanuit het bestandssysteem van de cam en schrijft het terug met het juiste Content-Type, afgeleid van de extensie. Eén catch-all-route onderaan de routeringstabel stuurt alles wat niet op een API-route matchte:

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

De <path:filename>-converter van de vorige pagina matcht segmenten die schuine strepen bevatten – zowel app.js als css/site.css komen erdoor. De tweeregelige controle weigert elk pad dat probeert te ontsnappen aan /sdcard/static/ met .. of een absoluut pad – zonder die controle zou een nieuwsgierige client ../../boot.py of /flash/secrets.txt kunnen lezen. send_file() zelf doet geen sanitisering: het opent welk pad je het ook aanreikt.

10.6.2. De dashboardbestanden

Drie bestanden op de cam onder /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%; }

De HTML laadt de MJPEG-stream rechtstreeks in een <img> – de browser handelt de multipart-response transparant af. De schuifregelaar leest de drempelwaarde uit /config bij het laden van de pagina en post de nieuwe waarde bij elke wijziging terug.

10.6.3. MIME-types en compressie

send_file() leest de extensie van het bestand en kiest een Content-Type uit microdot.Response.types_map. .html wordt text/html, .js wordt text/javascript, .css wordt text/css, .jpg wordt image/jpeg. Extensies die je aan je dashboard hebt toegevoegd (.svg, .ico) zijn al geregistreerd. Alles wat onbekend is valt standaard terug op application/octet-stream – om een nieuwe mapping toe te voegen, breid je Response.types_map eenmaal bij het opstarten uit.

Voor assets die je vooraf hebt gecomprimeerd (style.css.gz, app.js.gz) geef je compressed=True mee en serveert microdot het .gz-bestand met Content-Encoding: gzip. De browser decomprimeert transparant en je bespaart een paar KB bij elke pagina-lading.

De eigenaar opent nu http://yard-cam.local/index.html en ziet de live feed, de huidige drempelwaarde en een leeg gebeurtenissenlogboek.