Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Raspberry Pi 5 · 4G LTE Gateway

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
        └──────────────┘

Contents


Features

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

Hardware

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

The power supply is not the place to save money

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

Software stack

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

Why QMI and not PPP

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.


Repository layout

.
├── 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

Installation

Start from a fresh Raspberry Pi OS Lite install with SSH enabled and the Pi on your normal WiFi.

1. Clone and configure

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, subnets

00-config.sh is gitignored, so your passphrase never lands in a commit.

2. Bring up the mobile link

sudo ./01-modem-4g.sh

Confirm the modem enumerated first — mmcli -L should list it. If it does not, read docs/MODEM-SETUP.md before going further.

3. Share the link over Ethernet, install the watchdog

sudo ./02-lan-share-watchdog.sh

4. Install the services

sudo ./04-install-services.sh
sudo nano /etc/sms-control/allowed      # add your mobile number
sudo systemctl restart sms-control

5. UPS support, if you have a HAT

sudo ./05-ups-i2c.sh
sudo reboot
sudo ./tools/ups-detect.sh              # confirm the gauge was found

6. Start the access point — last, because it drops your SSH

sudo ./03-wifi-hotspot.sh

The 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.


The web dashboard

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.


SMS control

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 logging

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 and UPS monitoring

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:

  1. MAX17040/48 — a dedicated fuel gauge. Preferred when present because it tracks charge directly instead of inferring it from voltage.
  2. INA226 — identified positively by its manufacturer ID register (0xFE reads 0x5449).
  3. 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.

Calibration

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.

Thresholds

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.


Security model

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.


Things that cost real time

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.


Troubleshooting

Modem not detected

lsusb | grep -i sim            # absent => cable, connector or power
dmesg | tail -30
mmcli -L

4G 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 reconnect

Dashboard unreachable

systemctl status hexaind-dash
journalctl -u hexaind-dash -n 50
curl -I http://127.0.0.1/login

SMS commands ignored

sudo cat /etc/sms-control/allowed     # is your number there, right format?
journalctl -u sms-control -n 50

Battery 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.sh

Undervoltage warnings

vcgencmd get_throttled         # bit 16 set = undervoltage occurred
vcgencmd pmic_read_adc | tail -5

Repeated sags at low measured wattage point at the cable or charger, not the load.


License

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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages