PR

【第9回】名前を付けて、置きっぱなしにする — 表示名の管理と systemd 自動起動

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

第8回で、HTTP と USB シリアルの 2 経路を 1 つの SQLite に落とすところまで出来ました。ブラウザで開けば数字が並びます。ただ、並んでいるのは NOW-D4E5F6 のような文字列です。現場に立って「D4E5F6 が 32 度」と言われても、それがどこの温度なのか分かりません。

それと、いまのサーバーは SSH を切ると死にます。Pi を再起動しても上がってきません。置いておける状態ではないということです。

今回はこの 2 つを片付けます。ついでに、放っておくと後で困る 2 つ — DB の肥大化と、初期パスワードのまま AP を立てていること — も潰しておきます。


  1. この記事の構成 — 何を検証したか
  2. 1. 今回やること
  3. 2. 表示名を付ける — ただし device_id は絶対に書き換えない
    1. 2.1 テーブルを 1 枚足す
    2. 2.2 app.py に API を 3 本足す
    3. 2.3 表示用 API に LEFT JOIN を 1 行足す
    4. 2.4 管理画面 (templates/manage.html)
    5. 2.5 ダッシュボード側は 2 か所だけ
  4. 3. systemd で自動起動させる
    1. 3.1 venv は今回いりません (理由も含めて)
    2. 3.2 設定を .env に逃がす
    3. 3.3 unit ファイル
    4. 3.4 有効化する
    5. 3.5 ログは journalctl で見る
    6. 3.6 手で起動したまま enable すると、10 秒ごとに再起動を繰り返します
    7. 3.7 シリアルポートを開く権限
    8. 3.8 /dev/ttyACM0 という名前は固定ではありません
  5. 4. DB が太らないようにする
    1. 4.1 どのくらい増えるのか (概算)
    2. 4.2 期限切れの行を消す
    3. 4.2 の検証 — 90 日待たずに確かめる
    4. 4.3 DELETE してもファイルは小さくなりません
    5. 4.4 バックアップは 1 行で取れます
  6. 5. pi のパスワードを変える — 後回しにしていた宿題
    1. 5.1 パスワードを変える
    2. 5.2 パスワード認証をやめて鍵にする
    3. 5.3 ついでに確認しておくこと
  7. 6. 動かして確かめる
    1. 6.1 まず「どこまで入ったか」をまとめて確認する
    2. 6.2 手で叩いて確かめる
  8. 7. よくある失敗
  9. 8. AI との会話例
    1. コツ: 「既に動いているものがある」と最初に言う
    2. コツ: 「なぜそう書いたか」を 1 行ずつ聞く
  10. 9. ここまでで出来上がったもの
  11. 次回予告

この記事の構成 — 何を検証したか

先に断っておきます。今回のコードは、私が別に持っている実運用版のサーバーから該当部分を抜き出し、第8回の単一ファイル構成に合わせて書き直したものです。

部分出どころ状態
SQL (テーブル定義、UPSERT、LEFT JOIN、期限削除)実運用版から抜粋長期稼働の実績あり
管理画面の組み立てロジック実運用版から抜粋。DOM API に書き換え (理由は 2.4)ロジックは実績あり、書き方は新規
Flask のルート定義単一ファイル版に合わせて新規2026-09-08 実機で検証済み
systemd unit実運用版から抜粋、パスのみ変更実績あり
DB の増加量の見積り私の計算概算。実測ではありません

「未検証」だった部分は、2026-09-08 に私の Pi で通しました。 本文のコードと出力は、そのとき動いたものです。ここに至るまでに AssertionError で起動しない、JavaScript が丸ごと死ぬ、テーブルが無くて 500、と 3 回転んでいます。その 3 つを全部検出できるようにしたのが 6.1 のスクリプトです。

読者の方も、貼り付けたらまず手で動かしてから systemctl enable してください。順番が逆だと、動かない原因が「コード」なのか「systemd」なのか切り分けられなくなります。

1. 今回やること

やることこれで何が変わるか
2表示名 (nickname) の割当NOW-D4E5F6 が「冷却塔1」になる
3systemd で自動起動SSH を切っても、停電から復帰しても勝手に上がる
4DB の肥大化対策1 年放っておいてもグラフが重くならない
5pi のパスワード変更AP に繋がった誰かに乗っ取られない

触るファイルは第8回と同じ場所です。

~/tempserver/
├── app.py              ← 追記する
├── temperature.db      ← テーブルが 1 枚増える
└── templates/
    ├── index.html      ← 2 行だけ直す
    └── manage.html     ← 新規

/etc/systemd/system/tempserver.service   ← 新規

今回は同じファイルを何度も開いて追記します。各節の冒頭に「どこへ移動して、何を開くか」を毎回書いておきますので、そのとおりに打ってください。nano の操作は以下の 4 つだけ覚えれば足ります。

Ctrl + O  →  Enter    保存する
Ctrl + X              終了する
Ctrl + W              ファイル内を検索する  ★追記する場所へ飛ぶのに使います
Ctrl + K              カーソル行を切り取る

とくに Ctrl + W です。 「app.py の◯◯のセクションに追記」と言われても、400 行のファイルを目で追うのは大変です。検索すれば一発で飛べます。

2. 表示名を付ける — ただし device_id は絶対に書き換えない

一番やってはいけないのが、temperatures テーブルの device_id を「冷却塔1」に書き換えることです。理由は 3 つあります。

  1. 過去データが壊れます。 名前を変えた瞬間、それ以前の行は別デバイス扱いになり、グラフが 2 本に割れます
  2. チップを交換できなくなります。 ESP が壊れて別の個体に載せ替えると MAC が変わります。device_id が名前を兼ねていると、置き換えのたびに過去データとの繋がりが切れます
  3. ESP 側と Pi 側が結びついてしまいます。 名前を変えるたびにスケッチを書き直して現地へ行く、という運用になります

第4回で決めた 3 層分離 — MAC 由来の device_id / ネットワーク層 / 人間が読む名前 — の 3 層目がここです。名前は別の表に持ち、表示するときだけ差し替えます。

層誰が決めるか変わるとき持ち場
device_id (NOW-D4E5F6)チップの MACチップを交換したときだけESP のスケッチ
IP アドレスMAC 下位バイト (方式D)同上ESP のスケッチ
表示名 (「冷却塔1」)人間いつでも。ブラウザからPi の DB

2.1 テーブルを 1 枚足す

app.py の init_db() に追記します。CREATE TABLE IF NOT EXISTS なので、既にデータが入っている DB に対して実行しても既存の行は無事です。

cd ~/tempserver
nano app.py
#   開いたら Ctrl+W で  def init_db  を検索して、その関数の中へ
#   貼り付けたら Ctrl+O → Enter → Ctrl+X で保存して終了
nano エディタで app.py の init_db を開いた画面

エディターを開いたらCtrl+WでSearchにdef init_dbをコピペするか入力してEnter。

nano の Ctrl + W 検索で def init_db を探す

カーソルが当該箇所に移動するので、カーソルは移動させず、Alt + A を押します(画面下に Mark Set と表示されます)。矢印キーで書き換えたい範囲の最後までカーソルを移動させてを選択(PowerShellを使ってSHHで接続中のターミナルはGUIではないうえnanoエディターのショートカットコマンドはWindowsと違うので同じように扱えない)。マウスドラッグして選択しても選択出来ているように見えません。

Alt + A で init_db 関数を範囲選択した nano エディタ

選択出来たらCtrl +K でカットして以下のコードを貼り付け(PowerShellの貼り付けは右クリック)。

def init_db():
with db() as conn:
conn.execute(“””
CREATE TABLE IF NOT EXISTS temperatures (
… 第8回のまま、変更なし …
)
“””)
conn.execute(“””
CREATE INDEX IF NOT EXISTS idx_device_timestamp
ON temperatures(device_id, timestamp DESC)
“””)

# ▼▼▼ ここから追記 ▼▼▼
# 表示名テーブル。temperatures とは device_id で対応する。
# 過去データは一切書き換えず、表示のときだけ優先して使う。
conn.execute(“””
CREATE TABLE IF NOT EXISTS device_nicknames (
device_id TEXT PRIMARY KEY,
nickname TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
“””)

init_db に nickname テーブルを追加した後のコード

Ctrl+O → Enter → Ctrl+Xで保存して終了。

device_id を PRIMARY KEY にしているのが肝です。1 デバイスに名前は 1 つ、というのが SQLite 側で保証されます。同じ device_id に 2 回名前を付けたら上書きになります (次の UPSERT)。

2.2 app.py に API を 3 本足す

一覧・設定・削除の 3 本です。app.py の「表示用 API」のセクションの並びに追記します。

貼る位置に神経質になる必要はありません。 @app.get("/") (ダッシュボード) の上でも下でも動きます。守るのは 1 つだけ — if __name__ == "__main__": より上に置くことです。

理由は 2 つあります。@app.get(...) はファイルが読み込まれた時点で URL 表に登録されるので、app.run() が走る前でありさえすれば順番は問われません。app.run() より後ろに書くと、そこへ到達する前に app.run() が動き続けてしまい、永久に登録されません。もう 1 つ、リクエストが来たときにどのルートへ当てるかは、書いた順ではなく URL の形で決まります。/ が先にあるからといって /api/nicknames が飲み込まれることはありません。

cd ~/tempserver
nano app.py
#   Ctrl+W で  ===== 起動 =====  を検索。その手前(上ならどこでもいい)に貼り付けます
#   貼り付けたら Ctrl+O → Enter → Ctrl+X で保存して終了

エディターを開いてカーソルを#===== 起動 =====の真上まで移動

app.py の起動処理の直前にカーソルを置いた nano エディタ
# ===== 表示名 (nickname) の API =====
@app.get("/api/nicknames")
def api_nicknames():
    """登録されている表示名を全部返す"""
    with db() as conn:
        rows = conn.execute("""
            SELECT device_id, nickname, updated_at
            FROM device_nicknames
            ORDER BY device_id
        """).fetchall()
    return jsonify([dict(r) for r in rows])


@app.put("/api/nicknames/<device_id>")
def api_nickname_set(device_id):
    """表示名を設定する (無ければ追加、あれば更新)"""
    data = request.get_json(silent=True) or {}
    nickname = (data.get("nickname") or "").strip()

    if not nickname:
        return jsonify(status="error", reason="nickname が空です"), 400
    if len(device_id) > 100:
        return jsonify(status="error", reason="device_id が長すぎます"), 400
    if len(nickname) > 50:
        return jsonify(status="error", reason="nickname は 50 文字までです"), 400

    with db() as conn:
        conn.execute("""
            INSERT INTO device_nicknames (device_id, nickname, updated_at)
            VALUES (?, ?, CURRENT_TIMESTAMP)
            ON CONFLICT(device_id) DO UPDATE SET
                nickname   = excluded.nickname,
                updated_at = excluded.updated_at
        """, (device_id, nickname))

    print(f"[nickname] {device_id} -> {nickname}", flush=True)
    return jsonify(status="ok", device_id=device_id, nickname=nickname)


@app.delete("/api/nicknames/<device_id>")
def api_nickname_delete(device_id):
    """表示名を消す。温度データには触らない"""
    with db() as conn:
        deleted = conn.execute(
            "DELETE FROM device_nicknames WHERE device_id = ?",
            (device_id,)
        ).rowcount
    return jsonify(status="ok", device_id=device_id, deleted=deleted)

右クリックで張り付け。

表示名管理用の API 3 本を貼り付けた app.py

3 点だけ補足します。

ON CONFLICT DO UPDATE (UPSERT) — 「あれば更新、無ければ挿入」を 1 文で書く SQLite の構文です。先に SELECT して分岐すると、その隙に別スレッドが書き込んで壊れる可能性があります。1 文で済ませるのが安全です。SQLite 3.24 (2018 年) 以降で使えます。

長さ制限 — 50 文字と 100 文字で切っています。これは「たまたま」ではなく、実運用版で使っている値をそのまま持ってきました。上限が無いと、ブラウザから数 MB の文字列を投げ込まれたときに DB が膨らみます。

URL のデコード — /api/nicknames/NOW-D4E5F6 のように device_id を URL に入れます。ブラウザ側で encodeURIComponent() して送れば、Flask が受け取るときに自動でデコードしてくれるので、こちら側で何もする必要はありません。ただし device_id に / が入ると壊れます。第4回の命名規則 (英数字とハイフンのみ) を守っていれば起きません。

2.3 表示用 API に LEFT JOIN を 1 行足す

名前を保存できるようになっても、表示側が知らなければ意味がありません。/api/latest と /api/history を差し替えます。

ここは「追記」ではなく「置き換え」です。 第8回で書いた api_latest と api_history を消してから貼ってください。古いほうを残したまま下に貼り足すと、同じ関数名が 2 つになって起動時に落ちます。

AssertionError: View function mapping is overwriting an existing
endpoint function: api_latest

出してしまったときの直し方 — 4 手で戻せます。エラーの末尾に、重複している関数名 (api_latest か api_history) が書いてあります。

# 1) まずバックアップ
cd ~/tempserver
cp app.py app.py.bak

# 2) 重複箇所を探す。"def api_history" などが 2 行出るはず
grep -n "def api_latest\|def api_history\|LEFT JOIN\|COALESCE" app.py
#    LEFT JOIN / COALESCE を含まないほうが第8回の古い版。行番号を控える

#    ルートを全部並べて見たいときはこちら
grep -n "@app\.\(get\|post\|put\|delete\|route\)" app.py
# 3) 古いほうを消す
nano app.py
#   Ctrl + _   行番号ジャンプ (手順 2 で控えた行番号を入力)
#   Ctrl + K   1 行切り取り。関数の終わり (return jsonify(series)) まで 18〜19 回
#   Ctrl + U   切りすぎたら貼り戻せます
#   Ctrl + O → Enter → Ctrl + X で保存して終了

# 4) 確認。どちらも 1 になれば OK
grep -c "def api_latest"  app.py
grep -c "def api_history" app.py
python3 app.py

失敗しても cp app.py.bak app.py で戻せます。手順 1 のバックアップを飛ばさないでください。

ここで LEFT JOIN を使うのが重要です。 ふつうの JOIN にすると、名前を付けていないデバイスが一覧から消えます。新しく設置したセンサーが管理画面にも出てこない、という一番困る挙動になります。LEFT JOIN なら名前が無い側は NULL で返ってくるだけです。

cd ~/tempserver
nano app.py
#   Ctrl+W で  def api_latest  を検索。api_latest と api_history の
#   2 つの関数を、下のコードでまるごと置き換えます
#   貼り付けたら Ctrl+O → Enter → Ctrl+X で保存して終了
nano の検索で表示用 API の関数を探す

Alt + A ( Mark Set )と矢印キーで当該関数を選択。

表示用 API の関数を範囲選択した nano エディタ
@app.get("/api/latest")
def api_latest():
    """デバイスごとの最新 1 件 (表示名を付けて返す)"""
    with db() as conn:
        rows = conn.execute("""
            SELECT t.*, n.nickname AS nickname
            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
            LEFT JOIN device_nicknames n ON n.device_id = t.device_id
            ORDER BY COALESCE(n.nickname, 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))

    with db() as conn:
        rows = conn.execute("""
            SELECT t.device_id,
                   COALESCE(n.nickname, t.device_id) AS label,
                   t.temperature, t.timestamp
            FROM temperatures t
            LEFT JOIN device_nicknames n ON n.device_id = t.device_id
            WHERE t.timestamp >= datetime('now', ?)
            ORDER BY t.timestamp
        """, (f"-{hours} hours",)).fetchall()

    series = {}
    for r in rows:
        entry = series.setdefault(r["device_id"],
                                  {"label": r["label"], "points": []})
        entry["points"].append({"t": r["timestamp"], "v": r["temperature"]})
    return jsonify(series)
LEFT JOIN で表示名を結合するように書き換えた API

選択した箇所に右クリックで関数が書き換えられているが先に Ctrl + K でカットしていない場合マークセットが残っているので、Alt + A でマークセットから抜ける必要がある。

COALESCE(n.nickname, t.device_id) は「左が NULL なら右を使う」という関数です。名前があればそれ、無ければ device_id をそのまま。ここで分岐を SQL に押し込んでおくと、Python 側もブラウザ側も「label をそのまま出せばいい」で済みます。

/api/history の戻りの形が変わっています。 第8回は {device_id: [点の配列]} でした。名前を一緒に返す必要が出たので {device_id: {label, points}} にしています。グラフのキーを名前にしなかったのは意図的です — 2 台に同じ名前を付けたときに、グラフが 1 本にまとまって消えてしまうからです。キーは常に device_id、名前は表示だけ。ここでも 3 層分離です。

あわせて管理画面のルートも足します。

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

これも #===== 起動 ===== の真上に貼っておきます。

管理画面用のルートを追加した app.py

Ctrl+O → Enter → Ctrl+X で保存して終了。

2.4 管理画面 (templates/manage.html)

第8回の index.html と同じ templates フォルダの中に作ります。

cd ~/tempserver/templates
pwd                  # /home/pi/tempserver/templates と出れば OK
nano manage.html
#   貼り付けたら Ctrl+O → Enter → Ctrl+X で保存して終了

丸ごと貼り付けてください。CDN を一切使っていませんので、Pi がインターネットに繋がっていなくても開けます (第8回 6.2 の話です)。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>デバイス表示名の管理</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 16px; max-width: 900px; }
  h1 { font-size: 1.3rem; }
  table { width: 100%; border-collapse: collapse; margin-top: 12px; }
  th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
  th { background: #f4f4f4; }
  .id { font-family: monospace; font-size: .85em; }
  .none, .note { color: #999; font-size: .9em; }
  input[type=text] { width: 100%; padding: 4px; box-sizing: border-box; }
  button { padding: 4px 10px; margin-right: 4px; cursor: pointer; }
  #msg { margin-top: 10px; min-height: 1.4em; }
  .ok { color: #16a34a; }
  .ng { color: #dc2626; }
</style>
</head>
<body>
<h1>デバイス表示名の管理</h1>
<p><a href="/">← ダッシュボードに戻る</a></p>

<table>
  <thead><tr>
    <th>種類</th><th>device_id</th><th>現在の表示名</th>
    <th>新しい表示名</th><th>操作</th>
  </tr></thead>
  <tbody id="rows"><tr><td colspan="5">読み込み中...</td></tr></tbody>
</table>
<div id="msg"></div>

<script>
// device_id の接頭辞から種類を推測する。第4回で決めた命名規則が効いてくるところ。
function kindOf(id) {
  if (id.startsWith('MST-')) return 'Master';
  if (id.startsWith('ESP-')) return 'WiFi センサー';
  if (id.startsWith('NOW-')) return 'ESP-NOW センサー';
  return '不明';
}

function say(text, ok) {
  const m = document.getElementById('msg');
  m.textContent = text;
  m.className = ok ? 'ok' : 'ng';
}

async function load() {
  const [latest, nicks] = await Promise.all([
    fetch('/api/latest').then(r => r.json()),
    fetch('/api/nicknames').then(r => r.json())
  ]);

  const nickOf = {};
  for (const n of nicks) nickOf[n.device_id] = n.nickname;

  // 温度が来ているデバイスと、名前だけ登録済みのデバイスを合わせて並べる。
  // Master は温度を出さないので、後者を拾わないと一覧に現れない。
  const ids = new Set();
  const hasTemp = {};
  for (const r of latest) { ids.add(r.device_id); hasTemp[r.device_id] = true; }
  for (const id of Object.keys(nickOf)) ids.add(id);

  const order = id => (id.startsWith('MST-') ? '1'
                     : id.startsWith('ESP-') ? '2'
                     : id.startsWith('NOW-') ? '3' : '9') + id;
  const list = Array.from(ids).sort((a, b) => order(a).localeCompare(order(b)));

  const tbody = document.getElementById('rows');
  tbody.textContent = '';

  if (list.length === 0) {
    const tr = tbody.insertRow();
    const td = tr.insertCell();
    td.colSpan = 5;
    td.textContent = 'まだデバイスが 1 台も見つかっていません';
    return;
  }

  for (const id of list) {
    const tr = tbody.insertRow();

    tr.insertCell().textContent = kindOf(id);

    const cId = tr.insertCell();
    cId.className = 'id';
    cId.textContent = id;
    if (!hasTemp[id]) {
      const s = document.createElement('span');
      s.className = 'note';
      s.textContent = ' (温度データなし)';
      cId.appendChild(s);
    }

    const cNow = tr.insertCell();
    cNow.textContent = nickOf[id] || '(未設定)';
    if (!nickOf[id]) cNow.className = 'none';

    const input = document.createElement('input');
    input.type = 'text';
    input.maxLength = 50;
    input.placeholder = '例: 冷却塔1、外気温';
    input.value = nickOf[id] || '';
    tr.insertCell().appendChild(input);

    const cOp = tr.insertCell();
    const save = document.createElement('button');
    save.textContent = '保存';
    save.onclick = () => setNick(id, input.value.trim());
    cOp.appendChild(save);

    if (nickOf[id]) {
      const del = document.createElement('button');
      del.textContent = '削除';
      del.onclick = () => delNick(id);
      cOp.appendChild(del);
    }
  }
}

async function setNick(id, nickname) {
  if (!nickname) { say('表示名を入力してください', false); return; }
  const res = await fetch('/api/nicknames/' + encodeURIComponent(id), {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ nickname: nickname })
  });
  const data = await res.json();
  if (data.status === 'ok') { say('保存しました: ' + id + ' → ' + nickname, true); load(); }
  else { say('保存できません: ' + (data.reason || res.status), false); }
}

async function delNick(id) {
  if (!confirm('表示名を削除しますか?  device_id: ' + id)) return;
  const res = await fetch('/api/nicknames/' + encodeURIComponent(id),
                          { method: 'DELETE' });
  const data = await res.json();
  if (data.status === 'ok') { say('削除しました: ' + id, true); load(); }
  else { say('削除できません: ' + res.status, false); }
}

load();
</script>
</body>
</html>

Ctrl+O → Enter → Ctrl+X で保存して終了。

実運用版から書き方を変えた点が 1 つあります。 私の実運用版は HTML を文字列で組み立てて innerHTML に入れ、ボタンには onclick="saveNickname('...')" と device_id を文字列で埋め込んでいます。動いてはいますが、この書き方だと device_id やニックネームに ' や < が入った瞬間に壊れます。エスケープ関数を通して回避していますが、1 か所忘れると穴になります。

記事版では document.createElement() と textContent だけで組んでいます。textContent は中身を必ず「ただの文字」として扱うので、エスケープ処理そのものが要りません。ボタンのクリックもクロージャで id を直接掴んでいるため、文字列に埋める工程がありません。行数はほとんど変わらないのに、考えることが 1 つ減ります。

1 つ落とし穴があります。 この一覧は「温度が来ているデバイス ∪ 表示名が登録済みのデバイス」で作っています。つまり Master のように温度を出さないデバイスは、表示名を削除した瞬間に一覧から消えます。画面からは登録し直せません。戻すには curl を使います。

curl -X PUT http://localhost:5000/api/nicknames/MST-1A2B3C \
     -H "Content-Type: application/json" -d '{"nickname":"集約Master"}'

2.5 ダッシュボード側は 2 か所だけ

templates/index.html の <script> を 2 か所直します。

cd ~/tempserver/templates
nano index.html
#   1 か所目は Ctrl+W で  tr.innerHTML
#   2 か所目は Ctrl+W で  const datasets
#   貼り付けたら Ctrl+O → Enter → Ctrl+X で保存して終了

1) 最新値テーブルの 1 列目 — 名前があれば名前を大きく、device_id を小さく下に添えます。

// 変更前
    tr.innerHTML =
      `<td>${r.device_id}</td>` +

// 変更後 (エスケープを避けるため textContent で先に作る)
    const tdName = document.createElement('td');
    if (r.nickname) {
      tdName.textContent = r.nickname;
      const sub = document.createElement('div');
      sub.style.cssText = 'font-size:.8em;color:#888;font-family:monospace';
      sub.textContent = r.device_id;
      tdName.appendChild(sub);
    } else {
      tdName.textContent = r.device_id;
    }
    tr.appendChild(tdName);
    tr.insertAdjacentHTML('beforeend',

以降の <td> 群 (温度・電圧・RSSI・経路・時刻) はそのままです。tr.innerHTML = を tr.insertAdjacentHTML('beforeend', に変え、末尾の ; を ); にするだけで繋がります。

index.html の表の行を insertAdjacentHTML に書き換える箇所

マークセットして選択。カットして差し替えコードをペースト。インデントがずれて成形が面倒ならAlt + Iで自動インデントを無効化して貼り付け。

index.html の表描画部分を差し替えた後のコード

2) グラフの凡例 — /api/history の形が変わった分です。

// 変更前
  const datasets = Object.keys(series).sort().map((id, i) => ({
    label: id,
    data: series[id].map(p => ({ x: toDate(p.t), y: p.v })),

// 変更後
  const datasets = Object.keys(series).sort().map((id, i) => ({
    label: series[id].label,
    data: series[id].points.map(p => ({ x: toDate(p.t), y: p.v })),

あとは <body> のどこかに管理画面へのリンクを置いておくと便利です。

<p><a href="/manage">デバイスの表示名を編集</a></p>
index.html のグラフ凡例部分を差し替える箇所

Alt + Aでマークセットに入って選択。Ctrl + K でカットして変更後のコードに差し替え。

グラフ凡例に表示名を使うよう差し替えた後のコード

Ctrl+O → Enter → Ctrl+X で保存して終了。ダッシュボード画面にデバイス表示名を編集する画面に遷移するリンクが設置されています。


表示名管理画面へのリンクが付いた温度ダッシュボード

デバイス表示衛の管理画面でdevice_idと表示名を設定しています。

device_id ごとに表示名を設定するデバイス管理画面

3. systemd で自動起動させる

いまは python3 ~/tempserver/app.py で起動しています。この状態には 3 つ問題があります。

  • SSH を切ると、そのセッションの子プロセスなので一緒に死ぬ
  • Pi を再起動したら上がってこない。停電のたびに人が行く必要がある
  • 例外で落ちたら、それきり。誰も気付かない

systemd に面倒を見てもらえば 3 つとも消えます。

3.1 venv は今回いりません (理由も含めて)

Python の記事だとまず venv を作るのが定石ですが、第8回の構成では要りません。Flask も pyserial も apt install python3-flask python3-serial で入れており、これは OS のパッケージとしてシステム全体に入っています。venv を作ると、その中からは逆に見えなくなります。

venv を使いたい場合は 2 通りあります。

# A) apt で入れたパッケージも見える venv (混在するので好みが分かれます)
python3 -m venv --system-site-packages ~/tempserver/venv

# B) venv の中に pip で入れ直す (apt 版とは別物になります)
python3 -m venv ~/tempserver/venv
~/tempserver/venv/bin/pip install flask pyserial

どちらにしても、後述の unit ファイルの ExecStart を /home/pi/tempserver/venv/bin/python3 に差し替えるだけです。venv を作ったのに ExecStart が /usr/bin/python3 のまま、というのが定番の事故です。

3.2 設定を .env に逃がす

先に app.py の設定部分を環境変数から読むようにします。シリアルポート名は機材で変わるので、コードを書き換えずに済むほうが楽です。

cd ~/tempserver
nano app.py
#   Ctrl+W で  ===== 設定 =====  を検索
#   貼り付けたら Ctrl+O → Enter → Ctrl+X で保存して終了
# ===== 設定 =====
import glob                                 # ← 追加
import os                                   # ← 追加

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

# 環境変数があればそちらを使う。無ければ既定値。
SERIAL_PORT    = os.environ.get("SERIAL_PORT", "auto")   # "auto" で自動検出
SERIAL_BAUD    = int(os.environ.get("SERIAL_BAUD", "115200"))
RETENTION_DAYS = int(os.environ.get("RETENTION_DAYS", "90"))   # 4 章で使います


def find_serial_port():
    """繋がっているシリアルポートを 1 つ返す。無ければ None。

    CH340 / CH341 / FT232 / CP2102 などの USB-シリアル変換チップは、
    ドライバは別々でも Linux からはどれも /dev/ttyUSB* に見える。
    XIAO ESP32-C3 のように USB を自前で持つチップは /dev/ttyACM*。
    両方を見に行くので、変換チップの種類を気にする必要はない。
    """
    if SERIAL_PORT != "auto":
        return SERIAL_PORT
    ports = sorted(glob.glob("/dev/ttyUSB*")) + sorted(glob.glob("/dev/ttyACM*"))
    return ports[0] if ports else None

改行でスペースを開けて設定を追加。

.env ファイルに設定値を書き出した画面

保存して終了してください。

cd ~/tempserver
nano .env
#   貼り付けたら Ctrl+O → Enter → Ctrl+X で保存して終了

ファイル名が . で始まるので、ls では見えません。ls -a で確認してください。

# SERIAL_PORT は書かなくて構いません (既定の "auto" で自動検出します)。
# 変換基板を複数挿していて Master を名指ししたいときだけ、
# 3.8 の /dev/serial/by-id/... を書きます。
#SERIAL_PORT=/dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_XX:XX:XX-if00
SERIAL_BAUD=115200
RETENTION_DAYS=90
ls -a で .env ファイルの存在を確認

保存して終了してください。

systemd の EnvironmentFile はシェルではありません。export は書かない、値をクォートで囲まない、行末にコメントを付けない — この 3 つを守ってください。# で始まる行だけがコメントです。

なお .env は GitHub に上げないでください。今回の中身は無害ですが、ここは将来パスワードを置く場所です。.gitignore に .env を書き、共有用には .env.example を置く、というのが定石です。

ついでに serial_loop() の頭も直します。接続し直すたびにポートを探し直すのが肝です。USB を抜き差しして ttyUSB0 と ttyUSB1 が入れ替わっても、そのまま追随します。

cd ~/tempserver
nano app.py
#   Ctrl+W で  def serial_loop  を検索。関数の先頭 5 行ほどを置き換えます
#   直したら Ctrl+O → Enter → Ctrl+X で保存して終了
# ===== 経路 2: USB シリアル (第7回の ESP-NOW Master) =====
def serial_loop():
    """別スレッドで動く。ポートが消えても開き直す。"""
    while True:
        port = find_serial_port()          # ← 毎回探し直す
        if port is None:
            print("[serial] シリアルポートが見つかりません。5 秒後に再試行",
                  flush=True)
            time.sleep(5)
            continue

        try:
            ser = serial.Serial(port, SERIAL_BAUD, timeout=5)
            print(f"[serial] {port} を開きました", flush=True)
        except Exception as e:
            print(f"[serial] {port} を開けません ({e})。5 秒後に再試行", flush=True)
            time.sleep(5)
            continue

        #  ここから下は第8回のまま (readline のループ)
シリアルポートを自動で探し直すように書き換える app.py の箇所

赤い矩形の部分を書き換える。

シリアルポート自動再接続の処理に書き換えた後のコード

保存して終了してください。journalctl -u tempserver -f で接続状況を確認して、ESP-NOWのMasterであるESP32をUSBポートから抜くと「切断されました」と表示され、差し込みなおすと「… を開きました」と表示が有り、通信を再開して子機からのデータを受け取っていることが分かります。

Master の USB を抜き差ししたときに自動で再接続する journalctl のログ

効いていることは、USB を抜いてみれば分かります。 ログを流したまま Master の USB を抜き、数秒おいて挿し直してください。

journalctl -u tempserver -f

実機のログです (抜いたのが 07:00:49、挿し直したのが 07:01:0x)。

[serial] 切断されました (device reports readiness to read but returned no data
         (device disconnected or multiple access on port?))。開き直します
[serial] シリアルポートが見つかりません。5 秒後に再試行
[serial] シリアルポートが見つかりません。5 秒後に再試行
[serial] シリアルポートが見つかりません。5 秒後に再試行
[serial] /dev/ttyACM0 を開きました

「シリアルポートが見つかりません」は、決め打ち版では絶対に出ない行です。 決め打ち版は「開けません (No such file or directory)」と報告します。この行が出ているということは、glob でポートを探しに行った結果「1 本も無い」と判断した、という意味です。挿し直してから 17 秒で自動復帰しており、人手はかかっていません。

Master に使うものLinux での見え方ドライバ
XIAO ESP32-C3 / ESP32-S3 (USB 内蔵)/dev/ttyACM0cdc_acm
CH340 / CH341 搭載ボード/dev/ttyUSB0ch341
CP2102 / CP2104 搭載ボード/dev/ttyUSB0cp210x
FT232 (FTDI) 搭載ボード/dev/ttyUSB0ftdi_sio

分かれ目はチップの銘柄ではなく「USB を自前で持っているか、変換基板を挟んでいるか」です。変換チップは 3 種類ともドライバは別物ですが、Linux から見える名前はどれも ttyUSB で同じ。だから「CH340 なら◯◯」と場合分けするより、ttyUSB* と ttyACM* の両方を舐めるほうが正しく、増改築にも耐えます。

自動検出は「最初に見つかった 1 つ」を取ります。 Master のほかに書き込み用の変換基板を挿していると、そちらを掴むことがあります。心当たりがあるときは ls /dev/ttyUSB* /dev/ttyACM* で本数を数え、2 本以上あるなら 3.8 の /dev/serial/by-id/ を .env に書いて名指ししてください。

3.3 unit ファイル

sudo nano /etc/systemd/system/tempserver.service
[Unit]
Description=Temperature Server (tempserver)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/tempserver
EnvironmentFile=-/home/pi/tempserver/.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 /home/pi/tempserver/app.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

ファイルの中身をコピペするだけです。保存して終了してください。

ユーザー名が pi でない場合 (Raspberry Pi OS Bookworm 以降は初回セットアップで自分で決めます) は、User= Group= と 3 か所のパスを自分のユーザー名に置き換えてください。whoami で確認できます。

行意味外すとどうなるか
After=network-online.target
Wants=network-online.target
ネットワークが上がってから起動する起動が速すぎて AP の準備前に立ち上がる。Restart=always があるので結局は繋がるが、起動直後のログが汚れる
Type=simpleExecStart のプロセスがそのまま本体—
User=piroot ではなく一般ユーザーで動かすroot で動かすと、バグが即システム全体の被害になる
EnvironmentFile=-....env を読む。先頭の - は「無くてもエラーにしない」- を書き忘れると、.env が無いだけで起動失敗
Environment=PYTHONUNBUFFERED=1print() を即座に吐かせるjournalctl に何も出なくなります。 3.5 参照
Restart=always
RestartSec=10
落ちたら 10 秒後に再起動例外で落ちたきり誰も気付かない

3.4 有効化する

# ★ 先に、手で起動している app.py を Ctrl+C で止めること (3.6 参照)

sudo systemctl daemon-reload          # unit を読み直す
sudo systemctl enable --now tempserver # 自動起動を有効化 + いま起動
systemctl status tempserver

Active: active (running) と緑で出れば成功です。enable が自動起動の登録、--now が「ついでに今すぐ起動」。分けて書くと enable だけして起動を忘れる、が起きます。

systemctl enable --now で tempserver サービスを有効化・起動
# 覚えておくコマンド
sudo systemctl restart tempserver     # コードを直したら再起動
sudo systemctl stop tempserver        # 一時停止 (手で動かして調べたいとき)
sudo systemctl disable tempserver     # 自動起動をやめる

本当に再起動後も上がるかは、一度 sudo reboot して確かめてください。「enable したから大丈夫だろう」で放置すると、半年後の停電で初めて失敗に気付きます。

3.5 ログは journalctl で見る

手で動かしていたときにターミナルへ流れていた [wifi] ESP-... 26.81℃ は、systemd 配下では journal に入ります。

journalctl -u tempserver -f            # 流れっぱなしで見る (Ctrl+C で抜ける)
journalctl -u tempserver -n 50         # 直近 50 行
journalctl -u tempserver --since "1 hour ago"
journalctl -u tempserver --since today | grep serial
journalctl -u tempserver -p err        # エラーだけ

ここが一番ハマるところです。 PYTHONUNBUFFERED=1 を書かないと、journalctl に何も出ません。Python は出力先が端末でないとき (パイプや journal) に print() をバッファに溜め込むためです。数 KB 溜まるまで、あるいはプロセスが終了するまで出てきません。

「サービスは active (running) なのにログが 1 行も無い。動いていないのでは」と何時間も調べる、というのがこの症状です。第8回のコードで print(..., flush=True) を全部に付けていたのはこの対策ですが、環境変数で一括指定するほうが確実です。付け忘れが効かなくなります。

3.6 手で起動したまま enable すると、10 秒ごとに再起動を繰り返します

私がやりました。python3 app.py を別のターミナルで動かしたまま systemctl enable --now したときに起きます。

OSError: [Errno 98] Address already in use
tempserver.service: Main process exited, code=exited, status=1/FAILURE
tempserver.service: Scheduled restart job, restart counter is at 4.
手動起動と systemd 起動がポート 5000 を取り合って再起動を繰り返すログ

ポート 5000 は先に掴んだほうの勝ちです。systemd 側が負け続け、Restart=always で 10 秒ごとに起動→失敗を繰り返します。restart counter が増え続けていたらこれです。

シリアルポートでも同じことが起きます。 しかもこちらは症状が分かりにくい。/dev/ttyACM0 は 2 つのプロセスから開けてしまうことがあり、その場合行が両方に半分ずつ配られます。「なぜか 2 回に 1 回しかデータが入らない」という、原因の見当がつかない不具合になります。

対処は単純です。手で動かすときは必ず先に止める。

sudo systemctl stop tempserver     # systemd 側を止めてから
python3 ~/tempserver/app.py        # 手で動かす

# 誰がポートを掴んでいるか調べる
sudo ss -lptn 'sport = :5000'
sudo fuser -v /dev/ttyACM0

3.7 シリアルポートを開く権限

手では読めていたのに systemd 配下だと Permission denied になる、というパターンがあります。/dev/ttyACM0 は dialout グループのものだからです。

ls -l /dev/ttyACM0
# crw-rw---- 1 root dialout 166, 0 ...  ← 所有グループが dialout

groups                        # 自分が dialout に入っているか
sudo usermod -aG dialout $USER
# ★ 反映にはログインし直しが必要 (サービスなら sudo systemctl restart tempserver)

Raspberry Pi OS の既定ユーザーは最初から dialout に入っているので、多くの場合は何もしなくて済みます。自分で作ったユーザーで動かしている場合だけ引っかかります。User=root にすれば通りますが、それは解決ではなく先送りです。

3.8 /dev/ttyACM0 という名前は固定ではありません

USB 機器を挿す順番や、起動時のタイミングで ttyACM0 と ttyACM1 が入れ替わります。子機を 2 台以上 USB で挿すなら、まず間違いなく踏みます。

Linux は同じデバイスに対して変わらない名前も用意してくれています。

ls -l /dev/serial/by-id/
# lrwxrwxrwx 1 root root 13 ... usb-Espressif_USB_JTAG_serial_debug_unit_XX:XX:XX-if00 -> ../../ttyACM0

この長い名前を .env に書いておけば、番号がずれても追従します。3.2 の自動検出だけで足りている場合は、書く必要はありません。ここを書くのは「複数挿さっていて、そのうちの 1 本を名指ししたい」ときだけです。

SERIAL_PORT=/dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_XX:XX:XX-if00

XX の部分は自分の Master の MAC です。ls の出力からそのままコピーしてください。これも 3 層分離の考え方の続きです — 「たまたま今この番号だった」ものに依存しない。


4. DB が太らないようにする

4.1 どのくらい増えるのか (概算)

まず桁を掴みます。以下は計算による概算で、実測ではありません。

1 台あたりの行数2 分間隔 → 1 日 720 行
3 台なら1 日 2,160 行 / 1 年 約 79 万行
1 行のおおよそのサイズ本体 + インデックスで 100〜150 バイト程度
1 年後の .db ファイルおおむね 80〜120 MB

SD カードの容量としては大した問題ではありません。効いてくるのはクエリの重さのほうです。/api/history?hours=720 (30 日) は 6 万行以上を読んで JSON に詰めます。Pi 4B なら耐えますが、ブラウザ側で Chart.js が 6 万点を描こうとして固まります。

対策は 2 段構えです。1 つは古い行を消すこと。これはこの章でやります。もう 1 つはグラフに渡す点を間引くことですが、この連載では実装しません。表示範囲を 24 時間程度に留めておけば 6 万点にはならないので、当面は困らないからです。30 日を常用したくなったときの宿題として残しておきます。

4.2 期限切れの行を消す

app.py に足します。既定は 90 日、.env の RETENTION_DAYS で変えられます (3.2 で仕込んだところ)。

cd ~/tempserver
nano app.py
#   Ctrl+W で  ===== 表示用 API =====  を検索して、そのすぐ手前に貼り付け
#   貼り付けたら Ctrl+O → Enter → Ctrl+X で保存して終了
# ===== 古いデータの掃除 =====
def purge_old_rows(days=None):
    """指定日数より古い行を消す。消した件数を返す"""
    days = RETENTION_DAYS if days is None else days
    with db() as conn:
        deleted = conn.execute(
            "DELETE FROM temperatures WHERE timestamp < datetime('now', ?)",
            (f"-{days} days",)
        ).rowcount
    if deleted:
        print(f"[purge] {days} 日より古い {deleted} 件を削除しました", flush=True)
    return deleted


def purge_loop():
    """1 日 1 回、掃除する。serial_loop と同じく daemon スレッドで回す"""
    while True:
        try:
            purge_old_rows()
        except Exception as e:
            print(f"[purge] 失敗しました ({e})", flush=True)
        time.sleep(24 * 60 * 60)

もう 1 か所、同じファイルの一番下です。Ctrl+W で __main__ を検索してください。

# ===== 起動 =====
if __name__ == "__main__":
    init_db()
    threading.Thread(target=serial_loop, daemon=True).start()
    threading.Thread(target=purge_loop,  daemon=True).start()   # ← 追加
    app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)

赤い矩形の部分を書き換えました。

期限切れの行を消す処理を追加した app.py

timestamp は第8回の設計どおり UTC で入っています。datetime('now', '-90 days') も UTC で計算されるので、そのまま比較できます。ここで JST に変換しようとすると 9 時間分ずれます。 「入れるときも出すときも UTC、人間に見せる直前だけローカル時刻」という第8回 5.5 の約束を守ってください。

4.2 の検証 — 90 日待たずに確かめる

「90 日より古い行を消す」機能を素直に検証しようとすると、90 日待つことになります。待つ必要はありません。 古いダミー行を 1 件仕込んで、関数を直接呼べば済みます。3 分です。

cd ~/tempserver
cp temperature.db temperature.db.bak      # 保険

# 200 日前のダミーを 1 件入れる
sqlite3 temperature.db "INSERT INTO temperatures(device_id,temperature,connection_type,timestamp)
  VALUES('TEST-OLD',1.0,'test',datetime('now','-200 days'));"
sqlite3 temperature.db "SELECT COUNT(*) FROM temperatures;"

次に purge_old_rows() を直接呼びます。import app しても if __name__ == "__main__": の中は実行されないので、Flask は起動しません。関数だけを取り出して試せます。

python3 -c "import sys; sys.path.insert(0,'/home/pi/tempserver'); import app; print('削除:', app.purge_old_rows())"
sqlite3 temperature.db "SELECT COUNT(*) FROM temperatures;"

実機での出力です。

466
[purge] 90 日より古い 1 件を削除しました
削除: 1
465

1 件減っています。ダミー行は消えたので後片付けも要りません。境界は datetime('now', '-90 days') との単純な比較なので、89 日前の行は残り、91 日前の行は消えます。日数を変えて確かめたければ app.purge_old_rows(days=1) のように引数で渡せます。

ここで私は 1 度転びました。 purge_old_rows() だけを追記して、3.2 の RETENTION_DAYS の定義を入れ忘れていたのです。

NameError: name 'RETENTION_DAYS' is not defined

この関数は 1 日 1 回しか呼ばれないので、起動時には何も起きません。 サービスは active のまま、24 時間後にバックグラウンドのスレッドの中で静かに落ちます。今回のように手で呼んでいなければ、気づくのは「DB が全然減らない」と思ったときです。時間差で効く機能は、時間差のまま放置せず、こうやって前倒しで叩いておくべきです。

6.1 のスクリプトは purge_old_rows() の有無しか見ません。定義漏れまでは拾えないので、この節の 3 分は省かないでください。

4.3 DELETE してもファイルは小さくなりません

ここは知らないと必ず不安になるところです。79 万行消しても ls -lh temperature.db のサイズは変わりません。

SQLite は削除した領域をファイル内の空き地として再利用するだけで、OS に返しません。次に入るデータがそこを埋めるので、実用上はこれで正しい挙動です。ファイルサイズが頭打ちになるのが期待する結果であって、縮むことではありません。

それでも本当に縮めたいとき (バックアップを持ち出す前など) は VACUUM です。

sudo systemctl stop tempserver
sqlite3 ~/tempserver/temperature.db "VACUUM;"
sudo systemctl start tempserver

VACUUM は DB 全体を書き直します。 一時的に元のファイルと同じだけの空き容量が要り、SD カードには全ページ書き込みという一番きつい負荷がかかります。毎日回すものではありません。年に数回、必要になったときだけです。サービスを止めてから実行してください。

sqlite3 で VACUUM を実行して DB ファイルを小さくする

4.4 バックアップは 1 行で取れます

SQLite の DB はただの 1 ファイルです。ただし書き込み中に cp でコピーすると壊れたコピーが取れます。専用のコマンドを使ってください。

# サービスを止めずに、整合性の取れたコピーを作る
sqlite3 ~/tempserver/temperature.db ".backup '/home/pi/backup-$(date +%Y%m%d).db'"

# CSV で持ち出す
sqlite3 -header -csv ~/tempserver/temperature.db \
  "SELECT * FROM temperatures ORDER BY timestamp;" > ~/temps.csv

.backup は SQLite が中で整合性を見ながらコピーしてくれます。cp との違いはここだけですが、この違いで「バックアップから復元しようとしたら壊れていた」が防げます。


5. pi のパスワードを変える — 後回しにしていた宿題

第4回で Pi に AP を立てました。あの時点から、この Pi は「誰でも電波が届けば接続を試せる機械」になっています。

AP のパスワードは掛かっています。ただし WPA2 のパスワードは、それ自体が総当たりの対象です。そして中に入られた後の最後の砦が SSH のパスワードで、それが raspberry のままだと砦がありません。ssh pi@192.168.4.1 は、AP に繋がってさえいれば誰でも叩けます。

今回でこの Pi は「置きっぱなしにする機械」になります。人が見ていない時間が一気に増えるので、ここで片付けます。

5.1 パスワードを変える

passwd

現在のパスワード → 新しいもの → もう一度、の 3 回聞かれます。入力しても画面には何も出ません(カーソルも動きません)。これは仕様です。

Raspberry Pi OS Bookworm 以降は初回セットアップで自分でユーザーを作るので、既定の pi / raspberry は存在しません。それより前のイメージを使い回している場合だけ該当します。心当たりがなければ確認してください。

whoami          # 自分のユーザー名
sudo passwd -S pi 2>/dev/null || echo "pi ユーザーはありません"

5.2 パスワード認証をやめて鍵にする

パスワードを強くしても、総当たりを試されること自体は止まりません。鍵認証に移行して、パスワードでの SSH を閉じるのが確実です。

順番を間違えると自分が入れなくなります。 必ずこの順で、しかもいま繋がっている SSH セッションは閉じないまま作業してください。別のターミナルをもう 1 枚開いて確認します。

  1. 手元の PC で鍵を作る (無ければ)
  2. 公開鍵を Pi に送る
  3. 別のターミナルから鍵でログインできることを確かめる
  4. それができてから、パスワード認証を無効化する
# 1) 手元の PC で (Windows なら PowerShell、Mac / Linux なら端末)
ssh-keygen -t ed25519 -C "temp-server"

# 2) 公開鍵を Pi へ
ssh-copy-id pi@192.168.4.1
#   ssh-copy-id が無い Windows なら:
#   type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh pi@192.168.4.1 ^
#     "mkdir -p -m 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

# 3) 別のターミナルで、パスワードを聞かれずに入れることを確認
ssh pi@192.168.4.1

3 が通ってから 4 に進みます。

# 4) Pi 側で
sudo nano /etc/ssh/sshd_config.d/99-nopassword.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
sudo sshd -t                       # 設定ファイルの文法チェック。ここで必ず確認
sudo systemctl restart ssh

/etc/ssh/sshd_config を直接いじらず、sshd_config.d/ にファイルを足しているのは、OS のアップデートで本体が置き換わっても設定が残るからです。

sudo sshd -t は必ず打ってください。何も出なければ OK です。文法エラーのまま restart すると sshd が上がらず、リモートからは二度と入れません。 その場合はモニタとキーボードを Pi に直結して直すことになります。

5.3 ついでに確認しておくこと

項目コマンド見るところ
SSH が外向きに開いていないかsudo ss -lptn0.0.0.0:22 が LAN と AP の両方に開いている。Tailscale だけにしたいなら ListenAddress で絞る
Flask が誰でも見られる状態—host="0.0.0.0" なので AP に繋がれば誰でもダッシュボードを開けます。温度が見えるだけとはいえ、承知の上で運用してください
自動ログインsudo raspi-config → System Options → Boot / Auto Loginデスクトップ自動ログインのままだと、Pi 本体に触れる人は無条件で入れます
ログイン失敗の記録journalctl -u ssh --since today | grep -i fail総当たりを食らっていれば大量に出ます

ここは深追いするといくらでも続きます。「初期パスワードのまま置きっぱなしにしない」の 1 点だけでも、危険度はまったく変わります。


6. 動かして確かめる

6.1 まず「どこまで入ったか」をまとめて確認する

ここまでで 4 ファイル・8 か所を手で編集しました。app.py だけで 5 か所です。これを目で追って全部そろっていることを確認するのは、まず無理です。

私は実際にやらかしました。2.1 (テーブル定義) と 2.3 (LEFT JOIN) を飛ばしたまま、2.2 だけ入れて先へ進みました。 症状は「表示名を保存しようとすると 500」。原因にたどり着くまで半日かかっています。しかも途中、/api/history が 200 を返していたので「2.3 は入っている」と思い込んでいました。旧版が 200 を返していただけでした。

抜けを一発で洗い出すスクリプトを置いておきます。読み取り専用で、何も変更しません。 作業の前後に毎回叩いてください。

cd ~
nano check_state.sh
#   貼り付けたら Ctrl+O → Enter → Ctrl+X で保存して終了
#!/bin/bash
# 温度サーバーの適用状況をまとめて確認する。読み取り専用。何も変更しない。
#   使い方:  bash ~/check_state.sh
DIR="$HOME/tempserver"
APP="$DIR/app.py"
DB="$DIR/temperature.db"
PORT="${PORT:-5000}"
SVC=tempserver

ok(){ printf '  &#91;OK] %s\n' "$1"; }
ng(){ printf '  &#91;NG] %s\n' "$1"; }
wn(){ printf '  &#91;--] %s\n' "$1"; }
hd(){ printf '\n%s\n' "$1"; }

has(){ grep -qF -- "$2" "$1" 2&gt;/dev/null; }

hd "== ファイル =="
&#91; -f "$APP" ] &amp;&amp; ok "app.py             ($(wc -l &lt; "$APP") 行)" || { ng "app.py がありません: $APP"; exit 1; }
&#91; -f "$DIR/templates/index.html" ]  &amp;&amp; ok "templates/index.html"  || ng "templates/index.html がありません"
&#91; -f "$DIR/templates/manage.html" ] &amp;&amp; ok "templates/manage.html" || ng "templates/manage.html がありません(/manage が 500 になります)"

hd "== app.py に何が入っているか =="
has "$APP" 'CREATE TABLE IF NOT EXISTS device_nicknames' \
  &amp;&amp; ok "第9回 2.1  device_nicknames テーブル定義" \
  || ng "第9回 2.1  device_nicknames テーブル定義 が無い → PUT が 'no such table' で 500"

n22=0
for f in api_nicknames api_nickname_set api_nickname_delete; do
  has "$APP" "def $f(" &amp;&amp; n22=$((n22+1))
done
has "$APP" 'def manage(' &amp;&amp; n22=$((n22+1))
&#91; "$n22" -eq 4 ] &amp;&amp; ok "第9回 2.2  表示名 API 3 本 + /manage ルート" \
                 || ng "第9回 2.2  4 つ中 $n22 つしかありません → /api/nicknames や /manage が 404"

nlj=$(grep -c 'LEFT JOIN device_nicknames' "$APP" 2&gt;/dev/null || echo 0)
if &#91; "$nlj" -ge 2 ] &amp;&amp; has "$APP" '"points": &#91;]'; then
  ok "第9回 2.3  LEFT JOIN 版の api_latest / api_history"
elif &#91; "$nlj" -ge 2 ]; then
  wn "第9回 2.3  LEFT JOIN はあるが api_history が旧形式かもしれません"
else
  ng "第9回 2.3  LEFT JOIN が $nlj か所 → 表示名がダッシュボードに出ない/グラフが壊れる"
fi

has "$APP" 'def find_serial_port(' \
  &amp;&amp; ok "第9回 3.2  find_serial_port() (ポート自動検出)" \
  || wn "第9回 3.2  find_serial_port() が無い(SERIAL_PORT 決め打ちで動作します)"

has "$APP" 'def purge_old_rows(' \
  &amp;&amp; ok "第9回 4.2  purge_old_rows() (古いデータの掃除)" \
  || wn "第9回 4.2  purge_old_rows() が無い(DB は増え続けます)"

hd "== 重複と構文 =="
python3 - "$APP" &lt;&lt;'PY'
import ast, sys, collections
src = open(sys.argv&#91;1], encoding="utf-8").read()
try:
    t = ast.parse(src)
except SyntaxError as e:
    print(f"  &#91;NG] 構文エラー: {e.lineno} 行目 {e.msg}"); sys.exit(0)
print("  &#91;OK] 構文 OK")
c = collections.Counter(n.name for n in t.body if isinstance(n, ast.FunctionDef))
dup = {k: v for k, v in c.items() if v &gt; 1}
if dup:
    for k, v in dup.items():
        print(f"  &#91;NG] 関数 {k} が {v} 個 → 起動時に AssertionError で落ちます")
else:
    print("  &#91;OK] 関数名の重複なし")
PY

hd "== index.html =="
IDX="$DIR/templates/index.html"
if &#91; -f "$IDX" ]; then
  nl=$(grep -c 'async function loadLatest' "$IDX")
  &#91; "$nl" -eq 1 ] &amp;&amp; ok "loadLatest の定義は 1 個" || ng "loadLatest が $nl 個 → JS が構文エラーで全滅する可能性"
  has "$IDX" 'series&#91;id].points' &amp;&amp; ok "グラフが新形式 (points/label) に対応" \
                                 || ng "グラフが旧形式のまま → 2.5 の 2 か所目が未適用"
  has "$IDX" 'r.nickname' &amp;&amp; ok "表が表示名に対応" || ng "表が表示名に未対応 → 2.5 の 1 か所目が未適用"
  command -v node &gt;/dev/null 2&gt;&amp;1 &amp;&amp; {
    python3 -c "
import re,sys,pathlib
h=pathlib.Path('$IDX').read_text(encoding='utf-8')
m=re.findall(r'&lt;script&gt;(.*?)&lt;/script&gt;',h,re.S)
open('/tmp/_chk.js','w',encoding='utf-8').write(m&#91;-1] if m else '')" \
    &amp;&amp; (node --check /tmp/_chk.js &gt;/dev/null 2&gt;&amp;1 &amp;&amp; ok "JavaScript 構文 OK" || ng "JavaScript 構文エラー")
  }
fi

hd "== データベース =="
python3 - "$DB" &lt;&lt;'PY'
import sqlite3, sys, os
p = sys.argv&#91;1]
if not os.path.exists(p):
    print("  &#91;NG] temperature.db がありません"); sys.exit(0)
c = sqlite3.connect(p)
tabs = {r&#91;0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='table'")}
for t in ("temperatures", "device_nicknames"):
    print(f"  &#91;OK] テーブル {t}" if t in tabs
          else f"  &#91;NG] テーブル {t} がありません")
if "temperatures" in tabs:
    n = c.execute("SELECT COUNT(*) FROM temperatures").fetchone()&#91;0]
    last = c.execute("SELECT device_id, temperature, connection_type, timestamp "
                     "FROM temperatures ORDER BY id DESC LIMIT 1").fetchone()
    print(f"     行数 {n}" + (f" / 最新 {last&#91;0]} {last&#91;1]}℃ {last&#91;2]} {last&#91;3]} UTC" if last else ""))
if "device_nicknames" in tabs:
    for r in c.execute("SELECT device_id, nickname FROM device_nicknames ORDER BY device_id"):
        print(f"     表示名 {r&#91;0]} -&gt; {r&#91;1]}")
PY

hd "== サービス =="
if command -v systemctl &gt;/dev/null 2&gt;&amp;1; then
  st=$(systemctl is-active "$SVC" 2&gt;/dev/null); &#91; -z "$st" ] &amp;&amp; st="不明 (systemd 管理外)"
  &#91; "$st" = active ] &amp;&amp; ok "$SVC : active" || ng "$SVC : $st"
  started=$(systemctl show -p ActiveEnterTimestampMonotonic --value "$SVC" 2&gt;/dev/null || echo 0)
  if &#91; "${started:-0}" -gt 0 ]; then
    boot_ms=$(awk '{print int($1)}' /proc/uptime)
    now_epoch=$(date +%s)
    start_epoch=$(( now_epoch - (boot_ms - started/1000000) ))
    for f in "$APP" "$DIR/templates/index.html" "$DIR/templates/manage.html"; do
      &#91; -f "$f" ] || continue
      if &#91; "$(stat -c %Y "$f")" -gt "$start_epoch" ]; then
        wn "$(basename "$f") はサービス起動より新しい → sudo systemctl restart $SVC が必要"
      fi
    done
  fi
fi

hd "== HTTP 応答 =="
for path in / /api/latest /api/history?hours=24 /api/nicknames /manage; do
  code=$(curl -s -o /dev/null -m 5 -w '%{http_code}' "http://localhost:$PORT$path" 2&gt;/dev/null)
  &#91; -z "$code" ] &amp;&amp; code=000
  case "$code" in
    200) ok  "$(printf '%-24s' "$path") $code" ;;
    404) ng  "$(printf '%-24s' "$path") $code  ルートが app.py に無い" ;;
    500) ng  "$(printf '%-24s' "$path") $code  journalctl -u $SVC -n 40 で原因を見る" ;;
    000) ng  "$(printf '%-24s' "$path") ---  サーバーに繋がりません (停止中か別ポート)" ;;
    *)   ng  "$(printf '%-24s' "$path") $code" ;;
  esac
done
echo
bash ~/check_state.sh

私の実機 (2026-09-08) での出力です。

== ファイル ==
  [OK] app.py             (333 行)
  [OK] templates/index.html
  [OK] templates/manage.html

== app.py に何が入っているか ==
  [OK] 第9回 2.1  device_nicknames テーブル定義
  [OK] 第9回 2.2  表示名 API 3 本 + /manage ルート
  [OK] 第9回 2.3  LEFT JOIN 版の api_latest / api_history
  [OK] 第9回 3.2  find_serial_port() (ポート自動検出)
  [OK] 第9回 4.2  purge_old_rows() (古いデータの掃除)

== 重複と構文 ==
  [OK] 構文 OK
  [OK] 関数名の重複なし

== index.html ==
  [OK] loadLatest の定義は 1 個
  [OK] グラフが新形式 (points/label) に対応
  [OK] 表が表示名に対応

== データベース ==
  [OK] テーブル temperatures
  [OK] テーブル device_nicknames
     行数 962 / 最新 ESP-A30492 28.38℃ wifi 2026-09-08 21:43:19 UTC
     表示名 ESP-A30492 -> 外気温
     表示名 NOW-F7282C -> 冷却塔1

== サービス ==
  [OK] tempserver : active

== HTTP 応答 ==
  [OK] /                        200
  [OK] /api/latest              200
  [OK] /api/history?hours=24    200
  [OK] /api/nicknames           200
  [OK] /manage                  200

[OK] JavaScript 構文 OK の行が出ていませんが、異常ではありません。node が入っていない Pi では、この検査だけ黙って飛ばします。 入れておくと index.html の構文エラーまで拾えるようになります (sudo apt install -y nodejs)。2.5 でダッシュボードを直したあとに効きます。

[OK] JavaScript 構文 OK の行が出ていませんが、これは異常ではありません。node が入っていない Pi では、この検査だけ黙って飛ばします。 入れておくと index.html の構文エラーまで拾えるようになります (sudo apt install -y nodejs)。

[NG] が出たら、その行が指す節へ戻ってください。[--] は「入れなくても動くが、入れたほうがいい」ものです。

出た行そのまま放置すると戻る先
2.1 device_nicknames テーブル定義 が無い表示名を保存した瞬間に no such table で 5002.1
2.2 4 つ中 N つしかありません/api/nicknames や /manage が 4042.2
2.3 LEFT JOIN が N か所表示名がダッシュボードに出ない。グラフが真っ白2.3
関数 xxx が 2 個起動時に AssertionError で落ちる2.3 (置き換えのつもりが追記になっている)
loadLatest が 2 個 / JavaScript 構文エラーJS が丸ごと死ぬ。 表もグラフも出ない2.5
templates/manage.html がありません/manage が 5002.4
... はサービス起動より新しい直したのに反映されないsudo systemctl restart tempserver

最後の行が地味に効きます。 Flask は debug=False のときテンプレートをキャッシュするので、index.html を直しただけでは反映されません。「直したはずなのに変わらない」の大半はこれです。

6.2 手で叩いて確かめる

順番が大事です。先に手で動かして通してから、systemd に載せます。

# ① 手で起動して、例外が出ないことを確認
sudo systemctl stop tempserver 2>/dev/null
python3 ~/tempserver/app.py
# ② 別のターミナルから API を叩く
curl http://localhost:5000/api/nicknames
# → []  (まだ空)

curl -X PUT http://localhost:5000/api/nicknames/NOW-D4E5F6 \
     -H "Content-Type: application/json" \
     -d '{"nickname":"冷却塔1"}'
# → {"device_id":"NOW-D4E5F6","nickname":"冷却塔1","status":"ok"}

curl http://localhost:5000/api/latest | head -c 400
# → nickname":"冷却塔1" が入っていること
# ③ テーブルが出来ているか直接覗く
sqlite3 ~/tempserver/temperature.db ".schema device_nicknames"
sqlite3 ~/tempserver/temperature.db "SELECT * FROM device_nicknames;"

④ ブラウザで http://192.168.4.1:5000/manage を開き、一覧が出ること・保存できること・ダッシュボードに名前が反映されることを確認します。

ここまで通ったら Ctrl+C で止め、それから 3.4 の systemctl enable --now に進みます。最後に sudo reboot して、電源が入り直しても勝手に上がることを見届けてください。


7. よくある失敗

症状原因対処
active (running) なのに journalctl が空PYTHONUNBUFFERED=1 を書いていないunit に Environment=PYTHONUNBUFFERED=1 を足して daemon-reload → restart
restart counter が増え続けるポート 5000 かシリアルの取り合い。手で起動した app.py が残っているsudo ss -lptn 'sport = :5000' で犯人を探して止める
Failed to start ... unit not founddaemon-reload していない / ファイル名の綴り違いls /etc/systemd/system/tempserver.service で存在を確認して sudo systemctl daemon-reload
status=203/EXECExecStart のパスが違う。~ は展開されません絶対パスで書く。 which python3 と ls /home/pi/tempserver/app.py で両方確認
status=200/CHDIRWorkingDirectory が存在しないパスとユーザー名を見直す
シリアルだけ Permission denied実行ユーザーが dialout に入っていない3.7 を参照。User=root で逃げないこと
Master ではない別の機器を掴んでいる (温度が来ない、文字化けする)自動検出が最初に見つけた 1 本を取った。書き込み用の変換基板を挿しっぱなしにしているls /dev/ttyUSB* /dev/ttyACM* で本数を確認し、.env に /dev/serial/by-id/... を書いて名指しする (3.8)
DB が一向に減らない / 24 時間後に NameError: name 'RETENTION_DAYS' is not definedpurge_old_rows() だけ追記して 3.2 の RETENTION_DAYS の定義を入れていない3.2 の設定ブロックを見直す。起動時には落ちないので、4.2 の検証手順で手で呼んで確かめる
再起動後だけ繋がらないUSB の番号が入れ替わった3.8 の /dev/serial/by-id/ に切り替える
管理画面が真っ白templates/ の外に置いた / ファイル名の綴り違いls ~/tempserver/templates/。ブラウザの F12 → Console にエラーが出ます
名前を付けたのにグラフの凡例が device_id のままindex.html の 2 か所目 (series[id].label) を直していない2.5 を参照。ブラウザの強制リロード (Ctrl+Shift+R)
名前を付けたデバイスが一覧から消えたLEFT JOIN を JOIN と書いた2.3 を参照
起動時に AssertionError: View function mapping is overwriting...同じ関数名を 2 回定義した。2.3 で古い api_latest / api_history を消し忘れている古いほうを削除する。Ctrl+W で def api_latest を検索すると 2 つ見つかります
足したはずの API が 404if __name__ == "__main__": より下に貼った# ===== 起動 ===== より上へ移動する。2.2 参照
DELETE したのに .db が縮まない仕様です4.3 を参照。縮める必要は基本ありません

8. AI との会話例

コツ: 「既に動いているものがある」と最初に言う

今回いちばん効いたのはこれです。私は同じ機能をすでに別のサーバーで動かしています。それを言わずに「ニックネーム機能を作って」と頼むと、AI はゼロから設計を始めます。出てくるものは動きますが、私が 1 年かけて踏んだ落とし穴 (LEFT JOIN でないと消える、UPSERT でないと競合する) は入っていません。

私: 「表示名の管理機能は、別に持っている実運用版のサーバーで既に動いている。そこから SQL とロジックを抜き出して、第8回で作った単一ファイルの app.py に合う形に直してほしい。新しく設計はしないでいい」

この頼み方だと、AI の仕事は「設計」ではなく「移植」になります。移植なら答え合わせができます。 元のコードと突き合わせて、SQL が変わっていないかを見ればいい。設計を任せると、良し悪しを判断する基準が自分の中にありません。

コツ: 「なぜそう書いたか」を 1 行ずつ聞く

今回の記事で AI が書き換えを提案してきた箇所が 1 つあります。管理画面を innerHTML から textContent に変える、というものでした。私は最初「動いているものをなぜ変えるのか」と聞き返しました。

返ってきた理由 — device_id に引用符が入ると onclick="save('...')" が壊れる — は納得できるものだったので採用しました。納得できなければ却下すればいいし、そのために理由を聞きます。「AI が書き換えを提案してきたら、まず理由を言わせる」を癖にしておくと、意味の分からない差分が積み上がるのを防げます。

逆に、今回 AI に押し切られなかったものもあります。delay の位置や VACUUM の自動化について、私は「実測していないなら断定するな」と言い続けました。第6回・第7回で電池電圧まわりに「暫定・未検証」と書いて残してあるのも同じ理由です。動いているように見えることと、確かめたことは別です。


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

[ESP8266 子機] ──HTTP POST──┐
                             ├→ [Pi: Flask + SQLite] → ブラウザ
[XIAO 子機] ─ESP-NOW→ [Master] ──USB シリアル──┘         ├ / ダッシュボード
                                                          └ /manage 表示名の管理

  ・電源を入れれば勝手に上がる     (systemd)
  ・落ちても 10 秒後に戻ってくる   (Restart=always)
  ・ログは journalctl に残る
  ・90 日より古いデータは自動で消える
  ・device_id ではなく「冷却塔1」で読める
  ・SSH は鍵だけ

序章で「しまい込んでいた部品を形にする」と書きました。ここまでで、形になったものを置いておけるところまで来ました。ここから先は、置いておいたものをどう読むか、の話になります。

次回予告

第10回は、出来上がったものを一度ひととおり眺めます。第8回・第9回で足していった app.py が、いま全体として何をどう受けて、どこに書いて、どう返しているのか。1 本のファイルとして読み直します。継ぎ足しで作ってきたので、通しで読むのは実はこれが初めてです。

後半は、GitHub から取ってきて自分の Pi で動かす手順です。git clone から、.env の書き換え、必要なものの入れ方、最初の 1 行がデータベースに入るまで。ここまで読んでいなくても、この回だけで動かせるように書きます。

最終回は、ブレッドボードのままだったものをブレッドボード基板にはんだ付けして「現地出動盤」にします。電池の分圧回路もそこで本実装です。第6回で測った 4.30 V の飽和と、第7回・第9回でしきい値 3.3 V を「実測ではない」と書いて残してある件に、そこで決着を付けます。


← 第8回 | 連載目次 | 第10回 →

コメント

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