WPE/Cog kiosk browser block for Balena. Runs a fullscreen browser on a DRM display without an X11 or Wayland compositor, and exposes a small HTTP API for URL navigation and health checks.
- 🖥️ Fullscreen WPE/Cog browser on DRM/KMS display — no X11 or Wayland compositor required
- 🌐 HTTP API for URL navigation, reload, status, and health checks
- 🔄 Display rotation with automatic touch coordinate calibration via udev hwdb
- 🔁 Automatic crash recovery with exponential backoff (up to 30 s); instant detection via process exit channel
- 💾 URL persistence in a named volume — survives Cog crashes, container restarts, and image updates
- ⏳ Startup readiness check — waits for the target URL to be reachable before launching Cog
Set these fleet variables in Balena Cloud to get going:
| Variable | Example | Description |
|---|---|---|
LAUNCH_URL |
https://example.com |
Page to display on startup |
ROTATE_DISPLAY |
right |
Rotate screen: left, right, inverted |
TOUCH_DEVICE |
*Waveshare* |
Glob to match your touchscreen (see Touch calibration) |
| Variable | Default | Description |
|---|---|---|
LAUNCH_URL |
about:blank |
URL loaded on startup |
ROTATE_DISPLAY |
(unset) | Rotate display: inverted/180, left/90, right/270 |
COG_PLATFORM_PARAMS |
(derived from ROTATE_DISPLAY) |
Override Cog DRM platform params directly (see Display rotation) |
COG_PLATFORM_DRM_VIDEO_MODE |
(unset) | Force a specific video mode, e.g. 1920x1080 |
COG_PLATFORM_DRM_MODE_MAX |
(unset) | Cap available modes, e.g. 1920x1080@60 |
COG_PLATFORM_DRM_CURSOR |
(unset) | Set to any non-empty value to show the mouse cursor |
| Variable | Default | Description |
|---|---|---|
TOUCH_DEVICE |
(unset) | Glob matched against the evdev device name (e.g. *Waveshare*, *eGalax*). Required for touch calibration — if unset, display rotation does not transform touch coordinates. |
| Variable | Default | Description |
|---|---|---|
IGNORE_TLS_ERRORS |
(unset) | Set to 1 to ignore TLS certificate errors |
COG_EXTRA_ARGS |
(unset) | Extra CLI flags passed to Cog (see Cog flags) |
| Variable | Default | Description |
|---|---|---|
KIOSK_API_PORT |
5011 |
Port for the control API |
Set ROTATE_DISPLAY to rotate both the framebuffer and (when TOUCH_DEVICE is also set) the touch coordinate matrix:
ROTATE_DISPLAY |
Rotation | Touch matrix |
|---|---|---|
right / 270 |
90° CW | 0 -1 1 1 0 0 |
left / 90 |
90° CCW | 0 1 0 -1 0 1 |
inverted / 180 |
180° | -1 0 1 0 -1 1 |
For advanced use, COG_PLATFORM_PARAMS accepts a comma-separated list of DRM parameters. The two supported keys are renderer (modeset (default) or gles) and rotation (0–3, counter-clockwise 90° steps). Rotation requires renderer=gles. ROTATE_DISPLAY sets both automatically — only use COG_PLATFORM_PARAMS to override.
TOUCH_DEVICE is a glob matched against the evdev NAME attribute of your touchscreen. The controller logs all detected input device names at startup — check the container logs in Balena Cloud to find the right pattern:
Detected input devices:
Waveshare WS170120
...
Then set TOUCH_DEVICE=*Waveshare* (or whatever matches your device) as a fleet variable. Without this, touch coordinates are not corrected for rotation.
Pass any of these via COG_EXTRA_ARGS:
| Flag | Description |
|---|---|
--scale=FACTOR |
Zoom/scaling applied to web content (e.g. 1.5) |
--device-scale=FACTOR |
Device pixel ratio for high-DPI displays (e.g. 2.0) |
--bg-color=COLOR |
Background color as CSS name or #RRGGBBAA hex |
--webprocess-failure=ACTION |
On WebProcess crash: error-page (default), exit, exit-ok, restart |
--proxy=PROXY |
HTTP proxy URL |
--ignore-host=HOSTS |
Proxy bypass hosts |
--content-filter=PATH |
Path to a content filter JSON rule set |
--automation |
Enable WebDriver automation mode |
For WebKit-level settings (fonts, JavaScript, media, etc.) run cog --help-websettings on the device.
| Method | Path | Description |
|---|---|---|
GET |
/url |
Current URL as plain text |
POST |
/url |
{"url": "https://..."} — navigate via D-Bus without restarting Cog (async, returns 200 immediately). Falls back to a hard restart if D-Bus is unavailable. Only http://, https://, and about: schemes accepted. |
POST |
/refresh |
Re-navigate to the current URL via D-Bus without restarting Cog (async, returns 200 immediately). Falls back to a hard restart if D-Bus is unavailable. |
POST |
/restart |
Fully restart Cog and re-apply touch calibration (async, returns 200 immediately). Use when Cog is in a bad state. |
GET |
/status |
JSON with url, running, crash_count, ready, started_at, uptime_seconds, cog_started_at, last_crash_at, cog_version |
GET |
/health |
200 OK while healthy; 503 when crash_count exceeds 5 (crash loop detected) |
# Current URL
curl http://<device>:5011/url
# Navigate (no Cog restart)
curl -X POST http://<device>:5011/url \
-H 'content-type: application/json' \
-d '{"url":"https://example.com"}'
# Soft reload (no Cog restart)
curl -X POST http://<device>:5011/refresh
# Hard restart (re-applies touch calibration)
curl -X POST http://<device>:5011/restart
# Diagnostics (includes uptime, cog version, last crash time, readiness)
curl http://<device>:5011/statusHow to setup the development environment.
You need the following tools to get started:
- uv - A Python virtual environment/package manager (for dev tooling)
- Go (1.23+) - The programming language
- golangci-lint - Go linter
- Clone the repository
- Install the dev tooling dependencies
uv sync --dev- Setup the pre-commit hooks
uv run pre-commit install- Build the controller binary
go build -o kiosk_controller .As this repository uses the pre-commit framework, all changes are linted with each commit. You can run all checks manually using the following command:
uv run pre-commit run --all-filesTo run only on staged files:
uv run pre-commit run- The controller is a static Go binary — no runtime dependencies in the image.
- URL navigation and page reloads use D-Bus (
org.gtk.Application.Openoncom.igalia.Cog) so Cog never needs to restart for a URL change. A D-Bus session daemon is started bystart.shand its address is exported asDBUS_SESSION_BUS_ADDRESS. If D-Bus is unavailable, all navigation falls back to a hard restart. - After a D-Bus navigation,
udevadm trigger --action=changeis fired after 500 ms so libinput re-reads the hwdb calibration matrix for any input device opened by the new WPEWebProcess. - Cog and all its WPE subprocesses run in their own process group; on a hard restart the entire group is signalled so DRM/GL resources are fully released before the new instance starts.
- A 500 ms settle delay after stopping Cog prevents "Cannot set mode (Permission denied)" DRM errors when using the
glesrenderer. - Cog crashes are detected instantly (via process exit channel) and restarted with exponential backoff (max 30 s). The crash counter resets only after 30 s of stable uptime.
/healthreturns 503 whencrash_countexceeds 5, signalling a crash loop to external monitors.- The active URL is persisted to
/data/kiosk-url(a named Docker volume). It survives Cog crashes, container restarts, and image updates.LAUNCH_URLis only used when no persisted URL exists. Mount thebrowser-datavolume at/datain yourdocker-compose.yml. - udev is started in-container;
io.balena.features.udevdoes not reliably mount/run/udevon all Balena OS versions. A warning is logged to stderr if udev fails to start. - Setting
ROTATE_DISPLAYwithoutTOUCH_DEVICElogs a warning — touch coordinates will not be corrected for rotation.
Distributed under the MIT License - see LICENSE for details.