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,便會看到即時畫面、目前的閾值,以及一份空的事件記錄。