10.6. 构建仪表盘

摄像头的 JSON API 对于 curl 来说很好用,但拥有者希望用手机指向一个 URL 就能看到些什么。这意味着要从摄像头上与 API 一起提供 HTML、JS 和 CSS。

10.6.1. 来自文件系统的静态文件

microdot.Response.send_file() 会从摄像头的文件系统读取一个文件,并把它写回,同时根据扩展名推断出正确的 Content-Type。在路由表底部的一个通配路由会发送所有未匹配到任何 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)

上一页讲到的 <path:filename> 转换器会匹配包含斜杠的段——app.jscss/site.css 都能匹配上。那两行健全性检查会拒绝任何试图用 .. 或绝对路径逃出 /sdcard/static/ 的路径——没有它,一个好奇的客户端就能读取 ../../boot.py/flash/secrets.txtsend_file() 本身不做任何清理:你递给它什么路径它就打开什么路径。

10.6.2. 仪表盘文件

摄像头上 /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%; }

HTML 把 MJPEG 流直接加载进一个 <img> 中——浏览器会透明地处理这个 multipart 响应。滑块会在页面加载时从 /config 读取阈值,并在每次变化时把新值提交回去。

10.6.3. MIME 类型与压缩

send_file() 会读取文件的扩展名,并从 microdot.Response.types_map 中选取一个 Content-Type.html 变成 text/html.js 变成 text/javascript.css 变成 text/css.jpg 变成 image/jpeg。你添加到仪表盘里的扩展名(.svg.ico)都已经注册好了。任何未知类型都默认为 application/octet-stream——要添加新的映射,只需在启动时扩展一次 Response.types_map

对于你已经预压缩的资源(style.css.gzapp.js.gz),传入 compressed=True,microdot 就会带着 Content-Encoding: gzip 提供那个 .gz 文件。浏览器会透明地解压,而你在每次页面加载时都能省下几 KB。

现在拥有者打开 http://yard-cam.local/index.html,就能看到实时画面、当前阈值,以及一个空的事件日志。