Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 30 additions & 19 deletions bin/start_viewer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -116,25 +116,34 @@ fi
# we just set; -E alone is subject to env_check / env_delete and is not
# guaranteed for XDG_* on Debian's default sudoers.
#
# x86 boards run under `cage`, a kiosk wlroots compositor, because
# balenaOS x86 doesn't expose /dev/fb0 (Qt's linuxfb plugin has nothing
# to draw to) and there's no host display server. cage acquires DRM
# master as root, exports WAYLAND_DISPLAY for its child, and exits when
# the child exits — so the existing kill -0 watchdog below still works.
# The inner sudo drops back to the viewer user; WAYLAND_DISPLAY has to
# be added to --preserve-env to survive sudo's env scrub.
if [ "$DEVICE_TYPE" = "x86" ] || [ "$DEVICE_TYPE" = "arm64" ]; then
# All Qt6 boards (pi4-64, pi5, x86, arm64) run under `cage`, a kiosk
# wlroots compositor. This is required on x86 (balenaOS doesn't expose
# /dev/fb0) and on generic arm64 (Armbian boots Mesa straight to KMS
# with no fbdev); the Pi boards historically used Qt linuxfb + mpv
# --vo=drm directly on KMS, but were consolidated onto the same
# Wayland path so the four Qt6 images share one display stack. cage
# acquires DRM master as root, exports WAYLAND_DISPLAY for its child,
# and exits when the child exits — so the existing kill -0 watchdog
# below still works. The inner sudo drops back to the viewer user;
# WAYLAND_DISPLAY has to be added to --preserve-env to survive sudo's
# env scrub. Qt5 boards (pi2/pi3) stay on the legacy linuxfb path.
case "$DEVICE_TYPE" in
x86|arm64|pi4-64|pi5)
# /dev/dri/renderD128 carries the host's `render` group, whose
# numeric GID is distro-dependent (typically 992 on Debian/Ubuntu,
# 109 elsewhere) and not present in the container's /etc/group.
# Without membership the `viewer` user can open card0 (group
# `video`, GID 44 — already a member) but not the render node, and
# VAAPI silently fails with "wayland: failed to open
# /dev/dri/renderD128". mpv then falls back to software decode and
# frames drop at 1080p on entry-level x86. Mirror the host GID
# into the container as a synthetic `host-render` group and add
# `viewer` to it, so the supplementary group list `sudo -u viewer`
# later resolves from /etc/group already includes render access.
# 109 elsewhere, 106 on Pi OS Bookworm) and not always present in
# the container's /etc/group. Without membership the `viewer`
# user can open card0 (group `video`, GID 44 — already a member)
# but not the render node. On x86 that means VAAPI silently fails
# with "wayland: failed to open /dev/dri/renderD128" and mpv
# falls back to software decode — frames drop at 1080p on
# entry-level x86. On Pi4-64 / Pi5 the GL context (V3D render
# node) and v4l2-request hwdec equivalents need the same access
# for --vo=gpu --gpu-context=wayland. Mirror the host GID into
# the container as a synthetic `host-render` group and add
# `viewer` to it, so the supplementary group list `sudo -u
# viewer` later resolves from /etc/group already includes render
# access.
if [ -e /dev/dri/renderD128 ]; then
render_gid=$(stat -c %g /dev/dri/renderD128)
if [ "$render_gid" -ne 0 ]; then
Expand Down Expand Up @@ -168,10 +177,12 @@ if [ "$DEVICE_TYPE" = "x86" ] || [ "$DEVICE_TYPE" = "arm64" ]; then
-E -u viewer \
dbus-run-session /venv/bin/python -m anthias_viewer
' &
else
;;
*)
sudo --preserve-env=XDG_RUNTIME_DIR,QT_SCALE_FACTOR,PYTHONPATH,LANG,LANGUAGE,LC_ALL -E -u viewer \
dbus-run-session /venv/bin/python -m anthias_viewer &
fi
;;
esac

# Wait for the viewer
while true; do
Expand Down
16 changes: 9 additions & 7 deletions docker/Dockerfile.viewer.j2
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ COPY --from=webview-builder /out/bin/AnthiasWebview /usr/local/bin/AnthiasWebvie
COPY --from=webview-builder /out/share/AnthiasWebview /usr/local/share/AnthiasWebview

ENV QT_QPA_EGLFS_FORCE888=1
{% if board in ('x86', 'arm64') %}
# balenaOS x86 doesn't expose /dev/fb0, so the linuxfb plugin has
# nothing to draw to. Run Qt under Wayland instead; bin/start_viewer.sh
# wraps the viewer in `cage`, a kiosk Wayland compositor that talks to
# KMS via wlroots. The same path covers arm64 boards (Armbian
# on Rock Pi / Orange Pi / Banana Pi, …), which similarly boot Mesa
# straight to DRM/KMS without a fbdev emulation node.
{% if is_qt6 %}
# All Qt6 boards run the viewer under `cage`, a kiosk Wayland
# compositor that talks to KMS via wlroots. bin/start_viewer.sh wraps
# the viewer process in cage; this env var tells Qt to load the
# qt6-wayland platform plugin so it renders into cage's surface.
# Covers x86 (balenaOS doesn't expose /dev/fb0), arm64 Armbian
# boards (Rock Pi / Orange Pi / Banana Pi, …), and the Pi4-64 / Pi5
# (consolidated onto Wayland so all Qt6 boards share one display
# stack — see #TBD).
ENV QT_QPA_PLATFORM=wayland
{% else %}
ENV QT_QPA_PLATFORM=linuxfb
Expand Down
58 changes: 28 additions & 30 deletions src/anthias_viewer/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,39 +205,37 @@ def play(self) -> None:
# effect without a viewer restart, matching VLCMediaPlayer.
settings.load()

# Pin to 1080p on Pi4-64/Pi5: mpv's default --drm-mode=preferred
# reads the connector's EDID-preferred mode (4K on most modern
# TVs) and runs CPU zimg upscale, which drops below real-time
# on the A72. Software decode of 1080p H.264 fits 4 cores fine.
# 4-thread software decode tuning for the Pi4-64 A72 / Pi5 A76:
# mpv's default --vd-lavc-threads=0 (auto) picks min(cores+1, 16)
# but caps under-utilization on small cores. Pinning to 4 fits
# 1080p H.264 with measurable headroom on both SoCs.
# --drm-mode=1920x1080@60 was previously paired with this on the
# --vo=drm path to dodge A72 CPU zimg upscale at 4K; under
# --vo=gpu --gpu-context=wayland the GPU does the scaling, so
# mpv can render at the connector's native mode and the
# --drm-mode pin is a no-op (cage holds DRM master).
device_type = os.environ.get('DEVICE_TYPE', '')
extra_args: list[str] = []
if device_type in ('pi4-64', 'pi5'):
extra_args = [
'--drm-mode=1920x1080@60',
'--vd-lavc-threads=4',
]

# x86 runs under `cage` (a wlroots kiosk compositor — see
# bin/start_viewer.sh); cage holds DRM master, so --vo=drm is
# denied. Route mpv through the GL VO over a Wayland EGL
# context, which is the generic path mpv supports on every x86
# GPU with Mesa or vendor GL drivers. Paired with
# --hwdec=auto-safe, VAAPI-capable iGPUs (Intel iHD/i965, AMD
# radeonsi, …) decode in hardware and hand frames to the GL
# context as DMA-BUFs via dmabuf-interop-gl; software decode
# still works via the same VO for codecs without HW support.
# --vo=dmabuf-wayland would skip the GL upload entirely but
# segfaults under cage in the viewer's background-spawn path
# (mpv 0.40.0 + wlroots-0.18 + libplacebo dies between hwdec
# init and file open). Pi boards (pi4-64/pi5) keep --vo=drm —
# they own the framebuffer directly with no compositor.
# arm64 (non-Pi ARM SBCs on Armbian) goes the same
# route as x86 because the viewer container is wrapped in
# cage there too; hwdec is best-effort per SoC.
if device_type in ('x86', 'arm64'):
vo_args = ['--vo=gpu', '--gpu-context=wayland']
else:
vo_args = ['--vo=drm']
extra_args = ['--vd-lavc-threads=4']

# All Qt6 boards run under `cage` (a wlroots kiosk compositor —
# see bin/start_viewer.sh); cage holds DRM master, so --vo=drm
# is denied. Route mpv through the GL VO over a Wayland EGL
# context, the generic path mpv supports on every x86 / Pi /
# Mesa-on-arm64 GPU. Paired with --hwdec=auto-safe, hardware
# decoders hand frames to the GL context as DMA-BUFs via
# dmabuf-interop-gl: VAAPI on x86 (Intel iHD/i965, AMD
# radeonsi, …), V4L2-request / V4L2-M2M on Pi4-64 (vc4) and
# Pi5 (hantro), and per-SoC best-effort on arm64 (rkvdec /
# cedrus / meson-vdec). Software decode still works via the
# same VO for codecs without HW support. --vo=dmabuf-wayland
# would skip the GL upload entirely but segfaults under cage
# in the viewer's background-spawn path (mpv 0.40.0 +
# wlroots-0.18 + libplacebo dies between hwdec init and file
# open). Qt5 boards (pi2/pi3) are routed through VLC by
# MediaPlayerProxy and never reach this code path.
vo_args = ['--vo=gpu', '--gpu-context=wayland']

self.process = subprocess.Popen(
[
Expand Down
46 changes: 18 additions & 28 deletions tests/test_media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,17 @@ def test_play_invokes_popen_with_expected_args(
mock_popen: Any, _mock_detect: Any, mpv: _MPVFixtures
) -> None:
mpv.player.set_asset('file:///test/video.mp4', 30)
with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4'}):
with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}):
mpv.player.play()

mock_popen.assert_called_once_with(
[
'mpv',
'--no-terminal',
'--vo=drm',
'--vo=gpu',
'--gpu-context=wayland',
'--hwdec=auto-safe',
'--vd-lavc-threads=4',
'--audio-device=alsa/sysdefault:CARD=vc4hdmi0',
'--',
'file:///test/video.mp4',
Expand All @@ -70,35 +72,36 @@ def test_play_invokes_popen_with_expected_args(


@patch('anthias_viewer.media_player.subprocess.Popen')
def test_play_pins_1080p_mode_on_pi4_64(
def test_play_tunes_decoder_threads_on_pi4_64(
mock_popen: Any, mpv: _MPVFixtures
) -> None:
mpv.player.set_asset('file:///test/video.mp4', 30)
with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}):
mpv.player.play()

args, _ = mock_popen.call_args
assert '--drm-mode=1920x1080@60' in args[0]
assert '--vd-lavc-threads=4' in args[0]
assert '--hwdec=auto-safe' in args[0]
assert '--hwdec=v4l2m2m-copy' not in args[0]
# --drm-mode pinning is gone: under cage+Wayland the GPU does the
# scaling, so the A72 no longer runs CPU zimg upscale at 4K.
assert '--drm-mode=1920x1080@60' not in args[0]


@patch('anthias_viewer.media_player.subprocess.Popen')
def test_play_pins_1080p_mode_on_pi5(
def test_play_tunes_decoder_threads_on_pi5(
mock_popen: Any, mpv: _MPVFixtures
) -> None:
mpv.player.set_asset('file:///test/video.mp4', 30)
with patch.dict('os.environ', {'DEVICE_TYPE': 'pi5'}):
mpv.player.play()

args, _ = mock_popen.call_args
assert '--drm-mode=1920x1080@60' in args[0]
assert '--vd-lavc-threads=4' in args[0]
assert '--drm-mode=1920x1080@60' not in args[0]


@patch('anthias_viewer.media_player.subprocess.Popen')
def test_play_does_not_pin_mode_on_x86(
def test_play_omits_pi_tuning_on_x86(
mock_popen: Any, mpv: _MPVFixtures
) -> None:
mpv.player.set_asset('file:///test/video.mp4', 30)
Expand All @@ -110,29 +113,16 @@ def test_play_does_not_pin_mode_on_x86(
assert '--vd-lavc-threads=4' not in args[0]


@pytest.mark.parametrize('device_type', ['x86', 'arm64', 'pi4-64', 'pi5'])
@patch('anthias_viewer.media_player.subprocess.Popen')
def test_play_uses_wayland_vo_on_x86(
mock_popen: Any, mpv: _MPVFixtures
) -> None:
mpv.player.set_asset('file:///test/video.mp4', 30)
with patch.dict('os.environ', {'DEVICE_TYPE': 'x86'}):
mpv.player.play()

args, _ = mock_popen.call_args
assert '--vo=gpu' in args[0]
assert '--gpu-context=wayland' in args[0]
assert '--vo=drm' not in args[0]


@patch('anthias_viewer.media_player.subprocess.Popen')
def test_play_uses_wayland_vo_on_arm64(
mock_popen: Any, mpv: _MPVFixtures
def test_play_uses_wayland_vo_on_all_qt6_boards(
mock_popen: Any, mpv: _MPVFixtures, device_type: str
) -> None:
# arm64 runs under cage (same as x86), so mpv must go
# through --vo=gpu --gpu-context=wayland — cage holds DRM master
# and would deny --vo=drm.
# All Qt6 boards run under cage (a wlroots kiosk compositor); cage
# holds DRM master, so --vo=drm would be denied. Every Qt6 board
# must route through --vo=gpu --gpu-context=wayland instead.
mpv.player.set_asset('file:///test/video.mp4', 30)
with patch.dict('os.environ', {'DEVICE_TYPE': 'arm64'}):
with patch.dict('os.environ', {'DEVICE_TYPE': device_type}):
mpv.player.play()

args, _ = mock_popen.call_args
Expand Down
48 changes: 19 additions & 29 deletions tools/image_builder/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,14 @@ def get_viewer_context(board: str, target_platform: str) -> dict[str, Any]:
# from apt at runtime — except on Qt 6 boards where qt6-*-dev
# below also provides the runtime libs.)
#
# X11/XCB packages are intentionally absent: the WebView is
# configured with `-no-xcb -no-xcb-xlib -qpa eglfs` (see
# webview/build_qt5.sh) and runs under QT_QPA_PLATFORM=linuxfb
# straight on KMS/DRM, so Qt has no X code path to dlopen. mpv
# uses --vo=drm. Wayland is similarly absent on Pi for the same
# reason; the x86 board is the one exception (it has no /dev/fb0,
# so the qt6-wayland + cage pair is added to the per-board apt
# extension below).
# X11/XCB packages are intentionally absent. Qt5 boards (pi2/pi3)
# use a custom -no-xcb -no-xcb-xlib -qpa eglfs build of the WebView
# (see webview/build_qt5.sh) and run under QT_QPA_PLATFORM=linuxfb
# straight on KMS/DRM, with mpv on --vo=drm. Qt6 boards (pi4-64,
# pi5, x86, arm64) run the viewer under `cage` (a kiosk wlroots
# compositor) with QT_QPA_PLATFORM=wayland and mpv on --vo=gpu
# --gpu-context=wayland — no X code path on either track. The
# cage + qt6-wayland pair is added to the Qt6 apt extension below.
Comment on lines +187 to +191
viewer_extra_apt_dependencies = [
'ca-certificates',
'dbus-daemon',
Expand Down Expand Up @@ -252,36 +252,26 @@ def get_viewer_context(board: str, target_platform: str) -> dict[str, Any]:
]

if is_qt6:
# pi4-64/pi5 use mpv --vo=drm; x86 uses mpv --vo=gpu
# --gpu-context=wayland under cage with VAAPI hwdec (see
# MPVMediaPlayer.play in src/anthias_viewer/media_player.py).
# VLC is deliberately *not* installed: MediaPlayerProxy routes
# Qt6 boards to MPVMediaPlayer, so VLC would just be ~80–100 MB
# of dead weight here.
# All Qt6 boards (pi4-64, pi5, x86, arm64) run the viewer
# under `cage`, a wlroots kiosk compositor, with mpv on
# --vo=gpu --gpu-context=wayland for video. See
# MPVMediaPlayer.play in src/anthias_viewer/media_player.py
# and bin/start_viewer.sh for the runtime wiring. VLC is
# deliberately *not* installed: MediaPlayerProxy routes Qt6
# boards to MPVMediaPlayer, so VLC would just be ~80–100 MB
# of dead weight here. qt6-wayland is the Qt platform plugin
# the viewer loads to render into cage's surface.
viewer_extra_apt_dependencies.extend(
[
'cage',
'mpv',
'qt6-base-dev',
'qt6-wayland',
'qt6-webengine-dev',
'qt6-image-formats-plugins',
]
)

if board in ('x86', 'arm64'):
# balenaOS x86 has no /dev/fb0 for Qt's linuxfb plugin and
# no host display server. cage is a kiosk wlroots
# compositor that talks straight to KMS; qt6-wayland is
# the Qt platform plugin the viewer loads to render into
# cage's surface. The same wiring fits arm64
# (non-Pi 64-bit ARM SBCs running Armbian): no /dev/fb0
# by default, so cage is the portable kiosk path.
viewer_extra_apt_dependencies.extend(
[
'cage',
'qt6-wayland',
]
)

if board == 'x86':
# va-driver-all is a Debian metapackage that pulls in
# intel-media-va-driver (modern Intel iHD), i965-va-driver
Expand Down
4 changes: 2 additions & 2 deletions website/data/faq.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
items:
- question: How do I rotate the screen for portrait orientation?
answer: |
Rotation happens at the kernel / firmware level. The Anthias viewer renders straight to the Linux framebuffer (Qt `linuxfb`) and to KMS (mpv `--vo=drm`) — there's no Wayland compositor in the stack — so the standard Raspberry Pi config knobs apply directly.
Rotation happens at the kernel / firmware level on the Pi. The viewer renders through `cage` (a kiosk Wayland compositor that talks straight to KMS) on Pi4/Pi5/x86/arm64, and through Qt `linuxfb` directly on legacy 32-bit Pi boards (Pi2/Pi3) — neither path goes through a desktop compositor, so the standard Raspberry Pi config knobs apply directly.

Edit `/boot/firmware/config.txt` and add one of:

Expand All @@ -98,7 +98,7 @@
1. SSH in and run `docker compose ps` from `~/anthias/` — all four containers (`anthias-server`, `anthias-celery`, `anthias-viewer`, `redis`) should be `Up`.
2. If a container is restarting, tail its logs: `docker logs -f anthias-anthias-viewer-1` (or `…-server-1`).
3. Open the dashboard from another device and confirm at least one asset is **enabled** with a current schedule.
4. If the display has gone to power save, briefly unplug and reconnect the HDMI cable, or check the TV's input source. Anthias renders to the framebuffer (Qt `linuxfb`) and uses no X server, so `xset` won't help.
4. If the display has gone to power save, briefly unplug and reconnect the HDMI cable, or check the TV's input source. Anthias renders directly to KMS (via `cage` on Pi4/Pi5/x86/arm64, or Qt `linuxfb` on Pi2/Pi3) and uses no X server, so `xset` won't help.

If a recent update broke the display, running `~/anthias/bin/upgrade_containers.sh` re-creates the containers cleanly.

Expand Down