😺

【連載#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)

この回でやること(全体像)

  1. FlutterからRESTで操作するクライアントを用意する
  2. STOP/解除/全OFFボタンを追加する
  3. mode(auto/manual)切替を追加する
  4. 状態機械(state_machine)と安全状態(safety.stopped)を表示する
  5. 画面の情報構造を監視向けに整理する

1. 依存関係(HTTPクライアント)

FlutterからRESTを叩くため http を使います。

pubspec.yaml に追加:

dependencies:
  http: ^1.2.0

反映:

flutter pub get
  1. 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 の例
http://192.168.0.10:8000
注意
・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