Blog by Saw Win Naung

Official

Turning a Cheap 4G LTE Stick into a Standalone OTP Receiver with Linux


I keep a Myanmar SIM alive for one reason: OTPs. Banks and a few government services back home still send codes by SMS, and swapping SIMs in and out of a phone every time one arrives got old fast.

What I wanted was boring. A small box on a phone charger that holds the SIM, roams onto a Canadian network, and pushes every SMS to my phone. No second handset, no SIM tray.

I already had the hardware: a HiMiLink “4G UFI” LTE USB stick, the kind sold as a pocket MiFi. Inside is a Qualcomm MSM8916 with 512 MB of RAM, the same SoC as old Android phones. It ended up running mainline Linux, forwarding SMS to Telegram, and serving its own web UI over home Wi-Fi.

The HiMiLink 4G UFI running Nura on a phone charger: red is power, blue is registered on LTE

The code, device tree and notes are on GitHub: sawwn23/himi-ufi-nura.

Warning. Getting here meant replacing the stick’s boot firmware over Qualcomm’s EDL mode. That can brick the device or wipe its IMEI and RF calibration. Other UFI sticks are wired differently. If you try this, take a full backup of your own stick first.

Round 1: the stock firmware

The stick ships with Android 4.4.4 and a vendor web UI on port 8000 (admin/admin, of course). That UI has no SMS feature at all. I checked the web server binary for the string “sms” and got zero hits. The API only covers Wi-Fi, APN, network, reboot and USB tethering.

So I looked for the messages themselves:

I did find one useful lever. The vendor API call sethimiusbtether with params:"0" switches the stick from RNDIS tethering into plain modem mode, which exposes a QMI port that ModemManager can drive. Setting sys.usb.config directly doesn’t stick, because a vendor watchdog reverts it.

The other problems were physical. Inside the Proxmox chassis the stick had no signal at all. One laptop USB port browned it out, while a USB 3.1 port powered it fine. Once it had enough power and a window, it roamed onto Rogers LTE straight away. Roaming had never been the problem: placement and power were.

Round 2: a web UI over ModemManager

With the stick in modem mode on the laptop, I wrote a small Go web app (standard library only) on top of ModemManager. It shows status, a signal chart, SMS threads, sending, USSD codes and SIM PIN entry.

Two design choices held up all the way through:

That worked, but the stick was still tied to the laptop. The next step was to run Linux on the stick itself.

Back up before anything else

Before touching the firmware I took a full eMMC dump over EDL: every partition, both GPTs and a SHA256SUMS file. Six of those partitions (fsc, fsg, modemst1, modemst2, persist, modem) hold the IMEI and RF calibration. Lose those and the radio is gone for good.

The dump had a bonus. EDL reads the raw flash, so Android’s permissions don’t apply to it, and mmssms.db came along with everything else. The three old messages that adb root had been guarding came straight out of the backup.

Round 3: mainline Linux on the stick

The OS is Nura, which is postmarketOS under its new name (it was renamed in September 2026). MSM8916 has good mainline support thanks to the msm8916-mainline project, and OpenStick had already shown that these sticks can run it. Mine still needed its own work.

Fingerprinting the board

The vendor’s device trees are generic Qualcomm reference trees, so they don’t describe this board. I worked out the real wiring from the stock DTB and live Android sysfs:

One detail to watch: the DTB has to be built against the msm8916-mainline kernel tree (v6.12.1-msm8916), not Linus’s tree, because it uses nodes that only exist there.

The kernel died before it started

Every mainline kernel I tried, arm64 and armv7, died before any driver probed. The cause was the stock 32-bit TrustZone, which has no PSCI. Without PSCI the kernel can’t bring up the CPUs the way it expects.

The fix was to replace the whole boot chain:

sbl1 / rpm / tz   DragonBoard 410c, Linaro release 176
hyp               qhypstub
aboot             lk1st (lk2nd 23.1 built as the primary bootloader)

The DragonBoard tz doesn’t fit in the stock partition, so this also needed a new GPT, based on the OpenStick layout. The six calibration partitions were then restored from the backup at exactly their original sizes.

lk2nd didn’t know this board, so I added a himi,ufi-esim entry. It matches on the panel string the stock bootloader passes, which is st7735s_128160. That’s an LCD controller, and this stick has no screen, but the string is unique, so it works as an ID. The patch is in nura/fw/patches/. The firmware binaries themselves are not in the repo. They’re Qualcomm/Linaro blobs, and the GPT only fits one stick’s partition sizes.

Packaging

The board lives in a pmbootstrap device package, device-himi-ufi, based on upstream device-zhihe-generic. It uses msm-firmware-loader, so at boot the modem and Wi-Fi firmware are read from the stick’s own modem and persist partitions. I never had to redistribute vendor firmware.

After pmbootstrap install, the stick booted Nura, joined Wi-Fi, registered on LTE and received SMS.

The modem crashed every 15 minutes

Then the modem firmware started crashing about every 15 minutes, logged as a fatal error in lte_ml1_common_timer.c.

The cause turned out to be deliberate. About 300 seconds after the modem starts, the HiMiLink baseband compares NV item 2500 against the string HiMI_OK. If it doesn’t match, the modem shuts itself down roughly 10 minutes later. The modem also clears that item on every boot. On stock Android, some vendor process must write it back each time. Under Nura, nothing did.

I found this thanks to the “Modem Stability” notes in akbar-npj/msm8916-openwrt. The fix is a small service, himi-ok-guard. It binds the modem’s DIAG channel (/dev/rpmsg0) and uses a tiny Go tool, diag_nv, to write HiMI_OK back into NV 2500, and only when the item is all zeros. There’s been no crash since.

I kept a modem-watchdog as a fallback anyway. On a firmware crash it stops and restarts the modem’s remoteproc and re-unlocks the SIM, which takes about a minute. It only reboots the stick if that fails, with a back-off that runs from 10 minutes up to 6 hours. systemd also feeds the PM8916 hardware watchdog, so if the whole system freezes it hard-resets after 60 seconds.

What runs on the stick

Rogers LTE ── modem DSP (stock Qualcomm firmware, from the stick's own partitions)
                 │ QMI
           ModemManager ◄── sim-autounlock.service (PIN from /etc/sim-pin)
                 │ D-Bus (polkit rule for user lte-dongle)
        lte-dongle.service  (Go, user lte-dongle)
           ├── :8080 web UI + JSON API (login required) ──► home Wi-Fi only (nftables)
           └── forward loop, every 5 s ──► api.telegram.org ──► my phone

After power-on, everything comes up by itself. Wi-Fi connects in about 30 seconds, the SIM unlocks, and the stick registers on Rogers about a minute after power-up. An SMS reaches Telegram within about 5 seconds of arriving.

SMS from the Myanmar SIM arriving in Telegram, OTP blurred

Some details that took a few tries:

CPU on a 512 MB stick

My first version polled mmcli separately for each feature, and the service sat at about 30% of a core. Each mmcli call costs 60–85 ms of CPU on this SoC.

Now there’s one background poll every 5 seconds. It makes a single D-Bus GetManagedObjects call (about 25 ms), and forwarding, the LED and SMS listing all share the result. With no dashboard open, the service uses about 1% of a core.

Security

This box holds a SIM that receives bank OTPs, so I treated it accordingly:

The weak spot is Telegram itself. OTPs pass through a bot on Telegram’s servers. For my threat model, that’s better than a SIM I keep forgetting to check, but it’s a choice you should make knowingly.

What I’d tell myself at the start

  1. Back up the raw flash first. It’s your undo button. Here it also recovered the data the vendor had locked away.
  2. Most “it doesn’t work” problems were physical. Power, cable and placement explained more failures than any software bug. Moving the stick to a better room gained 10 dB of RSRP.
  3. When a kernel dies before driver probe, look below the kernel. Here it was a firmware limitation (no PSCI), not a bad config.
  4. Vendor firmware has opinions. The 15-minute crash was a check the vendor built in on purpose, not a bug. Somebody else had already found it, so it pays to read the forums and notes of people working on similar hardware.
  5. On small hardware, count your subprocess calls. Merging the polling into one D-Bus call took the service from about 30% of a core to 1%.

Credits

This was only possible because of other people’s work: msm8916-mainline (lk2nd, qhypstub, qtestsign and the kernel tree), OpenStick for the GPT layout and the firmware-swap approach, postmarketOS / Nura, and akbar-npj/msm8916-openwrt for the modem stability notes.

The repo is sawwn23/himi-ufi-nura. Original code is MIT, and the device tree is GPL-2.0-only.