How an $8 4G modem stick became a Linux server
A tale of IRQ conflicts, soft-bricks, and questionable life choices
____________________
/ \
| UZ801 v3.0 |
| ================== |
| | 128x128 LCD | |
| | GC9107 | |
| |______________| |
| |
| [SIM] -----. |
| [uSD] ----' |
| |
| MSM8916 PM8916 |
| Cortex-A53 PMIC |
| 381MB RAM |
| 3.6GB eMMC |
| |
| [USB-A connector] |
\____________________/
"It's not about the specs you have. It's about the specs you hack around." — Every embedded developer, probably
The OpenStick project — started by Handsome Yingyang — turns cheap 4G USB modems with Qualcomm MSM8916 SoCs into Linux single-board computers. Quad-Core ARM Cortex-A53 @ 1.2 GHz with integrated LTE modem, all for pocket change.
8 months. 7 versions. 1 soft-brick. Infinite dmesg | grep commands.
It started, as all great engineering projects do, with the question: "What if I put Debian on this $8 modem stick?"
$ ssh user@192.168.200.1Welcome to Debian GNU/Linuxopenstick:~$ _
The stick that was meant to provide internet now needs internet. Ironic.
Armed with a running Linux system, the exploration began. Display? Battery? Multiple distros? Why not all of them!
128x128 pixels of pure potential. A GC9107 SPI display, wired through BLSP1 QUP3 at 24MHz. The vendor kernel used the ancient Framebuffer subsystem. The mainline kernel uses DRM.
"We'll get to the display later" — famous last words, September 2025
Narrator: It was very hard.
The PM8916 PMIC has an integrated Battery Management System (BMS) and Linear Charger. Two kernel modules exist. The device tree nodes exist. They're just... disabled.
$ cat /proc/device-tree/.../charger@1000/statusdisabled$ cat /proc/device-tree/.../battery@4000/statusdisabled$ # Cool. Cool cool cool.
Meanwhile, the modem firmware investigation revealed the Debian image shipped with 36 firmware files. Android had 56. The math didn't add up.
But every cloud has a silver lining. During the recovery analysis, THE bug that had blocked battery support all along finally surfaced:
genirq: Flags mismatch irq 30 00002003 (pm8916_lbc) vs. 00002003 (usb-detect@1300) pm8916-lbc: probe failed with error -16
Translation: The charger and USB detector both want the same interrupt line. The kernel says "there can be only one". Neither called IRQF_SHARED. Classic.
"Always label your backup images. Or don't. YOLO." — Lesson #47 from the embedded development handbook
After months of struggle, the breakthrough came from understanding the bootloader. The stock Android aboot kept defaulting to fastboot mode instead of actually booting. Solution? Borrow OpenWRT's bootloader.
The modem stick can now... be a modem. On Linux. Revolutionary.
Remember the IRQ conflict from v4.0? Four months later, the solution turned out to be elegant device tree surgery.
By giving the charger the USB detector's phandle (0x8b), every reference in the USB subsystem (extcon = <0x8b>) now points to the charger instead. The charger provides both USB detection AND battery charging. One driver, one IRQ, zero conflicts.
$ ls /sys/class/power_supply/ pm8916-bms-vm pm8916-lbc-chgr $ cat /sys/class/power_supply/pm8916-lbc-chgr/uevent POWER_SUPPLY_TYPE=USB POWER_SUPPLY_ONLINE=1 POWER_SUPPLY_CONSTANT_CHARGE_CURRENT=990000 $ cat /sys/class/extcon/*/state USB=1
Both power supplies active. Extcon providing USB detection. USB gadget working. No IRQ conflicts. Chef's kiss.
The Qualcomm modem processor apparently has an identity crisis every boot:
qcom-q6v5-mss: fatal error received: THIS IS INTENTIONAL RESET, NO RAMDUMP EXPECTED
It crashes itself on purpose 30 seconds after boot. ModemManager loses track. Solution: a systemd service that waits for the existential crisis to pass, then gently reminds ModemManager that the modem still exists.
$ systemctl status modem-fix Active: active (exited) # "I checked. The modem is fine. It just needed a moment."
After 6 months of "we'll get to the display later", the 128x128 GC9107 SPI screen is alive. And it has a full interactive UI. And a VNC desktop. Because why stop at one display?
A modular Python daemon drives the 128x128 display with a full status screen and interactive menu system. One button to rule them all — short press to cycle, long press to select.
The daemon was built as a single 2,568-line monolith, then refactored into 8 clean modules with a DAG dependency chain. Zero circular imports. Software engineering on a device the size of a USB stick.
constants.py ─── hardware.py (FrameBuffer, Backlight, Input)
│ ├── graphics.py (text, shapes, 17 icons)
│ │ ├── tiles.py (menu tiles)
│ │ └── status.py (LTE, WiFi, battery, throughput)
│ └── homeassistant.py (HA REST API)
│ └── renderer.py (12 screen draw methods)
│ └── daemon.py (state machine, entry point)
TigerVNC + Openbox running natively on the stick. 800×600, 24-bit color, accessible from any device. The display daemon can mirror the VNC framebuffer onto the tiny SPI screen in real time.
A full graphical desktop on a $8 modem stick. What a time to be alive.
The hotspot kept dying on every daemon restart. Root cause? The backlight driver was mmapping 48 MB of physical MMIO via /dev/mem — covering the entire low-address SoC register space including clocks, GPIOs, and the SPMI bus.
# Before (broken): maps 12,288 pages of MMIO mmap(fd, 0x3000000, offset=0) # 48 MB from address 0 # After (working): maps 1 page, just the MPP4 channel mmap(fd, 0x1000, offset=0x26F0000) # 4 KB at the exact register
A 12,000× reduction in mapped memory. The WiFi survived.
What runs inside a device smaller than your thumb.
HOST PC UZ801 v3.0 OpenStick
------- ----------------------
USB
[Terminal] ----[RNDIS/gadget]---- [Debian Linux]
ssh user@ Kernel 6.7.0-rc4-msm8916
192.168.200.1 |
+-- [WiFi AP] --- 192.168.100.1
| wlan0 (WCN3620)
|
+-- [LTE Modem] --- internet
| wwan0 (Q6 remoteproc)
| ModemManager + NetworkManager
|
+-- [PMIC PM8916] --- SPMI Bus
| +- charger@1000 (pm8916-lbc)
| | extcon USB + charging
| +- battery@4000 (pm8916-bms-vm)
| | voltage, SOC, health
| +- adc@3100 (VADC)
| +- gpio, rtc, temp-alarm
|
+-- [Display UI] --- SPI (/dev/fb0)
| 128x128 GC9107 (XRGB8888)
| openstick-ui.service (8 modules)
| Status, menus, HA control
|
+-- [VNC Desktop] --- port 5901
| TigerVNC + Openbox (800x600)
| ~96 MB RAM footprint
|
+-- [LEDs]
Red/Green/Blue status
From power-on to ping google.com, a journey of a thousand interrupts.
3.6 GB of flash storage. 22 partitions. Every byte accounted for.
# Print partition table (the moment of truth) $ edl printgpt --memory=emmc Parsing Qualcomm Sahara / Firehose protocol... Name Offset Size Flags ───────────────────────────────────────────────── sbl1 0x00000000 128.0 KB bootable tz 0x00020000 256.0 KB hyp 0x00060000 128.0 KB rpm 0x00080000 128.0 KB aboot 0x000A0000 1.0 MB bootable boot 0x001A0000 32.0 MB bootable modem 0x021A0000 64.0 MB system 0x061A0000 1.6 GB userdata 0x261A0000 1.2 GB cache 0x6E1A0000 128.0 MB recovery 0x761A0000 32.0 MB ───────────────────────────────────────────────── 22 partitions found. No refunds.
"3.6 GB seemed like enough space. Then Debian said 'hold my dpkg'." — The sad story of apt autoremove
When things go wrong (and they will), EDL is your friend.
# Read entire eMMC (the "oh no" button) edl rf backup.bin --memory=emmc # Read individual partitions (the "surgical" approach) edl rl partitions/ --memory=emmc --genxml # Write it all back (the "undo" button) edl wf backup.bin --memory=emmc # Print partition table (the "what am I looking at" button) edl printgpt --memory=emmc
Commands typed. Sanity lost. A tribute to the tools of the trade.