aptpod Tech Blog

株式会社アプトポッドのテクノロジーブログです

Raspberry Piで作る車載器向けキオスク端末

aptpod Advent Calendar 2025 – 12 月 12 日の記事です。

こんにちは、intdashグループで組み込みソフトウェアを担当しているOchiaiです。

みなさんは、ディスプレイもキーボードもない「ヘッドレス」なエッジコンピューターの状態を確認する際、どうされていますか?
わざわざノートPCを開いてLANケーブルで繋いだり、HDMIモニターと電源タップを探し回ったり……。現場でのこうした作業は、物理的な準備だけで一苦労です。

aptpodではこうした課題に対し、専用ディスプレイデバイスの仕組みも準備してあります。ただ今回はエンジニアの遊び心として、それとは別の仕組みでメンテナンス用キオスク端末を作ってみたいと思います。

作るもの

解決したい課題
車載器(エッジコンピューター)は、REST APIで制御可能な場合があります。しかし、現場でAPIを叩くにはPCが必要です。 そこで今回の以下のゴールとします。

簡単に持ち運びでき、ケーブルを1本繋ぐだけでGUI操作したい!

アーキテクチャ
Raspberry Piを使い、以下の構成を組みます。

  1. 物理層: USBケーブル1本で接続(給電 + イーサネット通信)。
  2. OS層: Raspberry Piを「USB LANアダプタ」として認識させる(USB Ethernet Gadget)。
  3. アプリ層: ブラウザ(Chromium)をキオスクモードで起動し、操作パネルを表示。
  4. 通信層: Nginxを挟むことで、WebアプリからスムーズにAPIを叩けるようにする。

システム構成図

今回は Raspberry Pi 4 Model B を使用します。

作ったもの

完成したハードウェア構成です。

ハードウェア構成

今回は、Raspberry Piに小型タッチディスプレイを取り付ける予定だったのですが、配送遅延によりタッチパネル化が間に合いませんでした。そのため、暫定的にHDMIディスプレイとUSBマウスを接続しています(つまり現段階ではゴール達成できていない…)。

それでは、動かしてみます。

まずは、エッジコンピューターのUSBポートにRaspberry Piを挿します。Raspberry Pi が起動し、しばらくすると、自動的にキオスクモードのブラウザでWebアプリが表示されます。

計測開始前

「計測開始」をクリックすると、計測開始のREST APIが実行され、ステータスが CONNECTED に変化しました。ステータスもステータス取得用のREST APIで取得しています。

計測開始後

Raspberry Piとエッジコンピューター間は、ケーブルを1本繋ぐだけでGUIで操作できました。

技術解説と構築手順

ここからは、実際にこの環境を構築するための「勘所」を、設定ファイル付きで解説します。 既存の技術の組み合わせですが、いくつかハマりポイントがあります。

1. Raspberry Piを「USBデバイス」化する

通常のRaspberry Piは「USBホスト(親)」ですが、Raspberry Pi 4 Model Bでは設定を変えることで「USBデバイス(子)」として振る舞えます。

OS(Raspberry Pi OS (64-bit))を焼いたSDカードに対し、初回起動前に以下の編集を行います。

/boot/config.txt

# USBコントローラー(dwc2)を有効化
dtoverlay=dwc2

/boot/cmdline.txt

# 末尾に追加(イーサネットガジェットとしてロード)
modules-load=dwc2,g_ether

これで、Raspberry Piはホストマシンから見て「USB接続のLANアダプタ」として認識されるようになります。

2. ネットワークの設定

Raspberry Piのネットワーク設定を行います。

まずは、USB接続のLANアダプタに固定IPを割り当てます。

sudo nmcli connection add type ethernet ifname usb0 con-name usb-gadget ipv4.method manual ipv4.addresses 192.168.7.1/24 ipv6.method ignore

Raspberry Pi側でDHCPサーバー(dnsmasq)を動かし、接続相手(車載器)に固定のIPアドレスを配布できるようにします。ここでは、「接続相手のインターネット接続を邪魔しない」 ような設定も行っています。

sudo apt update && sudo apt install -y dnsmasq
cat << 'EOF' | sudo tee /etc/dnsmasq.d/usb0-dhcp.conf
# 対象インターフェース
interface=usb0

# DHCPで常に「192.168.7.2」のみを割り当てるようにする
dhcp-range=192.168.7.2,192.168.7.2,255.255.255.0,1m
dhcp-leasefile=/dev/null
dhcp-authoritative

# ホスト側のインターネット通信を邪魔しない設定
# これにより、ホストはインターネット接続に既存の経路(LTE/Wi-Fi)を使い続けられます。
dhcp-option=3

# option 6 (DNS Server) も通知しません。
dhcp-option=6
EOF

3. Nginxによる「CORS回避」と「API中継」

Webページ(localhost)から、異なるIPアドレス(192.168.7.2)のAPIを直接JavaScriptで叩くと、ブラウザのセキュリティ機能によりブロックされる(CORSエラー)ことがあります。

これを回避するため、Raspberry Pi内にWebサーバー(Nginx)を立て、リバースプロキシ 構成にします。

  • ブラウザは http://localhost/api/ にアクセスする。
  • Nginxがそれを http://192.168.7.2:8081/ (車載器APIベースURL)に中継する。

こうすることで、ブラウザから見れば「同じサーバー内の通信」に見えるため、CORSエラーを回避できます。
ついでにREST APIにアクセスするためのBasic認証のヘッダー付与もここで設定します。今回は admin:pass123 をbase64変換した YWRtaW46cGFzczEyMw== を固定で設定しています。

sudo apt update && sudo apt install -y nginx
cat << 'EOF' | sudo tee /etc/nginx/sites-available/kiosk
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name localhost;

    set $api_target "http://192.168.7.2:8081";

    # 1. 作成したHTMLファイルを表示する設定
    location / {
        # HTMLファイルを置く場所
        root   /var/www/html;
        index  kiosk.html;
        try_files $uri $uri/ =404;
    }

    # 2. 通常のAPI通信用
    # ブラウザから '/api/' で始まるアクセスが来たら、ここへ転送する
    location /api/ {
        # ここに実際のAPIサーバーのアドレスを記述します
        proxy_pass $api_target;

        # Basic認証設定
        proxy_set_header Authorization "Basic YWRtaW46cGFzczEyMw==";
            
        # その他の転送設定
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
EOF
sudo unlink /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/kiosk /etc/nginx/sites-enabled/default

4. アプリケーションとキオスクモード

Webアプリは1つのHTMLファイルで作成します。Geminiに作ってもらったものです。

sudo install -d -m 0755 -o root -g root /var/www
sudo install -d -m 0755 -o www-data -g www-data /var/www/html
cat << 'EOF' | sudo -u www-data tee /var/www/html/kiosk.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>計測コントロールパネル</title>
    <style>
        /* === デザイン設定 (変更なし) === */
        :root {
            --bg-color: #121212;
            --card-color: #1e1e1e;
            --text-color: #e0e0e0;
            --accent-green: #00d084;
            --accent-red: #ff4d4f;
            --gray: #666;
        }

        body {
            background-color: var(--bg-color);
            color: var(--text-color);
            font-family: "Helvetica Neue", Arial, sans-serif;
            margin: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            overflow: hidden;
            user-select: none;
        }

        #app { width: 80%; max-width: 800px; text-align: center; }

        .header h1 { margin-bottom: 40px; border-bottom: 2px solid #333; padding-bottom: 10px; }

        .status-panel {
            background-color: var(--card-color);
            padding: 30px;
            border-radius: 8px;
            margin-bottom: 40px;
            border: 1px solid #333;
        }

        .status-label { font-size: 1.2rem; color: #888; margin-bottom: 10px; }
        
        .status-value {
            font-size: 4rem;
            font-weight: bold;
            text-transform: uppercase;
            letter-spacing: 2px;
        }

        /* 状態に応じた色設定 */
        .state-connected { 
            color: var(--accent-green); 
            text-shadow: 0 0 20px rgba(0, 208, 132, 0.5); 
        }
        .state-disconnected { color: var(--gray); }
        .state-error { color: var(--accent-red); }

        .controls { display: flex; gap: 20px; }

        button {
            flex: 1;
            padding: 30px;
            font-size: 1.5rem;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            color: white;
            font-weight: bold;
            transition: opacity 0.2s, transform 0.1s;
        }
        button:active { opacity: 0.7; transform: scale(0.98); }
        button:disabled { opacity: 0.3; cursor: not-allowed; transform: none; }

        .btn-start { background-color: var(--accent-green); }
        .btn-stop { background-color: var(--accent-red); }
    </style>
</head>
<body>

<div id="app">
    <div class="header">
        <h1>計測コントロール</h1>
    </div>

    <div class="status-panel">
        <div class="status-label">SYSTEM STATUS</div>
        <div id="status-display" class="status-value state-disconnected">---</div>
    </div>

    <div class="controls">
        <button id="btn-start" class="btn-start" onclick="sendMeasureCommand('start')">
            計測開始
        </button>
        <button id="btn-stop" class="btn-stop" onclick="sendMeasureCommand('stop')">
            計測停止
        </button>
    </div>
</div>

<script>
    // === 設定エリア (ここだけ書き換えてください) ===
    const CONFIG = {
        apiBase: '/api'
    };
    // ===========================================

    // HTML要素の取得
    const statusEl = document.getElementById('status-display');
    const btnStart = document.getElementById('btn-start');
    const btnStop = document.getElementById('btn-stop');

    // 状態を取得して画面を更新する関数
    async function fetchState() {
        try {
            const res = await fetch(`${CONFIG.apiBase}/agent/upstreams/-/state`);
            
            if (res.ok) {
                const data = await res.json();
                
                // 配列の中に code: "connected" があるかチェック
                let isConnected = false;
                if (Array.isArray(data)) {
                    isConnected = data.some(item => item.code === 'connected');
                }

                // 画面更新
                if (isConnected) {
                    statusEl.textContent = 'CONNECTED';
                    statusEl.className = 'status-value state-connected';
                } else {
                    statusEl.textContent = 'DISCONNECTED';
                    statusEl.className = 'status-value state-disconnected';
                }
            }
        } catch (e) {
            console.error('Fetch error:', e);
            statusEl.textContent = 'ERROR';
            statusEl.className = 'status-value state-error';
        }
    }

    // コマンド送信関数 (ボタンから呼ばれる)
    async function sendMeasureCommand(action) {
        // ボタンを一時的に無効化
        setButtonsDisabled(true);

        try {
            const res = await fetch(`${CONFIG.apiBase}/docker/composes/measurement/${action}`, {
                method: 'POST'
            });

            if (res.ok) {
                // 成功したらすぐに状態確認
                setTimeout(fetchState, 1000);
            } else {
                alert(`コマンド失敗: ${res.status}`);
            }
        } catch (e) {
            alert('通信エラーが発生しました');
        } finally {
            // ボタンを再度有効化
            setButtonsDisabled(false);
        }
    }

    // ボタンの有効/無効を切り替えるヘルパー関数
    function setButtonsDisabled(disabled) {
        btnStart.disabled = disabled;
        btnStop.disabled = disabled;
    }

    // --- 初期化処理 ---
    // ページ読み込み時に実行
    window.addEventListener('DOMContentLoaded', () => {
        fetchState();
        // 3秒ごとに定期更新
        setInterval(fetchState, 3000);
    });

</script>

</body>
</html>
EOF

次に、OS起動時にChromiumブラウザが全画面(キオスクモード)で立ち上がるよう設定します。

# 設定用ディレクトリの作成
mkdir -p ~/.config/autostart
cat << 'EOF' | sudo tee ~/.config/autostart/kiosk.desktop
[Desktop Entry]
Type=Application
Name=Kiosk
Comment=Start Chromium in Kiosk Mode
NoDisplay=false
Exec=/usr/bin/chromium --kiosk --incognito --password-store=basic http://localhost/kiosk.html
X-GNOME-Autostart-enabled=true
EOF

chromiumの設定は以下になります。

  • --kiosk: 全画面モード
  • --incognito: キャッシュを残さないシークレットモード
  • --password-store=basic: 起動時のキーリング解除ポップアップを回避

それから、raspi-config コマンドで「デスクトップへの自動ログイン有効化」と「スクリーンスリープの無効化」を行えば、完全な専用端末の出来上がりです。

5. ホスト側のネットワーク設定

エッジコンピューター側にRaspberry Piが接続された時に、自動的にUSB Ethernet GadgetのNICをupするように設定する必要があります。

今回はNetwork Managerを使用しているので、以下のように設定しました。

nmcli connection add type ethernet con-name kiosk-device ifname usb0

まとめ

Raspberry PiをUSBポートに挿すだけで、電源供給、ネットワーク接続、REST APIによる情報送受信までが全自動で行われるメンテナンス用キオスク端末が完成しました。

今回は基本的な構成のみ紹介しましたが、デバイスの実体がLinuxマシンであるので拡張はさまざまあると思います。

  • 自動診断機能: REST APIから情報を取得することでよくあるエラーが起きてないか即時診断
  • テザリング機能: オンボードのWi-Fiモジュールを使って、オフラインのエッジコンピューターにテザリング。
  • 物理スイッチ: GPIOに緊急停止ボタンやログマーカーボタンを付ける。

今回の技術のように、クラウドだけでなく、ハードウェアに関する使いやすさまで考慮して一気通貫で提供できるのがaptpodの強みです。

気になった方は、ぜひお手元のRaspberry Piで試してみてください!