はじめに
ChatGPT の Canvas や Gemini の Canvas のような、AI ツール側の作業キャンバス上に作った UI から、M5Stack の GPIO5 に接続した LED を ON / OFF できる仕組みを作りました。
やりたかったことはシンプルで、ChatGPT / Gemini の Canvas 上のボタンを押したら M5Stack 側の LED が点灯・消灯する、というものです。
ただし、これらの Canvas からはローカル PC の COM ポートを直接触れないため、そのままでは M5Stack にシリアル送信できませんでした。
そこで今回は、Python でローカルブリッジサーバーを立て、そのサーバー経由で COM7 に接続された M5Stack へコマンドを送る構成にしました。
最終的には、以下の構成で動作確認できました。
ChatGPT / Gemini の Canvas UI
↓ HTTP
ngrok URL
↓ HTTP
Python ブリッジサーバー
↓ Serial(COM7 / 9600bps)
M5Stack
↓ GPIO5
LED
やりたかったこと
やりたかったのは以下です。
- ChatGPT / Gemini の Canvas 上にボタンを作る
- ボタン操作で M5Stack にコマンドを送る
- M5Stack の GPIO5 に接続した LED を ON / OFF する
今回は以下の仕様にしました。
- M5Stack は
COM7 - ボーレートは
9600bps -
'1'を受信したら LED ON -
'0'を受信したら LED OFF
まず M5Stack 単体で LED 制御を確認
最初に、M5Stack 側で GPIO5 が正しく使えるか確認しました。
LED は以下のように接続しています。
- GPIO5 → 抵抗 → LED の長い足(アノード)
- LED の短い足(カソード) → GND
まずは単純な点滅テストで、配線とピン制御が正しいことを確認しました。
const int LED_PIN = 5;
void setup() {
pinMode(LED_PIN, OUTPUT);
}
void loop() {
digitalWrite(LED_PIN, HIGH);
delay(1000);
digitalWrite(LED_PIN, LOW);
delay(1000);
}
ここで一度 LED が光らず悩みましたが、原因は アノード / カソードの向きが逆 だっただけでした。
配線を直したら正常に点灯しました。
M5Stack 側のシリアル受信スケッチ
次に、PC から送った '1' / '0' で GPIO5 を制御するスケッチを書きました。
const int LED_PIN = 5;
void setup() {
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
Serial.begin(9600);
}
void loop() {
while (Serial.available() > 0) {
int v = Serial.read();
if (v == '1') {
digitalWrite(LED_PIN, HIGH);
} else if (v == '0') {
digitalWrite(LED_PIN, LOW);
}
}
}
このスケッチを書き込んだあと、Arduino IDE のシリアルモニタから 1 / 0 を送って LED が ON / OFF することを確認しました。
ここで確認できたことで、
- GPIO5 の制御はできる
- シリアル受信もできる
- M5Stack 側の最低限の処理は完成
という状態になりました。
ChatGPT / Gemini の Canvas から直接 COM ポートに触れない問題
次に ChatGPT / Gemini の Canvas 側に HTML / JavaScript で UI を作りました。
作った UI の役割は以下です。
- ブリッジ疎通確認
- M5Stack 接続
- LED ON
- LED OFF
- 状態表示
- 診断ログ表示
ただ、ここで問題がありました。
ChatGPT / Gemini の Canvas からはローカル PC の COM ポートを直接触れません。
そのため、Canvas 側からは HTTP リクエストだけを送り、実際のシリアル制御はローカルの Python サーバー側で行う構成に変更しました。
Python ブリッジサーバーを作る
ローカル PC 上で Python の簡易 HTTP サーバーを立て、ChatGPT / Gemini の Canvas UI からのリクエストを受けて COM7 の M5Stack にシリアル送信するようにしました。
ブリッジの役割は以下です。
-
/health: 疎通確認 -
/connect: COM7 を開く -
/disconnect: COM7 を閉じる -
/on:'1'を送信 -
/off:'0'を送信
概ね以下のようなコードを書きました。
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import serial
HOST = "127.0.0.1"
PORT = 8787
SERIAL_PORT = "COM7"
BAUD_RATE = 9600
class SerialBridge:
def __init__(self, port_name: str, baud_rate: int) -> None:
self.port_name = port_name
self.baud_rate = baud_rate
self._serial = None
self._lock = threading.RLock()
def _is_connected_unlocked(self) -> bool:
return self._serial is not None and self._serial.is_open
def _build_state_unlocked(self) -> dict:
return {
"ok": True,
"serial_connected": self._is_connected_unlocked(),
"port": self.port_name,
"baud_rate": self.baud_rate,
}
def health(self) -> dict:
with self._lock:
return self._build_state_unlocked()
def connect(self) -> dict:
with self._lock:
if not self._is_connected_unlocked():
self._serial = serial.Serial(
port=self.port_name,
baudrate=self.baud_rate,
timeout=1,
write_timeout=1,
)
return self._build_state_unlocked()
def disconnect(self) -> dict:
with self._lock:
if self._serial is not None:
try:
if self._serial.is_open:
self._serial.close()
finally:
self._serial = None
return self._build_state_unlocked()
def send_bytes(self, data: bytes) -> dict:
with self._lock:
if not self._is_connected_unlocked():
raise RuntimeError("M5Stack が未接続です")
self._serial.write(data)
self._serial.flush()
return self._build_state_unlocked()
def send_on(self) -> dict:
return self.send_bytes(b"1")
def send_off(self) -> dict:
return self.send_bytes(b"0")
bridge = SerialBridge(SERIAL_PORT, BAUD_RATE)
class RequestHandler(BaseHTTPRequestHandler):
def _set_headers(self, status_code: int = 200) -> None:
self.send_response(status_code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
requested_headers = self.headers.get("Access-Control-Request-Headers")
if requested_headers:
self.send_header("Access-Control-Allow-Headers", requested_headers)
else:
self.send_header("Access-Control-Allow-Headers", "Content-Type, ngrok-skip-browser-warning")
self.end_headers()
def _write_json(self, payload: dict, status_code: int = 200) -> None:
self._set_headers(status_code)
self.wfile.write(json.dumps(payload, ensure_ascii=False).encode("utf-8"))
def do_OPTIONS(self) -> None:
self._set_headers(204)
def do_GET(self) -> None:
if self.path == "/health":
self._write_json(bridge.health())
return
self._write_json({"ok": False, "error": "not found"}, 404)
def do_POST(self) -> None:
if self.path == "/connect":
self._write_json(bridge.connect())
return
if self.path == "/disconnect":
self._write_json(bridge.disconnect())
return
if self.path == "/on":
self._write_json(bridge.send_on())
return
if self.path == "/off":
self._write_json(bridge.send_off())
return
self._write_json({"ok": False, "error": "not found"}, 404)
if __name__ == "__main__":
server = ThreadingHTTPServer((HOST, PORT), RequestHandler)
server.serve_forever()
ChatGPT / Gemini の Canvas から localhost に届かない
ローカルの Python ブリッジができたので、最初は ChatGPT / Gemini の Canvas から http://127.0.0.1:8787 を直接叩こうとしました。
しかし、ここで Failed to fetch が発生しました。
原因は、ChatGPT / Gemini の Canvas 側の実行環境からローカルの 127.0.0.1 に直接アクセスできなかったためです。
つまり、Python ブリッジ自体は動いていても、Canvas からはその URL に届きませんでした。
そこで、ローカルの Python ブリッジを ngrok で外部公開 し、ChatGPT / Gemini の Canvas 側からは ngrok の HTTPS URL にアクセスする構成へ変更しました。
例:
https://xxxxx.ngrok-free.dev -> http://127.0.0.1:8787
これで Canvas からブリッジへ届くようになりました。
ngroの起動方法
ngroアカウント作ってここでアクセストークン取得
https://dashboard.ngrok.com/get-started/your-authtoken
ngrok config add-authtoken $YOUR_AUTHTOKEN
ngrok http 8787
ngrok 利用時のハマりどころ
ngrok 経由にしたあとも、そのままではうまく動きませんでした。
無料 URL 特有のブラウザ警告ページやカスタムヘッダの影響があり、追加対応が必要でした。
具体的には以下です。
- ChatGPT / Gemini の Canvas 側で
ngrok-skip-browser-warningヘッダを付与 - Python 側で
Access-Control-Allow-Headersにそのヘッダを許可 -
OPTIONSに応答する
このあたりを入れることで、ChatGPT / Gemini の Canvas → ngrok → Python ブリッジ の通信が通るようになりました。
Python 側のデッドロックも踏んだ
さらに途中で /connect が固まる問題もありました。
原因は Python 側のロック処理です。
Lock を取得した状態で、別メソッドから同じロックを再取得しようとしてデッドロックしていました。
最終的には以下の対応を入れています。
-
threading.Lock()ではなくthreading.RLock()を使う - 状態生成処理を別メソッドに分離する
これで /connect が返らない問題も解消しました。
最終的に確認できたこと
最終的に以下を確認できました。
- ブリッジ疎通確認が成功する
- M5Stack 接続が成功する
-
LED ONで GPIO5 の LED が点灯する -
LED OFFで GPIO5 の LED が消灯する
つまり、
ChatGPT / Gemini の Canvas → ngrok → Python ブリッジ → COM7 の M5Stack → GPIO5 LED
という経路で、UI から実機の LED を操作できるところまで動作確認できました。
まとめ
今回やったことを一言でまとめると、
ChatGPT / Gemini の Canvas から直接シリアルを触れない制約を、Python ブリッジと ngrok を挟むことで回避し、M5Stack の GPIO5 に接続した LED を UI から制御できるようにした
という内容です。
単に LED を点けるだけなら簡単ですが、Canvas から実機制御しようとすると、
- ローカルアクセスの制約
- ngrok 経由の通信
- CORS
- Python 側のロック
のような、UI とハードをつなぐ部分のハマりどころがいくつかありました。
同じように、ChatGPT / Gemini の Canvas からローカル接続のデバイスを触りたい場合の参考になればと思います。
別の AI に実装依頼するときの要点
以下を満たす構成で作れば、今回と同じものを再現できます。
- UI は ChatGPT / Gemini の Canvas 上で動かす HTML / JavaScript
- M5Stack は COM7 / 9600bps
- M5Stack 側は
1/0受信で GPIO5 を HIGH / LOW - Python でローカルブリッジを作る
- API は
/health,/connect,/disconnect,/on,/off - CORS 対応を入れる
- ngrok 経由のアクセスを考慮する
- ngrok 利用時は
ngrok-skip-browser-warningヘッダを付ける - UI に状態表示と診断ログを入れる
おまけ
chatgpt上で動いたもの
https://chatgpt.com/canvas/shared/6a4b2778f1d0819192d9aad8d4ffecce
gemini上で動いたもの
https://share.gemini.google/1rrzDqGxdg4E
comポートが固定だったりするので
下記はチャッピーが書いたものです。
チャッピーに好きなポートに変更してとか環境に合わせて書き直してって言うと
いい感じに修正してくれますよ。
長いんでたたむ
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>M5Stack GPIO5 LED コントローラー</title>
<style>
:root {
color-scheme: light dark;
--bg: #0f172a;
--panel: #1e293b;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
--on: #22c55e;
--off: #ef4444;
--warn: #f59e0b;
--border: #334155;
--ok-bg: rgba(34, 197, 94, 0.12);
--warn-bg: rgba(245, 158, 11, 0.12);
--err-bg: rgba(239, 68, 68, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(135deg, #020617, #0f172a 45%, #111827);
color: var(--text);
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.card {
width: min(820px, 100%);
background: rgba(30, 41, 59, 0.92);
border: 1px solid var(--border);
border-radius: 20px;
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(8px);
}
h1 {
margin: 0 0 8px;
font-size: 28px;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.section {
margin-top: 20px;
}
.status {
margin-top: 16px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(15, 23, 42, 0.7);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.status strong {
color: var(--accent);
}
.status.ok { background: var(--ok-bg); }
.status.warn { background: var(--warn-bg); }
.status.error { background: var(--err-bg); }
.button-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
}
button {
appearance: none;
border: none;
border-radius: 12px;
padding: 14px 18px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: transform 0.08s ease, opacity 0.2s ease, filter 0.2s ease;
min-width: 140px;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
filter: brightness(1.05);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.connect { background: var(--accent); color: #082f49; }
.disconnect { background: #64748b; color: white; }
.on { background: var(--on); color: white; }
.off { background: var(--off); color: white; }
code {
background: rgba(15, 23, 42, 0.85);
padding: 2px 6px;
border-radius: 8px;
border: 1px solid var(--border);
}
ul {
margin: 10px 0 0;
padding-left: 20px;
color: var(--muted);
line-height: 1.7;
}
.meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
margin-top: 14px;
}
.meta-item {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(15, 23, 42, 0.5);
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.meta-item b {
color: var(--text);
}
.notice {
margin-top: 14px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(56, 189, 248, 0.08);
line-height: 1.6;
font-size: 14px;
}
.notice strong {
color: var(--text);
}
.field-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
align-items: end;
}
.field {
flex: 1 1 280px;
display: grid;
gap: 8px;
}
label {
font-size: 13px;
color: var(--muted);
}
input {
width: 100%;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(15, 23, 42, 0.9);
color: var(--text);
padding: 12px 14px;
font-size: 15px;
}
.log {
margin-top: 12px;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(2, 6, 23, 0.8);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
color: #cbd5e1;
}
</style>
</head>
<body>
<main class="card">
<h1>M5Stack GPIO5 LED コントローラー</h1>
<p>
Canvas から直接シリアルを触らず、<strong>ブリッジ</strong>経由で M5Stack に <code>'1'</code> / <code>'0'</code> を送信します。<br />
ローカルの Python ブリッジが <strong>COM7 / 9600bps</strong> の M5Stack を扱い、GPIO5 の LED を ON / OFF する前提です。
</p>
<div class="notice">
<strong>構成:</strong><br />
この画面 → ブリッジURL → COM7 の M5Stack → GPIO5 の LED<br />
ngrok を使う場合は https の ngrok URL をブリッジURLに入れてください。
</div>
<div class="section field-row">
<div class="field">
<label for="bridge-url">ブリッジURL</label>
<input id="bridge-url" type="text" value="http://127.0.0.1:8787" />
</div>
<button id="check-bridge" class="connect">ブリッジ確認</button>
</div>
<div class="section button-row">
<button id="connect" class="connect">1. M5Stack接続</button>
<button id="disconnect" class="disconnect" disabled>切断</button>
</div>
<div class="section button-row">
<button id="on" class="on" disabled>LED ON</button>
<button id="off" class="off" disabled>LED OFF</button>
</div>
<div id="status" class="status warn">
<strong>状態:</strong> ブリッジ未確認
</div>
<div class="meta">
<div class="meta-item"><b>ブリッジ疎通</b><br><span id="bridge-health-state">確認中...</span></div>
<div class="meta-item"><b>M5Stack接続</b><br><span id="serial-connected-state">確認中...</span></div>
<div class="meta-item"><b>使用ポート</b><br><span id="port-name-state">確認中...</span></div>
<div class="meta-item"><b>ボーレート</b><br><span id="baud-rate-state">確認中...</span></div>
</div>
<div class="section">
<p>診断ログ</p>
<div id="log" class="log"></div>
</div>
</main>
<script>
const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:8787';
const REQUEST_TIMEOUT_MS = 10000;
const bridgeUrlInput = document.getElementById('bridge-url');
const checkBridgeButton = document.getElementById('check-bridge');
const connectButton = document.getElementById('connect');
const disconnectButton = document.getElementById('disconnect');
const onButton = document.getElementById('on');
const offButton = document.getElementById('off');
const statusBox = document.getElementById('status');
const logBox = document.getElementById('log');
const bridgeHealthState = document.getElementById('bridge-health-state');
const serialConnectedState = document.getElementById('serial-connected-state');
const portNameState = document.getElementById('port-name-state');
const baudRateState = document.getElementById('baud-rate-state');
let pollTimer = null;
let actionInFlight = false;
let bridgeIsOk = false;
let m5IsConnected = false;
function appendLog(message) {
const time = new Date().toLocaleTimeString();
logBox.textContent += `[${time}] ${message}
`;
logBox.scrollTop = logBox.scrollHeight;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function setStatus(message, type = 'warn') {
statusBox.className = `status ${type}`;
statusBox.innerHTML = `<strong>状態:</strong> ${escapeHtml(message)}`;
appendLog(message);
}
function normalizeBaseUrl(raw) {
const value = String(raw || '').trim();
if (!value) return DEFAULT_BRIDGE_URL;
return value.replace(/\/+$/, '');
}
function getBridgeBaseUrl() {
const normalized = normalizeBaseUrl(bridgeUrlInput.value);
bridgeUrlInput.value = normalized;
return normalized;
}
function buildBridgeUrl(path) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${getBridgeBaseUrl()}${normalizedPath}`;
}
function describeError(error) {
if (!error) return '不明なエラー';
if (typeof error === 'string') return error;
if (error.name === 'AbortError') {
return `タイムアウト(${REQUEST_TIMEOUT_MS / 1000}秒以内に応答なし)`;
}
if (error instanceof TypeError && error.message === 'Failed to fetch') {
return 'Failed to fetch(ngrok / CORS / ブラウザ側制限の可能性)';
}
if (error.message) return error.message;
try {
return JSON.stringify(error);
} catch (_) {
return String(error);
}
}
function updateUiState() {
checkBridgeButton.disabled = actionInFlight;
bridgeUrlInput.disabled = actionInFlight;
connectButton.disabled = actionInFlight || !bridgeIsOk || m5IsConnected;
disconnectButton.disabled = actionInFlight || !m5IsConnected;
onButton.disabled = actionInFlight || !m5IsConnected;
offButton.disabled = actionInFlight || !m5IsConnected;
}
function updateMeta(state) {
bridgeIsOk = Boolean(state && state.ok);
m5IsConnected = Boolean(state && state.serial_connected);
bridgeHealthState.textContent = bridgeIsOk ? 'OK' : 'NG';
serialConnectedState.textContent = m5IsConnected ? '接続中' : '未接続';
portNameState.textContent = state && state.port ? state.port : '不明';
baudRateState.textContent = state && state.baud_rate ? String(state.baud_rate) : '不明';
updateUiState();
}
function beginAction(label, statusMessage) {
actionInFlight = true;
updateUiState();
appendLog(`${label} 開始`);
if (statusMessage) {
setStatus(statusMessage, 'warn');
}
}
function endAction(label) {
actionInFlight = false;
updateUiState();
appendLog(`${label} 終了`);
}
async function parseJsonResponse(response) {
const text = await response.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch (_) {
throw new Error(`JSON解析失敗: ${text}`);
}
}
if (!response.ok) {
const message = data && data.error ? data.error : `${response.status} ${response.statusText}`;
throw new Error(message);
}
return data || {};
}
async function callBridge(path, options = {}) {
const url = buildBridgeUrl(path);
const method = options.method || 'GET';
const hasBody = Boolean(options.body);
const headers = {
...(options.headers || {}),
};
if (hasBody) {
headers['Content-Type'] = 'application/json';
}
const baseUrl = getBridgeBaseUrl();
if (/ngrok/i.test(baseUrl)) {
headers['ngrok-skip-browser-warning'] = 'true';
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
appendLog(`${method} ${url}`);
const response = await fetch(url, {
method,
mode: 'cors',
cache: 'no-store',
headers,
body: hasBody ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
});
appendLog(`HTTP ${response.status} ${response.statusText}`);
return await parseJsonResponse(response);
} finally {
clearTimeout(timeoutId);
}
}
async function refreshHealth({ silent = false } = {}) {
try {
const state = await callBridge('/health');
updateMeta(state);
if (!silent) {
setStatus('ブリッジ疎通OK', 'ok');
}
} catch (error) {
bridgeIsOk = false;
m5IsConnected = false;
bridgeHealthState.textContent = 'NG';
serialConnectedState.textContent = '不明';
portNameState.textContent = '不明';
baudRateState.textContent = '不明';
updateUiState();
if (!silent) {
setStatus(`ブリッジ疎通失敗: ${describeError(error)}`, 'error');
} else {
appendLog(`health poll失敗: ${describeError(error)}`);
}
}
}
async function connectM5Stack() {
beginAction('M5Stack接続', 'M5Stack接続中...');
try {
const result = await callBridge('/connect', { method: 'POST' });
updateMeta(result);
if (!result.serial_connected) {
throw new Error('接続APIは成功したが、serial_connected が false のままです。');
}
setStatus(`M5Stack接続成功: ${result.port || 'COM7'}`, 'ok');
} catch (error) {
setStatus(`M5Stack接続失敗: ${describeError(error)}`, 'error');
} finally {
endAction('M5Stack接続');
}
}
async function disconnectM5Stack() {
beginAction('M5Stack切断', 'M5Stack切断中...');
try {
const result = await callBridge('/disconnect', { method: 'POST' });
updateMeta(result);
setStatus('M5Stack切断完了', 'warn');
} catch (error) {
setStatus(`M5Stack切断失敗: ${describeError(error)}`, 'error');
} finally {
endAction('M5Stack切断');
}
}
async function sendCommand(path, label) {
beginAction(`送信 ${label}`, `送信中: '${label}'`);
try {
const result = await callBridge(path, { method: 'POST' });
updateMeta(result);
setStatus(`送信成功: '${label}'`, 'ok');
} catch (error) {
setStatus(`送信失敗: ${describeError(error)}`, 'error');
} finally {
endAction(`送信 ${label}`);
}
}
function startHealthPolling() {
if (pollTimer) {
clearInterval(pollTimer);
}
pollTimer = setInterval(() => {
if (!actionInFlight) {
refreshHealth({ silent: true });
}
}, 3000);
}
function runSelfTests() {
const results = [];
function assert(condition, name) {
if (!condition) {
throw new Error(name);
}
results.push(`PASS: ${name}`);
}
assert(normalizeBaseUrl('http://127.0.0.1:8787/') === 'http://127.0.0.1:8787', 'normalizeBaseUrl trims trailing slash');
assert(normalizeBaseUrl('') === DEFAULT_BRIDGE_URL, 'normalizeBaseUrl falls back to default');
assert(buildBridgeUrl('/health').includes('/health'), 'buildBridgeUrl joins base and path');
assert(escapeHtml(`<tag>'"&`) === '<tag>'"&', 'escapeHtml sanitizes special chars');
assert(describeError(new Error('sample')) === 'sample', 'describeError formats Error');
results.forEach(line => appendLog(line));
}
checkBridgeButton.addEventListener('click', async () => {
beginAction('ブリッジ確認', 'ブリッジ確認中...');
try {
await refreshHealth();
} finally {
endAction('ブリッジ確認');
}
});
connectButton.addEventListener('click', async () => {
await connectM5Stack();
});
disconnectButton.addEventListener('click', async () => {
await disconnectM5Stack();
});
onButton.addEventListener('click', async () => {
await sendCommand('/on', '1');
});
offButton.addEventListener('click', async () => {
await sendCommand('/off', '0');
});
runSelfTests();
updateUiState();
setStatus('ブリッジ未確認', 'warn');
refreshHealth({ silent: true });
startHealthPolling();
</script>
</body>
</html>
