Orange Pi Zero 2W を Web サイネージにする方法

2026-02-09 18:34 (3時間前) ytyng
View in English

はじめに

Orange Pi Zero 2W は Allwinner H618 SoC を搭載した小型のシングルボードコンピュータです。 HDMI 出力があり、Linux が動作するため、Web サイネージ(デジタルサイネージ)端末として使うことができます。

この記事では、以下の2つを実現します。

  1. Chromium をキオスクモードで自動起動して、電源を入れるだけで Web ページを全画面表示するサイネージにする
  2. GPIO に接続したタクトスイッチで、ディスプレイの ON/OFF やブラウザのリロードを物理ボタンで操作できるようにする

OS は Debian ベースの Orange Pi 公式イメージを前提としています。


Part 1: Chromium キオスクモードで Web サイネージにする

前提: 必要なパッケージのインストール

X サーバーと Chromium ブラウザ、日本語フォントをインストールします。

sudo apt update
sudo apt install -y xserver-xorg xinit chromium x11-xserver-utils xdotool fonts-noto-cjk

x11-xserver-utilsxrandr, xset, xhost などの X ユーティリティを含みます。 xdotool は後述のボタンからのキー送信に使います。

X サーバーの設定

root ユーザーから startx を実行できるようにします。

# /etc/X11/Xwrapper.config
allowed_users=anybody
needs_root_rights=yes

kiosk.sh — Chromium 起動スクリプト

kiosk.sh は X サーバーから呼ばれるスクリプトで、画面の設定と Chromium の起動を行います。

#!/bin/bash

# 起動時のログを見るには
# journalctl -u kiosk.service -f -n 100

export DISPLAY=:0
# orangepi ユーザーからの X アクセスを許可
xhost +local:

# 画面回転 (縦長ディスプレイを横向きに使う場合)
xrandr --output HDMI-1 --rotate left

# 画面の自動オフを無効化
xset s off          # スクリーンセーバー無効
xset -dpms          # DPMS(電源管理)無効
xset s noblank      # 画面ブランク無効

# 回転が反映されるのを待つ
sleep 2

# Chromium を orangepi ユーザーで起動
# dbus-run-session: boot 時は orangepi ユーザーの D-Bus セッションが存在しないため、
#   Chromium 用に一時的なセッションバスを起動する
# --disable-gpu: H618 の Mali G31 GPU は Chromium の初期化に失敗するため、
#   ソフトウェアレンダリングを使用する (GPU なしでも結局フォールバックされるが、
#   初期化失敗の待ち時間とエラーログを回避できる)
# --window-size と --window-position で全画面を強制
sudo -u orangepi \
    DISPLAY=:0 \
    HOME=/home/orangepi \
    dbus-run-session \
    chromium --kiosk --noerrdialogs --disable-infobars \
        --disable-session-crashed-bubble \
        --disable-gpu \
        --start-fullscreen \
        --window-size=1920,480 \
        --window-position=0,0 \
        "https://your-dashboard-url.example.com/"

ポイント解説

dbus-run-session

systemd サービスから起動した場合、orangepi ユーザーの D-Bus セッションバスが存在しません。 dbus-run-session を付けることで、Chromium 用の一時的な D-Bus セッションを作成します。 これがないと、Chromium が D-Bus に接続できず描画が正常に行われないことがあります。

--disable-gpu

H618 の Mali G31 GPU は Chromium の GPU プロセス初期化に失敗します。 GPU なしでも最終的にソフトウェアレンダリングにフォールバックされますが、 初期化の失敗を待つ時間とエラーログを省略するために明示的に無効化しています。

--kiosk

アドレスバーやタブバーなどの UI を非表示にし、全画面で Web ページのみを表示します。

kiosk.service — systemd サービスファイル

電源投入時に自動で kiosk.sh を起動する systemd サービスです。

[Unit]
Description=Kiosk Browser
After=multi-user.target systemd-user-sessions.service
# getty@tty1 が vt1 を奪うのを防ぐ
Conflicts=getty@tty1.service
# Unlimited restart attempts
StartLimitIntervalSec=0

[Service]
# idle: 他のジョブが完了してから起動する
Type=idle
User=root
Environment=DISPLAY=:0
# Wait until all udev hardware init events are processed
ExecStartPre=/bin/udevadm settle --timeout=30
# Wait for HDMI to be connected at the kernel DRM level
ExecStartPre=/bin/bash -c 'for i in $(seq 1 30); do for f in /sys/class/drm/card*-HDMI-A-*/status; do [ -f "$f" ] && grep -q "^connected$" "$f" && exit 0; done; sleep 2; done; echo "HDMI not detected"; exit 1'
ExecStart=/usr/bin/startx /home/orangepi/kiosk.sh -- :0 vt1
# 起動失敗時は 10 秒後にリトライ (StartLimitIntervalSec=0 により無制限)
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

ポイント解説

Conflicts=getty@tty1.service

これが最も重要な設定です。 X サーバーは vt1 (仮想ターミナル1) 上で起動しますが、 Linux のデフォルトでは getty@tty1 も vt1 上でログインプロンプトを表示しようとします。 この競合により、X が起動した直後に画面がターミナルに切り替わってしまうことがあります。 Conflicts を指定すると、kiosk.service の起動時に自動的に getty@tty1 が停止され、 vt1 の競合が解消されます。

StartLimitIntervalSec=0

systemd はデフォルトで、短時間に連続して起動失敗するとサービスの再起動を諦めます。 0 に設定することで、この制限を無効化し、何度でもリトライします。 組み込みサイネージでは、最終的に起動できることが重要なので、この設定が有効です。

Type=idle

他の systemd ジョブが完了してから起動します。 ネットワークや各種デーモンの準備が整った後に X を起動するためです。

ExecStartPre=/bin/udevadm settle --timeout=30

カーネルの udev イベントキューが空になるまで待ちます。 HDMI や GPU などのハードウェアデバイスが確実に初期化された状態で起動します。

HDMI 接続チェック

/sys/class/drm/card*-HDMI-A-*/status を最大30回ポーリングし、 HDMI が connected になるまで待ちます。 ディスプレイが接続されていない状態で X を起動しても意味がないためです。

サービスの有効化

sudo cp kiosk.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/kiosk.service
sudo systemctl daemon-reload
sudo systemctl enable kiosk.service
sudo systemctl start kiosk.service

これで、電源を入れるだけで Chromium が全画面で起動し、指定した Web ページが表示されます。


Part 2: GPIO 物理ボタンでサイネージを操作する

ハードウェア: タクトスイッチの接続

H618 の GPIO は 3.3V で動作します(5V トレラントではありません)。 内蔵のプルアップ抵抗が利用できるため、タクトスイッチは GPIO ピンと GND の間に接続するだけで OK です(Active LOW 方式)。

GPIO ピン ---[タクトスイッチ]--- GND

外付けの抵抗は不要です。ソフトウェア側でプルアップを有効にします。

使用する GPIO ピン

Linux の GPIO 番号は (ポート番号 × 32) + ピン番号 で計算します。 例えば PI3 は (8 × 32) + 3 = 259 です。

ボタン ピン名 GPIO 番号 機能
PI3 259 ディスプレイ ON/OFF
中央 PI16 272 ブラウザリロード (Shift+F5)
PI4 260 未割り当て(予備)

GPIO の動作確認

libgpiod パッケージのコマンドラインツールで動作確認できます。

sudo apt install -y gpiod

# 1回読み取り (プルアップ有効)
gpioget --bias=pull-up gpiochip0 259

# イベント監視 (ボタンを押すと FALLING EDGE が出力される)
gpiomon --bias=pull-up --falling-edge gpiochip0 259

switch_daemon.py — GPIO ボタン監視デーモン

gpiomon を子プロセスとして起動し、複数のボタンを1プロセスで同時監視する Python デーモンです。 gpiomon はカーネルの割り込みを使うため、ポーリング方式と違って CPU 負荷はほぼゼロです。

#!/usr/bin/env python3
"""
Orange Pi Zero 2W GPIO スイッチデーモン

3つのタクトスイッチ(gpiochip0 の 259, 272, 260)を
gpiomon 1プロセスで同時監視し、ボタン押下時にコマンドを実行する。

gpiomon はカーネルの割り込みを使うため CPU 負荷はほぼゼロ。

ログを見るには:
$ journalctl -u switch-daemon.service -f -n 50
"""
import asyncio
import logging
import os
import re
import subprocess
import time

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)

CHIP = "gpiochip0"
GPIO_LINES = [259, 272, 260]

BUTTONS = {
    259: "left",
    272: "center",
    260: "right",
}

DEBOUNCE_SEC = 0.5
_last_fired: dict[int, float] = {}

# gpiomon 出力パース: "event: FALLING EDGE offset: 260 timestamp: [...]"
RE_OFFSET = re.compile(r"offset:\s*(\d+)")


def get_display_is_on() -> bool:
    """DPMS の状態から、ディスプレイが ON かどうかを判定"""
    try:
        result = subprocess.run(
            ["xset", "q"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        output = result.stdout
        for line in output.splitlines():
            stripped = line.strip()
            if stripped.startswith("Monitor is"):
                return "On" in stripped
        if "DPMS is Disabled" in output:
            return True
    except Exception as e:
        logger.warning("xset q failed: %s", e)
    return True


def on_left_button():
    """左ボタン (259): ディスプレイ ON/OFF トグル"""
    if get_display_is_on():
        logger.info("Display OFF")
        subprocess.run(["xset", "dpms", "force", "off"])
    else:
        logger.info("Display ON")
        subprocess.run(["xset", "s", "off", "-dpms", "s", "noblank"])


def on_center_button():
    """真ん中ボタン (272): Shift+F5 でキャッシュクリアリロード"""
    logger.info("Sending Shift+F5")
    subprocess.run(["xdotool", "key", "shift+F5"])


def on_right_button():
    """右ボタン (260): 未割り当て"""
    logger.info("Right button pressed")


BUTTON_HANDLERS = {
    259: on_left_button,
    272: on_center_button,
    260: on_right_button,
}


def handle_button(gpio: int):
    now = time.monotonic()
    if now - _last_fired.get(gpio, 0) < DEBOUNCE_SEC:
        return
    _last_fired[gpio] = now

    name = BUTTONS.get(gpio, str(gpio))
    logger.info("Button pressed: %s (GPIO %d)", name, gpio)

    handler = BUTTON_HANDLERS.get(gpio)
    if handler:
        handler()


def parse_offset(line: str) -> int | None:
    """gpiomon の出力行から offset を抽出"""
    m = RE_OFFSET.search(line)
    if m:
        return int(m.group(1))
    return None


async def monitor():
    """gpiomon 1プロセスで全ボタンを同時監視"""
    lines_str = [str(g) for g in GPIO_LINES]
    # stdbuf -oL でラインバッファ強制(子プロセスだとバッファリングされるため)
    cmd = [
        "stdbuf",
        "-oL",
        "gpiomon",
        "--bias=pull-up",
        "--falling-edge",
        CHIP,
    ] + lines_str

    while True:
        logger.info("Starting gpiomon: %s", " ".join(cmd))
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        try:
            while True:
                raw = await proc.stdout.readline()
                if not raw:
                    break
                line = raw.decode().strip()
                gpio = parse_offset(line)
                if gpio is not None:
                    handle_button(gpio)
        except Exception as e:
            logger.error("Error: %s", e)
        finally:
            proc.kill()
            await proc.wait()

        logger.warning("gpiomon exited, restarting in 1s...")
        await asyncio.sleep(1)


async def main():
    os.environ.setdefault("DISPLAY", ":0")
    logger.info("Switch daemon starting")
    await monitor()


if __name__ == "__main__":
    asyncio.run(main())

設計ポイント

gpiomon による割り込み監視

gpiomon は Linux カーネルの GPIO 割り込みを使ってエッジイベントを検出します。 スリープ+ポーリング方式と比べて CPU 負荷がほぼゼロになります。 --falling-edge を指定して、プルアップされたピンが GND に落ちた瞬間(ボタン押下)のみを検出します。

stdbuf -oL

gpiomon を子プロセスとして起動すると、stdout がブロックバッファになり イベントがリアルタイムに読めなくなります。 stdbuf -oL でラインバッファを強制することで、1行出力されるごとに即座に読み取れます。

ソフトウェアデバウンス

タクトスイッチはバウンシング(チャタリング)が発生するため、 同じボタンの連続イベントを 0.5 秒間無視するデバウンス処理を入れています。

自動リカバリ

gpiomon プロセスが予期せず終了した場合、1秒後に自動で再起動します。

switch-daemon.service — systemd サービスファイル

[Unit]
Description=GPIO Switch Daemon
After=multi-user.target systemd-user-sessions.service

[Service]
Type=simple
User=root
# xset, xdotool 等の X コマンド実行に必要
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/orangepi/.Xauthority
# Wait until all udev hardware init events are processed (gpiochip0 etc.)
ExecStartPre=/bin/udevadm settle --timeout=30
ExecStart=/usr/bin/python3 /home/orangepi/switch-server/switch_daemon.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

ポイント

Environment=DISPLAY=:0 / XAUTHORITY

スイッチデーモンは xset(ディスプレイ制御)や xdotool(キー送信)を使うため、 X サーバーに接続する環境変数が必要です。

ExecStartPre=/bin/udevadm settle

gpiochip0 デバイスはカーネルの初期化後に利用可能になります。 udevadm settle で udev イベントの処理完了を待つことで、 GPIO デバイスが確実に使える状態で起動します。

Restart=always

kiosk.service の on-failure と異なり、always を指定しています。 デーモンが正常終了した場合でも再起動させるためです。

サービスの有効化

sudo cp switch-daemon.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/switch-daemon.service
sudo systemctl daemon-reload
sudo systemctl enable switch-daemon.service
sudo systemctl start switch-daemon.service

トラブルシューティング

画面が一瞬映ってターミナルに戻る

getty@tty1.service と X サーバーが vt1 を取り合っています。 kiosk.service に Conflicts=getty@tty1.service を追加してください。

起動時にブラウザが表示されないことがある

起動タイミングの問題です。以下を確認してください。

Chromium の D-Bus エラー

Failed to connect to the bus: Could not parse server address が大量に出る場合、 dbus-run-session を Chromium の起動コマンドに追加してください。

GPU 初期化エラー

Exiting GPU process due to errors during initialization が出る場合、 --disable-gpu フラグを追加してソフトウェアレンダリングに切り替えてください。 H618 の Mali G31 GPU は Chromium との互換性に問題があります。

ボタンが反応しない

GPIO デバイスが初期化される前にデーモンが起動した可能性があります。 switch-daemon.service の ExecStartPreudevadm settle が入っているか確認してください。


まとめ

Orange Pi Zero 2W を Web サイネージにするためのポイントをまとめます。

組み込みサイネージでは「電源を入れたら確実に動く」ことが最も重要です。 各種ハードウェアの初期化タイミングに起因する不安定さは、 適切な待機処理とリトライ設定で対処できます。

現在未評価
タイトルとURLをコピー
著者は、アプリケーション開発会社 Cyberneura を運営しています。
開発相談をお待ちしています。

アーカイブ

2026
2025
2024
2023
2022
2021
2020
2019
2018
2017
2016
2015
2014
2013
2012
2011