---
slug: "orangepi-zero2w-web-signage"
title: "Orange Pi Zero 2W を Web サイネージにする方法"
description: "Orange Pi Zero 2W で Chromium キオスクモードを自動起動して Web サイネージ化する方法を解説。systemd の安定起動設定（getty 競合回避、D-Bus/GPU 対策、無制限リトライ）と、gpiomon 割り込み方式による物理ボタン制御の実装を紹介します。"
url: "https://www.ytyng.com/blog/orangepi-zero2w-web-signage"
publish_date: "2026-02-09T09:34:38Z"
created: "2026-02-09T09:34:38.942Z"
updated: "2026-02-27T03:55:10.144Z"
categories: []
keywords: ""
featured_image_url: "https://media.ytyng.com/resize/20260209/44a8ebd91d014613ba3f7abf8d3ff3c8.png.webp?width=768"
has_video: true
has_music: true
video_urls: ["https://media.ytyng.net/ytyng-blog/332/featured-video-1.mp4", "https://media.ytyng.net/ytyng-blog/332/featured-video-2.mp4", "https://media.ytyng.net/ytyng-blog/332/featured-video-3.mp4"]
music_urls: ["https://media.ytyng.net/ytyng-blog/332/featured-music-332-6.mp3", "https://media.ytyng.net/ytyng-blog/332/featured-music-332-7.mp3"]
lang: "ja"
---

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

## はじめに

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 ブラウザ、日本語フォントをインストールします。

```bash
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` は後述のボタンからのキー送信に使います。

### X サーバーの設定

root ユーザーから startx を実行できるようにします。

```
# /etc/X11/Xwrapper.config
allowed_users=anybody
needs_root_rights=yes
```

### kiosk.sh — Chromium 起動スクリプト

kiosk.sh は X サーバーから呼ばれるスクリプトで、画面の設定と Chromium の起動を行います。

```bash
#!/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 サービスです。

```ini
[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 を起動しても意味がないためです。

### サービスの有効化

```bash
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` パッケージのコマンドラインツールで動作確認できます。

```bash
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 負荷はほぼゼロ**です。

```python
#!/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 サービスファイル

```ini
[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` を指定しています。
デーモンが正常終了した場合でも再起動させるためです。

### サービスの有効化

```bash
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` でハードウェア初期化を待っているか
- HDMI 接続チェックの `ExecStartPre` が入っているか

### 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 の `ExecStartPre` に `udevadm settle` が入っているか確認してください。

---

## まとめ

Orange Pi Zero 2W を Web サイネージにするためのポイントをまとめます。

- **キオスクブラウザ**: startx + Chromium `--kiosk` で全画面 Web 表示
- **安定起動**: `Conflicts=getty@tty1.service` で VT 競合を防止、`udevadm settle` でハードウェア初期化待ち、`StartLimitIntervalSec=0` で無制限リトライ
- **D-Bus / GPU 対策**: `dbus-run-session` と `--disable-gpu`
- **物理ボタン**: `gpiomon` の割り込み方式で CPU 負荷ほぼゼロのボタン監視
- **デバウンス**: ソフトウェアで 0.5 秒のチャタリング対策

組み込みサイネージでは「電源を入れたら確実に動く」ことが最も重要です。
各種ハードウェアの初期化タイミングに起因する不安定さは、
適切な待機処理とリトライ設定で対処できます。
