10.6. Construir el panel de control

La API JSON de la cámara está bien para curl, pero el propietario quiere apuntar un teléfono a una URL y ver algo. Eso significa servir HTML, JS y CSS desde la cámara junto con la API.

10.6.1. Archivos estáticos desde el sistema de archivos

microdot.Response.send_file() lee un archivo del sistema de archivos de la cámara y lo devuelve con el Content-Type correcto inferido a partir de la extensión. Una ruta general (catch-all) al final de la tabla de enrutamiento envía todo lo que no coincidió con una ruta de la 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)

El convertidor <path:filename> de la página anterior coincide con segmentos que contienen barras: app.js y css/site.css pasan ambos por ahí. La comprobación de cordura de dos líneas rechaza cualquier ruta que intente escapar de /sdcard/static/ con .. o con una ruta absoluta; sin ella, un cliente curioso podría leer ../../boot.py o /flash/secrets.txt. send_file() en sí no hace ninguna depuración: abre cualquier ruta que le pases.

10.6.2. Los archivos del panel de control

Tres archivos en la cámara bajo /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%; }

El HTML carga el flujo MJPEG directamente en un <img>; el navegador maneja la respuesta multiparte de forma transparente. El deslizador lee el umbral desde /config al cargar la página y reenvía el nuevo valor en cada cambio.

10.6.3. Tipos MIME y compresión

send_file() lee la extensión del archivo y elige un Content-Type de microdot.Response.types_map. .html se convierte en text/html, .js en text/javascript, .css en text/css, .jpg en image/jpeg. Las extensiones que has añadido a tu panel de control (.svg, .ico) ya están registradas. Cualquier cosa desconocida toma por defecto application/octet-stream; para añadir una nueva asignación, amplía Response.types_map una vez al arrancar.

Para los activos que has precomprimido (style.css.gz, app.js.gz), pasa compressed=True y microdot sirve el archivo .gz con Content-Encoding: gzip. El navegador lo descomprime de forma transparente y ahorras unos KB en cada carga de página.

El propietario abre ahora http://yard-cam.local/index.html y ve el feed en vivo, el umbral actual y un registro de eventos vacío.