TAPO PDU PROJECT VOL.2
Tapo P110M × FastAPIで電力監視ダッシュボードを作る【SQLite記録・リアルタイム堅牢化編】
こんにちは。ニンジンです🥕
前回は、Tapo P110Mをpython-kasaで制御するための環境構築、実際にハマった「Unsupported device」エラーの原因究明、MACアドレスベースのデバイス管理、個別/一括ON-OFFの実装までを紹介しました。
今回はその続きとして、電力データをSQLiteに記録する仕組みと、ブラウザから一括/個別ON-OFF・消費電力を確認できるFastAPI製のWebダッシュボードを構築していきます。
Series Navigation
このシリーズの記事一覧(全3回)
この記事で分かること
- 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.py・bulk_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.pyとweb_dashboard.pyの両方から使い回すので、power_store.pyという共通モジュールに切り出します。
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 conndev.modules[“Energy”]から取れる値・取れない値
python-kasaでは、電力モニタリング対応デバイスの数値はdev.modules["Energy"]から取得します。
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回失敗する」不安定さの影響を受けやすくなるためです)。
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回だけ記録する場合は次のように実行します。
python power_logger.py once間隔を指定してループさせる場合は、秒数を引数に渡します(未指定時は60秒間隔)。
python power_logger.py 1515秒間隔で2〜3周ほど待ってからCtrl+Cを押すと、次のように動作します。
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行だけが出てプロンプトに戻れば正常です。🔧 コラム(上級者向け)
Ctrl+Cを押してもasyncio.run()が正しく止まらない(KeyboardInterruptがCancelledErrorになるPython 3.11以降の仕様)
実装時、except KeyboardInterrupt:を書いておいたにもかかわらず、Ctrl+Cを押すと次のようなトレースバックが出て終了する現象に遭遇しました。
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が届く CancelledErrorはmain()内のexcept KeyboardInterrupt:ではキャッチされずに素通りするmain()の外(Runner.run()側)まで伝播して、初めてKeyboardInterruptが投げ直される
つまりKeyboardInterruptはmain()関数の外側でしか発生しないため、関数内に書いたexcept KeyboardInterrupt:は意味を成していませんでした(Python 3.10以前のasyncio.run()では挙動が異なり、直接KeyboardInterruptが届いていました)。
対処法は、except節にasyncio.CancelledErrorも加えるだけです。
except (KeyboardInterrupt, asyncio.CancelledError):
print("ポーリングを停止しました。")これでasyncio.sleep()中のキャンセルをその場でキャッチし、finallyブロックまできれいに通ってから終了するようになります。
view_log.pyで記録を確認する
記録された内容を簡単に確認するためのスクリプトです。
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()python view_log.py直近の記録が古い順に、plug1・plug2それぞれの行として表示されます。
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)もこの中に組み込んでいます。
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_statusにonline: falseとしてエラー情報を残し、画面側でOFFLINE表示に使う
/api/status・/api/history・個別/一括ON-OFF APIを実装する
画面から使うAPIをまとめて実装します。
@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/OFFPOST /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で呼ぶ
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);実際に起動して動作確認する
python web_dashboard.py以下のように、起動案内メッセージのあとにUvicorn running on ...まで表示され、ウィンドウが開きっぱなしの状態になれば成功です。
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 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実行するだけ」の使用感にそろえました。
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/を開くと、以下のようなダッシュボードが表示されます。
ポーリング間隔と再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」バッジ表示に切り替え、直近の値を保持したまま復旧を待つ見た目にしています。
.badge.offline { background: #fdecea; color: #b3261e; }run_dashboard.batでダブルクリック起動できるようにする(常駐化は見送った理由)
PCを起動するたびにVSCodeを開いてターミナルからコマンドを打つのは面倒なので、ダブルクリックで起動できる簡易スクリプトを用意しました。
@echo off
cd /d "%~dp0"
python web_dashboard.py
pauseなお、今回はPCの電源を入れっぱなしにして24時間監視し続けるような常駐化(タスクスケジューラへの登録など)は見送りました。理由は、今回作ったダッシュボードはあくまで個人PC上でのデモ用途であり、本格的に24時間稼働させる運用は、低消費電力なRaspberry Pi等のサーバーへ移行したうえで改めて検討する方針としたためです。
🔧 コラム(上級者向け)
batファイルの日本語echoが文字化けし「’あ’ は認識されていません」エラーになる原因と対処
当初、案内メッセージをrun_dashboard.batの中で直接echoしていましたが、ダブルクリックで実行すると次のようなエラーが出て、起動する前にウィンドウが終了してしまう現象に遭遇しました。
'す...' 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 をフォローシリーズ記事一覧
電子工作・自動化ツールの製作記録をシリーズごとにまとめています。気になるテーマからお読みください。
-
FILE.01 — IoT
Raspberry Pi Pico W × GASラズパイPico W×GASで自作!「LINEで動く温湿度&スマートリモコン」完全ロードマップ
「外出中のペットの室温が心配…」そんな思いからスタートしたIoT自作プロジェクトの総集編。温湿度監視からエアコン遠隔操作まで、仕組みをゼロから作りたい方のための全5回の開発記録です。
シリーズを読む -
FILE.02 — CNC
Arduino × レーザー刻印CNCCNC自作シリーズ
高精度な加工を目指し、本格的な自作CNC製作に挑戦中。現在は基幹パーツであるオリエンタルモーターの納品を待つ「設計・準備編」を公開。ハードとソフトの両面から、理想のマシンを形にする過程をリアルタイムにお届けします。
シリーズを読む -
FILE.03 — XY軸制御
Raspberry Pi × ステッピングモーターXYテーブルシリーズ
Raspberry Piとステッパモーターを使い、ゼロから2軸制御に挑む記録。OS設定から回路設計、多軸制御のPythonコードまで、躓きやすいポイントを徹底図解。電子工作初心者が「動く感動」を味わうための実戦ガイドです。
シリーズを読む -
FILE.04 — 業務自動化
Python × Excel × CustomTkinter【Python開発記】NINJIN Mail制作記
PythonとExcelを連携させ、実務で即戦力となるメール送信ツールを開発。CustomTkinterによるUI構築や、ミスを防ぐテンプレート活用術など、現場の「痒い所に手が届く」自動化ノウハウを細部まで丁寧に解説します。
シリーズを読む -
FILE.05 — 業務自動化
Python × Excel × GUIアプリ化Invoice Maker(請求書・見積書自動作成)
手作業の請求書作成から卒業!PythonでExcelデータを読み込み一括PDF化する「Invoice_Maker」の作り方を全3回で解説。基本ロジックからGUIアプリ化まで、実務で役立つ自動化ノウハウが満載です。
シリーズを読む -
FILE.06 — 業務自動化
Access × VBA【Access VBA開発記】対応履歴管理データベース
問い合わせ・トラブル対応履歴がExcelでバラバラに散らばる悩みから、Access+VBAで検索性の高い管理データベースを自作。テンプレート配布から設計解説、Win32 API実装で味わった失敗談まで、生成AI時代の検証記録を全3回でお届けします。
シリーズを読む



コメント