root@openstick:~#

        

OpenStick

How an $8 4G modem stick became a Linux server

A tale of IRQ conflicts, soft-bricks, and questionable life choices

scroll down
0x00

The Patient

    ____________________
   /                    \
  |  UZ801 v3.0          |
  |  ==================  |
  |  | 128x128 LCD  |    |
  |  |   GC9107     |    |
  |  |______________|    |
  |                      |
  |  [SIM] -----.        |
  |  [uSD] ----'         |
  |                      |
  |  MSM8916   PM8916    |
  |  Cortex-A53  PMIC    |
  |  381MB RAM           |
  |  3.6GB eMMC          |
  |                      |
  |  [USB-A connector]   |
   \____________________/
🧠
SoC
Qualcomm MSM8916
ARM Cortex-A53, because who needs more than 4 cores at 1.2GHz?
💾
RAM
381 MB
Chrome would like to have a word with you
💽
Storage
3.6 GB eMMC
22 partitions in 3.6 gigs. It's cozy.
📶
Modem
LTE Cat 4
Bands 1,3,5,7,8,20,38,40,41 — it speaks fluent 4G
🔋
Battery
3000 mAh
Optional. Like documentation.
💻
Display
128x128 SPI
4K? We have 16K pixels. Total.
"It's not about the specs you have. It's about the specs you hack around." — Every embedded developer, probably
0x01

Compatible Devices & Resources

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.

💰 $12–$15 on AliExpress & Amazon
UFI001B / UFI001C
The original OpenStick target
UZ801
4G LTE USB WiFi Key — the star of this page
UF896
Compact 4G router
SP970
Pocket WiFi router
Alcatel IK41VE / Vodafone K5160
Carrier-branded USB modems
ZTE MF927U / MF823
ZTE mobile hotspot & USB modem
Huawei E3372
HiLink USB modem

Resources & Guides

📦 GitHub Repository 👥 OpenStick Organization 📖 Detailed Guide (wvthoog.nl) 🔧 Extrowerk Guide 📰 Liliputing Article 🔗 UZ801 Armbian Guide
0x02

Current Status

Working

  • Debian Linux (kernel 6.7.0-rc4)
  • LTE internet (4G, O2)
  • WiFi hotspot — OpenSpot (192.168.100.1)
  • USB networking (192.168.200.1)
  • Battery monitoring (BMS)
  • Charger + USB detection (extcon)
  • Modem recovery service
  • SSH remote access
  • LED control (R/G/B)
  • Full EDL backup system
  • Display UI daemon (8 Python modules)
  • VNC remote desktop (TigerVNC + Openbox)
  • Home Assistant integration (lights, thermostat)

TODO

  • Battery charging calibration
  • Thermal management
  • Audio subsystem
  • GPS (if hardware present)
0x03

The Journey

8 months. 7 versions. 1 soft-brick. Infinite dmesg | grep commands.

v1.0
July 2025

Genesis 🌱

"Let there be Linux"

It started, as all great engineering projects do, with the question: "What if I put Debian on this $8 modem stick?"

First Debian boot on MSM8916
EDL flashing toolkit working
Android fully backed up (for "just in case")
$ ssh user@192.168.200.1
Welcome to Debian GNU/Linux
openstick:~$ _

The stick that was meant to provide internet now needs internet. Ironic.

v2.0
September 2025

The Great Exploration 🔍

"What does this GPIO do?"

Armed with a running Linux system, the exploration began. Display? Battery? Multiple distros? Why not all of them!

📺 The Display Dream

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

Debian
❤ The chosen one
OpenWRT
📦 Donated its bootloader
PostmarketOS
🏋 Too heavy for 381MB
v3.0
September 2025

The Deep Dive 🧿

"How hard can battery support be?"

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/status
disabled
$ cat /proc/device-tree/.../battery@4000/status
disabled
$ # 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.

v4.0
October 2025

The Dark Times 🔥

"I'm sure this boot image is the right one"
INCIDENT REPORT
T+0 Flash custom battery kernel boot image
T+10s Device doesn't boot. DTB incompatible with bootloader.
T+30s "No worries, I have a backup"
T+45s Flash "backup". It was the wrong image. 12MB Debian instead of 32MB Android.
T+60s Device enters Sahara mode. EDL unresponsive. Fastboot gone.
T+120s 💀 Soft-bricked.

But every cloud has a silver lining. During the recovery analysis, THE bug that had blocked battery support all along finally surfaced:

The IRQ Conflict

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
v5.0
January 2026

LTE Goes Brrr 🚀

"We have internet. FROM SPACE. Well, from a cell tower."

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.

📡 MODEM STATUS
Status Connected
Technology LTE (4G)
Signal 71%
Operator E-Plus
Latency 36ms
Packet Loss 0%

The modem stick can now... be a modem. On Linux. Revolutionary.

🏆 Achievement Unlocked: "It's a modem again!"
v6.0
February 2026

The Phandle Swap 🧠

"What if the charger IS the USB detector?"

Remember the IRQ conflict from v4.0? Four months later, the solution turned out to be elegant device tree surgery.

The Operation

BEFORE (broken)
usb-detect@1300 phandle = 0x8b IRQ: usb_vbus ⚡ extcon provider
charger@1000 status = "disabled" IRQ: usb_vbus ⚡
battery@4000 status = "disabled"
⬇ device tree surgery ⬇
AFTER (working!)
usb-detect@1300 status = "disabled"
charger@1000 phandle = 0x8b ← reassigned! IRQ: usb_vbus ✅ extcon + charger
battery@4000 status = "okay" ✅

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.

📡 Bonus Fix: The Modem's Existential Crisis

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."
🏆 Achievement: "The Phandle Swap" — Resolve IRQ conflict with DT surgery
🏆 Achievement: "Therapist for Modems" — Fix existential reset with a systemd service
🏆 Achievement: "Full Stack" — LTE + WiFi + USB + Battery on an $8 stick
v7.0
February 2026

The Display Awakens 📺

"Remember that 128x128 screen? We finally got to it."

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?

🖥 Display UI Daemon

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.

OpenSpot
4G O2 lte 71%
● WiFi up 2 clients
VIVI
XCover6-Pro
DL:1.2M UL:340k
🔋 78% ⚡ up 2h14m
128×128 px • 16,384 pixels of glory

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)

🖥 VNC Remote Desktop

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.

Resolution 800×600
RAM Usage ~96 MB
Port 5901

A full graphical desktop on a $8 modem stick. What a time to be alive.

🛠 The 48MB Bug

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.

🏆 Achievement: "16K Pixels of Glory" — Full interactive UI on 128×128
🏆 Achievement: "Clean Split" — Refactor 2,568 lines into 8 modules, zero circular imports
🏆 Achievement: "Desktop on a Stick" — VNC + Openbox on 381MB RAM
🏆 Achievement: "Precision Mapping" — Fix 48MB mmap → 4KB, save WiFi
0x04

The Architecture

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
0x05

The Boot Sequence

From power-on to ping google.com, a journey of a thousand interrupts.

T+0s
SBL1 — Qualcomm's first-stage bootloader wakes up. Existential dread begins.
T+1s
aboot (LK) — Little Kernel bootloader. Borrowed from OpenWRT.
T+3s
Linux Kernel — 6.7.0-rc4-msm8916 decompresses. Device tree parsed. 381MB of RAM? Let's make it work.
T+6s
Modem boots — Q6 remoteproc fires up. MBA loaded. MPSS loaded. All is well.
T+10s
USB gadget — RNDIS interface up. SSH available. Charger extcon says "USB=1". The host PC sees a new network interface.
T+30s
💥 MODEM CRASH — "THIS IS INTENTIONAL RESET, NO RAMDUMP EXPECTED".
The modem has feelings. It needs a moment.
T+33s
Modem recovery — remoteproc restarts it. BAM-DMUX channels reopen. ModemManager panics.
T+45s
modem-fix.service — "Hey ModemManager, the modem is right there." *restarts MM*. Modem rediscovered with 12 ports.
T+65s
LTE Connected — O2 network, IPv4+IPv6, 71% signal, 36ms latency. We have internet.
T+4s
openstick-ui.service — Display daemon starts. 8 Python modules loaded. Tux splash on 128×128 screen. Backlight on (4KB SPMI mmap, not 48MB).
T+7s
Display active — Status screen showing LTE, WiFi clients, battery, throughput. Menu available via power button. The stick has eyes.
0x06

The eMMC Map

3.6 GB of flash storage. 22 partitions. Every byte accounted for.

💾 eMMC — 3.6 GB (3,758,096,384 bytes) sdhci-msm 7824900.mmc • HS200 @ 192MHz
Boot chain Modem Linux OS Data Security Misc
sbl1
tz
hyp
rpm
aboot
boot
modem 64 MB
fsg
nv1
nv2
misc
system ~1.6 GB Debian rootfs (ext4)
userdata ~1.2 GB ext4
cache 128 MB
recovery
persist
ssd
DDR
key
Linux OS (boot + system + recovery) ~46%
Data (userdata + cache + persist) ~37%
Modem firmware (modem + fsg + nv) ~2%
Boot chain (sbl1 + rpm + aboot) <1%
# 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
0x07

The Recovery Toolbox

When things go wrong (and they will), EDL is your friend.

Normal
reboot bootloader
Fastboot
fastboot oem reboot-edl
EDL (9008)
edl rf full_backup.bin
# 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
0x08

The Debug Graveyard

Commands typed. Sanity lost. A tribute to the tools of the trade.

0
dmesg | grep commands
(estimated, conservatively)
0
boot images created
at least 1 was wrong
0
lines of display UI
across 8 Python modules
0
modem firmware files
all 56 are important. all of them.
0
pixels on the display
128×128 of pure potential
0
phandles swapped
the one that saved everything