10.6. Construindo o painel¶
A API JSON da câmera é boa para o curl, mas o proprietário quer apontar um celular para uma URL e ver algo. Isso significa servir HTML, JS e CSS a partir da câmera, ao lado da API.
10.6.1. Arquivos estáticos do sistema de arquivos¶
microdot.Response.send_file() lê um arquivo do sistema de arquivos da câmera e o devolve com o Content-Type correto inferido a partir da extensão. Uma rota coringa no final da tabela de roteamento envia tudo que não correspondeu a uma rota da 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 – tanto app.js quanto css/site.css passam por ele. A verificação de sanidade de duas linhas rejeita qualquer caminho que tente escapar de /sdcard/static/ com .. ou um caminho absoluto – sem ela, um cliente curioso poderia ler ../../boot.py ou /flash/secrets.txt. O próprio send_file() não faz sanitização: ele abre qualquer caminho que você passar.
10.6.2. Os arquivos do painel¶
Três arquivos na câmera 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 stream MJPEG diretamente em um <img> – o navegador lida com a resposta multipart de forma transparente. O slider lê o limiar de /config ao carregar a página e envia de volta o novo valor a cada mudança.
10.6.3. Tipos MIME e compressão¶
send_file() lê a extensão do arquivo e escolhe um Content-Type a partir 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. Extensões que você adicionou ao seu painel (.svg, .ico) já estão registradas. Qualquer coisa desconhecida assume o padrão application/octet-stream – para adicionar um novo mapeamento, estenda Response.types_map uma vez na inicialização.
Para ativos que você pré-comprimiu (style.css.gz, app.js.gz), passe compressed=True e o microdot serve o arquivo .gz com Content-Encoding: gzip. O navegador descomprime de forma transparente e você economiza alguns KB a cada carregamento de página.
O proprietário agora abre http://yard-cam.local/index.html e vê o feed ao vivo, o limiar atual e um registro de eventos vazio.