Orange Pi Zero 2W は Allwinner H618 SoC を搭載した小型のシングルボードコンピュータです。 HDMI 出力があり、Linux が動作するため、Web サイネージ(デジタルサイネージ)端末として使うことができます。
この記事では、以下の2つを実現します。
OS は Debian ベースの Orange Pi 公式イメージを前提としています。
X サーバーと Chromium ブラウザ、日本語フォントをインストールします。
sudo apt update
sudo apt install -y xserver-xorg xinit chromium x11-xserver-utils xdotool fonts-noto-cjk
x11-xserver-utils は xrandr, xset, xhost などの X ユーティリティを含みます。
xdotool は後述のボタンからのキー送信に使います。
root ユーザーから startx を実行できるようにします。
# /etc/X11/Xwrapper.config
allowed_users=anybody
needs_root_rights=yes
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.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 ページが表示されます。
H618 の GPIO は 3.3V で動作します(5V トレラントではありません)。 内蔵のプルアップ抵抗が利用できるため、タクトスイッチは GPIO ピンと GND の間に接続するだけで OK です(Active LOW 方式)。
GPIO ピン ---[タクトスイッチ]--- GND
外付けの抵抗は不要です。ソフトウェア側でプルアップを有効にします。
Linux の GPIO 番号は (ポート番号 × 32) + ピン番号 で計算します。
例えば PI3 は (8 × 32) + 3 = 259 です。
| ボタン | ピン名 | GPIO 番号 | 機能 |
|---|---|---|---|
| 左 | PI3 | 259 | ディスプレイ ON/OFF |
| 中央 | PI16 | 272 | ブラウザリロード (Shift+F5) |
| 右 | PI4 | 260 | 未割り当て(予備) |
libgpiod パッケージのコマンドラインツールで動作確認できます。
sudo apt install -y gpiod
# 1回読み取り (プルアップ有効)
gpioget --bias=pull-up gpiochip0 259
# イベント監視 (ボタンを押すと FALLING EDGE が出力される)
gpiomon --bias=pull-up --falling-edge gpiochip0 259
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秒後に自動で再起動します。
[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 を追加してください。
起動タイミングの問題です。以下を確認してください。
StartLimitIntervalSec=0 でリトライ回数制限を無効化しているかRestart=on-failure と RestartSec=10 で再起動設定があるかudevadm settle でハードウェア初期化を待っているかExecStartPre が入っているかFailed to connect to the bus: Could not parse server address が大量に出る場合、
dbus-run-session を Chromium の起動コマンドに追加してください。
Exiting GPU process due to errors during initialization が出る場合、
--disable-gpu フラグを追加してソフトウェアレンダリングに切り替えてください。
H618 の Mali G31 GPU は Chromium との互換性に問題があります。
GPIO デバイスが初期化される前にデーモンが起動した可能性があります。
switch-daemon.service の ExecStartPre に udevadm settle が入っているか確認してください。
Orange Pi Zero 2W を Web サイネージにするためのポイントをまとめます。
--kiosk で全画面 Web 表示Conflicts=getty@tty1.service で VT 競合を防止、udevadm settle でハードウェア初期化待ち、StartLimitIntervalSec=0 で無制限リトライdbus-run-session と --disable-gpugpiomon の割り込み方式で CPU 負荷ほぼゼロのボタン監視組み込みサイネージでは「電源を入れたら確実に動く」ことが最も重要です。 各種ハードウェアの初期化タイミングに起因する不安定さは、 適切な待機処理とリトライ設定で対処できます。