{
"$type": "site.standard.document",
"content": "---\ntitle: \"Getting a reTerminal DM running as a Nerves kiosk in 2026\"\ndescription: \"A guide to running Elixir/Nerves on Seeed's reTerminal DM with its custom DSI\n display, capacitive touchscreen, and a Cog/WPE browser kiosk---including the workarounds\n you'll need for touch input.\"\ntags:\n - dev\n---\n\nThe [Neon Perceptron](https://github.com/ANUcybernetics/neon-perceptron) is a\nphysical neural network I'm building with my colleague Brendan Traw---a modern\ntake on [Rosenblatt's Perceptron](https://en.wikipedia.org/wiki/Perceptron)\nwhere every wire is a flexible LED that lights up with its activation. I wrote\nabout the [interactive digital twin](/blog/2025/12/11/neon-perceptron-digital-twin/)\na few months back.\n\nThe brain of the thing is a [Seeed reTerminal\nDM](https://www.seeedstudio.com/reTerminal-DM-p-5616.html)---essentially a\nRaspberry Pi Compute Module 4 in an industrial enclosure with a 10.1\" capacitive\ntouchscreen, GPIO, and CAN bus. It runs\n[Nerves](https://github.com/nerves-project/nerves) (Elixir's embedded Linux\nframework) with a Phoenix LiveView UI displayed in a fullscreen kiosk browser.\n\nGetting all of this working with a current stack took more yak-shaving than\nI'd hoped. Here's what I learned, so you don't have to.\n\n## The display problem\n\nThe reTerminal DM's 800×1280 DSI display uses an ILI9881D panel controller with\na Goodix GT9271 capacitive touchscreen. Neither of these are in the mainline\nLinux kernel's driver set---the panel's compatible string (`gjx,gjx101c7`)\ndoesn't match the upstream `panel-ilitek-ili9881c` driver, so if you boot a\nstock Nerves system the DRM pipeline never initialises and the screen stays\nblack.\n\nI initially tried\n[kiosk_system_rpi4](https://hex.pm/packages/kiosk_system_rpi4)---a maintained\nNerves system with Cog/WPE built in---but hit this wall immediately. The\nElixirForum thread on\n[browser kiosks in Nerves](https://elixirforum.com/t/browser-in-nerves-kiosk-mode/43250)\nwas invaluable here, as was the\n[bringing up cool hardware with Nerves](https://elixirforum.com/t/bringing-up-cool-hardware-with-nerves/64566)\nthread where others had got the reTerminal working. The solution was to fork\n[formrausch/frio_rpi4](https://github.com/formrausch/frio_rpi4), a Nerves system\nthat includes the custom `panel-ili9881d.c` kernel module and device tree\noverlays for the reTerminal DM hardware. Our fork is at\n[ANUcybernetics/reterminal_dm](https://github.com/ANUcybernetics/reterminal_dm).\n\nThe frio_rpi4 system was on an older `nerves_system_br` (1.28.3) and OTP 27, so\nI updated it to nerves_system_br 1.33.4 and OTP 28. This also meant rewriting\nthe firmware update config (`fwup.conf`) to use the RPi4's modern tryboot A/B\npartition scheme[^fwup-ops], which gives you automatic rollback if a firmware\nupdate fails to boot.\n\n[^fwup-ops]:\n One thing that caught me out: `fwup-ops.conf` must stay in sync with\n `fwup.conf`. They define the same partition layout from different\n perspectives, and `fwup-ops.conf` gets compiled into `ops.fw` and baked into\n the system image. If they diverge, `Nerves.Runtime.validate_firmware()` can't\n detect the active slot. The failure mode is insidious: the upload succeeds,\n the device reboots, the new firmware runs---but validation silently targets\n the wrong slot, so the _next_ reboot rolls back to the old firmware. You end\n up staring at logs wondering why your changes keep disappearing. And since\n `ops.fw` is baked into the system image, you can't fix it via OTA---you need\n a full system rebuild and reflash.\n\n## Booting and the kiosk stack\n\nThe DSI display requires a specific initialisation dance in\n`Application.start/2`, before the compositor launches:\n\n```elixir\ndefp prepare_hardware do\n # start udevd so the touchscreen gets enumerated\n System.cmd(\"udevd\", [\"--daemon\"])\n System.cmd(\"udevadm\", [\"trigger\"])\n System.cmd(\"udevadm\", [\"settle\"])\n\n # reload the vc4 DRM driver---the DSI panel needs this\n System.cmd(\"modprobe\", [\"-r\", \"vc4\"])\n Process.sleep(500)\n System.cmd(\"modprobe\", [\"vc4\"])\n Process.sleep(1000)\n\n # suppress kernel messages on the display\n :os.cmd(~c\"dmesg -n 1\")\n\n # re-enumerate after vc4 reload\n System.cmd(\"udevadm\", [\"trigger\"])\n System.cmd(\"udevadm\", [\"settle\"])\nend\n```\n\nSkip any of these steps and you'll get either a black screen, no touchscreen, or\nkernel log spam over your UI. The vc4 reload is the key bit---without it the DRM\ndevice doesn't fully initialise for the DSI panel. Credit to the\n[frio_rpi4 README](https://github.com/formrausch/frio_rpi4) for documenting\nthis workaround.\n\nAfter `prepare_hardware`, a supervision tree starts:\n\n1. **seatd** (seat daemon)---manages access to input and DRM devices\n2. **Weston** (Wayland compositor)---runs in kiosk-shell mode, no panel, no\n cursor. Make sure you _don't_ pass `--continue-without-input`---despite the\n name, it doesn't just mean \"start even if no input devices are found.\" It\n skips input device enumeration entirely, so Weston never discovers the\n touchscreen.\n3. **Cog** (minimal WPE WebKit browser)---connects to Weston via `--platform=wl`\n and loads `http://localhost:4000/ui`\n\nAll three are managed as\n[MuonTrap](https://hex.pm/packages/muontrap) daemons under a\n`rest_for_one` supervisor, so if Weston crashes, Cog gets restarted too.\n\nI'd previously done [scripted RPi kiosk setups](/blog/2025/07/16/automated-rpi-web-kiosk-setup-in-2025/)\nwith Raspberry Pi OS and labwc, but Nerves gives you something qualitatively\ndifferent: the entire system---OS, compositor, browser, application---is a single\nfirmware image that you build with `mix firmware` and deploy over the air with\n`mix upload nerves.local`. No SD cards or apt-get, and no configuration drift.\n\n## The touch problem\n\nThis is the frustrating part. The Goodix touchscreen works fine at\nthe kernel level---`/dev/input/event0` delivers events, Weston picks them up via\nlibinput and associates them with the DSI-1 output. But\n[Cog](https://github.com/Igalia/cog) 0.18.5 / WPE WebKit 2.48.3 simply does\nnot forward those touch events to the browser. No `pointerdown` or `click`\nevents; nothing reaches the DOM.\n\nThis is a [known category of issues](https://github.com/Igalia/cog/issues/213)\nin Cog. The Wayland platform backend's touch handling has had multiple bugs\n(multitouch broken, long-press not working, fd leaks), and while there have been\npatches, the fundamental `wl_touch` forwarding doesn't work reliably on this\nhardware.\n\nI investigated several alternatives:\n\n- **Cog's DRM platform** (`--platform=drm`) bypasses Weston and uses libinput\n directly, which should fix touch. But the current system doesn't compile Cog\n with DRM platform support---it SIGABRTs on launch. Adding\n `BR2_PACKAGE_COG_PLATFORM_DRM=y` to the Buildroot defconfig and rebuilding\n would likely work, but I haven't done that yet.\n\n- **webengine_kiosk** (Qt WebEngine for Nerves) manages input directly from\n `/dev/input`, bypassing Wayland entirely. But it's been\n [archived since 2021](https://github.com/nerves-web-kiosk/webengine_kiosk) and\n targets Qt5; the current Buildroot ships Qt6.\n\n- The **virtualinput fix** from\n [meta-wpe#224](https://github.com/WebPlatformForEmbedded/meta-wpe/issues/224)\n only applies to `wpebackend-rdk`, not the standard Cog Wayland platform.\n\n## The workaround: server-side touch with synthetic events\n\nRather than rebuild the system image (again), I went server-side. The pipeline\nis:\n\n```\nGoodix touchscreen\n → /dev/input/event0 (kernel evdev)\n → InputEvent library (Elixir, poll-based)\n → Touch GenServer (processes raw events, broadcasts via PubSub)\n → LiveView subscribes, calls push_event/3\n → JS hook dispatches synthetic PointerEvents on the correct DOM element\n```\n\nThe `Touch` GenServer reads raw evdev events---`abs_mt_position_x`,\n`abs_mt_position_y`, `btn_touch`, `syn_report`---and broadcasts\n`{:touch, :down | :move | :up, {x, y}}` tuples via Phoenix PubSub. The LiveView\nforwards these to the browser with `push_event`, and a colocated JS hook\ndispatches real `PointerEvent` objects:\n\n```javascript\ndispatchSyntheticPointer(type, x, y) {\n const target = document.elementFromPoint(x, y) || document.body;\n target.dispatchEvent(new PointerEvent(pointerType, {\n bubbles: true,\n cancelable: true,\n clientX: x,\n clientY: y,\n pointerId: 1,\n pointerType: \"touch\",\n isPrimary: true,\n }));\n}\n```\n\nFrom the browser's perspective, these look like native touch events. Any JS\nlibrary or CSS `:active` state that listens for pointer events will work. The\nround-trip latency through the server is negligible on localhost---the whole\nthing runs on the same device.\n\n## Other things that'll bite you\n\nWatch out for NIF cross-compilation. If you're using NIFs on Nerves, make\nsure they actually cross-compile for your target. I had\n[NxEigen](https://hex.pm/packages/nx_eigen) in my deps, and `cc_precompiler`\nsilently skipped building the NIF for `aarch64-nerves-linux-gnu` because no\nprebuilt binary existed. The firmware built fine, but the app crash-looped on\nboot because `libnx_eigen.so` was missing. I ended up dropping it in favour of\n`Nx.BinaryBackend` on the target[^exla].\n\n[^exla]:\n On the host I use EXLA for GPU-accelerated training. On the target, inference\n on small models is fast enough with the default backend.\n\nPartition scheme migration is another trap. If your device was originally\nflashed with one partition layout (e.g. `kiosk_system_rpi4`'s tryboot scheme)\nand you try to OTA a firmware image built for a different layout (e.g.\nfrio_rpi4's older MBR-swap scheme), the upload will appear to succeed but\nwrite to the wrong partitions. The fix is a full reflash via rpiboot. This\nonly bites you once, during the initial migration, but it's confusing when it\nhappens.\n\nAnd one hardware-specific gotcha: the reTerminal DM is eMMC-only. It has no\nSD card slot---it boots exclusively from 32GB eMMC. For the initial flash, you need to toggle the boot switch next\nto the USB-C port, connect to a Linux machine, run\n[rpiboot](https://github.com/raspberrypi/usbboot) to expose the eMMC as a block\ndevice, and then `mix firmware.burn`. After that, OTA updates via `mix upload`\nwork fine---the A/B partition scheme means you can't brick the device\nremotely[^brick].\n\n[^brick]:\n Well, you _could_ if you deliberately wrote broken firmware to both slots.\n But normal OTA updates only touch the inactive slot, so a bad firmware just\n reverts on next boot.\n\n## Summing up\n\nIf you're trying to get a reTerminal DM running with a modern Nerves stack,\nhere's what you need:\n\n1. A **custom Nerves system** with the ILI9881D panel driver and reTerminal DM\n device tree overlays---stock systems won't drive the display. Start from\n [frio_rpi4](https://github.com/formrausch/frio_rpi4) or use\n [our fork](https://github.com/ANUcybernetics/reterminal_dm) directly.\n\n2. A **vc4 driver reload** in your application startup, before the compositor\n launches.\n\n3. **Server-side touch input** via the `input_event` library, because Cog/WPE's\n Wayland touch forwarding is broken. Dispatch synthetic `PointerEvent`s from a\n LiveView hook and everything just works.\n\n4. **rpiboot** for the initial eMMC flash, then OTA from there.\n\nThe source is at\n[ANUcybernetics/neon-perceptron](https://github.com/ANUcybernetics/neon-perceptron).\n\nNext up: the Neon Perceptron's output layer uses multiple Nerves devices driving\nseven-segment displays, so I need to get BEAM clustering working across a few\nCM4s on a local network. The nice thing about Nerves is that distributed Erlang\nis just... there. Drop in `libcluster` with gossip discovery on a gigabit\nswitch and suddenly `GenServer.call` works across devices. But that's a post\nfor another day.\n",
"createdAt": "2026-05-13T23:14:37.018Z",
"description": "A guide to running Elixir/Nerves on Seeed's reTerminal DM with its custom DSI display, capacitive touchscreen, and a Cog/WPE browser kiosk---including the workarounds you'll need for touch input.",
"path": "/blog/2026/04/08/reterminal-dm-nerves-kiosk",
"publishedAt": "2026-04-08T00:00:00.000Z",
"site": "at://did:plc:tevykrhi4kibtsipzci76d76/site.standard.publication/self",
"tags": [
"dev"
],
"textContent": "A guide to running Elixir/Nerves on Seeed's reTerminal DM with its custom DSI display, capacitive touchscreen, and a Cog/WPE browser kiosk---including the workarounds you'll need for touch input.",
"title": "Getting a reTerminal DM running as a Nerves kiosk in 2026"
}