A Raspberry Pi 5 turned into a self-contained 4G router: it takes a SIM, brings up an LTE data link, and shares that link over WiFi and Ethernet. It is administered from a web dashboard, controlled by SMS when there is no network to reach it on, and it rides through power cuts on a UPS HAT while reporting its own battery state.
Everything runs from four small Python daemons and a handful of NetworkManager profiles. There is no container, no database, and no cloud dependency.
┌──────────────┐
│ 4G / LTE │ SIM7600G-H over QMI
└──────┬───────┘
│ wwan0
┌──────┴───────┐
│ Raspberry Pi │──── wlan0 ──► WiFi clients 192.168.10.0/24
│ 5 │──── eth0 ──► wired clients 192.168.20.0/24
└──────┬───────┘
│ GPIO 5V + I²C
┌──────┴───────┐
│ UPS HAT │ Li-ion pack, INA219 current/voltage monitor
└──────────────┘
- Features
- Hardware
- Software stack
- Repository layout
- Installation
- The web dashboard
- SMS control
- Call logging
- Battery and UPS monitoring
- Security model
- Things that cost real time
- Troubleshooting
- License
Networking
- LTE-only WAN over the SIM7600G-H in QMI mode, managed by NetworkManager
- WPA2 access point on 5 GHz sharing the mobile link
- Shared Ethernet with DHCP for wired clients
- Cron watchdog that restarts the link, then resets the modem, when traffic stops
- Every profile persisted to disk and set to autoconnect, so an unattended box recovers from a power cut with no keyboard attached
Web dashboard — nine tabs, auto-refreshing, no JavaScript framework
- Live signal strength, network operator, access technology, cell info
- Data counters, public IP, connection state
- Connected client list for both the WiFi and wired subnets
- CPU temperature, load, memory, disk, throttle flags
- Service health for every daemon, with log tails
- Call log and battery views with a two-hour charge history chart
- Change the WiFi SSID and passphrase, the dashboard password, and the SMS allowlist from the browser
- Reboot the gateway
SMS control — 14 numbered commands for when the box is unreachable
- Query signal, data usage, system health, network state, uptime, temperature
- Run a speed test, read back the SMS and call logs, read battery state
- Reboot or power off, guarded by a random four-digit confirmation code
- Replies only to numbers on an allowlist, with an hourly cap on outbound messages so a loop cannot run up a bill
Power
- Auto-detecting battery monitor supporting INA219, INA226 and MAX17040/48
- Voltage-to-state-of-charge curves for Li-ion and LiFePO4, with automatic cell-count inference
- SMS alerts on mains loss and restore, and at two low-battery thresholds
- Graceful shutdown before the pack reaches cutoff, so the SD card is never yanked mid-write
| Part | Notes |
|---|---|
| Raspberry Pi 5 Model B (16 GB) | Any Pi 5 works; a Pi 4 works with the caveat below |
| SIM7600G-H 4G HAT / USB dongle | Global band support, exposes QMI + AT + NMEA ports |
| Nano SIM with a data plan | Any carrier; the APN is configurable |
| LTE antennas | Two for the main/diversity paths; GPS antenna optional |
| UPS HAT with Li-ion cells | Must feed the Pi over the GPIO 5V pins and expose an I²C monitor |
| microSD card, 32 GB+ | A quality A2 card — this box writes logs continuously |
| USB-C PSU, 27 W official | See the power note below. This one is not optional |
Two failure modes here cost more debugging time than everything else combined.
A UPS HAT powers the Pi through the GPIO header, not the USB-C socket. That
means the Pi 5 never performs a USB-PD negotiation, and without a PD contract the
firmware silently caps total USB current at 600 mA. A SIM7600 pulls well past
that during LTE transmit bursts, so the modem drops off the bus and re-enumerates
in a loop that looks exactly like a driver problem. The fix is one line in
/boot/firmware/config.txt:
usb_max_current_enable=1
Set it only when a HAT is feeding the Pi. On a proper PD supply the negotiation already grants full current and the override is unnecessary.
An underpowered supply produces a distinctive fault. If you see undervoltage
events at a steady low wattage — a periodic sag and recover every twenty seconds
or so, while vcgencmd pmic_read_adc reports only a couple of watts — that is a
supply or cable in current-limit hiccup mode, not a load problem. Suspect the
cable first, the charger second, and the HAT's charge circuit third.
Check with:
vcgencmd get_throttled # 0x0 is healthy; bit 16 = undervoltage occurred| Layer | Choice | Why |
|---|---|---|
| OS | Raspberry Pi OS Lite 64-bit (Debian 13 Trixie) | No desktop; every watt goes to the radio |
| Kernel | 6.18.x rpt-rpi-2712 |
Ships the SIM7600 QMI driver in-tree |
| Modem stack | ModemManager + libqmi | QMI is faster and far more stable than PPP |
| Network | NetworkManager | One tool for the WAN, the AP and the shared LAN |
| Web | Flask 3.1 on Python 3.13 | A single file, no build step, no framework churn |
| Firewall | nftables | A tiny table that hides the dashboard from the WAN |
| Init | systemd | Restart supervision and journal integration for free |
| I²C | smbus2 | The battery monitor talks to the gauge directly |
The SIM7600 will happily speak PPP over a serial port, and most tutorials use it.
QMI is a binary control protocol that hands the kernel a real network interface
(wwan0) instead of a point-to-point serial tunnel. It is dramatically faster,
survives signal drops without tearing down the interface, and lets NetworkManager
manage the link like any other connection. The setup script locks the modem to
LTE-only with AT+CNMP=38, which is written to the modem's own flash and so
survives reboots and power cuts independently of the Pi.
.
├── bin/ Programs installed to /usr/local/bin
│ ├── hexaind-dash.py Flask dashboard (single file, ~840 lines)
│ ├── sms-control.py SMS command daemon
│ ├── call-watcher.py Call log collector
│ ├── battery-monitor.py UPS / battery telemetry daemon
│ ├── lte-watchdog.sh Link health check, run from cron
│ └── hexaind-dash-fw.sh nftables rules, run before the dashboard starts
├── config/ Templates for /etc
│ ├── sms-control/config Daemon settings
│ ├── sms-control/allowed.example Authorised numbers
│ ├── hexaind-ups/config Battery thresholds and gauge calibration
│ ├── modules-load.d/ i2c-dev module persistence
│ └── cron.d/ Watchdog schedule
├── systemd/ Unit files for the four daemons
├── setup/ Numbered installation scripts
│ ├── 00-config.sh.example Site settings — copy and edit this first
│ ├── 01-modem-4g.sh Modem configuration and WAN profile
│ ├── 02-lan-share-watchdog.sh Shared Ethernet + link watchdog
│ ├── 03-wifi-hotspot.sh Access point (disconnects your SSH)
│ ├── 04-install-services.sh Dashboard, SMS control, call logger
│ └── 05-ups-i2c.sh I²C, USB current budget, battery monitor
├── tools/
│ └── ups-detect.sh I²C scan and power diagnostics
└── docs/ Detailed guides per subsystem
├── MODEM-SETUP.md SIM checks, AT commands, QMI, the NM profile
├── NETWORKING.md Shared connections, routing, the watchdog
├── DASHBOARD.md Routes, tabs, auth, firewall
├── SMS-CONTROL.md Commands, confirmation flow, allowlist
├── CALL-LOGGING.md Log format, the polling constraint
└── POWER.md USB current, I2C, gauges, calibration
Start from a fresh Raspberry Pi OS Lite install with SSH enabled and the Pi on your normal WiFi.
git clone https://github.com/<your-user>/<your-repo>.git
cd <your-repo>/setup
cp 00-config.sh.example 00-config.sh
nano 00-config.sh # APN, hotspot SSID and passphrase, subnets00-config.sh is gitignored, so your passphrase never lands in a commit.
sudo ./01-modem-4g.shConfirm the modem enumerated first — mmcli -L should list it. If it does not,
read docs/MODEM-SETUP.md before going further.
sudo ./02-lan-share-watchdog.shsudo ./04-install-services.sh
sudo nano /etc/sms-control/allowed # add your mobile number
sudo systemctl restart sms-controlsudo ./05-ups-i2c.sh
sudo reboot
sudo ./tools/ups-detect.sh # confirm the gauge was foundsudo ./03-wifi-hotspot.shThe Pi's single radio cannot be a client and an access point at the same time, so the moment the AP starts, the WiFi connection you are sitting on disappears. The script re-execs itself detached from the terminal so a dropped SSH pipe cannot kill it mid-reconfiguration, and it automatically reverts to the client profile if the AP fails to come up — you cannot lock yourself out of a headless box.
Then join the new SSID and browse to http://192.168.10.1. The first page asks
you to set a dashboard password.
A single Flask file serving one HTML page. No build step, no bundler, no CDN — the gateway may be the only route to the internet, so the dashboard cannot depend on fetching anything from it. The battery chart is a hand-rolled SVG polyline for the same reason.
| Tab | Shows |
|---|---|
| Overview | Connection state, signal, public IP, data used, battery, quick actions |
| Cellular | Operator, access technology, band, cell ID, signal history |
| Network | Interface addresses, routes, DNS, per-interface counters |
| Clients | DHCP leases on both the WiFi and wired subnets |
| System | CPU temperature and load, memory, disk, throttle flags, service health |
| Logs | Tails of the daemon and system journals |
| Calls | Recent incoming and outgoing calls with duration and answered state |
| Battery | Charge, voltage, current, time remaining, two-hour history chart |
| Settings | WiFi SSID and passphrase, dashboard password, SMS allowlist, reboot |
Authentication is a scrypt-hashed password with a per-install random salt; the session secret is generated on first run. State-changing endpoints require a CSRF token. The dashboard binds to port 80 on all interfaces, and an nftables table installed before startup drops inbound 80, 443 and 53 arriving on the WAN interface, so it is reachable from the hotspot and the wired LAN but never from the mobile network.
When the gateway is misbehaving, the network you would use to fix it is often the thing that is broken. SMS rides the control channel rather than the data bearer, so it keeps working when the data link is down entirely.
Text any of these to the SIM's number from an authorised handset:
| Command | Command | ||
|---|---|---|---|
| 1 | Signal strength | 8 | Speed test |
| 2 | Data usage | 9 | Reboot (confirm) |
| 3 | System health | 10 | Power off (confirm) |
| 4 | Network state | 11 | SMS log |
| 5 | Uptime | 12 | Full status |
| 6 | Temperature and power | 13 | Call log |
| 7 | Modem detail | 14 | Battery |
Send anything unrecognised to get the menu back. Names work as well as numbers —
SIGNAL, BATTERY, STATUS.
Destructive commands are two-step. 10 does not power anything off; it replies
with a random four-digit code that you must send back within the timeout:
> 10
< CONFIRM POWEROFF
Reply: CONFIRM 4827
within 120s or it is cancelled.
> CONFIRM 4827
< Powering off.
This costs one extra message and makes an accidental shutdown of a remote, headless box effectively impossible.
Three further guards: only numbers in /etc/sms-control/allowed are answered,
outbound replies are capped per hour so no loop can run up a bill, and unknown
senders get silence rather than a rejection — replying at all would confirm the
number is live.
call-watcher.py follows ModemManager's call objects and appends each one to
/var/lib/sms-control/calls.jsonl, recording direction, number, ring duration and
whether it was answered.
The one non-obvious part: ModemManager garbage-collects call objects within seconds of a call terminating. Polling on any relaxed interval reliably captures the fact that a call happened and misses who it was from. The watcher polls every 1.5 seconds specifically to catch the caller ID while the call is still ringing, and caches it so the record survives the object's disappearance.
This project does not carry voice audio. The SIM7600G-H can, but only in a USB composition that exposes an audio interface, which requires changing the modem's USB PID — a change that can leave the modem in a state your kernel does not recognise. Logging works in the default composition; audio is deliberately out of scope.
battery-monitor.py writes a live snapshot to /var/lib/sms-control/battery.json
and appends a per-minute row to battery-history.jsonl, capped at 24 hours.
It probes for the gauge rather than assuming one. UPS HATs from different vendors use different chips at different addresses, and a wrong guess produces plausible nonsense rather than an obvious error. Detection runs in order of confidence:
- MAX17040/48 — a dedicated fuel gauge. Preferred when present because it tracks charge directly instead of inferring it from voltage.
- INA226 — identified positively by its manufacturer ID register (
0xFEreads0x5449). - INA219 — has no ID register at all, so it is identified by sweeping the likely addresses and accepting the first that returns a plausible pack voltage.
From there, pack voltage infers the cell count, and a chemistry-specific voltage-to-charge curve converts to a percentage.
Two settings in /etc/hexaind-ups/config need checking against your hardware:
SHUNT_OHMS must match your HAT's sense resistor — check the schematic. A
wrong value scales every current reading linearly, and the error is invisible
because the numbers still look reasonable.
CURRENT_SIGN sets which direction through the shunt counts as positive.
Vendors disagree, and getting it wrong inverts charge and discharge.
That second one is worth dwelling on, because it is the bug this build spent the longest chasing. The dashboard reported the pack discharging at 1.77 A while it was plugged in and charging. Every instantaneous reading agreed and looked entirely plausible. What exposed it was the history file: pack voltage had risen from 3.844 V to 3.876 V over ten minutes. A cell being drained at 1.7 A cannot rise in voltage. The current reading was sign-inverted, and left alone it would have driven a false low-battery shutdown of a healthy system.
If the dashboard says "on battery" while mains is connected, flip CURRENT_SIGN
and restart the service. It is the single most likely thing to be wrong on a HAT
this code has not seen.
| Setting | Default | Behaviour |
|---|---|---|
LOW_PCT |
20 | First warning SMS |
CRIT_PCT |
10 | Second warning SMS |
SHUTDOWN_PCT |
5 | Graceful poweroff, on battery only |
SHUTDOWN_HOLD |
3 | Consecutive samples required before acting |
SHUTDOWN_HOLD exists because a single spurious low reading — a loose connector,
a transient sag under LTE transmit — should never power down a healthy gateway.
This box has a public IP on the mobile network, so the exposure is real.
- The dashboard is never reachable from the WAN. An nftables table installed before the service starts drops inbound 80, 443 and 53 on the mobile interface.
- Passwords are scrypt hashed (n=16384, r=8, p=1) with a random per-install salt. No password is stored anywhere in plaintext.
- State-changing dashboard endpoints require a CSRF token.
- SMS commands are allowlisted by number, and destructive ones need a confirmation code.
- Unknown SMS senders get no reply at all, since answering confirms the number is live and in service.
- Outbound SMS is rate-limited as a cost guard.
- All credentials live in files that are gitignored —
00-config.sh,/etc/sms-control/allowed, and the dashboard's generated config.
Caller ID is trivially spoofable, so SMS allowlisting is a convenience guard against accident and casual misuse, not a security boundary against a motivated attacker. Do not extend the SMS interface to anything you would not accept an unauthenticated stranger triggering.
Four notes that are not in the obvious places.
A modem that does not enumerate is usually physical. No /dev/ttyUSB* and
nothing in dmesg means the kernel saw no device at all — which is a cable, a
connector or a power problem, not a missing driver. Chasing driver theories here
wastes hours. Check lsusb first: if the device is absent there, no amount of
software will conjure it.
dtparam=i2c_arm=on does not give you /dev/i2c-1. It enables the bus. The
separate i2c-dev kernel module creates the character devices userspace opens,
and on Debian Trixie /etc/modules is a deprecated stub that will not load it —
persistence belongs in /etc/modules-load.d/. The symptom is a bus that exists in
/sys/bus/i2c/devices/ while /dev/i2c-1 is missing and every tool reports no
such file.
sudo resets HOME to /root. Any script using ~ to find the invoking
user's files will silently look in the wrong place. Resolve it explicitly:
REAL_USER=${SUDO_USER:-$(id -un)}
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)Trust the trend over the reading. The sign-inversion bug above survived every instantaneous check because a single sample is unfalsifiable — it is just a number, and a wrong number looks like a right one. It only became obvious against recorded history, where physics contradicted it. This is the strongest argument for logging telemetry to disk even when a live view seems sufficient: a value you cannot check against its own past is a value you cannot check.
Modem not detected
lsusb | grep -i sim # absent => cable, connector or power
dmesg | tail -30
mmcli -L4G up but no traffic
mmcli -m any | grep -E 'state|signal'
ip route show default # is wwan0 the default route?
/usr/local/bin/lte-watchdog.sh # forces a reconnectDashboard unreachable
systemctl status hexaind-dash
journalctl -u hexaind-dash -n 50
curl -I http://127.0.0.1/loginSMS commands ignored
sudo cat /etc/sms-control/allowed # is your number there, right format?
journalctl -u sms-control -n 50Battery shows nothing
ls /dev/i2c-* # missing => see the i2c-dev note above
sudo i2cdetect -y 1
sudo tail -20 /var/log/battery-monitor.log
sudo ./tools/ups-detect.shUndervoltage warnings
vcgencmd get_throttled # bit 16 set = undervoltage occurred
vcgencmd pmic_read_adc | tail -5Repeated sags at low measured wattage point at the cable or charger, not the load.
MIT — see LICENSE.
Provided as-is. Radio behaviour, band support and carrier requirements vary by region; check that LTE-only operation and your chosen bands are appropriate where you are before deploying.