【連載#11】Raspberry Pi × FlutterでフィジカルAI:Flutter監視UI②(ライブ/オーバーレイ/操作UI)
【連載#11】Raspberry Pi × FlutterでフィジカルAI:Flutter監視UI②(ライブ/オーバーレイ/操作UI)
第11回は、監視UIを「見える」だけでなく「操作できる」状態にします。
第10回で実装した安全設計(STOPラッチ、解除、異常時OFF)を、Flutter側から安全に叩けるようにし、状態機械の状態もUIで確認できるようにします。
本回では、操作UI(STOP/解除、mode切替、簡易アクション)、状態機械表示、任意のライブ表示枠(カメラ映像は次回以降で段階導入)までを最小構成で作ります。
前提
- 第6回:WebSocketで snapshot を配信できる
- 第7回:Flutter監視UI①(ヘルス/推論/履歴)が動いている
- 第10回:/safety/stop /safety/clear /safety/off がある
- 第5回:/control がある(modeとaction)
この回でやること(全体像)
- FlutterからRESTで操作するクライアントを用意する
- STOP/解除/全OFFボタンを追加する
- mode(auto/manual)切替を追加する
- 状態機械(state_machine)と安全状態(safety.stopped)を表示する
- 画面の情報構造を監視向けに整理する
1. 依存関係(HTTPクライアント)
FlutterからRESTを叩くため http を使います。
pubspec.yaml に追加:
dependencies:
http: ^1.2.0
反映:
flutter pub get
- RESTクライアント(Pi API操作)
lib/api_client.dart を追加します。
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiClient {
final Uri base;
ApiClient(String baseUrl) : base = Uri.parse(baseUrl);
Uri _u(String path) => base.replace(path: path);
Future<void> safetyStop() async {
final r = await http.post(_u("/safety/stop"));
if (r.statusCode >= 400) {
throw Exception("stop failed: ${r.statusCode} ${r.body}");
}
}
Future<void> safetyClear() async {
final r = await http.post(_u("/safety/clear"));
if (r.statusCode >= 400) {
throw Exception("clear failed: ${r.statusCode} ${r.body}");
}
}
Future<void> safetyOff() async {
final r = await http.post(_u("/safety/off"));
if (r.statusCode >= 400) {
throw Exception("off failed: ${r.statusCode} ${r.body}");
}
}
Future<void> setMode(String mode) async {
final r = await http.post(
_u("/control"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"mode": mode, "action": "none"}),
);
if (r.statusCode >= 400) {
throw Exception("mode failed: ${r.statusCode} ${r.body}");
}
}
Future<void> action(String action) async {
final r = await http.post(
_u("/control"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"action": action}),
);
if (r.statusCode >= 400) {
throw Exception("action failed: ${r.statusCode} ${r.body}");
}
}
}
baseUrl の例
注意
・WebSocketは ws://、RESTは http:// です。混同しないでください。
3. snapshot から安全状態と状態機械を読む
第10回で snapshot.extra に次が載っている前提です。
・extra.safety.stopped
・extra.state_machine.state(IDLE/ARMED/ACTIVE/COOLDOWN)
・extra.state_machine.mode(auto/manual)
第7回の SnapshotVm を拡張します。
lib/snapshot_view_model.dart にフィールドを追加して安全に読みます。
追加フィールド
・safetyStopped
・smState
・smMode
差分(追加部分のみ)
final bool safetyStopped;
final String? smState;
final String? smMode;
fromMap の末尾付近に追加(Map安全読み):
final inf = (m["inference"] is Map) ? (m["inference"] as Map).cast<String, dynamic>() : null;
final extra = (inf?["extra"] is Map) ? (inf!["extra"] as Map).cast<String, dynamic>() : <String, dynamic>{};
final safety = (extra["safety"] is Map) ? (extra["safety"] as Map).cast<String, dynamic>() : <String, dynamic>{};
final safetyStopped = (safety["stopped"] is bool) ? safety["stopped"] as bool : false;
final sm = (extra["state_machine"] is Map) ? (extra["state_machine"] as Map).cast<String, dynamic>() : <String, dynamic>{};
final smState = sm["state"]?.toString();
final smMode = sm["mode"]?.toString();
コンストラクタへ反映:
return SnapshotVm(
mode: mode,
cameraOk: cameraOk,
consecutiveFailures: consecutiveFailures,
tempC: tempC,
fps: fps,
lastFrameTs: lastFrameTs,
inferenceTimestamp: inferenceTimestamp,
latencyMs: latencyMs,
top1Label: top1Label,
top1Score: top1Score,
safetyStopped: safetyStopped,
smState: smState,
smMode: smMode,
);
4. 操作UIを追加する(STOP/解除/mode)
lib/main.dart を更新します。
第7回の構造を維持し、上段に操作パネルを追加します。
・STOP(ラッチ)
・解除
・全OFF
・mode切替(auto/manual)
・簡易アクション(ブザー、LED ON/OFF)
main.dart の _DashboardPageState に ApiClient を追加します。
final api = ApiClient("http://<PI-IP>:8000");
UIに操作カードを追加します(抜粋、貼り付け用)。
build() の connectionRow の下あたりに入れます。
Widget _controlCard(SnapshotVm? vm) {
final stopped = vm?.safetyStopped == true;
final smState = vm?.smState ?? "-";
final smMode = vm?.smMode ?? "-";
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Control"),
const SizedBox(height: 8),
Text("safety_stopped=${stopped ? "true" : "false"} sm_state=$smState sm_mode=$smMode"),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: () async {
try {
await api.safetyStop();
} catch (e) {
_showError(e.toString());
}
},
child: const Text("STOP"),
),
ElevatedButton(
onPressed: () async {
try {
await api.safetyClear();
} catch (e) {
_showError(e.toString());
}
},
child: const Text("解除"),
),
OutlinedButton(
onPressed: () async {
try {
await api.safetyOff();
} catch (e) {
_showError(e.toString());
}
},
child: const Text("全OFF"),
),
const SizedBox(width: 16),
OutlinedButton(
onPressed: () async {
try {
await api.setMode("auto");
} catch (e) {
_showError(e.toString());
}
},
child: const Text("mode:auto"),
),
OutlinedButton(
onPressed: () async {
try {
await api.setMode("manual");
} catch (e) {
_showError(e.toString());
}
},
child: const Text("mode:manual"),
),
const SizedBox(width: 16),
OutlinedButton(
onPressed: stopped
? null
: () async {
try {
await api.action("buzzer");
} catch (e) {
_showError(e.toString());
}
},
child: const Text("ブザー"),
),
OutlinedButton(
onPressed: stopped
? null
: () async {
try {
await api.action("led_on");
} catch (e) {
_showError(e.toString());
}
},
child: const Text("LED ON"),
),
OutlinedButton(
onPressed: stopped
? null
: () async {
try {
await api.action("led_off");
} catch (e) {
_showError(e.toString());
}
},
child: const Text("LED OFF"),
),
],
),
],
),
),
);
}
エラー表示のヘルパーも追加します(State内)。
void _showError(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg)),
);
}
そして build() 内のレイアウトに _controlCard(vm) を差し込みます。
第7回の connectionRow の直下に置くのが自然です。
例(wide/normal分岐の前):
_connectionRow(vm),
const SizedBox(height: 12),
_controlCard(vm),
const SizedBox(height: 12),
5. ライブ表示枠(任意、まずはプレースホルダ)
この段階でカメラ映像そのものを埋め込みたい場合、配信方式(MJPEG/画像更新/WebRTC等)の選定が必要になります。
この連載では次の回以降に扱うため、本回は枠だけ用意します。
Widget _livePlaceholder() {
return Card(
child: SizedBox(
height: 220,
child: Center(
child: Text("Live view(次回以降で追加)"),
),
),
);
}
6. 動作確認
・Flutter Webを起動
・STOPを押すと snapshot の safety_stopped が true になる
・STOP中はブザー/LED操作ボタンが無効になる
・解除で safety_stopped が false になり操作が戻る
・mode切替で state_machine.mode が変わる(auto/manual)
第11回でできるようになること(チェックリスト)
・■監視UIが安全状態(STOP)を表示できる
・■STOP/解除/全OFFをUIから実行できる
・■mode(auto/manual)をUIから切り替えられる
・■状態機械の状態(IDLE/ARMED/ACTIVE/COOLDOWN)をUIで見られる
・■STOP中は操作ボタンが無効化される
よくあるつまずき(第11回)
・RESTが叩けない
・baseUrl が http://<PI-IP>:8000 になっているか確認
・PCとPiが同一ネットワークか確認(隔離に注意)
・STOPしても状態が変わらない
・snapshot.extra.safety.stopped を載せているか確認(第10回の実装)
・/safety/stop が 200 を返しているか curl で確認
・mode切替が効かない
・/control の mode を状態機械にも反映しているか確認(第9回の統合部)
次回予告(第12章)
次回はログとスナップショットです。
推論・ヘルス・状態機械・操作の履歴を残し、再現性と障害解析ができる形にします。JSONLの設計、保存ポリシー、容量上限、取り出し方を固めます。
Discussion