PR

Tapo P110M × FastAPIで電力監視ダッシュボードを作る【SQLite記録・リアルタイム堅牢化編】

ハードウェア制御 / 電子工作

TAPO PDU PROJECT VOL.2

スポンサーリンク

Tapo P110M × FastAPIで電力監視ダッシュボードを作る【SQLite記録・リアルタイム堅牢化編】

こんにちは。ニンジンです🥕

前回は、Tapo P110Mをpython-kasaで制御するための環境構築、実際にハマった「Unsupported device」エラーの原因究明、MACアドレスベースのデバイス管理、個別/一括ON-OFFの実装までを紹介しました。

今回はその続きとして、電力データをSQLiteに記録する仕組みと、ブラウザから一括/個別ON-OFF・消費電力を確認できるFastAPI製のWebダッシュボードを構築していきます。

この記事で分かること

  • Tapo P110Mの瞬間電力・積算電力量をpython-kasaのEnergyモジュールから取得し、SQLiteに記録する方法
  • consumption_total(生涯積算電力量)がP110Mでは常に取得できないと分かった実測結果
  • FastAPI + Uvicornで一括/個別ON-OFF・電力監視ができるWebダッシュボードの作り方
  • lifespanでアプリ起動時にバックグラウンドポーリング処理を仕込む設計
  • 通信に失敗したデバイスだけを自動で再discoverし、IPアドレスの変化に追従する堅牢化の実装
  • ダブルクリックで起動できる簡易起動スクリプトの作り方
スポンサーリンク

こんな人におすすめ

  • 前回の記事からの続きで読んでいる人
  • Tapoなどスマートプラグの数値をSQLiteに溜めて自分だけのダッシュボードで見たい人
  • FastAPI + asyncioでバックグラウンドポーリングを行うWebアプリを作りたい人

スポンサーリンク

消費電力データをSQLiteに記録する

前回作ったindividual_control.pybulk_control.pyは「今、ON/OFFする」ための道具でした。今回はそこに加えて、瞬間電力・積算電力量を定期的に記録し、あとから推移を追えるようにします。保存先はCSVも検討しましたが、複数デバイス・期間を指定した絞り込みがしやすいSQLiteを選びました。

power_readingsテーブルを設計する

記録する項目は、あとで「いつ・どのデバイスが・どれだけ使ったか」を追えるように、次のカラム構成にしました。

  • timestamp: 記録日時
  • device_name: plug1/plug2(前回tapo_devices.pyで定義した名前)
  • power_w: 瞬間電力(W)
  • energy_today_kwh: 本日の積算電力量(kWh)
  • energy_month_kwh: 当月の積算電力量(kWh)
  • voltage_v / current_a: 電圧・電流

DB周りの処理は、このあとpower_logger.pyweb_dashboard.pyの両方から使い回すので、power_store.pyという共通モジュールに切り出します。

VSCodeのエクスプローラー。tapo-pduフォルダの中にpower_store.pyがある

▲ power_store.pyも他のファイルと同じフォルダに置く

📄power_store.pyVSCode
import sqlite3
from datetime import datetime
from pathlib import Path

DB_PATH = Path(__file__).parent / "power_log.db"


def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS power_readings (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp TEXT NOT NULL,
            device_name TEXT NOT NULL,
            power_w REAL,
            energy_today_kwh REAL,
            energy_month_kwh REAL,
            voltage_v REAL,
            current_a REAL
        )
        """
    )
    conn.commit()
    return conn

dev.modules[“Energy”]から取れる値・取れない値

python-kasaでは、電力モニタリング対応デバイスの数値はdev.modules["Energy"]から取得します。

📄power_store.py(続き)VSCode
def log_reading(conn, device_name, dev):
    """dev(更新済みのDeviceオブジェクト)のEnergy情報をDBに記録し、Energyモジュールを返す。"""
    energy = dev.modules["Energy"]
    conn.execute(
        """
        INSERT INTO power_readings
            (timestamp, device_name, power_w, energy_today_kwh, energy_month_kwh, voltage_v, current_a)
        VALUES (?, ?, ?, ?, ?, ?, ?)
        """,
        (
            datetime.now().isoformat(timespec="seconds"),
            device_name,
            energy.current_consumption,
            energy.consumption_today,
            energy.consumption_this_month,
            energy.voltage,
            energy.current,
        ),
    )
    conn.commit()
    return energy

取得できた値は次の通りです。

  • current_consumption: 瞬間電力
  • consumption_today: 本日の積算電力量
  • consumption_this_month: 当月の積算電力量
  • voltage / current: 電圧・電流
デバイス購入からの生涯積算電力量にあたるconsumption_totalも試しに読んでみたのですが、Tapo P110Mでは常にNoneが返ることを実機で確認しました。今回のテーブル設計にこの項目を含めなかったのはこのためです。

power_logger.pyでポーリング記録する

記録用のスクリプトです。引数にonceを渡せば1回だけ、数値を渡せばその秒数間隔でループしながら記録し続けます。discover(デバイス検出)はループの最初に1回だけ行い、以降はdev.update()で使い回す設計にしています(毎回discoverすると非効率なうえ、前回説明した「discoverがまれに1回失敗する」不安定さの影響を受けやすくなるためです)。

VSCodeのエクスプローラー。power_logger.pyとview_log.pyが同じフォルダにある

▲ power_logger.py・view_log.pyも同じフォルダに置く

📄power_logger.pyVSCode
import asyncio
import sys
from datetime import datetime

from power_store import init_db, log_reading
from tapo_devices import discover_with_retry, disconnect_all

DEFAULT_INTERVAL_SEC = 60


async def main():
    once = len(sys.argv) > 1 and sys.argv[1] == "once"
    interval = DEFAULT_INTERVAL_SEC
    if not once and len(sys.argv) > 1:
        interval = int(sys.argv[1])

    conn = init_db()
    devices = await discover_with_retry()
    print(f"{len(devices)}台のデバイスを検出しました。")

    try:
        while True:
            for name, dev in devices.items():
                await dev.update()
                energy = log_reading(conn, name, dev)
                now = datetime.now().strftime("%H:%M:%S")
                print(f"[{now}] {dev.alias}: {energy.current_consumption}W / 本日 {energy.consumption_today}kWh")

            if once:
                break

            await asyncio.sleep(interval)
    except (KeyboardInterrupt, asyncio.CancelledError):
        print("ポーリングを停止しました。")
    finally:
        await disconnect_all(devices)
        conn.close()


if __name__ == "__main__":
    asyncio.run(main())

1回だけ記録する場合は次のように実行します。

PowerShellターミナル
python power_logger.py once
power_logger.py onceの実行結果

▲ onceを付けて実行→2台分の記録が1回だけ表示されプロンプトに戻る

間隔を指定してループさせる場合は、秒数を引数に渡します(未指定時は60秒間隔)。

PowerShellターミナル
python power_logger.py 15

15秒間隔で2〜3周ほど待ってからCtrl+Cを押すと、次のように動作します。

PowerShellターミナル
2台のデバイスを検出しました。
[09:11:59] Tapo P110M 2: 0.0W / 本日 0.0kWh
[09:11:59] Tapo P110M 1: 1.591W / 本日 0.01kWh
[09:12:14] Tapo P110M 2: 0.0W / 本日 0.0kWh
[09:12:15] Tapo P110M 1: 1.875W / 本日 0.01kWh
[09:12:30] Tapo P110M 2: 0.0W / 本日 0.0kWh
[09:12:30] Tapo P110M 1: 1.539W / 本日 0.01kWh
^C
ポーリングを停止しました。
赤いトレースバックが出ず、ポーリングを停止しました。の1行だけが出てプロンプトに戻れば正常です。
power_logger.py 15の実行結果。Ctrl+Cで正常終了

▲ 15秒間隔で数周ポーリング→Ctrl+Cで正常終了

🔧 コラム(上級者向け)

Ctrl+Cを押してもasyncio.run()が正しく止まらない(KeyboardInterruptがCancelledErrorになるPython 3.11以降の仕様)

実装時、except KeyboardInterrupt:を書いておいたにもかかわらず、Ctrl+Cを押すと次のようなトレースバックが出て終了する現象に遭遇しました。

PowerShellターミナル
File "...\asyncio\runners.py", line 119, in run
    return self._loop.run_until_complete(task)
...
File "power_logger.py", line 32, in main
    await asyncio.sleep(interval)
...
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

File "power_logger.py", line 41, in <module>
    asyncio.run(main())
...
File "...\asyncio\runners.py", line 124, in run
    raise KeyboardInterrupt()
KeyboardInterrupt

原因はPython 3.11以降のasyncio.run()の仕様変更でした。3.11以降、asyncio.run()は内部のRunnerクラスがCtrl+Cを検知すると、次のような流れで処理します。

  • まず実行中のタスク(main())に対してtask.cancel()を呼ぶ
  • この結果、await asyncio.sleep(interval)のようなawait中の箇所にはKeyboardInterruptではなくCancelledErrorが届く
  • CancelledErrormain()内のexcept KeyboardInterrupt:ではキャッチされずに素通りする
  • main()の外(Runner.run()側)まで伝播して、初めてKeyboardInterruptが投げ直される

つまりKeyboardInterruptmain()関数の外側でしか発生しないため、関数内に書いたexcept KeyboardInterrupt:は意味を成していませんでした(Python 3.10以前のasyncio.run()では挙動が異なり、直接KeyboardInterruptが届いていました)。

対処法は、except節にasyncio.CancelledErrorも加えるだけです。

📄power_logger.py(修正後)VSCode
except (KeyboardInterrupt, asyncio.CancelledError):
    print("ポーリングを停止しました。")

これでasyncio.sleep()中のキャンセルをその場でキャッチし、finallyブロックまできれいに通ってから終了するようになります。


view_log.pyで記録を確認する

記録された内容を簡単に確認するためのスクリプトです。

📄view_log.pyVSCode
import sqlite3
import sys
from pathlib import Path

DB_PATH = Path(__file__).parent / "power_log.db"


def main():
    limit = int(sys.argv[1]) if len(sys.argv) > 1 else 20

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    rows = conn.execute(
        "SELECT * FROM power_readings ORDER BY id DESC LIMIT ?", (limit,)
    ).fetchall()
    conn.close()

    if not rows:
        print("記録がまだありません。")
        return

    for row in reversed(rows):
        print(
            f"{row['timestamp']} | {row['device_name']:6} | "
            f"{row['power_w']:>6.2f} W | 本日 {row['energy_today_kwh']:.3f} kWh | "
            f"{row['voltage_v']:.1f} V | {row['current_a']:.3f} A"
        )


if __name__ == "__main__":
    main()
PowerShellターミナル
python view_log.py

直近の記録が古い順に、plug1plug2それぞれの行として表示されます。

view_log.pyの実行結果

▲ view_log.pyの実行結果。先ほどpower_logger.pyで記録した内容が確認できる


スポンサーリンク

FastAPI + Uvicornでリアルタイム監視ダッシュボードを構築する

ここまででSQLiteへの記録はできるようになりましたが、確認するたびにview_log.pyを実行するのは手間です。ブラウザから一括/個別ON-OFF・電力監視ができるWebダッシュボードを作ります。

なぜFastAPIか

python-kasaのAPIはすべてasync/await前提の非同期関数です。FastAPIはASGI(非同期のWebアプリケーション標準)ベースのフレームワークで、async defのエンドポイントをそのまま書けるため、同期処理とのブリッジを意識せずにpython-kasaをそのまま呼び出せます。加えてUvicornと組み合わせるだけで、追加のasyncioイベントループ管理も不要です。

lifespanで起動時discover→バックグラウンドポーリングを仕込む

FastAPIのlifespanという仕組みを使うと、アプリの起動時・終了時に処理を挟めます。起動時にdiscoverでデバイスを検出し、以降はバックグラウンドタスクとしてpoll_loop()を回し続けます。通信に失敗したデバイスへの対応(再discover)もこの中に組み込んでいます。

📄web_dashboard.pyVSCode
import asyncio
import os
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path

from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse

from power_store import DB_PATH, init_db, log_reading
from tapo_devices import DEVICES, discover_known_devices, discover_with_retry, disconnect_all

BASE_DIR = Path(__file__).parent

POLL_INTERVAL_SEC = int(os.environ.get("TAPO_POLL_INTERVAL_SEC", "15"))  # 電力値はそこまで急変しないため既定15秒
REDISCOVER_AFTER_FAILURES = 2  # この回数だけ連続で通信に失敗したら再discoverする

devices: dict = {}
latest_status: dict = {}
consecutive_failures: dict = {}


def _status_from_device(dev, online: bool = True) -> dict:
    energy = dev.modules["Energy"]
    return {
        "label": dev.alias,
        "online": online,
        "is_on": dev.is_on,
        "power_w": energy.current_consumption,
        "energy_today_kwh": energy.consumption_today,
        "energy_month_kwh": energy.consumption_this_month,
        "voltage_v": energy.voltage,
        "current_a": energy.current,
        "updated_at": datetime.now().isoformat(timespec="seconds"),
    }


async def _redisover_one(name: str):
    """1台だけを対象にdiscoverし直す(IPアドレスが変わった場合の追従用)。"""
    old = devices.get(name)
    if old is not None:
        try:
            await old.disconnect()
        except Exception:
            pass

    fresh = await discover_known_devices([name])
    if name in fresh:
        devices[name] = fresh[name]
        consecutive_failures[name] = 0
        print(f"{DEVICES[name]['label']} を再discoverし、接続を復旧しました。")
        return devices[name]
    return None


async def poll_loop():
    conn = init_db()
    try:
        while True:
            for name in list(devices.keys()):
                dev = devices[name]
                try:
                    await dev.update()
                    log_reading(conn, name, dev)
                    latest_status[name] = _status_from_device(dev, online=True)
                    consecutive_failures[name] = 0
                except Exception as exc:
                    consecutive_failures[name] = consecutive_failures.get(name, 0) + 1
                    prev = latest_status.get(name, {})
                    latest_status[name] = {
                        **prev,
                        "label": DEVICES[name]["label"],
                        "online": False,
                        "error": str(exc),
                        "updated_at": datetime.now().isoformat(timespec="seconds"),
                    }
                    if consecutive_failures[name] >= REDISCOVER_AFTER_FAILURES:
                        await _redisover_one(name)

            await asyncio.sleep(POLL_INTERVAL_SEC)
    finally:
        conn.close()


@asynccontextmanager
async def lifespan(app: FastAPI):
    global devices
    devices = await discover_with_retry()
    for name, dev in devices.items():
        await dev.update()
        consecutive_failures[name] = 0
        latest_status[name] = _status_from_device(dev, online=True)
    task = asyncio.create_task(poll_loop())
    try:
        yield
    finally:
        task.cancel()
        await disconnect_all(devices)


app = FastAPI(lifespan=lifespan)

ポイントは次の3つです。

  • lifespanの中でdiscover_with_retry()を1回だけ実行し、以降はpoll_loop()dev.update()で使い回す
  • デバイスごとにconsecutive_failuresで連続失敗回数を数え、REDISCOVER_AFTER_FAILURES(既定2回)に達したら_redisover_one()でそのデバイスだけ再discoverする
  • 失敗中もlatest_statusonline: falseとしてエラー情報を残し、画面側でOFFLINE表示に使う

/api/status・/api/history・個別/一括ON-OFF APIを実装する

画面から使うAPIをまとめて実装します。

📄web_dashboard.py(続き)VSCode
@app.get("/")
async def index():
    return FileResponse(BASE_DIR / "static" / "dashboard.html")


@app.get("/api/status")
async def api_status():
    return JSONResponse(latest_status)


@app.get("/api/history")
async def api_history(device: str | None = None, limit: int = 50):
    import sqlite3
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    if device:
        rows = conn.execute(
            "SELECT * FROM power_readings WHERE device_name = ? ORDER BY id DESC LIMIT ?",
            (device, limit),
        ).fetchall()
    else:
        rows = conn.execute(
            "SELECT * FROM power_readings ORDER BY id DESC LIMIT ?", (limit,)
        ).fetchall()
    conn.close()
    return [dict(row) for row in reversed(rows)]


async def _set_power(name: str, on: bool) -> dict:
    dev = devices.get(name)
    if dev is None:
        raise HTTPException(status_code=404, detail="device not found")

    try:
        if on:
            await dev.turn_on()
        else:
            await dev.turn_off()
        await dev.update()
    except Exception:
        dev = await _redisover_one(name)  # 通信失敗時はIPが変わった可能性を疑い、再discoverしてから1度だけ再試行する
        if dev is None:
            raise HTTPException(
                status_code=503,
                detail=f"{DEVICES[name]['label']} に接続できませんでした。ネットワークや電源を確認してください。",
            )
        if on:
            await dev.turn_on()
        else:
            await dev.turn_off()
        await dev.update()

    consecutive_failures[name] = 0
    latest_status[name] = _status_from_device(dev, online=True)
    return latest_status[name]


@app.post("/api/device/{name}/on")
async def device_on(name: str):
    if name not in DEVICES:
        raise HTTPException(status_code=404, detail="unknown device")
    return await _set_power(name, True)


@app.post("/api/device/{name}/off")
async def device_off(name: str):
    if name not in DEVICES:
        raise HTTPException(status_code=404, detail="unknown device")
    return await _set_power(name, False)


@app.post("/api/bulk/on")
async def bulk_on():
    return {name: await _set_power(name, True) for name in devices}


@app.post("/api/bulk/off")
async def bulk_off():
    return {name: await _set_power(name, False) for name in devices}

用意したAPIは次の通りです。

  • GET /api/status: 全デバイスの最新状態(瞬間電力・積算電力量・ON/OFF・オンライン状態)
  • GET /api/history: 直近の記録(グラフ描画用)
  • POST /api/device/{name}/on・off: 個別ON/OFF
  • POST /api/bulk/on・off: 一括ON/OFF

バニラJSのフロントで一括/個別操作・簡易グラフを表示する

フロント側は特別なビルド環境を用意せず、素のHTML/CSS/JavaScript(static/dashboard.html)だけで作りました。挙動は次の通りです。

  • 5秒おきに/api/statusを読みに行ってカードを再描画する
  • /api/historyの直近30件から簡易的な折れ線グラフを描く
  • ONボタン・OFFボタン・一括ON/OFFボタンから、それぞれ対応するAPIをPOSTで呼ぶ
📄static/dashboard.html(抜粋)VSCode
async function refresh() {
  const status = await api('/api/status');
  const cardsEl = document.getElementById('cards');
  cardsEl.innerHTML = '';

  for (const [name, s] of Object.entries(status)) {
    const card = el('div', { class: 'card' });
    const online = s.online !== false;
    const badgeClass = !online ? 'offline' : (s.is_on ? 'on' : 'off');
    const badgeText = !online ? 'OFFLINE' : (s.is_on ? 'ON' : 'OFF');
    const badge = el('span', { class: `badge ${badgeClass}`, text: badgeText });
    card.appendChild(el('h2', {}, [document.createTextNode(s.label + ' '), badge]));

    const metrics = el('div', { class: 'metrics' });
    metrics.innerHTML = `
      <div><span>瞬間電力</span><b>${online ? s.power_w.toFixed(2) + ' W' : '--'}</b></div>
      <div><span>本日の電力量</span><b>${online ? s.energy_today_kwh.toFixed(3) + ' kWh' : '--'}</b></div>
    `;
    card.appendChild(metrics);

    const canvas = el('canvas', {});
    card.appendChild(canvas);
    cardsEl.appendChild(card);

    api(`/api/history?device=${name}&limit=30`).then(history => {
      drawChart(canvas, history.map(r => r.power_w));
    });
  }
}

refresh();
setInterval(refresh, 5000);
実際の画面には、この他に「Excel」ボタンや年別レポート出力の入力欄も表示されていますが、これは次回(第3回)で解説します。今回はON/OFF操作と電力監視の部分に絞って進めてください。

実際に起動して動作確認する

PowerShellターミナル
python web_dashboard.py
VSCodeのエクスプローラー。web_dashboard.pyとstaticフォルダ(dashboard.html)が同じ場所にある

▲ web_dashboard.pyと、その中のstaticフォルダ(dashboard.html)が同じ場所にある

以下のように、起動案内メッセージのあとにUvicorn running on ...まで表示され、ウィンドウが開きっぱなしの状態になれば成功です。

PowerShellターミナル
Tapo P110M PDU ダッシュボードを起動します...
このPCのブラウザで http://127.0.0.1:8000/ を開いてください(PC専用運用)。
終了するにはこのウィンドウでCtrl+Cを押してください。
INFO:     Started server process [xxxxx]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
python web_dashboard.pyの実行結果

▲ python web_dashboard.pyの実行結果。Uvicorn running on…まで到達すれば成功

🔧 コラム(上級者向け)

python file.pyを実行しても何も起きない(FastAPIアプリにuvicorn.run()の呼び出しがなかった話)

最初、web_dashboard.pyを単体でpython web_dashboard.pyのように実行しても、何のエラーも出ずに一瞬でプロンプトに戻ってしまう現象がありました。原因は単純で、ファイルの中にapp = FastAPI(...)の定義はあるものの、それを実際にサーバーとして起動するuvicorn.run()の呼び出しがどこにもなかったためです。それまではpython -m uvicorn web_dashboard:app --host 127.0.0.1 --port 8000のようにモジュール名を指定してUvicorn側から読み込む起動方法しか用意していませんでした。

if __name__ == "__main__":ブロックを追加し、直接実行したときだけuvicorn.run()を呼ぶようにすることで、これまでのindividual_control.pyなどと同じ「そのままpython実行するだけ」の使用感にそろえました。

📄web_dashboard.py(末尾)VSCode
if __name__ == "__main__":
    import uvicorn

    print("Tapo P110M PDU ダッシュボードを起動します...")
    print("このPCのブラウザで http://127.0.0.1:8000/ を開いてください(PC専用運用)。")
    print("終了するにはこのウィンドウでCtrl+Cを押してください。")
    uvicorn.run(app, host="127.0.0.1", port=8000)

ブラウザでhttp://127.0.0.1:8000/を開くと、以下のようなダッシュボードが表示されます。

ブラウザで表示したTapo P110M PDUダッシュボード。一括ON/OFF・個別カードごとの数値とグラフ

▲ ブラウザで表示したダッシュボード。一括ON/OFF・個別カードごとの数値とグラフが確認できる


スポンサーリンク

ポーリング間隔と再discoverのしきい値をどう決めたか(堅牢化の設計判断)

一通り動くようになったあと、実運用に耐えるように2点調整しました。

ポーリング間隔を環境変数TAPO_POLL_INTERVAL_SECにした理由

電力値は数秒単位で激しく変化するものではないため、デバイスへの負荷を抑えつつ推移が見える間隔として既定15秒にしました。環境によってちょうどいい間隔は変わるはずなので、コードを直接書き換えなくてもTAPO_POLL_INTERVAL_SECという環境変数で調整できるようにしています。なお画面側の再描画は5秒おきですが、これはキャッシュされた/api/statusを読みに行くだけなので、デバイスへの通信負荷は増えません。

2回連続失敗で再discoverするしきい値とOFFLINE表示のUI

前回紹介した通り、Tapoデバイスとの通信はまれに失敗することがあります。1回の失敗で即座に再discoverすると、たまたま起きた通信の揺らぎに対して過剰に反応してしまうため、次のように設計しました。

  • REDISCOVER_AFTER_FAILURES = 2(2回連続で失敗したら再discover)
  • デバイスごとにconsecutive_failuresで失敗回数を数える
  • 閾値に達したときだけ_redisover_one()でそのデバイスだけを再discoverする(IPアドレスが変わっていても追従できる)

フロント側では、online: falseの場合にカードを「OFFLINE」バッジ表示に切り替え、直近の値を保持したまま復旧を待つ見た目にしています。

📄static/dashboard.html(CSS抜粋)VSCode
.badge.offline { background: #fdecea; color: #b3261e; }

スポンサーリンク

run_dashboard.batでダブルクリック起動できるようにする(常駐化は見送った理由)

PCを起動するたびにVSCodeを開いてターミナルからコマンドを打つのは面倒なので、ダブルクリックで起動できる簡易スクリプトを用意しました。

📄run_dashboard.batVSCode
@echo off
cd /d "%~dp0"
python web_dashboard.py
pause
run_dashboard.batをダブルクリックした結果

▲ run_dashboard.batをダブルクリック→起動案内とUvicorn running on…が表示される

run_dashboard.batを実行する際は、先にVSCode側で起動しているweb_dashboard.pyを停止(Ctrl+C)しておいてください。同じポート8000を使うため、両方同時に起動しようとするとエラーになります。

なお、今回はPCの電源を入れっぱなしにして24時間監視し続けるような常駐化(タスクスケジューラへの登録など)は見送りました。理由は、今回作ったダッシュボードはあくまで個人PC上でのデモ用途であり、本格的に24時間稼働させる運用は、低消費電力なRaspberry Pi等のサーバーへ移行したうえで改めて検討する方針としたためです。

🔧 コラム(上級者向け)

batファイルの日本語echoが文字化けし「’あ’ は認識されていません」エラーになる原因と対処

当初、案内メッセージをrun_dashboard.batの中で直接echoしていましたが、ダブルクリックで実行すると次のようなエラーが出て、起動する前にウィンドウが終了してしまう現象に遭遇しました。

PowerShellターミナル
'す...' is not recognized as an internal or external command,
operable program or batch file.

原因は文字コードの不一致です。バッチファイル自体はUTF-8で保存していましたが、Windowsの日本語環境でcmd.exeがダブルクリック実行時に使う既定のコードページはShift_JIS(932)です。そのためechoに書いた日本語のバイト列がShift_JISとして誤読され、文字列の断片がコマンドとして実行されてしまっていました。

ファイル冒頭にchcp 65001(UTF-8への切り替え)を追加する方法も試しましたが、cmd.exeがバッチファイルを先読み・バッファリングする既知の癖により、chcpの直後の行にはまだ古いコードページの解釈が残ってしまい、解決しませんでした。

最終的な対処は、バッチファイル自体から日本語を完全になくすことです。案内メッセージはweb_dashboard.py側のprint()に持たせ(Windows上のPythonコンソール出力はコードページの影響を受けにくいため)、バッチファイルはASCII文字だけの実行専用スクリプトに徹してもらいました。日本語を扱うバッチファイルを作る際は、そもそも日本語のechoを避けるのが一番シンプルな対策と言えそうです。


スポンサーリンク

よくある質問

QCSVではなくSQLiteを選んだ理由は何ですか?

A複数デバイス・期間を指定した絞り込みクエリがしやすいことを重視しました。あとから「今日の合計は」「先月のこのデバイスだけ」のような集計がSQLで書けるのは、ファイルベースのCSVより扱いやすいと判断しています。

Qポーリング間隔は何秒くらいが適切ですか?

A電力値は数秒単位で激しく変わるものではないため、今回はデバイスへの負荷と推移の見やすさのバランスから15秒を既定値にしました。環境変数TAPO_POLL_INTERVAL_SECで調整できるので、ご自身の用途に合わせて変更してください。

Qconsumption_total(生涯の積算電力量)は取得できないのですか?

A今回使用したTapo P110Mでは、実機で確認した限り常にNoneが返ってきました。本日/当月の積算値(consumption_today/consumption_this_month)は正常に取得できるため、今回はそちらを使っています。

Qスマートフォンなど別端末からもダッシュボードを見られるようにできませんか?

A技術的には–host 0.0.0.0にすれば同一LAN内の別端末からもアクセスできますが、今回は認証機能が未実装のダッシュボードを外部に公開するリスクを避けるため、127.0.0.1(PC上のみ)での運用に留めています。マルチデバイス対応は、次々回で検討しているRaspberry Piサーバー化のタイミングで、認証機能とあわせて改めて設計する予定です。

QFastAPIではなくFlaskではダメですか?

AFlaskでも実現は可能ですが、python-kasaのAPIがすべてasync/await前提のため、非同期処理をそのまま書けるFastAPI(ASGI)の方が素直に実装できると判断しました。

Qダッシュボードを起動しっぱなしにしても大丈夫ですか?

A今回の実装は個人PC上でのデモ用途を想定しており、常駐化(PC起動時の自動実行など)はあえて見送っています。本格的に24時間稼働させたい場合は、低消費電力なRaspberry Pi等のサーバーへの移行を検討することをおすすめします。


スポンサーリンク

まとめ・次回予告

今回は、Tapo P110Mの消費電力データをSQLiteに記録する仕組みと、FastAPI + Uvicornで一括/個別ON-OFF・電力監視ができるWebダッシュボードの構築、通信失敗時に自動で再discoverする堅牢化までを紹介しました。

  • python-kasaのdev.modules[“Energy”]から取得できる値をSQLiteに記録し、consumption_totalだけは常にNoneになることも確認した
  • FastAPIのlifespanでアプリ起動時にバックグラウンドポーリングを仕込み、非同期処理をシンプルに統合できた
  • 2回連続の通信失敗をきっかけに該当デバイスだけを再discoverすることで、IPアドレスの変化にも追従できる設計にした
  • python file.pyだけで起動できるようにする、日本語を含むバッチファイルの文字コード事故を避けるなど、地味だが実用上大事な調整を行った

次回は、この記録データをもとに、消費電力履歴をExcelでエクスポートする機能・年次集計・電気代の参考金額試算、そしてスマートフォンなど別端末からのアクセスをどう判断したか(セキュリティ観点)を紹介する予定です。


ニンジン🥕アイコン

Follow me on X

本ブログの中の人「ニンジン🥕」はXやってます。新着記事はいち早くポストでお知らせしているので、見逃したくない方は @ninjin_py_vba をフォローしてみてください🥕

@ninjin_py_vba をフォロー

この記事が役に立ったら、シェアしてもらえると嬉しいです🥕

Xでポスト はてブ LINE Pocket

PROJECT LOG — SERIES INDEX
スポンサーリンク

シリーズ記事一覧

電子工作・自動化ツールの製作記録をシリーズごとにまとめています。気になるテーマからお読みください。


Tapo P110Mの消費電力をExcelレポート化する【年次集計・電気代試算・セキュリティ判断編】
FastAPI+openpyxlでTapo P110Mの消費電力をExcelレポート化。15秒間隔の生ログを日次集計する設計や、31円/kWhでの電気代試算、外部公開を見送った理由まで解説します。
Tapo P110M × FastAPIで電力監視ダッシュボードを作る【SQLite記録・リアルタイム堅牢化編】
Tapo P110MのデータをSQLiteに記録し、FastAPI+Uvicornでリアルタイム電力監視ダッシュボードを作る方法を解説。通信失敗時の自動復旧など堅牢化のポイントも紹介します。
Tapo P110M × python-kasaで自作PDUを作る【環境構築・Unsupported device(TPAP)エラー解決編】
Tapo P110Mをpython-kasaで自作PDU化。「Unsupported device」TPAPエラーの原因と対処法、MACアドレスでIP変動に強くする設計、個別/一括ON-OFFの実装コードまで解説します。
Raspberry Pi Pico Wで作る熱中症警戒アラート表示器
Raspberry Pi Pico WとDHT11、0.96インチOLEDで熱中症警戒アラート表示器を自作。気温・湿度から簡易的な暑さ指数を計算し、5段階の顔アイコンで危険度を表示する作り方をコード付きで解説します。
CustomTkinterでオリジナルのメニューバー(File・表示・設定・Help)を自作する
CustomTkinterには無いメニューバー(File・表示・設定・Help)を、CTkFrameとCTkButtonで自作する方法を、5段階のサンプルコードとダウンロード付きで解説します。

コメント