10.6. Construir o painel de controlo

A API JSON da câmara é adequada para curl, mas o proprietário quer apontar um telemóvel para um URL e ver algo. Isso implica servir HTML, JS e CSS a partir da câmara juntamente com a API.

10.6.1. Ficheiros estáticos do sistema de ficheiros

microdot.Response.send_file() lê um ficheiro do sistema de ficheiros da câmara e devolve-o com o Content-Type correto inferido a partir da extensão. Uma rota catch-all no fim da tabela de rotas envia tudo o que não correspondeu a uma rota de 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)

O conversor <path:filename> da página anterior corresponde a segmentos que contêm barras – app.js e css/site.css passam ambos. A verificação de sanidade de duas linhas rejeita qualquer caminho que tente escapar de /sdcard/static/ com .. ou um caminho absoluto – sem isso, um cliente curioso poderia ler ../../boot.py ou /flash/secrets.txt. send_file() em si não realiza qualquer sanitização: abre o caminho que lhe for fornecido.

10.6.2. Os ficheiros do painel de controlo

Três ficheiros na câmara em /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%; }

O HTML carrega o fluxo MJPEG diretamente numa <img> – o browser trata a resposta multipart de forma transparente. O slider lê o limiar de /config ao carregar a página e publica o novo valor a cada alteração.

10.6.3. Tipos MIME e compressão

send_file() lê a extensão do ficheiro e escolhe um Content-Type de microdot.Response.types_map. .html torna-se text/html, .js torna-se text/javascript, .css torna-se text/css, .jpg torna-se image/jpeg. As extensões que adicionou ao seu painel (.svg, .ico) já estão registadas. Qualquer extensão desconhecida usa por defeito application/octet-stream – para adicionar um novo mapeamento, estenda Response.types_map uma vez no arranque.

Para recursos que pré-comprimiu (style.css.gz, app.js.gz), passe compressed=True e o microdot serve o ficheiro .gz com Content-Encoding: gzip. O browser descomprime de forma transparente e poupa alguns KB em cada carregamento de página.

O proprietário abre agora http://yard-cam.local/index.html e vê a transmissão em direto, o limiar atual e um registo de eventos vazio.