PR

【第8回】Flask サーバーを作る — 2 つの経路を 1 つに受けて SQLite に貯める

環境センサーデータサーバーシステム
記事内に広告が含まれています。

ここまで、Pi 側の受け皿は「その場しのぎ」で通してきました。

  • 第6回: recv_test.py — HTTP POST を受けて画面に出すだけ
  • 第7回: serial_test.py — USB シリアルを読んで画面に出すだけ

どちらも受け取った瞬間に流れていくだけで、電源を落としたら何も残りません。別々のスクリプトなので、同時に両方を受けることもできません。

今回はこの 2 つを 1 つのアプリに統合し、SQLite に貯めて、ブラウザでグラフを見るところまで作ります。

初心者には知らない言葉がいっぱい出てきますがなんとなく雰囲気だけ感じてください。私もAIに要望してコードを生成してもらっているので全部は理解できていません。


1. 今回作るもの

[ESP8266 子機] ── WiFi + HTTP POST ──┐
                                      ├──→ [Flask] ──→ [SQLite]
[XIAO C3 子機] ─ESP-NOW→ [Master] ── USB シリアル ──┘        │
                                                              ▼
                                                    ブラウザでグラフ

第6回の WiFi 子機と、第7回の ESP-NOW Master。この 2 つを同時に受けます。

作るファイルは 2 つだけです。

~/tempserver/
├── app.py                 ← 本体 (約 180 行)
└── templates/
    └── index.html         ← ダッシュボード

データベースファイル temperature.db は初回起動時に自動で作られます。

今回やらないこと: 表示名 (nickname) の割当と、systemd での自動起動は第9回に回します。今回は「両方受かって、貯まって、見える」までを確実に通します。


2. 置き場所とパッケージ

Pi に SSH で入って作業します。

mkdir -p ~/tempserver/templates
cd ~/tempserver
Raspberry Pi に SSH で入り tempserver と templates フォルダを作成

これでどうなるかというと、Piのフォルダの中にtempserverというフォルダを作り、更にその中にtemplatesというフォルダを作っています。PiのGUIで見るとどうなってかというと以下の通り

Raspberry Pi のファイルマネージャで見た tempserver フォルダの中身

前回の記事でnanoエディターで作成したrev_test.pyとserial_test.pyも作成されています。

更にtempserverというフォルダの中に作成されたtemplateフォルダにディレクトリを移動して必要な書類を作成しましょうという事です。

必要なパッケージは第6回・第7回で入れた 2 つだけです。まだなら:

sudo apt update
sudo apt install -y python3-flask python3-serial

SQLite は Python の標準ライブラリ (sqlite3) に含まれているので、追加インストールは要りません。sqlite3 コマンド (DB を手で覗くツール) が欲しい場合だけ:

sudo apt install -y sqlite3

apt で python3-flask をインストールする画面

3. データベースの形を先に決める

コードを書く前に、何を保存するかを決めます。ここを適当にすると、後から「あの値も取っておけばよかった」となって作り直しになります。

列型何を入れるか
idINTEGER連番 (自動)
device_idTEXTESP-A1B2C3 / NOW-D4E5F6 など。MAC 由来で自動生成された ID
temperatureREAL温度 (℃)、小数点 2 桁
voltageREAL電池電圧 (V)。常時給電なら NULL でよい
rssiINTEGER電波強度 (dBm)
battery_modeINTEGER電池駆動なら 1、常時給電なら 0
connection_typeTEXTwifi か espnow。どちらの経路で来たか
timestampDATETIME受信時刻 (自動)

connection_type を入れておくのがポイントです。第6回と第7回で 2 つの経路を作ったので、後から「ESP-NOW 側だけ欠測が多い」といった比較ができるようにしておきます。列を 1 つ足すコストは今ならゼロですが、データが貯まってから足しても過去分は埋まりません。

device_id に MAC 由来の ID をそのまま使う点も重要です。人間向けの名前 (「冷却塔1」など) はこの表に入れません。第9回で別の表を作って対応付けます。理由は第4回で書いた 3 層分離の考え方そのままで、チップを交換したときに過去データを壊さないためです。


4. app.py 全文

2 章で作った ~/tempserver に移動してから、エディターを開きます。

cd ~/tempserver
pwd                  # /home/pi/tempserver と出れば OK
nano app.py

~ は自分のホームフォルダ (/home/pi) を表す記号です。nano ~/tempserver/app.py のようにフルパスで書いても同じですが、先に cd しておくほうが後が楽です。この後もずっと同じフォルダで作業するので、毎回長いパスを打たずに済みます。

いま自分がどこにいるか分からなくなったら pwd、そのフォルダに何があるかは ls です。「ファイルを作ったはずなのに見つからない」の原因は、たいてい違うフォルダで作っていることです。

以下を丸ごと貼り付けてください。断片ではなくファイル全体です。コピーした後Power Shellに右クリックでペーストできます。

"""
第8回 温度サーバー最小版
  HTTP POST と USB シリアルの 2 経路を受けて SQLite に保存する

  起動: python3 ~/tempserver/app.py
"""
import json
import sqlite3
import threading
import time
from contextlib import contextmanager
from pathlib import Path

import serial
from flask import Flask, request, jsonify, render_template

# ===== 設定 =====
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "temperature.db"

SERIAL_PORT = "/dev/ttyACM0"   # XIAO ESP32-C3 など USB 内蔵チップは ttyACM*
                               # CH340 / CP2102 / FT232 の変換基板経由なら ttyUSB*
                               # 分からなければ  ls /dev/ttyACM* /dev/ttyUSB*  で確認
SERIAL_BAUD = 115200

app = Flask(__name__)


# ===== データベース =====
@contextmanager
def db():
    """1 回の操作ごとに接続を開いて閉じる。
    Flask のスレッドとシリアル読取りスレッドが別々に呼ぶので、
    接続を使い回さないほうが安全。"""
    conn = sqlite3.connect(DB_PATH, timeout=5.0)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
        conn.commit()
    finally:
        conn.close()


def init_db():
    with db() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS temperatures (
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
                device_id       TEXT NOT NULL,
                temperature     REAL NOT NULL,
                voltage         REAL,
                rssi            INTEGER,
                battery_mode    INTEGER DEFAULT 0,
                connection_type TEXT DEFAULT 'unknown',
                timestamp       DATETIME DEFAULT CURRENT_TIMESTAMP
            )
        """)
        conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_device_timestamp
            ON temperatures(device_id, timestamp DESC)
        """)


def save_reading(data, conn_type):
    """2 経路の受信データを 1 か所で正規化して保存する。
    戻り値: (成功したか, メッセージ)"""
    device_id = data.get("device_id")
    temp = data.get("temperature", data.get("temp"))

    if not device_id:
        return False, "device_id がありません"
    if temp is None:
        return False, "temperature がありません"
    try:
        temp = float(temp)
    except (TypeError, ValueError):
        return False, f"temperature が数値ではありません: {temp!r}"

    # 明らかな異常値は捨てる (DS18B20 の測定範囲は -55〜+125℃)
    if not (-55.0 <= temp <= 125.0):
        return False, f"温度が測定範囲外です: {temp}"

    rssi = data.get("rssi", data.get("signal_strength"))
    voltage = data.get("voltage")
    battery = 1 if data.get("battery_mode") else 0

    with db() as conn:
        conn.execute("""
            INSERT INTO temperatures
                (device_id, temperature, voltage, rssi, battery_mode, connection_type)
            VALUES (?, ?, ?, ?, ?, ?)
        """, (device_id, round(temp, 2), voltage, rssi, battery, conn_type))

    print(f"[{conn_type}] {device_id}  {temp:.2f}℃  rssi={rssi}  volt={voltage}",
          flush=True)
    return True, "ok"


# ===== 経路 1: HTTP POST (第6回の WiFi 子機) =====
@app.post("/api/temperature")
def api_temperature():
    data = request.get_json(silent=True) or {}
    ok, msg = save_reading(data, "wifi")
    if ok:
        return jsonify(status="ok")
    print(f"[wifi] 受信を拒否: {msg} / 生データ: {request.get_data(as_text=True)[:200]}",
          flush=True)
    return jsonify(status="error", reason=msg), 400


# ===== 経路 2: USB シリアル (第7回の ESP-NOW Master) =====
def serial_loop():
    """別スレッドで動く。ポートが消えても開き直す。"""
    while True:
        try:
            ser = serial.Serial(SERIAL_PORT, SERIAL_BAUD, timeout=5)
            print(f"[serial] {SERIAL_PORT} を開きました", flush=True)
        except Exception as e:
            print(f"[serial] {SERIAL_PORT} を開けません ({e})。5 秒後に再試行", flush=True)
            time.sleep(5)
            continue

        try:
            while True:
                raw = ser.readline().decode("utf-8", errors="replace").strip()
                if not raw:
                    continue
                if raw.startswith("#"):        # Master の人間向けメッセージ
                    print(f"[serial] {raw}", flush=True)
                    continue
                if not raw.startswith("{"):    # JSON 以外は捨てる
                    continue

                try:
                    data = json.loads(raw)
                except json.JSONDecodeError:
                    print(f"[serial] JSON として読めません: {raw[:100]}", flush=True)
                    continue

                kind = data.get("type")

                if kind in ("master_hello", "master_status"):
                    print(f"[serial] Master 生存確認: {data.get('device_id')} "
                          f"{data.get('mac', '')}", flush=True)

                elif "sensors" in data:
                    # 温度データ。Master が 1 秒ごとにまとめて配列で送ってくる。
                    # 注意: この階層の device_id は Master 自身の ID。
                    #       子機の ID は sensors[].sensor_id のほう。
                    for s in data["sensors"]:
                        save_reading({
                            "device_id":    s.get("sensor_id"),
                            "temperature":  s.get("temperature", s.get("temp")),
                            "rssi":         s.get("rssi"),
                            "voltage":      s.get("voltage"),
                            "battery_mode": s.get("battery_mode"),
                        }, "espnow")

                else:
                    print(f"[serial] 解釈できない JSON: {raw[:100]}", flush=True)

        except Exception as e:
            print(f"[serial] 切断されました ({e})。開き直します", flush=True)
        finally:
            try:
                ser.close()
            except Exception:
                pass
        time.sleep(2)


# ===== 表示用 API =====
@app.get("/api/latest")
def api_latest():
    """デバイスごとの最新 1 件"""
    with db() as conn:
        rows = conn.execute("""
            SELECT t.*
            FROM temperatures t
            JOIN (SELECT device_id, MAX(id) AS max_id
                  FROM temperatures GROUP BY device_id) m
              ON t.id = m.max_id
            ORDER BY t.device_id
        """).fetchall()
    return jsonify([dict(r) for r in rows])


@app.get("/api/history")
def api_history():
    """直近 N 時間の履歴を device_id ごとにまとめて返す"""
    hours = request.args.get("hours", default=24, type=int)
    hours = max(1, min(hours, 24 * 30))   # 1 時間〜30 日に制限

    with db() as conn:
        rows = conn.execute("""
            SELECT device_id, temperature, timestamp
            FROM temperatures
            WHERE timestamp >= datetime('now', ?)
            ORDER BY timestamp
        """, (f"-{hours} hours",)).fetchall()

    series = {}
    for r in rows:
        series.setdefault(r["device_id"], []).append(
            {"t": r["timestamp"], "v": r["temperature"]})
    return jsonify(series)


@app.get("/")
def dashboard():
    return render_template("index.html")


# ===== 起動 =====
if __name__ == "__main__":
    init_db()
    threading.Thread(target=serial_loop, daemon=True).start()
    # use_reloader=False は必須。理由は本文 5.2 参照
    app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)

nano エディタに app.py 全文を貼り付けた状態

コードを貼り付けたら Ctrl + O → Enter(保存)→ Ctrl + X でエディター終了

5. コードの読みどころ

行数は短いのですが、踏むと分かりにくい落とし穴がいくつか埋まっています。順に説明します。

5.1 なぜ DB 接続を毎回開いて閉じるのか

@contextmanager
def db():
    conn = sqlite3.connect(DB_PATH, timeout=5.0)
    ...

このアプリには別々に動く 2 つの流れがあります。

  • Flask が HTTP を処理するスレッド
  • シリアルを読み続けるスレッド

SQLite の接続オブジェクトは、既定では作ったスレッド以外から使うと例外になります。接続を 1 個作って共有すると、この制約に引っかかります。

回避策として check_same_thread=False を渡す手もありますが、そうすると今度は自分でロックを管理する必要が出てきます。操作のたびに開いて閉じるほうが、書く量も考える量も少なくて済みます。

温度データは 2 分〜5 分に 1 回しか来ないので、接続を開き直すコストはまったく問題になりません。秒間何百リクエストも捌くアプリなら別の設計が要りますが、今回はそうではない、という判断です。

timeout=5.0 は「他のスレッドが書込み中なら 5 秒待つ」という指定です。これが無いと、書込みが重なった瞬間に database is locked で落ちます。

5.2 use_reloader=False は必須です

app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)

Flask の開発サーバーには「ソースを書き換えたら自動で再起動する」機能があります。便利なのですが、この機能は Python プロセスを 2 つ起動します (親が監視役、子が実サーバー)。

すると serial_loop() のスレッドも 2 つ動きます。同じシリアルポートを 2 つのスレッドが奪い合うことになり、こういう症状になります。

  • 片方が Device or resource busy で開けない
  • 開けたほうも、1 行おきにデータを取りこぼす
  • 再起動のたびに挙動が変わる

debug=True にすると reloader も既定で有効になるので、このアプリでは debug も use_reloader も両方 False にしています。

デバッグ中にソース変更を自動反映させたい場合は、シリアル読取りを止めた状態でやるか、素直に Ctrl+C → 再実行してください。

5.3 シリアルは「切れる前提」で書く

serial_loop() が二重の while True になっているのは、ポートが消えることが日常的に起きるからです。

第7回で書いたとおり、XIAO ESP32-C3 の Master は USB がチップに内蔵されています。Master をリセットすると /dev/ttyACM0 が一瞬消えて、また現れます。このとき、開きっぱなしのファイルを読もうとしていたスクリプトは例外で落ちます。

外側の while が「開く → 読み続ける → 落ちたら閉じて、また開く」を回しているので、Master を挿し直しても Flask 側を再起動する必要がありません。現場に置く機械では、この手の自己回復が効きます。

errors="replace" は第7回の serial_test.py と同じ理由です。Master がリセットした瞬間に化けたバイトが飛んでくるので、そこで例外を出してループごと死ぬのを防いでいます。

5.4 2 経路を 1 つの save_reading() に流す

第6回の HTTP POST と第7回のシリアル JSON は、形がまるごと違います。ここを吸収するのが save_reading() の役目です。

HTTP (第6回の WiFi 子機)シリアル (第7回の Master)
1 回に入る件数1 件配列で複数件 (sensors)
子機の IDdevice_idsensors[].sensor_id
トップの device_id子機自身Master 自身 (子機ではない)
温度temperature と temp の両方temperature
電波強度rssi と signal_strength の両方rssi (Master が実測した値)
電池電圧voltagevoltage

一番の落とし穴が 3 行目です。 シリアル側のトップにある device_id は Master の ID であって、温度を測った子機ではありません。ここをそのまま渡すと、すべての温度が Master 名義で記録されます。グラフを見て「1 台しか出てこない」と気づくころには、区別のつかないデータが貯まっています。

だから serial_loop() では、配列を 1 件ずつほどいて sensor_id を device_id に置き換えてから save_reading() に渡しています。

elif "sensors" in data:
    for s in data["sensors"]:
        save_reading({
            "device_id":   s.get("sensor_id"),   # ← ここが肝
            "temperature": s.get("temperature", s.get("temp")),
            ...
        }, "espnow")

HTTP 側は 1 件ずつ来るのでほどく必要がありません。2 つの経路の違いはこの入口で吸収しきって、その先は 1 本にする。これが今回の設計の要点です。

HTTP 側で get の第 2 引数を使っているのも同じ発想です。

temp = data.get("temperature", data.get("temp"))
rssi = data.get("rssi", data.get("signal_strength"))

get の第 2 引数で「無ければこっち」を書いています。受け側で吸収しておくと、送り側のスケッチを後から変えても壊れません。

保存直前に範囲チェックも入れました。

if not (-55.0 <= temp <= 125.0):
    return False, f"温度が測定範囲外です: {temp}"

DS18B20 の測定範囲は -55〜+125℃ です。第6回で悩まされた -127 や、パワーオンリセット値の 85 は、子機側で弾く作りにしてありますが、受け側でももう一度見ておきます。片方だけの防御は、スケッチを書き換えたときに簡単に抜けます。

5.5 時刻は UTC で入ります

SQLite の CURRENT_TIMESTAMP は UTC です。日本時間ではありません。datetime('now', '-24 hours') も UTC 基準なので、比較としては正しく動きます。ずれるのは表示のときだけです。

この記事では、保存は UTC のまま、表示のときにブラウザ側で日本時間に直す方針にしました。

new Date(row.t.replace(' ', 'T') + 'Z')

末尾に Z を付けると「この文字列は UTC だ」とブラウザに伝わり、あとは端末のタイムゾーンで勝手に表示してくれます。

DB 側で datetime('now','localtime') を使って最初から日本時間で入れる方法もありますが、サーバーのタイムゾーン設定に結果が依存するので避けました。UTC で貯めておけば、後からどこで見ても意味が変わりません。


6. ダッシュボード (templates/index.html)

温度の表示画面(ダッシュボード)を作成します。

今度は templates フォルダの中です。app.py と同じ場所に置くと Flask が見つけられません (2 章で templates を作ったのはこのためです)。

cd ~/tempserver/templates
pwd                  # /home/pi/tempserver/templates と出れば OK
nano index.html

~/tempserver にいるなら cd templates だけでも同じです。1 つ上に戻るときは cd .. です。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>温度モニタ</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- 時間軸を使うには日付アダプタも要る (理由は 6.1) -->
<script src="https://cdn.jsdelivr.net/npm/date-fns@3.6.0/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
  body { font-family: sans-serif; margin: 1rem; background:#fafafa; }
  h1 { font-size: 1.2rem; }
  table { border-collapse: collapse; margin-bottom: 1.5rem; }
  th, td { border: 1px solid #ccc; padding: 4px 10px; text-align: left; }
  th { background: #eee; }
  .wrap { max-width: 900px; }
  .stale { color: #cf2e2e; }
  select { padding: 4px; }
</style>
</head>
<body>
<div class="wrap">
  <h1>温度モニタ</h1>

  <table id="latest">
    <thead>
      <tr><th>device_id</th><th>温度</th><th>電池</th><th>RSSI</th>
          <th>経路</th><th>最終受信</th></tr>
    </thead>
    <tbody></tbody>
  </table>

  <label>表示範囲:
    <select id="range">
      <option value="3">3 時間</option>
      <option value="24" selected>24 時間</option>
      <option value="168">7 日</option>
    </select>
  </label>

  <canvas id="chart" height="120"></canvas>
</div>

<script>
const COLORS = ['#2563eb','#dc2626','#16a34a','#d97706','#7c3aed','#0891b2'];
let chart = null;

// SQLite の "YYYY-MM-DD HH:MM:SS" (UTC) を Date に変換
function toDate(s) { return new Date(s.replace(' ', 'T') + 'Z'); }

async function loadLatest() {
  const rows = await (await fetch('/api/latest')).json();
  const tbody = document.querySelector('#latest tbody');
  tbody.innerHTML = '';
  const now = Date.now();
  for (const r of rows) {
    const d = toDate(r.timestamp);
    const mins = Math.round((now - d.getTime()) / 60000);
    const tr = document.createElement('tr');
    tr.innerHTML =
      `<td>${r.device_id}</td>` +
      `<td>${r.temperature.toFixed(2)} ℃</td>` +
      `<td>${r.voltage != null ? r.voltage.toFixed(2) + ' V' : '-'}` +
        `${r.battery_mode ? ' (残量低下)' : ''}</td>` +
      `<td>${r.rssi != null ? r.rssi + ' dBm' : '-'}</td>` +
      `<td>${r.connection_type}</td>` +
      `<td class="${mins > 15 ? 'stale' : ''}">` +
        `${d.toLocaleString('ja-JP')} (${mins} 分前)</td>`;
    tbody.appendChild(tr);
  }
}

async function loadChart() {
  const hours = document.querySelector('#range').value;
  const series = await (await fetch('/api/history?hours=' + hours)).json();

  const datasets = Object.keys(series).sort().map((id, i) => ({
    label: id,
    data: series[id].map(p => ({ x: toDate(p.t), y: p.v })),
    borderColor: COLORS[i % COLORS.length],
    backgroundColor: COLORS[i % COLORS.length],
    borderWidth: 2,
    pointRadius: 0,
    tension: 0.2
  }));

  if (chart) chart.destroy();
  chart = new Chart(document.querySelector('#chart'), {
    type: 'line',
    data: { datasets },
    options: {
      responsive: true,
      interaction: { mode: 'nearest', intersect: false },
      scales: {
        x: { type: 'time', time: { tooltipFormat: 'MM/dd HH:mm' },
             ticks: { maxTicksLimit: 8 } },
        y: { title: { display: true, text: '℃' } }
      }
    }
  });
}

async function refresh() { await loadLatest(); await loadChart(); }
document.querySelector('#range').addEventListener('change', loadChart);
refresh();
setInterval(refresh, 60000);   // 1 分ごとに更新
</script>
</body>
</html>
nano エディタで templates/index.html を作成した画面

コードを貼り付けたら Ctrl + O → Enter(保存)→ Ctrl + X 。

battery_mode は「電池の残量が少ない」という意味です。電池で動いているかどうかではありません。子機側がしきい値と比べて 1 か 0 を決めており、しきい値は電池の種類で違います — 単三 3 本なら 3.3V、LiPo なら 3.0V。どちらも「そろそろ交換」の目安で、使い切ったときのデータを取っていないので、実際にどう出るかは分かりません。表示は (残量低下) と出ます。

6.1 script タグが 3 本ある理由

<head> で読んでいる JavaScript が 3 本あります。多いと思われたかもしれませんが、全部要ります。

ファイル役割
chart.umd.min.jsChart.js 本体
date-fns日付を扱うライブラリ
chartjs-adapter-date-fnsChart.js に「日付の扱い方」を教えるアダプタ

このダッシュボードは横軸に時刻を使っています。Chart.js 本体は日付を解釈できません。 日付ライブラリと、それを Chart.js に繋ぐアダプタの 2 つを別に読ませて初めて時間軸が使えます。

アダプタを忘れると、グラフの領域が真っ白のまま何も出ません。表と数値は正常に出るので「グラフだけ壊れている」ように見えます。エラーはブラウザの開発者ツール (F12) のコンソールにしか出ないため、気づきにくい失敗です。グラフが出ないときは、まず F12 を開いてコンソールを見てください。

6.2 CDN を使うということ — どこにインターネットが要るのか

ここは誤解しやすいので、はっきり書いておきます。

Chart.js を取りに行くのは Pi ではありません。ブラウザです。 つまりインターネット接続が必要なのは、ダッシュボードを見ている端末のほうです。

見る場所グラフは出るか
家の LAN の PC から http://192.168.11.200:5000/出る (PC がインターネットに繋がっているため)自身のPiのIPを入れてください。
Pi の AP に直接繋いだスマホから http://192.168.4.1:5000/出ない

2 つ目が出ない理由は、第5回で書いたとおり Pi は AP 配下の端末をインターネットに中継していないからです。表と数値は出ますが、グラフの領域だけ空白になります。

対処は 2 つあります。

  1. 第5回の「4. インターネット共有 (IP フォワーディング)」を設定する
  2. Chart.js のファイルを Pi にダウンロードして置き、CDN の代わりにそこから読む

2 の手順は短いので書いておきます。

mkdir -p ~/tempserver/static
cd ~/tempserver/static
wget https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js
wget https://cdn.jsdelivr.net/npm/date-fns@3.6.0/cdn.min.js -O date-fns.min.js
wget https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js -O chartjs-adapter.min.js

取り込んだら index.html 側の読み込み先を書き換えます。

cd ~/tempserver/templates
nano index.html
#   Ctrl+W で  cdn.jsdelivr.net  を検索。3 か所あります
#   書き換えたら Ctrl+O → Enter → Ctrl+X で保存して終了

index.html の <script src="https://..."> を、それぞれ <script src="/static/chart.umd.min.js"> のように書き換えれば完了です。Flask は static/ を自動で配信するので、追加のコードは要りません。

現場に置く機械なら 2 を選ぶべきです。 インターネット回線が切れた瞬間にグラフが見えなくなるのは、監視装置としては困ります。この記事では手順を減らすため CDN のまま進めます。


7. 動かす

tempserverというディレクトリに移動してそこにあるpython3 app.pyという名前のファイルのPythonコードを実行するスクリプトです。SSHで接続状態から貼り付けてください。

cd ~/tempserver
python3 app.py
[serial] /dev/ttyACM0 を開きました
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://192.168.11.200:5000

[serial] ... を開きました が出れば、シリアル側の準備は完了です。Master を挿していない場合は 5 秒ごとに再試行のメッセージが出ますが、HTTP 側はそれと関係なく動きます。片方だけで先に試せる作りです。

Flask サーバーが WiFi と ESP-NOW の両経路からデータを受信しているログ

約5分おきにESP32からPOSTが成功しており、その間にMasterがPiから生存確認されています。

7.1 HTTP 側の疎通を確認する

子機を待つ前に、curl で自分に投げて確かめます。別の SSH 窓を開いて:

curl -X POST http://localhost:5000/api/temperature \
  -H "Content-Type: application/json" \
  -d '{"device_id":"TEST-001","temperature":23.45,"voltage":3.7,"rssi":-55,"battery_mode":1}'
{"status":"ok"}

サーバー側の窓にはこう出ます。

[wifi] TEST-001  23.45℃  rssi=-55  volt=3.7

わざと壊したデータも投げてみてください。

curl -X POST http://localhost:5000/api/temperature \
  -H "Content-Type: application/json" -d '{"device_id":"TEST-001"}'
{"status":"error","reason":"temperature がありません"}

エラーがどう返るかを先に見ておくと、後で子機が繋がらないときに「サーバーは生きている」と切り分けられます。

7.2 両方の子機を起動する

第6回の ESP8266 と、第7回の Master + XIAO C3 子機に電源を入れます。

[serial] Master 生存確認: MST-1A2B3C 84:CC:A8:1A:2B:3C
[wifi]   ESP-A1B2C3  26.75℃  rssi=-48  volt=3.31
[espnow] NOW-D4E5F6  26.81℃  rssi=-31  volt=3.92
[serial] Master 生存確認: MST-1A2B3C 84:CC:A8:1A:2B:3C

[wifi] と [espnow] が両方出れば、2 経路の統合は成功です。

7.3 ブラウザで見る

家の LAN の PC から:

http://192.168.11.200:5000/

(IP は第3回で確認した Pi のアドレス。第5回で固定した方はその値:自身のPiのIPを入れてください。)

表に各デバイスの最新値、その下に折れ線グラフが出ます。1 分ごとに自動更新されます。

ブラウザで開いた温度ダッシュボード、プローブを氷水に入れて温度が急降下したグラフ

測定中に温度プローブを氷水の中に入れたので急激に温度が下がっている様子が出力されています。

7.4 データベースファイルの中身を直接覗く

sqlite3 ~/tempserver/temperature.db \
  "SELECT device_id, temperature, connection_type, timestamp
   FROM temperatures ORDER BY id DESC LIMIT 10;"

出力例:

NOW-D4E5F6|26.81|espnow|2026-09-04 07:12:33
ESP-A1B2C3|26.75|wifi|2026-09-04 07:12:05
TEST-001|23.45|wifi|2026-09-04 07:03:41
sqlite3 で temperatures テーブルの中身を直接確認

テスト投入した TEST-001 が邪魔なら消しておきます。

sqlite3 ~/tempserver/temperature.db "DELETE FROM temperatures WHERE device_id LIKE 'TEST-%';"

8. よくある失敗

症状原因対処
Address already in use前回の app.py が生きているpkill -f app.py してから再実行
[serial] ... を開けません (Permission denied)dialout グループに入っていない第7回 3.4 参照。sudo usermod -a -G dialout $USER の後、SSH に入り直す
[serial] ... を開けません (No such file)Master が挿さっていない / ポート名が違うls /dev/ttyACM* /dev/ttyUSB* で確認し、SERIAL_PORT を修正
Device or resource busy他のプログラムがポートを掴んでいるsudo lsof /dev/ttyACM0 で犯人を特定。第7回の serial_test.py が動いたままなのが定番
[serial] 解釈できない JSON: {"type":"sensor",...} が繰り返し出るMaster に旧ファームが載っている。第7回の Master は 1 秒ごとに sensors 配列でまとめて 1 行出す形式で、1 行 1 センサーの "type":"sensor" 形式は受け付けない第7回 8.1 の Master と 8.2 の子機を両方書き直す。Master だけだと struct size mismatch になります。2 つの形式は device_id の意味が逆 (旧 = 子機 / 新 = Master 自身) なので、無理に通そうとすると過去データが壊れます。捨てられているのが正しい挙動です
シリアルのデータが 1 行おきに落ちるdebug=True か use_reloader=True になっている5.2 参照。両方 False にする
database is locked書込みが重なったtimeout=5.0 が消えていないか確認
表は出るがグラフが真っ白date アダプタを読んでいない、または CDN に届いていない6.1 と 6.2 を確認。F12 のコンソールにエラーが出ているはず
グラフの時刻が 9 時間ずれるZ を付けずに new Date() に渡している5.5 参照
子機からは送れているのに [wifi] が出ない子機の serverURL が古い / Pi の IP が変わった子機側は http://192.168.4.1:5000/api/temperature。AP 側の IP であって LAN 側ではない
ブラウザで開けないFlask が 127.0.0.1 だけで待ち受けているhost="0.0.0.0" になっているか確認

8.1 「動いていたのに翌日データが無い」

一番やりがちなのがこれです。python3 app.py は SSH を切ると死にます。

いま動かしているのは「SSH のセッションにぶら下がったプロセス」です。SSH を閉じたら一緒に終了します。ノート PC を閉じただけでも切れます。

第9回で systemd に登録して、Pi の電源を入れた時点で勝手に上がるようにします。それまでの間、試験的に流しっぱなしにしたい場合は:

nohup python3 ~/tempserver/app.py > ~/tempserver/run.log 2>&1 &

止めるときは pkill -f app.py です。これは仮の手段で、Pi を再起動したら上がってきません。あくまで第9回までのつなぎとして使ってください。


9. AI との会話例

今回のような「1 つのアプリに 2 つの入口がある」構成は、AI に頼むときの書き方でうまくいくかが決まります。

Raspberry Pi 上の Flask で温度サーバーを作りたい。入口が 2 つある。

  1. HTTP POST /api/temperature に JSON が来る
  2. USB シリアル /dev/ttyACM0 から 1 行 1 JSON で流れてくる

どちらも同じ SQLite テーブルに保存したい。 シリアルは別スレッドで読む。ポートが消えても開き直す作りにして。Python 3.11、Flask は apt の python3-flask。app.py をファイル全体で出力して。

コツ: 「入口が 2 つ、出口が 1 つ」と最初に言う

これを言わずに「HTTP を受ける Flask を作って」→「シリアルも読みたい」と順に頼むと、2 つ目を足すときに 1 つ目の構造を壊した答えが返ってくることがよくあります。保存処理が 2 か所にコピーされて、片方だけ直してバグる、という展開です。

最初に全体像を言えば、save_reading() のような共通の出口を先に設計してくれます。

コツ: 動く環境を具体的に伝える

「Raspberry Pi OS Trixie、Flask は apt 版」と書くのは重要です。書かないと pip install flask を前提にした手順が返ってきます。第6回で書いたとおり、Trixie は外部管理環境なので pip は拒否されます。

「本番は gunicorn を使いましょう」といった、今の段階では要らない提案を避けるのにも効きます。センサー数台の家庭・現場用途で秒間数リクエストも来ないなら、Flask の開発サーバーで十分です。


10. ここまでで出来上がったもの

[ESP8266]  ─ WiFi POST ─┐
                         ├→ [Flask :5000] → [SQLite] → ブラウザでグラフ
[XIAO C3] ─NOW→[Master] ─ USB ─┘

第1回でシリアルモニタに温度を出したところから、8 回かけてここまで来ました。2 種類のチップ、2 種類の通信方式、1 つのデータベースが繋がっています。

device_id に MAC 由来の ID をそのまま使い続けているのがポイントです。チップを何台足しても、スケッチは書き換えなくていい。この設計の効果が次回いちばん分かりやすく出ます。


次回予告

第9回は、今回あえて外した 2 つを片付けます。

表示名 (nickname) の割当 — NOW-D4E5F6 では現場で何のことか分かりません。「冷却塔1」「外気温」と名前を付けられるようにします。ここで重要なのは、温度データ側の device_id は一切書き換えないことです。別の表を作って表示のときだけ差し替えます。チップを交換しても過去データが壊れない、というのが第4回から積んできた 3 層分離の狙いでした。

systemd で自動起動 — SSH を切っても、Pi を再起動しても、勝手に上がってくるようにします。venv を使うかどうか、.env に何を逃がすか、journalctl でログをどう追うか、まで扱います。

ここまで来れば「置いておける」状態になります。


← 第7回 | 連載目次 | 第9回 →

コメント

タイトルとURLをコピーしました