10.6. Costruzione della dashboard

L’API JSON della camera va bene per curl, ma il proprietario vuole puntare il telefono a un URL e vedere qualcosa. Questo significa servire HTML, JS e CSS dalla camera insieme all’API.

10.6.1. File statici dal filesystem

microdot.Response.send_file() legge un file dal filesystem della camera e lo restituisce con il giusto Content-Type dedotto dall’estensione. Una route catch-all in fondo alla tabella di routing invia tutto ciò che non ha corrisposto a una route dell’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)

Il convertitore <path:filename> della pagina precedente corrisponde a segmenti che contengono slash: passano sia app.js sia css/site.css. Il controllo di validità di due righe rifiuta qualsiasi percorso che tenti di uscire da /sdcard/static/ con .. o con un percorso assoluto; senza di esso un client curioso potrebbe leggere ../../boot.py o /flash/secrets.txt. send_file() di per sé non esegue alcuna sanitizzazione: apre qualunque percorso gli si passi.

10.6.2. I file della dashboard

Tre file sulla camera sotto /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%; }

L’HTML carica lo stream MJPEG direttamente in un elemento <img>: il browser gestisce la risposta multipart in modo trasparente. Lo slider legge la soglia da /config al caricamento della pagina e invia il nuovo valore a ogni modifica.

10.6.3. Tipi MIME e compressione

send_file() legge l’estensione del file e sceglie un Content-Type da microdot.Response.types_map. .html diventa text/html, .js diventa text/javascript, .css diventa text/css, .jpg diventa image/jpeg. Le estensioni che hai aggiunto alla tua dashboard (.svg, .ico) sono già registrate. Qualsiasi estensione sconosciuta usa per impostazione predefinita application/octet-stream: per aggiungere una nuova mappatura, estendi Response.types_map una volta all’avvio.

Per gli asset che hai pre-compresso (style.css.gz, app.js.gz) passa compressed=True e microdot serve il file .gz con Content-Encoding: gzip. Il browser lo decomprime in modo trasparente e tu risparmi qualche KB a ogni caricamento di pagina.

Il proprietario ora apre http://yard-cam.local/index.html e vede il feed dal vivo, la soglia corrente e un registro eventi vuoto.